Documentation
Tutorials, how-to guides, reference material, and design notes for semantic jq: fuzzy JSON filters, bounded classification, deterministic jq execution, and explicit model-backed operators.
Everything you need to understand, evaluate, and use ajq.
ajq is usable today. The deterministic jq spine, semantic planning and execution,
local inference (--backend local/mock), Ollama/OpenAI-compatible/Anthropic backends,
cost controls, persistent judgement cache, local asset provisioning, model management, and
release archives with a checksum-verifying install script are shipped. Scale-out/windowing
remains a roadmap item. For the full picture, see
Project status.
Start here
Run your first ajq pipeline and inspect how semantic JSON filters are planned.
Install ajq, process JSON/NDJSON streams, write fuzzy filters, classify records, use
cloud or local models, control costs, manage the cache, and configure defaults.
CLI flags, configuration, backends, exit codes, I/O modes, semantic functions, and
--explain fields.
Split execution, the determinism contract, the executor, architecture, and project status.
Not sure where to look?
1 - Tutorials
Start-to-finish ajq walkthroughs.
Run these in order. The second tutorial uses the setup from the first.
Available tutorials
- Your first ajq pipeline — install nothing, run a real jq query
through ajq, and see byte-for-byte deterministic output.
- See how ajq plans a semantic query — write your first
fuzzy predicate and use
--explain to watch ajq split it into a deterministic part
and a semantic part.
For task-focused instructions, see the how-to guides.
1.1 - Your first ajq pipeline
Run a real jq query through ajq and see deterministic, byte-for-byte output.
Run a real jq query through ajq. You’ll process a small JSON document, filter it, and
confirm ajq matches jq for ordinary queries. No model, API key, or configuration.
Time: 5 minutes. Requires ajq on your PATH.
Step 1 — Get ajq
Use the checksum-verifying install script, a manual release download, or build from source
(see Install ajq for details):
curl -fsSL https://raw.githubusercontent.com/ricardocabral/ajq/main/scripts/install.sh | sh
Check that it works:
This tutorial uses only deterministic jq, so there’s nothing else to set up — no model
download, no API key.
Step 2 — Run a query
ajq reads JSON from standard input and applies the query you pass as its argument.
Let’s pipe a small document in and pull out one field:
printf '{"user":{"name":"Ada","active":true}}' | ajq '.user.name'
Output:
ajq quotes the string exactly as jq would. It uses
gojq under the hood.
Step 3 — Filter a list
Now something more interesting. Here’s a document with a list of users; we want the
names of the active ones:
printf '{"users":[{"name":"Ada","active":true},{"name":"Grace","active":false}]}' \
| ajq -r '.users[] | select(.active) | .name'
Output:
Two things happened:
.users[] | select(.active) | .name is an ordinary jq pipeline — iterate the users,
keep the active ones, project the name.- The
-r flag asked for raw output, so the string came out as Ada without quotes.
Step 4 — Prove it’s deterministic
Run the exact same command again:
printf '{"users":[{"name":"Ada","active":true},{"name":"Grace","active":false}]}' \
| ajq -r '.users[] | select(.active) | .name'
You get Ada again, byte for byte. Ordinary jq parts are byte-reproducible. Semantic
operators, added later, are the only parts that touch a model; everything else stays
deterministic. You’ll see that in the next tutorial.
Result
- ajq runs real jq queries and produces
jq-identical output. -r emits raw strings; without it, ajq emits JSON.- Ordinary pipelines are deterministic and reproducible.
Next
1.2 - See how ajq plans a semantic query
Write your first fuzzy predicate and watch ajq split it into deterministic and semantic parts with –explain.
Write a fuzzy match and inspect the plan. --explain does not contact a model, so you can
inspect and cost any query before running it for real.
Requires ajq on your PATH (see Install ajq).
Step 1 — Write a fuzzy predicate
Ordinary jq can test text with test() or ==, but only literally. ajq adds a fuzzy
operator, =~, that asks a model “does this value match this description?”. Here’s a
stream of three messages, and a query that keeps the ones that read as urgent:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --explain '.[] | select(.msg =~ "urgent") | .msg'
--explain prints the plan instead of running the query.
Step 2 — Read the plan
You’ll see output like this:
ajq explain v1
query: ".[] | select(sem_match(.msg; \"urgent\")) | .msg"
execution: semantic split plan
deterministic: no
model_calls: input-dependent
backend_calls: input-dependent
byte_reproducible: cache-dependent
stdin: harvested for estimates
planned_call_sites: 1
semantic_predicates: 1
semantic_values: 0
estimates:
estimate_status: available
static_call_sites: 1
input_frames: 1
harvested_judgements: 3
post_dedup_judgements: 2
mock_judge_batches: 1
over_harvest_bound: post_dedup_judgements == mock distinct judgements; may be a safe superset of execute-needed judgements
subgraphs:
deterministic: jq outside semantic call sites
semantic: 1 planned call site(s)
semantic_plan:
- call_id: 1
op: "sem_match"
kind: "predicate"
value_expr: ".msg"
specs: ["urgent"]
source_range: 13:38
gated: n/a
execution: 3-phase
subgraph: semantic
Important lines:
The query was rewritten
query: ".[] | select(sem_match(.msg; \"urgent\")) | .msg"
You wrote .msg =~ "urgent", but the plan shows sem_match(.msg; "urgent"). The =~
operator is surface sugar: ajq desugars it into a plain jq function call before
planning. Everything semantic is ultimately a sem_* function.
ajq found the semantic node
semantic_plan:
- call_id: 1
op: "sem_match"
kind: "predicate"
value_expr: ".msg"
specs: ["urgent"]
There is exactly one semantic call site — the sem_match on .msg. Everything else
in your query (.[], select, .msg projection) is deterministic jq. ajq has split the
pipeline into a deterministic majority and a single fuzzy decision.
Deduplication is already visible
harvested_judgements: 3
post_dedup_judgements: 2
Your input had three items, but two of them have the same .msg value ("urgent"). ajq
would collect 3 values, deduplicate them to 2 distinct ones, and only ever ask
the model about those 2. Identical values cost one decision — this is how ajq keeps
cost tied to the number of distinct fuzzy decisions, not the size of your input.
Add another distinct message and re-run:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"},{"msg":"ping the on-call"}]' \
| ajq --explain '.[] | select(.msg =~ "urgent") | .msg'
Now harvested_judgements becomes 4 and post_dedup_judgements becomes 3: one new
distinct value to judge.
Result
=~ is fuzzy matching; ajq desugars it to sem_match(...).--explain shows the plan without contacting a model — safe to run anytime.- ajq splits every query into a deterministic part and semantic call sites.
- Deduplication means identical values are judged once.
Running it for real
Drop the --explain flag and select the deterministic mock backend so the tutorial stays
network-free:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock '.[] | select(.msg =~ "urgent") | .msg'
Output:
Use --backend local or a cloud backend after following the matching how-to guide.
--explain stays the way to inspect and budget a query before you spend real model calls.
Next
2 - How-to guides
Recipes for specific ajq tasks.
Use these when you know the task. New to ajq? Start with
Your first ajq pipeline.
Guides
- Install ajq — get ajq onto your machine and provision local assets.
- Make ajq available to a coding agent — add a
project decision rule so agents choose ajq for semantic JSON work before writing a script.
- Process an NDJSON stream — handle newline-delimited JSON and raw
log lines.
- Filter JSON by meaning — find records by topic or intent
with explicit semantic predicates.
- Classify JSON and NDJSON streams — route records into a
fixed set of semantic labels.
- Use ajq safely from coding agents — start with mock
and
--explain, then cap and observe real model calls. - Install the ajq coding-agent skill — add the native Codex
marketplace or the optional Claude Code and Cursor adapter.
- Use ajq with other data formats — pipe JSON from
complementary jq ecosystem adapters into ajq.
- Write a semantic filter — use fuzzy
=~ predicates and bounded
semantic functions. - Estimate model calls before running — use
--explain to
budget cost and latency. - Use cloud backends — run semantic queries with Anthropic,
OpenAI, or OpenRouter.
- Control semantic costs — combine
--explain, --max-calls, and
--stats. - Manage the judgement cache — inspect, bypass, and clear cached
semantic judgements.
- Configure defaults — set backend, model, base URL, cost cap,
and cache defaults.
- Use a larger local model — pull and select a larger pinned
GGUF model.
2.1 - Install ajq
Install ajq and provision the local backend assets.
ajq is a single Go binary. Pure jq queries work immediately after the binary is on your
PATH. Semantic queries need an explicitly selected backend such as mock, local,
ollama, openai, openrouter, or --cloud.
Install with the script
The install script downloads the matching release archive for your OS/architecture,
verifies it against checksums.txt, and installs without sudo into ~/.local/bin (or
$AJQ_INSTALL_DIR).
curl -fsSL https://raw.githubusercontent.com/ricardocabral/ajq/main/scripts/install.sh | sh
Pin a version or install directory if needed:
AJQ_VERSION=v0.1.0 AJQ_INSTALL_DIR="$HOME/bin" sh scripts/install.sh
Install with Homebrew
The release workflow publishes the ajq cask to the public
ricardocabral/tap tap:
brew install --cask ricardocabral/tap/ajq
Install from a release archive
Download the archive for your platform from
GitHub Releases, verify its SHA-256
digest against checksums.txt, then copy the ajq binary somewhere on your PATH.
Release archives are named like:
ajq_<version>_Darwin_arm64.tar.gzajq_<version>_Linux_x86_64.tar.gzajq_<version>_Windows_x86_64.zip
Windows MSI and WinGet status
Windows MSI packaging is implemented and CI-validated, including an installed-binary
semantic smoke test, but the MSI is not yet released. ajq is not available
through WinGet; it remains unavailable until a future MSI release completes
Microsoft validation and merge and has public clean-install smoke evidence. Use the
published Windows ZIP in the meantime.
Verify a download
Checksum verification and provenance verification are separate checks. The install
script verifies the downloaded archive against checksums.txt; it does not run
GitHub attestation verification for you.
For release assets published after provenance attestations are enabled, install
the GitHub CLI and verify a downloaded archive or checksums.txt with:
gh attestation verify <downloaded-archive-or-checksums.txt> --repo ricardocabral/ajq
For manual downloads, also compare the archive SHA-256 digest with the matching
entry in checksums.txt.
Build from source
With the Go toolchain (Go 1.26 or newer):
go install github.com/ricardocabral/ajq/cmd/ajq@latest
Or from a checkout:
git clone https://github.com/ricardocabral/ajq.git
cd ajq
go build -o ajq ./cmd/ajq
install -m 0755 ajq /usr/local/bin/ajq # or ~/bin, etc.
Verify the binary
Run:
ajq --help should list the semantic backends and the shipped cache, daemon,
models, and provision subcommands.
Provision local semantic assets
Skip this section if you only use pure jq queries, --backend mock, Ollama, or cloud
backends.
--backend local needs a llama-server engine and an installed GGUF model under the ajq
cache. Provision them explicitly:
ajq provision
ajq provision --check
ajq provision auto-installs checksum-pinned engine bundles on supported platforms
(darwin/arm64, linux/amd64, and linux/arm64) and downloads the checksum-pinned
default model. It also reuses assets you already have, including explicit overrides,
cache entries, a legacy <cache>/bin/llama-server, or a llama-server on PATH.
Agent readiness probe
Use the status-only JSON probe when an agent needs to decide whether it can use
the managed local backend. A missing asset deliberately exits 1 after writing
its complete JSON result, so retain stdout instead of using a shell pipeline that
discards it:
set +e
ajq provision --check --json > ajq-readiness.json
status=$?
set -e
cat ajq-readiness.json
# status=0 means ready; status=1 means inspect .actions and provision explicitly.
The document includes ready, engine/model presence and local paths, and ordered
actions. It only inspects configuration, filesystem, and PATH state: it does
not download assets, start a daemon, or contact a backend. --json is valid only
with --check; use ajq provision separately to install the requested assets.
Package-manager status
The Homebrew cask is available from the public ricardocabral/tap tap. Windows MSI
packaging is implemented and CI-validated, but the MSI is not yet released. WinGet
remains unavailable until a future MSI release completes Microsoft validation and
merge and has public clean-install smoke evidence.
The install script, manual release archives, and Go-source installation remain
available when Homebrew is not the preferred installer.
Next steps
2.2 - Make ajq available to a coding agent
Make ajq discoverable in agent-visible project guidance so coding agents choose it for JSON and NDJSON semantic matching or bounded classification.
Use this guide when a team wants its coding agents to consider ajq before writing a
bespoke JSON-processing script. It assumes ajq is installed for the environment where the
agent runs and that the team already has a project-instruction mechanism its agents read.
1. Install ajq where the agent can execute it
Install ajq in the same execution environment as the coding agent, then verify that the
binary is visible on that environment’s PATH:
command -v ajq
ajq --version
ajq capabilities --json
If command -v does not print an executable path, install ajq or add its install directory
to the agent process’s PATH. Follow Install ajq for supported installation
methods. ajq capabilities --json is static introspection: it does not load credentials,
construct a backend, provision assets, or contact a network.
2. Add a JSON decision rule to agent-visible project guidance
Put the following guidance in the project instruction mechanism that your coding agents load
before acting. Keep it with the project’s data-handling and command-execution rules so it is
available when an agent is choosing between jq, ajq, and a new script.
### JSON and NDJSON processing
- Use `jq` for exact, structural JSON transformations.
- Use ajq for JSON or NDJSON semantic matching, or for classification into a
bounded set of labels.
- Do not describe ajq as a general semantic extraction or redaction tool.
Before an ajq semantic run:
1. Run `ajq capabilities --json` and use its versioned contract to confirm the
required operation and backend are available.
2. Validate the query shape with `--backend mock`; it is deterministic and
makes no network or model calls.
3. Run `--explain` against representative input and review the semantic plan
and estimated judgements.
4. Select a real backend explicitly only after review, and set a finite
`--max-calls` cap. Do not rely on a default backend for a real semantic run.
5. Add `--no-cache` for confidential or one-off input when persistent cache
reads and writes must both be bypassed.
This guide intentionally does not prescribe a particular agent, plugin, or MCP distribution
mechanism. Use the project’s existing instruction surface until ajq selects supported delivery
paths through its agent-skill distribution research.
3. Validate the guidance with safe commands
Use the mock backend to confirm that the agent can find ajq and form a semantic query without
model, network, or API-key access:
printf '[{"id":1,"message":"refund requested"},{"id":2,"message":"profile updated"}]' \
| ajq --backend mock -c '.[] | select(.message =~ "refund request") | .id'
The output is:
Then use representative, non-confidential input to inspect the plan before authorizing a
model-backed run:
printf '[{"message":"refund requested"},{"message":"profile updated"}]' \
| ajq --backend mock --explain '.[] | select(.message =~ "refund request") | .message'
--explain reports the semantic call site and estimated judgements without executing the
query against a model backend. The mock backend checks query shape; it does not evaluate a
query’s semantic quality on a production model.
4. Make real model use deliberate
After reviewing the plan and input scope, name the real backend and a finite call cap in the
command. For confidential or one-off input, add --no-cache:
# Requires a provisioned local backend. The backend, cap, and cache choice are explicit.
printf '[{"message":"refund requested"},{"message":"profile updated"}]' \
| ajq --backend local --max-calls 10 --no-cache \
-c '.[] | select(.message =~ "refund request") | .message'
--no-cache disables persistent cache reads and writes; it is not a call limit and does not
replace the data-handling review required for the selected backend. For cloud or other remote
backends, follow your organization’s data policy before sending confidential input.
5. Verify the agent can make the right choice
Ask the agent to propose, without executing it, a command for each of these tasks:
- Select objects whose
.status is exactly "open". - Select support records whose text means a refund request.
- Route records into the fixed labels
billing, bug, and account.
It should choose jq for the first task, and ajq’s explicit semantic matching or bounded
classification for the other two. Before a real semantic run, its proposed command should
name a backend and include a finite --max-calls value.
2.3 - Process an NDJSON stream
Handle newline-delimited JSON and raw log lines.
ajq auto-detects input framing. The common shapes:
Single JSON value
A single top-level JSON value is processed as one input frame:
printf '{"a":1}\n' | ajq -c '.a + 1'
# 2
NDJSON / JSON Lines
A stream of top-level JSON values (one per line, or concatenated) is processed one frame
at a time — each value flows through the query independently, and results are emitted as
they’re produced:
printf '{"a":1}\n{"a":2}\n' | ajq -c '.a, (.a + 10)'
# 1
# 11
# 2
# 12
There’s no whole-stream buffering in this mode, so it handles inputs larger than memory.
Semantic NDJSON windows
For a supported three-phase semantic query, ajq groups complete input frames into
byte-budgeted windows before resolving semantic judgements. The default budget is 262144
bytes (256 KiB). ajq harvests every frame in one window, deduplicates and cache-resolves
its judgements once, then executes and emits those frames in their original order.
A window never splits a JSON value or raw line. ajq retains at most the current window,
one complete-frame lookahead, and bounded framing buffers—not the whole stream. A single
record larger than the budget is accepted as a one-frame oversized window, so that record
itself can require more memory than the configured budget.
Use a larger budget to find duplicates across more nearby records and reduce backend
batches; the trade-off is that output waits until its full window has been harvested and
resolved. Use a smaller positive budget when lower first-record latency matters but
window-wide batching and deduplication are still useful:
# Keep semantic windows near 64 KiB for this invocation.
producer | ajq --backend local --window-bytes 65536 \
'select(.message =~ "payment failure")'
Choose --stream for first-frame latency
Use --stream when a supported semantic pipeline must resolve and emit each frame without
waiting for a window to fill:
producer | ajq --backend local --stream \
'select(.message =~ "payment failure")'
--stream selects inline semantic execution for queries that otherwise use three-phase
windows. It preserves input order, cache reads/writes and identity, schema validation,
cancellation, output/exit behavior, and the run-global --max-calls cap. An uncached
judgement resolves inline; a persistent cache hit still makes no backend call.
The latency trade-off is deliberate: inline execution has no window-wide backend batching
or cross-frame pre-resolve deduplication. It can therefore make more backend round trips
(and, with --no-cache, repeat a judgement in separate frames), while default windows can
reduce those calls at the cost of waiting for the window. --stats names the selected mode
and trade-off; --explain --stream reports that its harvest/dedup estimate is unavailable
without consuming stdin.
Pure-jq queries ignore --stream and remain deterministic. Queries the planner already
requires to interleave keep that existing inline behavior; --stream does not change their
selection. --window-bytes does not affect either inline path.
Raw lines (awk mode)
To treat each input line as a plain string instead of JSON, use -R / --raw-input. The
whole line becomes .:
printf 'error: disk full\nok: healthy\n' | ajq -R 'select(test("error"))'
# "error: disk full"
Combine -R with -r to keep the output unquoted:
printf 'error: disk full\nok: healthy\n' | ajq -R -r 'ascii_upcase'
# ERROR: DISK FULL
# OK: HEALTHY
Raw mode is also where the implicit-. form of semantic operators shines — e.g.
select(. =~ "stack trace") matches log lines fuzzily. See
Write a semantic filter.
Ignore stdin entirely
Use -n / --null-input to supply a single null frame and build output from scratch:
printf 'whatever' | ajq -n -c '{generated: true}'
# {"generated":true}
2.4 - Filter JSON by meaning
Find JSON records by intent or topic with ajq semantic filters, starting with a no-network mock backend and then switching to a real model.
Use this when exact string matching is too brittle: support tickets that say the same
thing in different words, log messages that imply the same failure, or records where the
field name is known but the wording is messy.
ajq keeps the surrounding jq pipeline deterministic. Only the explicit semantic predicate
(=~, !~, or sem_match) asks a backend for a judgement.
Try the query with no network
The mock backend is deterministic and in-process, so it needs no model, network, or API
key. It is useful for checking the jq shape before paying for model calls:
printf '[{"id":1,"msg":"payment failed during checkout"},{"id":2,"msg":"new avatar uploaded"}]' \
| ajq --backend mock -c '.[] | select(.msg =~ "payment failed") | {id, msg}'
Expected output with the mock backend:
{"id":1,"msg":"payment failed during checkout"}
The predicate .msg =~ "payment failed" is shorthand for
sem_match(.msg; "payment failed"). Everything else is ordinary jq.
Run the same filter on a real backend
After the command shape is correct, choose a backend that can make real semantic
judgements:
# Managed local backend after `ajq provision`
printf '[{"id":1,"msg":"card declined at checkout"},{"id":2,"msg":"profile photo changed"}]' \
| ajq --backend local --max-calls 10 -c '.[] | select(.msg =~ "payment failure") | {id, msg}'
# Anthropic cloud backend, using ANTHROPIC_API_KEY from the environment
printf '[{"id":1,"msg":"card declined at checkout"},{"id":2,"msg":"profile photo changed"}]' \
| ajq --cloud --max-calls 10 -c '.[] | select(.msg =~ "payment failure") | {id, msg}'
Paid backends default to a 100-call cap. Setting a smaller --max-calls while you tune a
query makes accidental large runs fail before crossing your budget.
Estimate, cap, and cache the work
Before running against a real model, ask ajq to explain the semantic plan:
printf '[{"msg":"payment failed"},{"msg":"payment failed"},{"msg":"avatar uploaded"}]' \
| ajq --explain '.[] | select(.msg =~ "payment failure") | .msg'
The value to budget against is post_dedup_judgements: repeated values are judged once
per backend/model/spec/value cache key. Successful judgements are stored in the persistent
cache, so rerunning the same query can hit the cache instead of the backend. Use
--no-cache only when you intentionally want to avoid reading or writing cached
judgements.
Keep filters within shipped semantic shapes
For production filtering, prefer =~, !~, or sem_match. Standalone semantic
extraction and redaction are registered but currently unsupported, so do not build a
filter that assumes ajq can extract arbitrary fields or redact text as a standalone
transform.
2.5 - Use ajq with other data formats
Feed ajq JSON produced by CLI, YAML, XML, TOML, or binary adapters.
ajq’s core input modes stay small on purpose: JSON, NDJSON, raw strings, and
null input. When the source data is another format, put a format adapter before
ajq and let ajq work on the JSON it emits.
This keeps pure jq-compatible ajq pipelines deterministic while still letting
you use the existing jq ecosystem around it. Tools such as jc, yq/xq, and
fq are commonly used alongside jq for this kind of conversion.
Convert command output with jc
Use jc when a system command prints text that you want to query as JSON:
df -h | jc --df | ajq -c '.[] | {filesystem, size, used_percent}'
Once jc emits JSON, ajq sees ordinary JSON values. Add semantic filters only
when you explicitly want model-backed judgement:
ps aux | jc --ps | ajq --backend mock -c '.[] | select(.command =~ "database server")'
Convert YAML, XML, or TOML with yq and xq
Use yq for YAML or TOML files that should flow into an ajq query:
yq -o=json '.services' docker-compose.yml \
| ajq -c 'to_entries[] | {name: .key, image: .value.image}'
Some yq distributions also provide xq for XML-to-JSON workflows:
xq '.rss.channel.item' feed.xml \
| ajq -c '.[] | {title, link}'
Check your installed yq/xq command’s flags, because option names vary by
implementation. The important contract is that the adapter writes JSON to
stdout before ajq starts.
Convert binary-derived data with fq
Use fq when a binary or structured file format can be projected as JSON. For
decoded values, use -V or tovalue when you need fq to write the JSON value
instead of its display tree.
fq '.frames[0:10] | map(tobytesrange.start)' file.mp3 \
| ajq -c '{frame_count: length, first_offset: .[0]}'
Keep the format-specific parsing in fq; keep jq and semantic operations in
ajq after the JSON boundary.
Most adapters emit a single JSON value, so the default ajq input mode is enough.
If an adapter emits one JSON value per line, ajq processes it as NDJSON:
some-adapter --json-lines input.dat \
| ajq -c 'select(.level == "error")'
If you only have raw text and do not want a JSON adapter, use ajq raw-input mode
instead:
some-command | ajq -R -r 'select(test("error"))'
2.6 - Write a semantic filter
Use fuzzy =~ predicates and bounded semantic functions in jq pipelines.
Use a semantic filter when literal matching with test() or == is too brittle. The
examples below use --backend mock so you can verify the command shape without a model or
API key; switch to --backend local, --cloud, or another backend for real judgement.
Fuzzy match with =~
The =~ operator asks the backend whether a value matches a description. Use it inside
select:
printf '[{"id":1,"feedback":"urgent"},{"id":2,"feedback":"other"}]' \
| ajq --backend mock -c '.[] | select(.feedback =~ "urgent") | .id'
Output with the mock backend:
.feedback =~ "urgent" desugars to sem_match(.feedback; "urgent"). Negate with !~:
printf '[{"id":1,"feedback":"urgent"},{"id":2,"feedback":"other"}]' \
| ajq --backend mock -c '.[] | select(.feedback !~ "urgent") | .id'
Match raw log lines
In raw-input mode, each input line is .:
printf 'panic stack trace\nok\n' \
| ajq --backend mock -R -r 'select(. =~ "stack trace")'
Output:
Classify into fixed labels
sem_classify(value; "a"; "b"; …) returns exactly one of the labels you provide:
printf '{"id":1,"text":"billing question"}' \
| ajq --backend mock -c '{id, route: sem_classify(.text; "billing"; "bug"; "feature")}'
Output with the mock backend:
{"id":1,"route":"billing"}
Use a fixed label set when downstream jq should route records deterministically by shape.
Stay with shipped execution shapes
The current executor fully supports predicate matching (=~ / sem_match) and bounded
classification (sem_classify). Unbounded value operators have narrower contracts in
0.0.1:
- Use
sort_by(sem_score(...)) when you need a semantic score as a sort key. - Use
group_by(sem_norm(...)) when you need semantic normalization as a grouping key. - Score or normalization values that feed a pruning gate may run through interleaved
fallback instead of the three-phase harvest path.
- Standalone
sem_extract(...) and sem_redact(...) currently report unsupported in
three-phase execution.
For production filters, prefer sem_match or sem_classify unless you specifically need
one of the limited score/normalization shapes above.
Check the plan first
Before running a semantic query on a paid or local model backend, inspect the estimate:
printf '[{"feedback":"urgent"},{"feedback":"urgent"},{"feedback":"other"}]' \
| ajq --explain '.[] | select(.feedback =~ "urgent") | .feedback'
Then use Control semantic costs to set a cap and inspect stats.
Account for the persistent cache
Successful semantic judgements are cached by op/spec/model/value. A second run over the
same values may skip backend calls and report cache hits. Use Manage the judgement
cache when you need to inspect, bypass, or clear those entries.
2.7 - Classify JSON and NDJSON streams
Route JSON or NDJSON records into fixed semantic labels with sem_classify while preserving deterministic jq output shapes.
Use sem_classify when each record needs one label from a small list: support route,
incident type, moderation bucket, or review theme. The output is bounded to the labels you
write in the query, which makes it safer for downstream jq and shell pipelines than free
text generation.
Classify a JSON array with no network
Start with --backend mock to validate the jq expression without a model, provider, or
API key:
printf '[{"id":1,"text":"billing question"},{"id":2,"text":"bug report"}]' \
| ajq --backend mock -c '.[] | {id, route: sem_classify(.text; "billing"; "bug"; "feature")}'
Expected output with the mock backend:
{"id":1,"route":"billing"}
{"id":2,"route":"bug"}
sem_classify returns exactly one of the labels from the call site. Keep labels short,
mutually exclusive, and stable because they become part of your output contract.
Classify NDJSON one record at a time
ajq processes newline-delimited JSON as independent input frames, so it can classify
streams without buffering the whole input:
printf '{"id":1,"text":"billing question"}\n{"id":2,"text":"bug report"}\n' \
| ajq --backend mock -c '{id, route: sem_classify(.text; "billing"; "bug"; "feature")}'
This emits one compact JSON object per input record, which is convenient for queues,
logs, and shell pipelines.
Route webhook events with bounded labels
If a webhook receiver writes each event body as NDJSON, add a stable route before passing
the stream to downstream workers:
printf '{"event":"invoice.payment_failed","data":{"message":"billing payment failed"}}\n{"event":"issue.created","data":{"message":"bug report: export crashes"}}\n' \
| ajq --backend mock -c '. + {route: sem_classify(.data.message; "billing"; "bug"; "other")}'
The command preserves each webhook event and adds a route value drawn from the three
labels in the query. Replace the mock backend with a capped real backend when the event
text requires semantic classification.
Switch to a real classifier backend
After the mock command produces the shape you need, run the same query with a real
backend:
# Local model after `ajq provision`
printf '{"id":1,"text":"billing question"}\n{"id":2,"text":"bug report"}\n' \
| ajq --backend local --max-calls 25 -c '{id, route: sem_classify(.text; "billing"; "bug"; "feature")}'
# OpenAI-compatible backend, using OPENAI_API_KEY from the environment
printf '{"id":1,"text":"billing question"}\n{"id":2,"text":"bug report"}\n' \
| ajq --backend openai --model gpt-4.1-mini --max-calls 25 \
-c '{id, route: sem_classify(.text; "billing"; "bug"; "feature")}'
For paid backends, keep --max-calls at or below your budget. Paid/cloud backends also
have a default 100-call guardrail when you do not set one explicitly.
Account for repeated values and cache hits
Semantic classification is cache-keyed by backend, model, operation, label set, and input
value. If many records contain the same text, ajq deduplicates the judgements before it
calls the backend, and later runs can reuse the persistent cache.
Estimate the distinct judgement count first:
printf '{"text":"billing question"}\n{"text":"billing question"}\n{"text":"bug report"}\n' \
| ajq --explain '{route: sem_classify(.text; "billing"; "bug"; "feature")}'
Then add --stats to the real run when you want to see post-dedup backend calls and cache
hits on stderr.
- Process an NDJSON stream — input framing for JSON, NDJSON, raw lines, and null input.
- Semantic functions reference — bounded classification semantics and limitations.
- Control semantic costs — combine estimates, caps, stats, and cache behavior.
- Backends reference — choose local, mock, Ollama, OpenAI, OpenRouter, or Anthropic.
2.8 - Estimate model calls before running
Use –explain to budget cost and latency before executing a semantic query.
Semantic queries cost one backend judgement per distinct value they judge. Use
--explain with representative input to estimate that number before you run a paid or
slow backend.
Pipe a sample of your data into --explain. ajq runs a deterministic mock harvest over
the input to estimate work — it never contacts a provider or local model:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --explain '.[] | select(.msg =~ "urgent") | .msg'
Read the estimate block
estimates:
estimate_status: available
static_call_sites: 1
input_frames: 1
harvested_judgements: 3
post_dedup_judgements: 2
mock_judge_batches: 1
The fields you’ll budget against:
| Field | Meaning |
|---|
harvested_judgements | Total values collected before deduplication. |
post_dedup_judgements | Distinct semantic judgements after deduplication — the estimated backend-call count. |
mock_judge_batches | How many backend batch calls the mock estimate path used. |
static_call_sites | Number of semantic call sites in the query, not input size. |
post_dedup_judgements is the number to compare with --max-calls. Paid backends default
to a 100-call cap; local, Ollama, and mock default to unlimited.
Turn the estimate into a cap
If the estimate is acceptable, set a cap when you run the real backend:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock --max-calls 2 '.[] | select(.msg =~ "urgent") | .msg'
For the full estimate → cap → stats workflow, see
Control semantic costs.
Estimate for your whole dataset
The estimate is based on the input you pass to --explain. To project a full run, either
pipe in the full dataset or sample representative records and extrapolate from your
distinct-value ratio. Because ajq deduplicates by value, repeated values cost less than
new values.
Remember the persistent cache
--explain estimates the semantic work implied by the input and query. A real run may
issue fewer backend calls if matching judgements are already in the persistent cache. Use
--stats on the real run to see cache_hits and post_dedup_backend_calls.
When estimates are unavailable
If the input is empty or invalid, or the query selects a currently unsupported semantic
execution shape, --explain keeps the static plan and marks estimate_status unavailable
with a stable reason. The plan (call sites, ops, specs) is still shown.
2.9 - Use ajq safely from coding agents
A safe workflow for agents that need semantic jq queries: start with mock and –explain, cap backend calls, and avoid unsupported extraction claims.
Coding agents often discover ajq while trying to answer a concrete data-cleaning
question: “find the records that mean X” or “route these JSON lines into labels”. Use
this workflow when an agent, script, or CI job needs semantic matching while keeping the
jq parts deterministic and reviewable.
1. Keep ordinary jq deterministic
First write the structural jq pipeline without semantic calls. This step should not need a
backend:
printf '[{"id":1,"msg":"refund requested"},{"id":2,"msg":"profile updated"}]' \
| ajq -c '.[] | {id, msg}'
Only add semantic operators once the field selection and output shape are correct.
2. Add semantics with the mock backend
Use --backend mock for the first semantic run. It is deterministic, in-process, and does
not use network access, cloud credentials, or local model assets:
printf '[{"id":1,"msg":"refund requested"},{"id":2,"msg":"profile updated"}]' \
| ajq --backend mock -c '.[] | select(.msg =~ "refund request") | {id, msg}'
Expected output with the mock backend:
{"id":1,"msg":"refund requested"}
For routing tasks, keep outputs bounded with sem_classify:
printf '{"id":1,"msg":"billing question"}\n{"id":2,"msg":"bug report"}\n' \
| ajq --backend mock -c '{id, route: sem_classify(.msg; "billing"; "bug"; "account")}'
3. Explain before executing with a model
Before a real backend run, inspect the plan and estimated judgement count:
printf '[{"msg":"refund requested"},{"msg":"refund requested"},{"msg":"profile updated"}]' \
| ajq --explain '.[] | select(.msg =~ "refund request") | .msg'
Review the semantic call sites, specs, and post_dedup_judgements. The estimate path uses
the mock backend and does not contact a provider.
4. Cap and observe the real run
When the estimate is acceptable, switch only the backend-related flags:
# Managed local backend after `ajq provision`
printf '[{"msg":"refund requested"},{"msg":"profile updated"}]' \
| ajq --backend local --max-calls 10 --stats \
-c '.[] | select(.msg =~ "refund request") | .msg'
# Anthropic cloud backend, using ANTHROPIC_API_KEY from the environment
printf '[{"msg":"refund requested"},{"msg":"profile updated"}]' \
| ajq --cloud --max-calls 10 --stats \
-c '.[] | select(.msg =~ "refund request") | .msg'
--max-calls is the hard budget. --stats reports harvested judgements, post-dedup
backend calls, cache hits, and elapsed time on stderr after a successful run. Paid/cloud
backends default to a 100-call cap, but agents should still set an explicit cap for the
current task.
5. Reuse or bypass the cache deliberately
Successful semantic judgements are cached by backend, model, operation, spec, and
canonical value. This improves repeated agent runs over the same data, but sensitive or
one-off workflows may prefer --no-cache to avoid cache reads and writes:
printf '[{"msg":"refund requested"}]' \
| ajq --backend mock --no-cache -c '.[] | select(.msg =~ "refund request") | .msg'
Use cache commands when an agent needs to inspect or clear local state:
ajq cache status
ajq cache clear
Guardrails for generated docs and prompts
- Do say ajq is useful for fuzzy JSON filtering, semantic grep over JSON/NDJSON, and
bounded classification.
- Do not claim standalone semantic extraction or redaction is supported today.
- Do not hide model calls behind pure jq examples; semantic work should always be visible
in the query and backend flags.
- Do not put API keys in commands, logs, or checked-in files. Paid backend credentials come
from environment variables.
2.10 - Use cloud backends
Run semantic queries against Anthropic, OpenAI, or OpenRouter.
Use a cloud backend when you want hosted inference instead of the local
llama-server backend. Each provider needs an API key in the environment; ajq does not
read API keys from config.toml.
Use Anthropic Claude
Export an Anthropic API key:
export ANTHROPIC_API_KEY="sk-ant-..."
Run a semantic query with --cloud:
printf '{"msg":"urgent billing issue"}' \
| ajq --cloud --model haiku '.msg =~ "urgent"'
--cloud is the same as --backend anthropic. If you omit --model, ajq uses
claude-haiku-4-5. The shipped aliases are haiku, sonnet, and opus.
If the key is missing, ajq stops before contacting the provider:
ajq: error: anthropic backend API key is empty; set ANTHROPIC_API_KEY
Use OpenAI
Export an OpenAI API key:
export OPENAI_API_KEY="sk-..."
Choose an OpenAI model explicitly:
printf '{"msg":"urgent billing issue"}' \
| ajq --backend openai --model gpt-4o-mini '.msg =~ "urgent"'
If you use an OpenAI-compatible proxy, pass its API root with --base-url:
ajq --backend openai --base-url http://127.0.0.1:8000/v1 --model my-model \
'.msg =~ "urgent"'
Use OpenRouter
Export an OpenRouter API key:
export OPENROUTER_API_KEY="sk-or-..."
Choose an OpenRouter model id:
printf '{"msg":"urgent billing issue"}' \
| ajq --backend openrouter --model openai/gpt-4o-mini '.msg =~ "urgent"'
OpenRouter uses https://openrouter.ai/api/v1 by default.
Keep a spending guardrail
Paid backends (anthropic, openai, and openrouter) default to a 100-judgement cap.
Lower it while testing:
ajq --cloud --max-calls 10 '.[] | select(.msg =~ "urgent")'
See Control semantic costs for the full estimate/cap/stats workflow.
2.11 - Control semantic costs
Estimate semantic work, cap backend calls, and inspect run statistics.
Use this workflow before running semantic queries on a paid backend.
1. Estimate calls with --explain
Run the query with representative input and --explain:
printf '[{"msg":"urgent"},{"msg":"other"}]' \
| ajq --explain '.[] | select(.msg =~ "urgent") | .msg'
Read post_dedup_judgements in the estimate block:
estimates:
estimate_status: available
static_call_sites: 1
input_frames: 1
harvested_judgements: 2
post_dedup_judgements: 2
That number is the count to budget against. Repeated values are deduplicated before the
backend is called.
2. Add a hard cap with --max-calls
Set the cap at or below your budget. ajq aborts before issuing the call that would cross
it:
printf '[{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock --max-calls 1 '.[] | select(.msg =~ "urgent") | .msg'
With two distinct judgements and a cap of one, the run fails safely:
ajq: error: query ".[] | select(.msg =~ \"urgent\") | .msg" runtime error in frame 1: max calls cap exceeded: cap 1, run needs 2 post-dedup backend judgements; aborting before issuing backend call 2.
Paid backends (anthropic, openai, and openrouter) default to --max-calls 100.
Use --max-calls 0 only when you intentionally want no cap. --backend-concurrency
changes only how many requests from an already-approved batch can be in flight; it does
not change this count or reserve extra calls. Keep paid providers at the sequential default
of 1 while establishing a rate-limit budget, then use at most 2 only if the provider’s
account limits and retry capacity allow it.
3. Inspect a successful run with --stats
After choosing a cap, run with --stats to print a summary to standard error:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock --stats '.[] | select(.msg =~ "urgent") | .msg'
The data still goes to standard output:
The stats show harvested work, post-dedup backend calls, cache hits, and elapsed time:
ajq stats:
input_frames: 1
semantic_call_sites: 1
harvested_judgements: 3
post_dedup_backend_calls: 2
cache_hits: 0
elapsed: ...
For paid models in ajq’s pricing table, stats also include an estimated USD cost. Treat it
as a budgeting estimate, not a provider bill.
2.12 - Install the ajq coding-agent skill
Install the ajq skill through the native Codex marketplace or the optional Claude Code and Cursor adapter.
Install the ajq skill when an agent should route semantic JSON or NDJSON
filtering and bounded classification work to ajq. Install ajq itself first so
the skill can run its safe discovery and mock checks.
Install for Codex
Add the public ajq marketplace and install its plugin:
codex plugin marketplace add ricardocabral/ajq
codex plugin add ajq@ajq
For a reproducible environment, pin the marketplace to an ajq release tag that
contains this plugin. Replace vX.Y.Z with that release tag:
codex plugin marketplace add ricardocabral/ajq --ref vX.Y.Z
codex plugin add ajq@ajq
Start a new Codex task after installation. Confirm that Codex can see the
plugin before relying on it:
Share it in a local workspace
When a team has a checkout of this repository, point Codex at that checkout
instead of a remote ref:
codex plugin marketplace add /path/to/ajq
codex plugin add ajq@ajq
The marketplace artifact lives in .agents/plugins/marketplace.json and the
plugin itself in plugins/ajq. Keep both paths together when sharing a branch
or archive.
Provision it in CI
Use a CI-specific CODEX_HOME, add the marketplace, and install the plugin
before starting Codex. Pin a release tag in reproducible jobs:
export CODEX_HOME="$RUNNER_TEMP/codex-home"
codex plugin marketplace add ricardocabral/ajq --ref vX.Y.Z
codex plugin add ajq@ajq
codex plugin list
Installing the skill does not select a semantic backend or grant access to a
model. The skill begins with ajq capabilities --json, --backend mock, and
--explain; any real backend still needs an explicit choice and finite
--max-calls value.
Optional adapter for Claude Code and Cursor
The third-party plugins installer can discover the same package from this
repository:
npx plugins add ricardocabral/ajq
Its current targets are Claude Code and Cursor. It is not the native Codex
installation path; use the Codex marketplace commands above for Codex. Preview
what the adapter finds without installing it:
npx plugins discover ricardocabral/ajq
After installing through either path, use the skill’s workflow: capabilities,
mock, explain, then an explicitly capped real backend. See the
agent-safe semantic workflow for commands
and current operator limits.
2.13 - Manage the judgement cache
Inspect, bypass, and clear ajq’s persistent semantic judgement cache.
ajq stores successful semantic judgements on disk so repeated runs over the same
op/spec/model/value identity can skip backend calls.
See where the cache lives
Run:
Example output:
location: /Users/you/Library/Caches/ajq/judgements
entries: 0
bytes: 0
Set AJQ_CACHE_DIR when you want an isolated cache for a project or test:
AJQ_CACHE_DIR="$PWD/.ajq-cache" ajq cache status
Inspect cache state from an agent
Use the versioned JSON probe instead of parsing the human table-like output:
ajq cache status --json
# {"schema_version":"1","availability":"available","path":".../judgements","entries":0,"bytes":0}
availability: "available" includes a missing cache directory (zero entries).
If ajq cannot inspect the configured location, it writes a complete document with
availability: "unavailable", zero counts, and error: "status_unavailable",
then exits non-zero. Paths are intentional local-state output; the document does
not include cache values, prompts, credentials, or raw filesystem errors.
Re-run a semantic query for free
Run a semantic query once with stats:
printf '[{"msg":"urgent"},{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock --stats '.[] | select(.msg =~ "urgent") | .msg'
The first run performs one backend judgement per distinct value:
ajq stats:
harvested_judgements: 3
post_dedup_backend_calls: 2
cache_hits: 0
Run the same command again with the same cache root. The output is the same, but the
judgements are cache hits:
ajq stats:
harvested_judgements: 3
post_dedup_backend_calls: 0
cache_hits: 3
Bypass the cache for one run
Use --no-cache when the judged values should not be read from or written to disk:
printf '[{"msg":"urgent"},{"msg":"other"}]' \
| ajq --backend mock --no-cache --stats '.[] | select(.msg =~ "urgent") | .msg'
You can also set no_cache = true in config.toml; use the flag when you only need the
bypass once.
Clear cached judgements
Run:
Example output:
location: /Users/you/Library/Caches/ajq/judgements
cleared_entries: 2
freed_bytes: 422
cache clear removes judgement entries only. It does not remove provisioned engines or
local GGUF models.
Privacy note
Cache files contain the values that semantic operators judged. Do not share the cache
folder if those values are sensitive. Use --no-cache or a disposable AJQ_CACHE_DIR for
one-off processing of private data.
2.14 - Configure defaults
Set backend, model, base URL, cost cap, and cache defaults in config.toml.
Use a config file when you do not want to repeat the same semantic-backend flags on every
command.
1. Create the config file
ajq reads this path by default:
${XDG_CONFIG_HOME:-~/.config}/ajq/config.toml
Create the directory and file:
mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/ajq"
cat > "${XDG_CONFIG_HOME:-$HOME/.config}/ajq/config.toml" <<'EOF'
backend = "mock"
max_calls = 25
no_cache = false
EOF
For a one-off location, set AJQ_CONFIG to an explicit file path.
2. Add semantic defaults
Recognized keys are:
backend = "local" # mock, local, ollama, openai, openrouter, anthropic
model = "qwen2.5-1.5b"
base_url = "http://127.0.0.1:8081"
max_calls = 100 # 0 = unlimited
no_cache = false
For example, to default to the deterministic mock backend with a small call cap:
backend = "mock"
max_calls = 1
no_cache = true
Then a semantic query can omit --backend:
printf '[{"msg":"urgent"},{"msg":"other"}]' \
| ajq '.[] | select(.msg =~ "urgent") | .msg'
With max_calls = 1, that example stops before making the second distinct judgement.
3. Override a default when needed
Precedence is:
- Command-line flags
AJQ_* environment variablesconfig.toml- Backend defaults
A flag wins for one command:
printf '[{"msg":"urgent"},{"msg":"other"}]' \
| ajq --max-calls 0 '.[] | select(.msg =~ "urgent") | .msg'
Environment variables are useful for a shell session or CI job:
export AJQ_BACKEND=mock
export AJQ_MAX_CALLS=0
The config file still supplies any setting that the flag or environment did not override.
4. Keep API keys out of config
API keys are environment-only:
export ANTHROPIC_API_KEY="sk-ant-..."
export OPENAI_API_KEY="sk-..."
export OPENROUTER_API_KEY="sk-or-..."
If a config file contains credential-looking keys such as api_key, apikey, or token,
ajq rejects it:
ajq: error: config key "api_key" looks like a credential; API keys are env-only
2.15 - Use a larger local model
List, download, and select a larger pinned GGUF model for the local backend.
The local backend defaults to qwen2.5-1.5b. Use ajq models when you want one of the
larger shipped catalog models.
1. List catalog models
Run:
Example output before any downloads:
NAME ACTIVE INSTALLED SIZE RAM
qwen2.5-1.5b * no 1.2GiB ~4 GiB RAM
qwen2.5-3b no 2.0GiB ~6 GiB RAM
qwen3-4b no 2.3GiB ~8 GiB RAM
ACTIVE shows the model selected by config/env/defaults. INSTALLED shows whether the
GGUF file exists in the ajq cache.
2. Download the model you want
Choose a catalog name and pull it:
ajq models pull qwen2.5-3b
The pull uses checksum-pinned public URLs and stores the model under the ajq cache. For a
project-local cache, set AJQ_CACHE_DIR before pulling:
AJQ_CACHE_DIR="$PWD/.ajq-cache" ajq models pull qwen2.5-3b
3. Select the installed model
Persist the selected local model:
ajq models use qwen2.5-3b
If the model is not installed yet, ajq tells you what to run:
ajq: error: model qwen2.5-3b is not installed; run `ajq models pull qwen2.5-3b` first
After the model is installed, models use writes the model name to config.toml:
active model set to qwen2.5-3b in /Users/you/.config/ajq/config.toml
The resulting config entry is:
models use preserves unrelated config keys, but it does not preserve comments or the
original formatting.
4. Run with the local backend
Use the selected model with the local backend:
printf '{"msg":"urgent billing issue"}' \
| ajq --backend local '.msg =~ "urgent"'
You can override the configured model for one command:
ajq --backend local --model qwen3-4b '.msg =~ "urgent"'
3 - Reference
CLI flags, configuration, backends, functions, and output formats.
Lookup pages for ajq behavior and interfaces.
For procedures, see the how-to guides. For design rationale, see
explanation.
3.1 - Command-line interface
ajq invocation, global flags, subcommands, and exit codes.
Synopsis
ajq [query] [flags]
ajq [command]
ajq reads input from standard input, applies one jq/semantic query, and writes results
to standard output. Errors are written to standard error and produce a non-zero exit code.
query is a single argument. ajq desugars =~/!~ to sem_match(...), parses with
gojq, and executes through the selected backend only
when semantic operators require one.
Global flags
| Flag | Short | Description |
|---|
--backend <name> | | Select the semantic backend: anthropic, local, mock, ollama, openai, or openrouter. |
--base-url <url> | | Set the HTTP base URL for backends that use one (local, ollama, openai, openrouter). For openai/openrouter, custom URLs must use HTTPS unless the host is loopback (127.0.0.1, localhost, or [::1]). |
--backend-concurrency <N> | | Maximum in-flight semantic requests in one provider batch. Default 1; maximum 2 for OpenAI-compatible/Anthropic and 4 for Ollama. Must be positive when set. |
--cloud | | Select the Anthropic cloud backend; equivalent to --backend anthropic. |
--compact-output | -c | Emit compact JSON output. |
--exit-status | -e | Set the exit status based on the last output value. |
--explain | | Print the deterministic/semantic execution plan and exit without executing the query. |
--max-calls <N> | | Maximum post-dedup backend judgements before aborting; 0 means unlimited. Paid backends default to 100; local/Ollama/mock default to unlimited. |
--model <id-or-alias> | | Semantic model id or alias for the selected backend. |
--no-cache | | Disable persistent on-disk judgement cache reads and writes for this run. |
--null-input | -n | Use a single null input value instead of reading stdin. |
--raw-input | -R | Read each input line as a string. |
--raw-output | -r | Emit strings without JSON quoting. |
--stats | | Print run statistics to stderr after a successful run. |
--stream | | Run supported semantic queries inline for low-latency frame output instead of window batching. Does not change backend, model, cache identity, or --max-calls; pure-jq and planner-required inline queries retain their existing behavior. |
--window-bytes <N> | | Maximum source bytes per supported three-phase semantic window; positive integer, default 262144. Has no execution effect for pure-jq or inline queries, including --stream. |
--version | -v | Print the version and exit. |
--help | -h | Print usage and exit. |
See Input and output modes for framing and formatting details, Backends
for backend-specific defaults, and Configuration for env/config
precedence.
Help-first agent probe
For machine consumers, begin with ajq capabilities --json, before examples
or query execution. It is static introspection: it does not load config or
credentials, construct a backend, start a daemon, inspect/provision assets, or
contact the network. Its versioned schema_version: "1" document is the stable
contract for input/output modes, semantic-function availability, backend
defaults, cost/cache/provisioning notes, safety guarantees, and discovery
commands. The plain ajq capabilities summary, ajq_version, and human
backend descriptions are informational build text; all other documented v1
fields and enum values are stable. Consumers must use schema_version as the
contract discriminator and ignore unknown fields and future enum values for
compatible v1 builds.
After capabilities discovery, run ajq examples for categorized, copy-pasteable
safe workflows. ajq examples [topic] can show one of pure-jq,
semantic-filter, explain, classification, or ndjson; its semantic
commands explicitly use --backend mock and therefore need no model, network,
or API key. It is the source of the detailed snippets; this reference keeps
only the workflow overview. The root help shows the same three safe first workflows:
# Pure jq remains deterministic and does not construct a backend.
printf '{"users":[{"name":"Ada"}]}' | ajq -r '.users[].name'
# --backend mock is the safe semantic-agent probe: deterministic, no model,
# no network access, and no API key.
printf '[{"id":1,"msg":"please keep this"}]' \
| ajq --backend mock -c '.[] | select(.msg =~ "keep") | .id'
# Review the semantic plan and estimated backend calls before execution.
printf '[{"msg":"refund demanded"}]' \
| ajq --backend mock --explain '.[] | select(.msg =~ "angry/frustrated") | .msg'
The mock backend is appropriate for checking query shape and split-execution
behavior; it is not a substitute for evaluating a query’s semantic quality on
a production model. --explain exits before query execution and does not
contact a model backend.
Discovery command help also includes safe inspection examples: ajq provision --check reports missing local assets without downloading them, ajq models list shows local model availability, ajq cache status inspects the local
judgement cache, and ajq daemon status checks daemon state without starting a
model.
JSON state probes
Coding agents can request versioned local-state documents with command-local
--json flags. Every document has schema_version: "1", is one JSON document
on stdout, and has a trailing newline. Consumers should use the schema version,
ignore unknown future fields/enums, and keep stdout even when a readiness check
has a non-zero exit status.
ajq models list --json returns active and ordered models. active.state
is catalog, path_like, or unknown; name occurs only for catalog and
path only for path_like. Each catalog row contains name, active,
installed, filename, path, size_bytes, and ram.ajq cache status --json returns availability (available or
unavailable), path, entries, and bytes. An unavailable local cache
also has error: "status_unavailable" and exits non-zero; it never exposes a
raw filesystem error.ajq provision --check --json returns platform, ready, engine,
model, and ordered actions. Assets contain identity, present, path,
and source when present; sources include override, bundle,
legacy_cache, path, cache, and unknown. Missing readiness produces a
complete document then exits 1. Actions are engine-first: provision runs
ajq provision, and a selected non-default model can add models_pull with
ajq models pull <name>. --json requires --check; ajq provision --json
is rejected and never starts provisioning.
These probes only read local configuration, filesystem, and PATH state. They do
not construct a backend, contact a provider, download assets, or start a daemon.
They intentionally omit credentials, prompts, provider responses, catalog URLs,
and checksums.
Subcommands
| Command | Description |
|---|
ajq cache status [--json] | Print persistent judgement cache status, or its versioned machine-readable probe. |
ajq cache clear | Delete persistent judgement cache entries and report what was freed. |
ajq capabilities [--json] | Print informational static metadata, or the versioned machine-readable capability contract for agents. |
ajq daemon status | Print the warm local daemon status. |
ajq examples [topic] | Print categorized safe examples; semantic examples explicitly use --backend mock. |
ajq daemon stop | Stop the local daemon if running; idempotent. |
ajq models list [--json] | Print the pinned local model catalog, or its versioned machine-readable probe. |
ajq models pull <name> | Download a checksum-pinned catalog model into the ajq cache. |
ajq models use <name> | Persist model = "<name>" to config after verifying the model is installed. |
ajq provision | Download or locate the local llama-server engine and default GGUF model. |
ajq provision --check [--json] | Report provisioning status (or its versioned JSON) and exit non-zero if assets are missing, without downloading. |
The root help also includes Cobra’s generated completion and help commands.
Exit codes
| Code | Meaning |
|---|
0 | Success. With --exit-status, also means the last output value was truthy. |
1 | With --exit-status: the last output value was false or null. Otherwise a general error. |
4 | With --exit-status: the query produced no output. |
| non-zero | A parse, runtime, configuration, backend, provisioning, or I/O error occurred. |
The --exit-status codes match jq’s convention.
Behavior notes
- If no semantic operators are present, ajq constructs no backend, makes no network calls,
and spawns no daemon.
- The managed
local backend starts llama-server on loopback with a per-daemon
--api-key; ajq stores the bearer key beside the daemon PID file in the cache
directory with 0600 permissions and sends it only to the managed daemon. - A healthy process already listening on the default managed local address is treated as
a port conflict unless it has ajq’s managed PID/key files. To use a server you started
yourself, pass
--base-url (or set AJQ_BASE_URL/base_url); that explicit server is
trusted by user intent and is not given ajq’s managed daemon key. --cloud with a conflicting explicit --backend is a CLI error.--backend openai and --backend openrouter send API keys on each request, so custom
--base-url values must be https://; http:// is accepted only for loopback
OpenAI-compatible proxies such as 127.0.0.1, localhost, or [::1].--explain on a pure-jq query validates the query and prints a byte-stable pure-jq
report without reading stdin.--explain on a semantic query prints the static plan and, when valid stdin is supplied,
mock-path estimates without contacting a real backend. With --explain --stream on a
supported semantic plan, it instead identifies user-selected inline execution, does not
consume stdin, and reports the harvest/batching/dedup estimate as unavailable.--stats writes only the summary to stderr; query output remains on stdout. Its
execution_mode is pure-jq, three-phase-windowed, planner-interleaved, or
user-stream; batching_dedup states the matching windowed or inline trade-off. The
numeric window_bytes, window_count, and oversized_window_count fields are zero
outside three-phase-windowed mode.--stream selects inline, input-order semantic execution only for supported semantic
plans that would otherwise be windowed. It gives first-frame latency but disables
cross-frame pre-resolve deduplication and window batching. Cache identity, cache hits,
schema validation, and the run-global --max-calls cap are unchanged.--window-bytes applies only to supported three-phase semantic execution. It forms
complete-frame windows, never buffers the entire input, and accepts a record larger than
the budget as a one-frame oversized window.
3.2 - Input and output modes
How ajq frames input and formats output.
ajq selects an input framing based on flags and the shape of stdin.
| Mode | Flag | Behavior |
|---|
| Auto (default) | — | Auto-detects a single JSON value or a stream of top-level / NDJSON JSON values. |
| Null input | -n, --null-input | Ignores stdin; supplies one null input frame. |
| Raw input | -R, --raw-input | Reads each stdin line as a string, excluding the line terminator. . is the line. |
In the default mode, a stream of multiple top-level JSON values (whitespace- or
newline-separated, i.e. NDJSON/JSON Lines) is framed independently, with no whole-stream
buffering. Pure-jq and planner-required interleaved semantic queries execute and emit one
frame at a time. Supported three-phase semantic queries instead group complete frames into
configured byte-budgeted windows, resolve each window once, then emit their frames in
original order. The window retains only its frames plus bounded framing lookahead; a record
larger than the budget forms one oversized window and is never split. This still allows
inputs larger than available memory.
For a supported semantic query, --stream selects that same per-frame inline behavior
instead of default windows so a result need not wait for a later frame. It trades
window-wide batching and cross-frame pre-resolve deduplication for low latency, without
changing cache identity or the run-global --max-calls cap. See
Process NDJSON for selection guidance and examples.
| Mode | Flag | Behavior |
|---|
| Pretty JSON (default) | — | Indented, human-readable JSON. |
| Compact JSON | -c, --compact-output | Single-line JSON, no extra whitespace. |
| Raw output | -r, --raw-output | String results are written without surrounding quotes; non-string results are unaffected. |
-c and -r can be combined, and both may be combined with any input mode.
Examples
# Single JSON value
printf '{"a":1}\n' | ajq -c '.a + 1'
# 2
# NDJSON, processed independently per frame
printf '{"a":1}\n{"a":2}\n' | ajq -c '.a, (.a + 10)'
# 1
# 11
# 2
# 12
# Null input — build output from scratch
printf '{"ignored":true}\n' | ajq -n -c '{generated: true}'
# {"generated":true}
# Raw input + raw output
printf 'error: disk full\n' | ajq -R -r 'ascii_upcase'
# ERROR: DISK FULL
3.3 - Semantic functions
The shipped semantic vocabulary, =/! sugar, arities, return types, and current limits.
Semantic operators are registered as jq functions, so gojq parses them inside ordinary jq
constructs. ajq adds only the =~ and !~ surface sugar before parsing.
Function vocabulary
| Function | Form | Kind | Returns | Execution status |
|---|
sem_match | sem_match(value; "spec") | predicate | boolean | Shipped |
sem_classify | sem_classify(value; "a"; "b"; …) | bounded value | one label from the given labels | Shipped |
sem_extract | sem_extract(value; "what") | unbounded value | string | Limited: supported only in gated/interleaved fallback contexts |
sem_score | sem_score(value; "spec") | unbounded value | number | Limited: supported in sort_by(...) three-phase placeholder mode and in gated/interleaved fallback contexts |
sem_norm | sem_norm(value; "canonicalization spec") | unbounded value | string | Limited: supported in group_by(...) three-phase placeholder mode and in gated/interleaved fallback contexts |
sem_redact | sem_redact(value; "redaction spec") | unbounded value | string | Limited: supported only in gated/interleaved fallback contexts |
The spec is a literal string in the query. Stream data is structurally fenced by
the selected backend’s output constraints.
Kinds
| Kind | Meaning |
|---|
| Predicate | Returns true or false; typically used in select or if. |
| Bounded value | Returns one of a finite set declared at the call site. |
| Unbounded value | Returns a string or number with no finite output set. These shapes need bounded executor contexts or interleaved fallback; unsupported forms fail loudly. |
Variadic implicit .
Every operator accepts both an explicit value and an implicit one. When the value argument
is omitted, it defaults to .:
sem_match(.field; "spec") # explicit value
sem_match("spec") # value defaults to .
The implicit form is what makes raw-line mode ergonomic.
The =~ and !~ sugar
| Surface | Desugars to |
|---|
X =~ "spec" | sem_match(X; "spec") |
X !~ "spec" | `sem_match(X; “spec”) |
The desugaring is performed by a jq-aware lexer, not a regular expression.
.users[] | select(.feedback =~ "angry/frustrated") | .id
# ≡
.users[] | select(sem_match(.feedback; "angry/frustrated")) | .id
Return-type discipline
Each semantic operator has a fixed output type: boolean, enum label, string, or number.
Backends constrain model output with GBNF grammar or provider structured-output/schema
features, so downstream jq receives a value of the expected shape when execution succeeds.
For sem_classify, the enum is exactly the label arguments at the call site:
sem_classify(.text; "billing"; "bug"; "feature")
That expression returns one of "billing", "bug", or "feature".
Scenario matrix
| Scenario | Invocation | Status |
|---|
| Fuzzy filter | select(.msg =~ "auth failure") | Shipped |
| Raw-line fuzzy filter | select(. =~ "stack trace") with -R | Shipped |
| Routing / labeling | {route: sem_classify(.text; "billing"; "bug"; "feature")} | Shipped |
| Semantic sort key | sort_by(sem_score(.review; "positivity")) | Limited, supported three-phase placeholder mode |
| Semantic grouping key | group_by(sem_norm(.company; "canonical name")) | Limited, supported three-phase placeholder mode |
| Gated semantic score | select(sem_score(.review; "positivity") > 0.8) | Limited, uses interleaved fallback |
| Gated typed extraction | select(sem_extract(.raw; "years") != "") | Limited, uses interleaved fallback |
| Gated semantic redaction | select(sem_redact(.notes; "PII") != .notes) | Limited, uses interleaved fallback |
sem_score and sem_norm are not general-purpose enrichment operators in 0.0.1. Use
sem_score as a sort_by(...) key and sem_norm as a group_by(...) key when you want
the three-phase executor. sem_extract and sem_redact have no supported three-phase
context; use them only in a gated control-flow context. When an unbounded value result is
used to prune control flow, such as a score comparison inside select or if, ajq may
choose an interleaved fallback instead of the harvest/resolve/execute path. That fallback
is bounded by the same backend, cache, and --max-calls controls, but its call count is
not available as a three-phase harvest estimate.
Current grammar scope
There is no custom jq grammar. @classify(...)-style @ forms and infix transform
operators such as ~> do not parse in gojq and are not shipped. Use the function-core
forms above.
3.4 - --explain output
Every field in the ajq explain v1 report.
--explain prints a stable plan report and exits without executing the query. It does not
contact a provider or local model.
Pure-jq report
ajq explain v1
query: ".users[] | select(.active) | .name"
execution: pure-jq deterministic
deterministic: yes
model_calls: 0
backend_calls: 0
byte_reproducible: yes
stdin: ignored
| Field | Meaning |
|---|
query | The query as parsed. |
execution | pure-jq deterministic — no semantic call sites. |
deterministic | yes. |
model_calls / backend_calls | 0. |
byte_reproducible | yes. |
stdin | ignored — pure-jq explain does not read input. |
Semantic report
ajq explain v1
query: ".[] | select(sem_match(.msg; \"urgent\")) | .msg"
execution: semantic split plan
deterministic: no
model_calls: input-dependent
backend_calls: input-dependent
byte_reproducible: cache-dependent
stdin: harvested for estimates
planned_call_sites: 1
semantic_predicates: 1
semantic_values: 0
estimates:
estimate_status: available
static_call_sites: 1
input_frames: 1
harvested_judgements: 3
post_dedup_judgements: 2
mock_judge_batches: 1
over_harvest_bound: post_dedup_judgements == mock distinct judgements; may be a safe superset of execute-needed judgements
subgraphs:
deterministic: jq outside semantic call sites
semantic: 1 planned call site(s)
semantic_plan:
- call_id: 1
op: "sem_match"
kind: "predicate"
value_expr: ".msg"
specs: ["urgent"]
source_range: 13:38
gated: n/a
execution: 3-phase
subgraph: semantic
| Field | Meaning |
|---|
execution | semantic split plan — the query has semantic call sites. |
deterministic | no. |
model_calls / backend_calls | input-dependent. |
byte_reproducible | cache-dependent — deterministic phases are byte-reproducible; semantic answers depend on backend/cache state. |
stdin | harvested for estimates when valid stdin is used for an estimate. With --stream on a supported plan, not harvested: inline execution has no cross-frame harvest estimate and explain leaves stdin untouched. |
planned_call_sites | Total semantic call sites found in the static plan. |
semantic_predicates / semantic_values | Counts of predicate-kind and value-kind ops. |
estimates block
Present when valid stdin was supplied and the mock harvest path can estimate the query.
| Field | Unit / meaning |
|---|
estimate_status | available, or unavailable with a stable reason. |
static_call_sites | Semantic call sites in the static plan; not input cardinality. |
input_frames | Number of input frames read for the estimate. |
harvested_judgements | Pre-dedup semantic judgements collected by the mock harvest pass. |
post_dedup_judgements | Distinct judgements after deduplication — the estimated backend-call count before considering persistent-cache hits in a later real run. |
mock_judge_batches | Backend.Judge batch invocations in the deterministic mock path. |
over_harvest_bound | Notes that post-dedup judgements may be a safe superset of execute-needed judgements; it never underestimates needed decisions. |
semantic_plan entries
| Field | Meaning |
|---|
call_id | Deterministic planner call-site ID. |
op | The sem_* operator. |
kind | predicate or value. |
value_expr | The jq expression producing the judged value. |
specs | Ordered literal spec arguments. |
source_range | Byte offsets into the desugared query for the call site, or unavailable. |
gated | For value ops: whether the result flows into a pruning gate; n/a for predicates. |
execution | Execution mode: 3-phase, planner-required interleaved, or user-stream-inline when --stream selects inline execution for a supported plan. |
subgraph | semantic. |
When estimates are unavailable
If stdin is empty or invalid, or the query uses a semantic execution shape the current
executor cannot safely estimate/execute, the static plan is still printed and
estimate_status is marked unavailable with a reason.
--explain --stream is intentionally unavailable without reading stdin. Its report names
semantic user-stream inline, says stdin: not harvested, and includes
execution_selection: user-selected --stream interleaving, inline per-uncached-judgement
batching, and disabled cross-frame pre-resolve deduplication. This is a mode choice, not a
change to backend/model/cache identity or the run-global call cap.
The important distinction is execution mode. sem_score in sort_by(...) and sem_norm
in group_by(...) can use the three-phase placeholder path. Gated unbounded value shapes,
such as select(sem_score(.review; "positivity") > 0.8), can use interleaved fallback;
their estimate status is unavailable because ajq resolves values as execution reaches
them rather than harvesting a complete up-front set. Standalone sem_extract and
sem_redact are registered but currently fail as unsupported in three-phase execution.
3.5 - Configuration
Config files, environment variables, precedence, and credential policy.
ajq resolves semantic-backend settings from flags, environment variables, a TOML config
file, and backend defaults.
Precedence
| Rank | Source | Notes |
|---|
| 1 | Command-line flags | --backend, --model, --base-url, --backend-concurrency, --max-calls, --window-bytes, and --no-cache. |
| 2 | Environment variables | AJQ_BACKEND, AJQ_MODEL, AJQ_BASE_URL, AJQ_BACKEND_CONCURRENCY, AJQ_MAX_CALLS, AJQ_WINDOW_BYTES. |
| 3 | TOML config | Default path or AJQ_CONFIG. |
| 4 | Backend defaults | For example, Anthropic’s default model and paid-backend call cap. |
Flags override environment variables; environment variables override config values; config
values override backend defaults.
Config file path
| Setting | Behavior |
|---|
AJQ_CONFIG=/path/to/config.toml | Read this exact file. If it is set and the file is missing or invalid, ajq errors. |
${XDG_CONFIG_HOME}/ajq/config.toml | Default path when XDG_CONFIG_HOME is set and AJQ_CONFIG is not. |
~/.config/ajq/config.toml | Default fallback path when neither AJQ_CONFIG nor XDG_CONFIG_HOME is set. |
A missing default config file is ignored. A missing explicit AJQ_CONFIG file is an error.
TOML keys
| Key | Type | Example | Meaning |
|---|
backend | string | "local" | Semantic backend: mock, local, ollama, openai, openrouter, or anthropic. |
model | string | "qwen2.5-3b" | Model id or shipped alias for the selected backend. |
base_url | string | "http://127.0.0.1:11434" | HTTP base URL for backends that accept one. |
backend_concurrency | positive integer | 1 | Maximum in-flight requests in one provider batch. The selected OpenAI-compatible or Anthropic backend permits at most 2; Ollama permits at most 4. |
max_calls | integer | 100 | Maximum post-dedup backend judgements; 0 means unlimited. Must be non-negative. |
window_bytes | positive integer | 262144 | Maximum source bytes in a supported three-phase semantic window. A larger value can deduplicate more nearby records but delays output until the window resolves. It has no execution effect for pure-jq or interleaved queries. |
no_cache | boolean | true | Disable persistent judgement cache reads/writes when true. |
Example:
backend = "local"
model = "qwen2.5-3b"
backend_concurrency = 1
max_calls = 50
window_bytes = 262144
no_cache = false
ajq models use <name> writes model = "<name>" after verifying the model is installed.
It preserves unrelated keys but not comments or original formatting.
Environment variables
| Variable | Type | Meaning |
|---|
AJQ_BACKEND | string | Same values as backend. |
AJQ_MODEL | string | Same meaning as model. |
AJQ_BASE_URL | string | Same meaning as base_url. |
AJQ_BACKEND_CONCURRENCY | positive integer | Same meaning as backend_concurrency. |
AJQ_MAX_CALLS | non-negative integer | Same meaning as max_calls. |
AJQ_WINDOW_BYTES | positive integer | Same meaning as window_bytes; defaults to 262144 when unset. |
AJQ_CONFIG | path | Explicit config file path. |
AJQ_CACHE_DIR | path | Cache root for provisioning assets, daemon state, local models, and judgement cache files. |
OLLAMA_HOST | URL or host[:port] | Used by the Ollama backend when no ajq base URL is set. |
ANTHROPIC_API_KEY | secret | Anthropic credential. |
OPENAI_API_KEY | secret | OpenAI credential. |
OPENROUTER_API_KEY | secret | OpenRouter credential. |
Provider API keys are not part of the generic AJQ_* config merge. Each provider backend
reads only its own credential environment variable. backend_concurrency and
window_bytes follow normal flag > environment > TOML > backend-default precedence.
An explicit concurrency value must be positive and within the selected provider’s maximum
(2 for OpenAI-compatible and Anthropic; 4 for Ollama); invalid flag, environment, or
TOML values are rejected when a semantic backend is resolved. Pure-jq execution does not
resolve a semantic backend, so its streaming behavior is unchanged by environment or TOML
backend settings.
API-key policy
API keys are environment-only. The TOML config rejects credential-looking keys including
api_key, apikey, and token:
config key "api_key" looks like a credential; API keys are env-only (use ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY)
Cache paths
| Data | Location under cache root |
|---|
| Persistent semantic judgements | <cache>/judgements/ |
| Local model files | <cache>/models/ |
| Engine bundles / legacy binary cache | <cache>/engine/ and legacy <cache>/bin/ |
| Daemon PID/activity files | Cache-root daemon state files. |
The cache root is AJQ_CACHE_DIR when set; otherwise it is the OS user cache directory
joined with ajq (for example ~/Library/Caches/ajq on macOS or ~/.cache/ajq on
Linux, with ~/.cache/ajq as a fallback).
3.6 - Backends
Semantic backend names, defaults, required settings, and paid-call caps.
Semantic operators require a backend. Pure jq queries do not construct a backend, spawn a
daemon, or make network calls unless a semantic operator is present and a backend is
selected by flags, environment, or config.
Backend summary
| Backend | Select with | Model default / requirement | Base URL | Credentials | Default max_calls |
|---|
mock | --backend mock | Built-in ajq-default-model identity | none | none | 0 (unlimited) |
local | --backend local | qwen2.5-1.5b unless overridden | Managed loopback http://127.0.0.1:8081; explicit --base-url for user-trusted servers | managed bearer key (internal) | 0 (unlimited) |
ollama | --backend ollama | Required --model / config / env | OLLAMA_HOST or http://127.0.0.1:11434 | none | 0 (unlimited) |
openai | --backend openai | Required --model / config / env | https://api.openai.com/v1 | OPENAI_API_KEY | 100 |
openrouter | --backend openrouter | Required --model / config / env | https://openrouter.ai/api/v1 | OPENROUTER_API_KEY | 100 |
anthropic | --backend anthropic or --cloud | claude-haiku-4-5; aliases haiku, sonnet, opus | provider default; no user base URL | ANTHROPIC_API_KEY | 100 |
max_calls counts post-dedup backend judgements. 0 means unlimited. For a
machine-readable static projection of these registered backends, use ajq capabilities --json; it does not resolve configured credentials or runtime
asset state. In that projection Ollama has no default_base_url, because its
runtime URL may come from OLLAMA_HOST before the documented loopback fallback.
Mock
The mock backend is deterministic and in-process. It is for examples, tests, fixtures, and
--explain estimates. It never contacts a model provider.
Local
The local backend uses a managed llama-server daemon and a GGUF model from the ajq model
catalog. ajq provision installs or locates the engine and default model. ajq models list|pull|use manages larger catalog models. Managed daemons are started with
--api-key; ajq keeps the generated bearer key beside the PID file in the cache directory
with 0600 permissions and sends it on /completion requests.
When ajq owns the managed daemon config, the base URL is an HTTP loopback URL with no path,
query, fragment, or userinfo; bracketed IPv6 loopback is accepted, for example
http://[::1]:8081. A health-only listener on the default managed address is a port
conflict, not silent adoption. An explicit --base-url, AJQ_BASE_URL, or config
base_url means you trust that external server; ajq bypasses managed provisioning/daemon
startup and does not send its managed daemon key to that server.
Model identity for cache keys is local/<catalog-name> for catalog models, such as
local/qwen2.5-3b. Path-like local model overrides use a stable hashed path identity.
Ollama
The Ollama backend uses Ollama’s native structured /api/chat endpoint. --model is
required unless supplied by AJQ_MODEL or config. If no ajq base URL is configured,
OLLAMA_HOST is honored before falling back to http://127.0.0.1:11434. Host-only forms
such as 127.0.0.1:11434 are accepted and normalized.
OpenAI and OpenRouter
OpenAI and OpenRouter use OpenAI-compatible /v1/chat/completions transport with
structured output. --model is required unless supplied by config or AJQ_MODEL.
| Backend | Default root | API-key env var |
|---|
openai | https://api.openai.com/v1 | OPENAI_API_KEY |
openrouter | https://openrouter.ai/api/v1 | OPENROUTER_API_KEY |
A custom --base-url is treated as an OpenAI-compatible API root.
Anthropic
--cloud selects the Anthropic backend and is equivalent to --backend anthropic.
The default model is claude-haiku-4-5. Model aliases resolve as follows:
| Alias | Model id |
|---|
haiku | claude-haiku-4-5 |
sonnet | claude-sonnet-5 |
opus | claude-opus-4-8 |
Anthropic credentials come only from ANTHROPIC_API_KEY.
Paid backend defaults
The paid/remote backends (anthropic, openai, and openrouter) default to
max_calls = 100 unless a flag, env var, or config value overrides it. Local, Ollama, and
mock default to unlimited.
Bounded batch concurrency
--backend-concurrency, AJQ_BACKEND_CONCURRENCY, and TOML
backend_concurrency set the maximum number of simultaneous requests while one provider
resolves a batch of distinct cache misses. The default is 1, which keeps the established
sequential path. Explicit values must be positive and are validated for the selected
provider before backend construction or a request:
| Provider | Default | Maximum | Why |
|---|
OpenAI-compatible (openai, openrouter) | 1 | 2 | A conservative opt-in ceiling for paid requests and their retries. |
| Anthropic | 1 | 2 | Bounds concurrent SDK-retried paid Messages API requests. |
| Ollama | 1 | 4 | A bounded local-service limit matching the managed local daemon’s default slot count. |
A higher value can reduce latency for a multi-judgement batch, but it does not increase a
batch’s judgement count, relax max_calls, change deduplication or cache keys, or alter
per-request retry policy. For paid providers, start at 1 and increase only after checking
the provider’s account rate limits; retries also consume request capacity. Managed-local
and mock backend scheduling are unchanged by this setting.
Results always retain their input-batch order even when requests complete out of order. A
per-item schema/coercion error stays attached to that item. In contrast, a transport or
system failure cancels queued and admitted sibling work, waits for cleanup, and returns no
partial batch results.
4 - Explanation
How ajq works and why it’s designed this way.
Design notes for ajq’s execution model, reproducibility guarantees, and architecture.
4.1 - Split execution
The core idea behind ajq — keep jq deterministic, call a model only for fuzzy operators.
The observation
A real-world “AI data” query is mostly not fuzzy. Consider triaging support tickets:
cat data.json | ajq --backend local '.users[] | select(.feedback =~ "angry/frustrated") | .id'
└────────┬───────┘ └────────────┬────────────┘ └┬┘
deterministic path semantic predicate proj
(pure gojq) (LLM, per value) (pure gojq)
Iterating the array, projecting .id, shaping the output — all of that is exact,
mechanical work that jq already does perfectly. Only one operator, =~, needs
judgement. The insight behind ajq is to treat that asymmetry as a first-class execution
strategy: run the deterministic majority through a real jq engine, and call a model only
for the fuzzy operators, on the smallest possible slices of data.
Why it matters
Splitting execution this way has three consequences that a “send each row to an LLM”
script cannot match:
Tiny context per decision. The model sees one field value at a time, not the whole
record and not your prompt scaffolding. A small (~1.5B-parameter) local model is enough
to decide “does this text read as angry?”. That’s what makes a local-by-default,
no-API-key tool viable.
Cost scales with fuzzy decisions, not input size. You pay for the number of
distinct judgements, not the number of bytes or rows. A 10,000-row file with lots of
repeated values costs far fewer than 10,000 decisions (see
The three-phase executor).
The deterministic majority is byte-reproducible. Because everything outside the
fuzzy operators runs through pure jq, most of your pipeline produces identical bytes on
every run. That reproducibility is the actual unique selling point — see
The determinism contract.
Keeping jq as the real parser
ajq does not fork the jq grammar. Semantic operators are registered as ordinary jq
functions through gojq’s WithFunction seam, so gojq parses and drives them natively:
gojq.WithFunction("sem_match", 2, 2, func(input any, args []any) any { … })
The only surface syntax ajq adds is a thin, jq-aware desugaring of the infix =~ / !~
operators into sem_match(...) calls. Everything else — composing sem_* inside
select, sort_by, group_by, |=, arithmetic, reduce — comes for free, because it’s
just jq.
This was a deliberate, empirically-tested decision. gojq’s existing grammar already
expresses every real scenario, so a custom grammar would add no expressiveness while
risking divergence from jq semantics. Richer surface forms like @classify(...) don’t
parse in gojq and are deferred rather than forced.
What the model is (and isn’t) asked to do
The model is never asked to produce your output structure. It answers a single, narrow,
typed question — a boolean, one of a fixed set of labels, a number, or a short string —
and that answer is fenced by a grammar so it cannot be malformed. jq does all the
structural work around it. This division is what lets ajq promise structural correctness
regardless of how small the model is.
4.2 - The determinism contract
What ajq’s byte-reproducibility guarantees, and what it deliberately doesn’t.
ajq separates deterministic structure from semantic judgement.
The contract
The deterministic pipeline is byte-reproducible. Semantic judgements are
structurally guaranteed, but their values are model- and cache-dependent.
Those are different promises.
Deterministic parts are byte-reproducible
Every part of a query that is not a semantic operator runs through pure gojq. Given the
same input, it produces the same output bytes. There is no model, no sampling, and no
network in that path.
This is why pure-jq queries and pure-jq --explain reports are reproducible, and why the
jq skeleton around a semantic query remains stable.
Semantic parts are structurally guaranteed
A model’s answer to “is this urgent?” can differ across model versions or providers. ajq
does not promise semantic value reproducibility across those changes. It promises the
shape of the answer: sem_match returns a boolean, and sem_classify returns one of the
labels declared at the call site.
That shape is enforced by GBNF grammar for the local llama-server backend and by
structured-output/schema mechanisms for Ollama, OpenAI-compatible, and Anthropic backends.
Downstream jq can rely on the type even when the judgement value changes.
Why caching does not change the contract
ajq caches successful semantic judgements by
(op, spec, model-id, canonical-value). The cache has an in-memory front for a run and a
persistent on-disk backing when caching is enabled. A repeated value under the same model
identity can therefore resolve to a cache hit instead of another backend call.
Caching improves cost and latency, but it does not turn semantic answers into a universal
truth. A different model identity is a different cache key, so model upgrades become clean
misses rather than silent reuse. --no-cache disables persistent judgement cache reads and
writes for a run.
The practical upshot
- Diff two runs of a pure jq pipeline: the bytes match.
- Repeated semantic values with the same op/spec/model identity can be served from cache.
- Change the model or provider: output structure remains valid, but judgement values may
change and cache keys miss.
- Clear or bypass the cache: ajq may ask the backend again, while preserving the same
structural contract.
4.3 - The three-phase executor
Harvest, resolve, execute — and why deduplication comes for free.
ajq runs supported semantic queries in three phases. For NDJSON and raw streams, the
unit of work is a bounded window of complete frames rather than the complete stream.
The three phases
Phase 1 HARVEST pure gojq run; semantic ops in COLLECT mode gather every
(op, spec, value) they observe. Permissive returns.
Phase 2 RESOLVE dedup collected values, consult/write the judgement cache,
and resolve misses through the selected backend.
Phase 3 EXECUTE pure gojq run; semantic ops in LOOKUP mode return resolved
values from cache. Deterministic given those cached values.
Phases 1 and 3 are pure gojq. Phase 2 is the isolated semantic boundary: it deduplicates,
checks cache identity, and calls the backend only for misses.
Complete-frame windows
Supported three-phase streams are grouped by source bytes into windows before phase 1.
The default is 262144 bytes (256 KiB), configurable by --window-bytes,
AJQ_WINDOW_BYTES, or TOML window_bytes with flag > environment > file > default
precedence. Each window harvests all of its frames, resolves its deduplicated cache misses
once, then executes and emits frames in source order. Persistent cache entries can be
reused by later windows.
The window boundary never splits a JSON value or raw line. The executor retains the
current window, one complete-frame lookahead, and bounded framing buffers, rather than the
whole input. A frame larger than the budget is processed as one valid oversized window;
the configured budget therefore does not cap the memory needed for an individual record.
A larger window can eliminate more nearby duplicate judgements and reduce backend batches,
but it waits to harvest and resolve all frames in that window before emitting its first
result. Choose a smaller positive budget when first-record latency is more important than
cross-frame deduplication.
User-selected inline execution
--stream gives supported semantic plans an explicit low-latency alternative to windows.
It resolves an uncached judgement inline as each frame reaches the semantic operation, so a
first result can emit before a later frame arrives. This preserves input order, the
backend/model/spec/value cache identity, persistent cache reads and writes, schema
validation, cancellation, output and exit behavior, and the run-global --max-calls cap.
The cap still aborts before backend call N+1; frames successfully completed before that
boundary may already have been written.
The price is no window-wide harvest, backend batch, or cross-frame pre-resolve
deduplication. Inline runs may require more backend round trips and, with caching disabled,
may resolve matching judgements in separate frames more than once. Cache hits still avoid
backend calls. Default windows remain the better choice for throughput and batching;
choose --stream when latency is more valuable. --stats distinguishes
three-phase-windowed from user-stream, and --explain --stream explicitly reports
that harvest/dedup estimates are unavailable without reading stdin.
Pure-jq ignores --stream and remains deterministic. Queries that already require
interleaving retain their planner-selected inline behavior, so the flag does not override
that selection. Neither inline path uses semantic windows.
Why two deterministic passes
Harvesting first, then executing, buys three things:
- Deduplication. All values a query will judge are known before backend calls, so
identical judgements collapse to one decision.
- Backend-level scheduling. Resolve hands the backend the distinct judgement set. The
local daemon and configured OpenAI-compatible, Anthropic, or Ollama backends can process
bounded-parallel requests while preserving result ordering. Provider concurrency defaults
to sequential (
1); it applies only inside this already-reserved batch. A whole-batch
transport failure cancels queued and in-flight siblings and yields no partial batch. - Reproducibility of structure. Execute is a pure function of input plus resolved
semantic judgements, so jq output shaping remains stable.
The cost is running gojq twice, which is negligible compared with model latency.
Cache identity
A semantic judgement is keyed by (op, spec, model-id, canonical-value). The canonical
value is a deterministic encoding that preserves scalar types, sorts object keys, keeps
array order, and normalizes JSON numbers. The model id is part of the key, so switching
models is a clean miss.
The cache has an in-memory front and, unless disabled, a persistent disk backing under
<cache>/judgements/. That persistent backing is why a second process can reuse a
successful judgement from an earlier run.
Over-harvest: correct, sometimes wasteful
In harvest, a predicate cannot yet know the model’s answer, so it returns permissively so
that downstream operators see a superset of possible inputs. This is correct because it
never misses a value that should be judged, but it may collect a value that execute later
prunes. Deduplication and the persistent cache reduce the cost of repeated over-harvested
values.
Gated value operators
A value-producing operator whose result feeds a pruning gate needs special care. Bounded
enums such as sem_classify can harvest all possible labels, so downstream survivors are
a safe superset. Unbounded values do not have a finite safe set, so ajq does not pretend
every shape can be harvested safely.
In 0.0.1, unbounded support is deliberately narrow:
sem_score is supported by the three-phase executor as a sort_by(...) key, where ajq
can harvest placeholder values for the sort key and then execute with resolved scores.sem_norm is supported by the three-phase executor as a group_by(...) key, using the
same placeholder pattern for grouping.- When an unbounded value result feeds a gate, such as
select(sem_score(.review; "positivity") > 0.8), ajq can choose an interleaved fallback. That mode resolves values
as the jq program reaches them instead of providing a harvest/dedup estimate up front. - Standalone
sem_extract and sem_redact currently fail as unsupported in three-phase
execution.
This means docs and scripts should distinguish “unsupported in three-phase harvest” from
“always unsupported.” Gated unbounded value operators are not all rejected; supported
fallback shapes still obey backend selection, cache identity, and --max-calls, but they
do not have the same up-front estimate as the three-phase path.
An execute-phase cache miss is a loud error. That backstop turns planner or cache gaps into
failures rather than silent data loss.
Guarding the planner
The executor relies on the static planner finding every semantic call site. ajq asserts
fired ⊆ planned at runtime: every semantic operator that fires must appear in the static
plan. A violation aborts before spending money and names the missed operation.
4.4 - Architecture
The components of ajq and how data flows between them.
ajq is a small pipeline of components.
Data flow
stdin → [Reader/Framer] → [Desugar] → [gojq.Parse] → [Planner] ──→ [Explain]
│
[3-phase Executor]
├─ gojq (deterministic)
└─ Semantic Executor → [Backend iface]
├─ mock
├─ local daemon
├─ ollama
├─ openai / openrouter
└─ anthropic cloud
[Grammar/Schema builder] → GBNF / json_schema ─────────────────────────────┘
stdout ← [Assembler / schema-invariance guard]
The components
| Component | Responsibility |
|---|
| Reader / Framer | Stream framing: single JSON, NDJSON, and raw lines (awk-mode). Supported three-phase semantic input is grouped into complete-frame byte-budgeted windows; pure-jq and interleaved paths remain streaming. |
| Desugar | Rewrites =~ / !~ into sem_match calls with a jq-aware lexer. |
| Planner | Exhaustive AST walk that discovers every semantic call site → a Plan used for --explain, validation, and cost estimation. |
| Executor | Runs the plan in three phases (harvest / resolve / execute), with interleaved fallback for gated unbounded value ops. |
| Semantic Executor | Deduplicates judgements, consults the persistent judgement cache when enabled, builds each op’s prompt and schema, and records run stats. |
| Backend | Pluggable inference. Shipped backends: mock, local (llama-server daemon), ollama, openai, openrouter, and anthropic/--cloud. |
| Grammar / schema builder | Turns an op’s return type / enum into a JSON Schema and, for the local engine, GBNF grammar constraints. |
| Assembler | Reassembles the output stream and enforces schema invariance. |
Windowed semantic execution
For supported three-phase semantic queries, the executor retains one complete-frame window
at a time, harvests its semantic judgements, resolves deduplicated cache misses in one
backend batch, then executes frames in source order. The default source-byte budget is
256 KiB and can be overridden with --window-bytes, AJQ_WINDOW_BYTES, or
window_bytes in TOML. A record that exceeds the budget is a valid one-frame oversized
window, so the budget is not a rejection limit. The executor does not retain the complete
stream: it keeps the current window, one lookahead frame, and bounded framing buffers.
The Backend seam
Inference sits behind a deliberately small interface, so backends are swappable without
touching the planner or executor:
type Backend interface {
Judge(ctx, batch []Judgement) ([]Result, error) // grammar/schema-constrained
Warm(ctx) error
}
A judgement carries the op, kind, return type, schema/enum constraints, specs, model ID,
and the canonical value. Results carry the value plus a per-item error. Resolve enforces
stable ordering, len(results) == len(batch), return/schema validation, and only caches
successful items. The deterministic mock backend powers tests and --explain estimates;
local, Ollama, OpenAI-compatible, and Anthropic backends preserve the same structural
contract through GBNF or provider JSON-schema/structured-output mechanisms.
Inference model
The local backend runs llama-server (llama.cpp) as a warm, lazily-spawned daemon —
chosen because ajq stays a pure-Go binary (no CGo, clean cross-compilation) and avoids
the 1.5–3 s cold model-load per invocation that would make a “filter” unusable. Outputs
are constrained by a JSON Schema / GBNF grammar derived from each op’s schema, so
structural correctness is a cross-backend invariant (see
the determinism contract).
Remote and alternate-local backends share that same seam: Ollama uses its native
/api/chat endpoint with format JSON schema, OpenAI/OpenRouter use OpenAI-compatible
/v1/chat/completions, and Anthropic uses Messages API structured outputs. Backend
selection, model identity, base URL, cost caps, and persistent-cache behavior are resolved
by the CLI/env/config layer before execution starts.
Where to go next
4.5 - Project status
The capabilities that ship today, and how they fit together.
ajq has shipped its deterministic jq spine, semantic planning and supported semantic
execution, local inference, bounded-parallel local daemon transport, Phase 3
backend/cloud controls, first-run local asset provisioning, local model management, the
persistent judgement cache, byte-budgeted semantic windows, an explicit low-latency
semantic stream mode, and checksummed release archives with a no-sudo install script.
Further streaming optimizations remain planned. The iterative-harvest experiment is an internal-only no-go prototype, not a shipped mode; default windowed execution is unchanged.
What ajq does today
- Deterministic stream framing — single JSON, NDJSON, and raw-line (
-R) input;
-n, -c, -r, and -e follow jq conventions. - Pure-jq execution through gojq — pure jq queries
are deterministic and byte-oriented.
--explain — the byte-stable ajq explain v1 report for pure-jq and semantic
queries, including harvest/dedup estimates when input is supplied.- Semantic query planning — an AST walk discovers
sem_* call sites and is guarded by
the runtime fired ⊆ planned invariant. =~ / !~ desugaring — a jq-aware lexer rewrites fuzzy match syntax into
sem_match(...) calls.- Supported semantic execution —
sem_match and bounded sem_classify run through the
split executor. sem_score is supported as a sort_by(...) key, sem_norm is
supported as a group_by(...) key, and gated unbounded value use can run through
interleaved fallback. Standalone sem_extract and sem_redact are registered but
currently fail as unsupported in three-phase execution. - Three-phase executor and stream selection — harvest / resolve / execute with
deduplication and cache identity based on op, spec, model, and canonical value. Supported
semantic NDJSON and raw streams default to complete-frame byte-budgeted windows (256 KiB
by default), preserving source order without retaining the complete stream.
--stream
selects low-latency inline execution instead when first-frame latency outweighs window
batching and cross-frame pre-resolve deduplication; identity and --max-calls semantics
remain unchanged. Pure-jq and planner-required inline paths stay streaming. - Local inference — a lazy
llama-server daemon with idle timeout and
ajq daemon status|stop; local requests use bounded parallelism while preserving result
ordering. - First-run provisioning and model management —
ajq provision installs or locates the
local engine and default model; ajq models list|pull|use manages larger pinned catalog
models. - Additional backends — Ollama, OpenAI, OpenRouter, and Anthropic (
--cloud) are
registered with structured-output constraints, provider-specific credential rules, and
bounded ordered batch concurrency. Provider requests remain sequential by default;
explicit OpenAI-compatible/Anthropic concurrency is capped at two and Ollama at four. - Config and cost controls — backend/model/base-url selection, TOML config, env vars,
--backend-concurrency, --max-calls, and --stats are shipped. Paid backends default
to a 100-call cap. - Persistent judgement cache — successful semantic judgements are stored under
<cache>/judgements/, can be bypassed with --no-cache, inspected with
ajq cache status, and removed with ajq cache clear. - Release packaging — GoReleaser builds checksummed archives and the install script
verifies
checksums.txt. The release workflow publishes the Homebrew cask to the
public ricardocabral/tap tap.
Windows MSI packaging is implemented and CI-validated, but the MSI is not yet
released. WinGet remains unavailable until a future MSI release completes Microsoft
validation and merge and has public clean-install smoke evidence.
Roadmap
Development proceeds by dependency. Phases 0–3 have shipped, along with selected Phase 4
and Phase 5 work pulled forward. Remaining roadmap items are scale and distribution
polish.
| Phase | Focus | Status |
|---|
| 0 — Deterministic spine | CLI, framing, pure-jq wrapper, --explain, golden harness. | ✅ Shipped |
| 1 — Split-execution core | Planner, desugar, semantic predicates, bounded classification, guarded executor. | ✅ Shipped with explicit unbounded value-op limits |
| 2 — Local inference | llama-server backend, daemon lifecycle, GBNF/schema constraints, provisioning. | ✅ Shipped |
| 3 — Backends & cloud | Ollama, OpenAI/OpenRouter, Anthropic, config/env selection, cost controls. | ✅ Shipped |
| 4 — Scale & chunking | Byte-budgeted complete-frame windows and explicit --stream low-latency inline execution for supported semantic streams, persistent cache, and bounded local parallelism are shipped. The iterative-harvest experiment is an internal-only no-go: it has no user flag and does not change the default executor. Further streaming optimizations remain planned. | 🟡 Partial |
| 5 — Polish & distribution | Models subcommand, release archives/install script, and the Homebrew cask published to the public ricardocabral/tap tap are shipped. Windows MSI packaging is implemented and CI-validated but remains unreleased; WinGet is unavailable until a future MSI release completes Microsoft validation and merge and has public clean-install smoke evidence. Standalone build, GPU auto-detect, richer vocabulary, and additional package managers remain planned. | 🟡 Partial |
Follow along
Development happens in the open at
github.com/ricardocabral/ajq. Issues and PRs are
welcome.
4.6 - Iterative-harvest prototype evaluation
Reproducible evidence and the no-go decision for the non-default iterative-harvest prototype.
Decision: no-go for productionization
The iterative-harvest implementation is an internal test and benchmark prototype. It is
not a CLI mode, is not selected by default, and must not be enabled for users.
The existing three-phase-windowed executor remains the default and unsupported query
shapes continue to use their existing windowed or planner-required interleaved fallback.
This is a no-go even though the pruning workload clears its judgement-saving target. A
reachable error case found during characterization has different behavior from the current
windowed executor: after an earlier gate rejects a value, a backend result/schema/transport
error for that pruned descendant is dispatched and returned by permissive windowed harvest,
but is intentionally not dispatched by iterative harvest or interleaved execution. That
changes the reachable error, cache, and exit contract. Reducing calls by dispatching the
unreachable descendant would defeat the prototype’s purpose. Until a separate design
resolves that semantic/error boundary, the required current-window parity gate is not met.
The deterministic no-prune control also exceeded both overhead limits on the recorded
machine, so performance alone would not support productionization.
Reproduce the fake evidence
Run the fixed, network-free paired protocol from the repository root:
make bench-iterative-evidence
# equivalent: go test -count=1 -run TestIterativeFakeEvidence -v ./internal/bench
The command uses 21 timed repetitions after one discarded warm-up per executor and rotates
the order of three-phase-windowed, iterative-harvest, and the user-stream
interleaved reference. Every sample checks selected executor mode, output parity, and the
controlled backend’s expected post-dedup call and batch oracle before accepting timing.
It emits a schema-versioned JSON record containing every sample and its min/median/max
latency summary.
The controlled fake corpus has fixed input, query, window, and answers:
| Workload | Purpose | Windowed → iterative post-dedup calls |
|---|
high-prune | one of eight values survives gate one | 16 → 9 |
low-prune | seven of eight values survive gate one | 16 → 15 |
no-prune | every value survives the same two-gate chain | 16 → 16 |
repeated-cache-hit | duplicate values with a deliberately pre-warmed shared store | 0 → 0 |
enum-gate | literal-label sem_classify gate followed by match | 6 → 4 |
multi-window | three NDJSON frames with a one-byte window budget | 6 → 5 |
The saving metric is always windowed PostDedupBackendCalls - iterative PostDedupBackendCalls; harvested counts are not compared because staged and permissive
harvest deliberately mean different things.
Protocol and acceptance thresholds
The recorded run used the repository at 0b79f89 on an Apple M3 Pro
(darwin/arm64, Go go1.26.5). It ran the network-free command above, then the
allocation benchmark below; both commands are included so the reported evidence can be
recreated without a model or credentials:
make bench-iterative-evidence
go test ./internal/engine -run '^$' -bench BenchmarkIterativeHarvestPaired -benchmem -benchtime=200ms
The paired runner performs one discarded warm-up and 21 alternating samples for each
of windowed, staged, and interleaved modes. It rejects a sample unless byte output,
result/error outcome, selected mode, and the declared post-dedup call/batch oracle all
match. Productionization required every parity/safety check plus all applicable limits:
| Gate | Limit | Recorded result | Outcome |
|---|
| high-prune judgement reduction | >=25% | 43.75% (7 of 16 calls avoided) | pass |
| no-prune median latency overhead | <=15% | 72.94% | fail |
| no-prune peak retained-memory overhead | <=25% | 76.60% | fail |
| strict current-window error/cache/exit parity | exact | pruned descendant error is dispatched only by windowed harvest | fail |
The final parity row is a semantic limitation, not a noisy benchmark measurement: with
a false earlier gate, permissive windowed harvest still resolves a later unreachable
judgement and can expose its backend error; staged and interleaved execution correctly
avoid that call. Therefore no performance result can make this prototype a go.
Memory and allocation method
For each timed fake sample the runner calls runtime.GC, records the baseline memory
statistics, runs the complete engine operation (compile, framing, cache, fake backend, and
discarded output), and samples HeapAlloc every 100 microseconds until completion. The
reported peak retained memory is the sampled HeapAlloc high-water above that post-GC
baseline, not final heap size. The report also records Mallocs and TotalAlloc deltas
per sample. This makes the memory unit and reset policy explicit; it is a process-local
comparison rather than a claim about a model server’s memory.
Recorded fake result
On 2026-07-13, the fixed protocol reported:
high-prune: 7 avoided judgements, 43.75% reduction, passing the required >=25%
judgement reduction threshold.no-prune: 72.94% median latency overhead (limit <=15%) and 76.60% median
peak-retained-memory overhead (limit <=25%), both failing. Exact timing is expected to
vary by machine; the command above is the authoritative repeatable evidence.- The allocation-aware engine benchmark independently showed the same fake-overhead shape:
at 200ms benchtime, no-prune windowed was about 146µs/op, 217KB/op, 2720 allocs/op;
iterative was about 260µs/op, 377KB/op, 4829 allocs/op.
These are fake executor costs, not model latency. Optional local-model evidence is
informational only and cannot replace the deterministic gate:
make bench-iterative-real
It requires AJQ_BENCH_REAL=1 plus the existing provisioned llama-server and model,
then runs the same chained workload through complete windowed and iterative engine paths
with alternating mode order and output-parity checks. It skips cleanly when the explicit
opt-in or assets are unavailable. On the recorded machine it was run with the provisioned
Qwen 2.5 1.5B Q5 model; model answers happened to pass all first gates, so both paths made
16 calls and its latency is deliberately not used for a threshold decision.
What a separate redesign would need
Do not turn this result into a production flag. A new issue/design would first need to
settle the pruned-descendant backend-error/cache/exit contract and restore the required
parity. Only after that should it propose a public mode selection policy. It would also
need a truthful --stats/--explain surface that names the selected staged mode, stage
batches, actual post-dedup calls, avoided judgements, cache hits, and why any query fell
back. The supported corpus would remain intentionally narrow: a linear stable-value chain
of sem_match gates and bounded literal enum sem_classify == label gates. Control flow,
value operators, generators, bindings, nested queries, non-literal inputs, and all other
ambiguous shapes must keep their existing executor fallback.