8000 feat(api): POST /_ask + xerj_plan MCP tool — prompt in, validated DSL out (#1056) by xerj-org · Pull Request #1076 · xerj-org/xerj · GitHub
[go: up one dir, main page]

Skip to content

feat(api): POST /_ask + xerj_plan MCP tool — prompt in, validated DSL out (#1056) - #1076

Merged
xerj-org merged 2 commits into
mainfrom
ask-endpoint
Sep 30, 2026
Merged

xerj-org merged 2 commits into
mainfrom
ask-endpoint

Conversation

@xerj-org
Copy link
Copy Markdown
Owner

What

Closes #1056 (engine side). POST /_ask (ES-compat, next to /_decide) and POST /v1/ask (native) take {index or pattern, prompt} and return {query, plan[], confidence, indices[]} where query is an ES query clause the caller can send straight to /{index}/_search as {"query": …}. Plus xerj_plan, the 12th MCP tool, a plain proxy for it with index defaulting to ax-* exactly like xerj_search (#962). The published schema landing/docs/agents/schemas/mcp-tools.json is regenerated (12 tools).

The four hard lines, and where each is enforced

  • Zero invalid DSL out. Every response is assembled into a full search body and passed through xerj_query::parse_request before it is returned (ask_api.rs, step 5 of the handler). Pinned by the route test's adversarial battery: 18 prompts, every 200's query re-parsed through the same parser; injection prose, 1e999, NaN, 9999-99-99, CJK/needle runs all yield 4xx or valid DSL.
  • Unresolved phrases → 422 naming them. Unknown value ("quarry blast"), unknown field ("literacy rate"), pattern matching nothing, multi-index pattern with no catalog to route by — each is a 422 whose error.phrases carries the phrase. No silent guess anywhere; prefix value completion is restricted to digit-free names precisely so v9999 can never "resolve" to v999.
  • Deterministic. Same request three times is byte-identical (route test). Ordered collections only (Vec, BTreeMap), ties reduced under explicit total orders, and no clock/timings/ids in the response body (which is why there is no took_ms).
  • Values from a terms agg while cardinality allows it; BM25 over the distinct-values approach otherwise. ONE batched terms agg fetches distinct values of up to 24 candidate keyword fields at once (≤1,000 buckets each). A field whose agg reports sum_other_doc_count > 0, or whose catalog cardinality_est exceeds the cap, is verified with a BM25 match probe instead (≤4 per request) and the clause honestly comes back as match. The BM25 arm is route-tested with a real 1,006-distinct-value field whose agg provably overflows.

Index routing over ax-* is by autoindex-catalog dataset description (slug, field names, examples, notes), read through the engine's own index APIs — one search of the catalog index; xerj-api gains no xerj-autoindex dependency (the constant mirrors xerj_autoindex::catalog::CATALOG_INDEX). Field gating is one batched typed call (Index::schema()), merged with catalog FieldSpecs. Numeric/date thresholds are only ever prompt literals — nothing generated; a threshold that would have to be invented is a 422.

What the planner is (honesty)

A deterministic rule-based parser — not an LLM, not fuzzy retrieval. Date literals and year phrases, comparator vocabulary (or greater, deeper than, between … and …, exactly), units (km, K, days, Earth radii), scale words (100 million → 10⁸) and a concept lexicon (magnitude→mag, life expectancy→lifeExp, GDP per capita→gdpPercap) resolve field phrases by scored matching against the index's real fields; equality values resolve against the live value map. Anything it cannot ground in the index is refused by name. confidence reports how much soft matching the plan needed (0.9 fully exact, lower per fallback, 0.5 bare match_all).

Measured (all numbers from this run, nothing asserted in code)

benchmarks/ask-plan harness (PR #1071, unmodified — extracted to a scratch dir, nothing in this PR touches it), private port + throwaway data dir, 230 pairs × 3 runs:

round 1 final (this PR)
macro result-set F1 0.9309 0.9975
invalid_dsl_out 0 0
non_deterministic 0 0

Gate (macro F1 ≥ 0.9 over ≥ 200 pairs) passes. Per dataset (final): gapminder 80/80, nasa 78/78, usgs 69/72. The only three sub-1.0 pairs (usgs-earthquakes-057/058/059) are the from A through B date pairs: English "through" is inclusive, so this PR emits an inclusive next-day bound, while the fixture's gold treats the end day as exclusive — a documented semantics divergence, not a planner miss (~0.003 macro; the gate passes either way).

Round-2 root cause, worth the history: an usize underflow in the separated-op scan (stronger than MAGNITUDE 3.5 with the number early in the prompt, gapminder-052 shape) indexed out of bounds and — under panic = "abort" — took the whole node down mid-harness. Fixed with a bound guard and pinned by two route tests. This is also why the adversarial battery exists.

ES-YAML conformance on this exact tree: 1380 passed / 0 failed / 3 skipped (private port, throwaway dir). Scoped tests: xerj-api 712 passed / 0 failed (16 of them the new route tests), xerj-mcp 57 lib + 4 schema-drift passed, clippy and cargo fmt --check clean.

Latency (p50 ≤ 300 ms CPU) is NOT claimed here — that gate is a release-time measurement on the harness; this PR's unit is correctness.

Reference-code note

The peer reference corpora (xerj code) are not available in this sandbox, so retrieval-before-writing could not be run against them. In-repo precedent followed instead, all read before writing: systemone_api.rs (/_decide — the body-carried-index + fail-closed authz posture this endpoint copies), es_compat.rs (search/agg execution through xerj_query::parse_request), the ES-compat router's _-prefixed cluster verbs, and xerj-mcp's tool_specs()/#962 ax-* defaulting. The catalog wire shape (doc_kind/slug/fields_json/notes/time_field) is read as untyped JSON so the no-dependency rule holds.

Deliberately out of scope (follow-ups)

… out (#1056)

An agent filtering structured data hand-writes ES DSL today, and a wrong
guess in a hand-written clause fails silently. POST /_ask (ES-compat) and
POST /v1/ask (native) take {index, prompt} and return
{query, plan[], confidence, indices[]} where query is an ES query clause
that has ALREADY passed xerj_query::parse_request on this node — zero
invalid DSL can leave the node, and a phrase the index cannot ground is
a 422 that names it, never a silent guess.

The planner is deterministic and rule-based (NOT an LLM): date literals
and year phrases, comparator vocabulary ("or greater", "deeper than",
"between ... and ..."), units (km, K, days, Earth radii), scale words
("100 million" -> 10^8) and a concept lexicon (magnitude->mag, "life
expectancy"->lifeExp, "GDP per capita"->gdpPercap) resolve field phrases
by scored matching against the index's real schema; equality values
resolve against ONE batched terms agg (<=1,000 buckets per field,
<=24 fields; fields whose agg reports sum_other_doc_count>0 or whose
catalog cardinality_est exceeds the cap verify via <=4 BM25 match probes
instead — the match arm). Numeric and date bounds are ONLY prompt
literals; nothing is generated. Same request three times is
byte-identical (ordered collections only, no clock in the body). Index
patterns (ax-*) route by the autoindex-catalog dataset description read
through the engine's own search API — xerj-api gains no
xerj-autoindex dependency.

Measured (benchmarks/ask-plan harness, PR #1071, unmodified; private
port + throwaway data dir, 230 pairs x 3 runs):
- round 1 (pre-fixes): macro result-set F1 0.9309, invalid_dsl_out 0,
  non_deterministic 0
- final: macro result-set F1 0.9975, invalid_dsl_out 0,
  non_deterministic 0 (gate >= 0.9 over >= 200 pairs)
- the only 3 sub-1.0 pairs (usgs 057/058/059) are the "from A through
  B" date pairs, where English "through" is inclusive by design and the
  fixture gold treats the end day as exclusive — documented divergence
- ES-YAML conformance on this tree: 1380 passed / 0 failed / 3 skipped
Round-2 root cause worth remembering: an usize underflow in the
separated-op scan ("stronger than MAGNITUDE 3.5") indexed out of bounds
and, under panic=abort, took the whole node down mid-harness; fixed with
a bound guard and pinned by two route tests (gapminder-052 shape).

Also: xerj_plan, the 12th MCP tool — a plain engine proxy with index
defaulting to ax-* exactly like xerj_search (#962). The published
schema landing/docs/agents/schemas/mcp-tools.json regenerated via
scripts/mcp-schema-check.sh --write (12 tools). Known follow-ups,
deliberately NOT touched here: landing/llms.txt still describes the MCP
tool set as "ten tools" (one llms.txt-editing PR at a time; fix queued
post-release), landing/answers/* embed the same stale count, and
anthropic/openai tool JSONs intentionally stay the six HTTP-core ops.

Files:
- engine/crates/xerj-api/src/ask_api.rs (new; planner + contract)
- engine/crates/xerj-api/tests/ask_endpoint.rs (new; 16 route tests:
  canonical casing, comparators/units, gold ordering, date bounds,
  determinism 3x, adversarial battery re-parsed, catalog routing,
  BM25 arm with a real overflowing agg, native /v1/ask)
- engine/crates/xerj-api/src/router.rs (/v1/ask + /_ask routes; same
  authz posture as /_decide — cluster verb, POST, fail-closed)
- engine/crates/xerj-mcp/src/lib.rs (xerj_plan spec + proxy + tests)
- landing/docs/agents/schemas/mcp-tools.json (regenerated)
CHANGELOG-only change: the Added entry for issue #1056, citing the
measured harness numbers (macro F1 0.9975 final vs 0.9309 round 1,
invalid_dsl_out 0, non_deterministic 0 over 230 pairs x 3 runs) and
stating plainly that the p50 latency gate is a release-time measurement,
not claimed here.
@cla-bot cla-bot Bot added the cla-signed label Sep 30, 2026
@xerj-org
xerj-org merged commit 62740ea into main Sep 30, 2026
23 checks passed
neoneye pushed a commit to agent-memory-atlas-archive/xerj-org--xerj that referenced this pull request Oct 1, 2026
The MCP registry grew twice without llms.txt or llms-full.txt noticing:
xerj_code_search + xerj_plan landed via xerj-org#1005/xerj-org#1076 and xerj_map via
xerj-org#1074 (merge f0b96d6). Both files still said the mcp-tools.json
registry holds 'ten tools (the six HTTP operations plus the four brain
tools)' and llms-full's own verify example printed the wrong count
(jq '.tools | length' -> 10). The 'seventh canonical operation'
arithmetic in the anthropic-tools.json line leaned on a seven-op
numbering that no longer describes the surface (/_ask is HTTP since
xerj-org#1076; the catalog read always was).

What changed, all outside the generated article tree (hand-written
regions per scripts/seo/README.md):

- llms.txt: mcp-tools.json line says thirteen and names all seven
  additions; anthropic line states the six-op files are the HTTP-core
  surface BY DESIGN (not 'everything else is CLI-only') and says
  'xerj mcp' serves all thirteen
- llms.txt: endpoint map gains POST /_ask (validated DSL out, 422
  naming ungrounded phrases, deterministic not-LLM) — it was entirely
  undocumented
- llms.txt: llms-full section pointer no longer claims 'all seven
  operations'
- llms-full.txt: section 3 retitled 'THE CORE AGENT OPERATIONS' (TOC
  + header; 'NOT an eighth operation' ordinals de-numbered), the
  schema paragraph carries thirteen + the full composition, and the
  jq verify example now reads -> 13

Checked against what is released, not asserted: the 13 names in
landing/docs/agents/schemas/mcp-tools.json (pinned to tool_specs() by
tests/published_schema_drift.rs) are exactly the 13 both files now
name; all 13 appear by name across the two files; seo_lint 0 gate
violations (30 pre-existing warnings, same as main).

Scope deliberately excluded (the queued llms rewrite, one job per PR):
the obligation-language block in llms-full section 9, capability
branching, last-updated stamp. The first-time-agent harness rerun
binds that rewrite, not an inventory correction.

Follow-up from plan xerj-org#1054 items xerj-org#1055/xerj-org#1056.
neoneye pushed a commit to agent-memory-atlas-archive/xerj-org--xerj that referenced this pull request Oct 1, 2026
….129 ms

The one xerj-org#1056 gate line still unmeasured after xerj-org#1076 merged: 'p50 <= 300 ms
CPU budget' per POST /_ask. The ask arm times every call but scores F1
only — the milliseconds were never recorded.

New scripts/latency_arm.py (python3 stdlib, same conventions as ask_arm):
one warmup pass + N measured passes over the same 230 pairs.jsonl prompts
on the same booted node, per-request wall-clock ms to latency-raw.jsonl,
nearest-rank p50/p90/p95/p99/max + a gate verdict to
latency-summary.json. Honest scope stated in the file and the summary:
wall-clock over loopback on an idle node is an UPPER BOUND on CPU time,
not CPU time; prompts carry their gold index so catalog routing is out
of the path (it has its own acceptance line); warmup reported separately
so the steady-state choice is visible, not flattering.

Measured run (release build of main f0b96d6, private :9681, throwaway
data dir, fixtures loaded by the unmodified ask_arm.py loader, selfcheck
green, run committed verbatim in results/2026-09-30-latency-gate/):

  measured  n=690 (230 x 3 passes, all HTTP 200)
  p50 1.129 ms · p90 1.883 · p95 2.033 · p99 2.852 · max 155.719
  warmup    p50 1.102 ms · max 468.927 (first-touch)
  gate p50 <= 300 ms: PASS — 265x under the line

Caveats kept in the README, not buried: the fixture indices are small
(655 / 3,000 / 1,704 docs) — the p50 is a fact about THIS fixture, not a
promise about larger corpora; the gate row claims wall-clock, nothing
about CPU.

README: the DESIGN banner is two runs stale — gate 2 (F1 0.9975, 0
invalid DSL, 0 nondeterminism, recorded in PR xerj-org#1076's body from an
unmodified scratch copy whose raw files were never committed — said
plainly in the row) and now gate 4 each carry MEASURED rows; the
NOT-measured list shrinks to what is actually unmeasured (token arms,
catalog routing). run.sh gains step 5 so a full run captures latency
alongside F1 from here on.
neoneye pushed a commit to agent-memory-atlas-archive/xerj-org--xerj that referenced this pull request Oct 1, 2026
The packaging half of the ten-item program's rule (discussion xerj-org#1054): a
benchmarks/<name> directory with raw results plus a blog post with the
losses left in. The gate directories landed through xerj-org#1079, xerj-org#1085, xerj-org#1086,
xerj-org#1087, xerj-org#1088 and xerj-org#1090; this is the post.

Structure follows the Jev posts (verdict first, wins then losses with
readings, then what the next window does about each loss). Every number is
verbatim from the committed gate artifacts:

- PASS: /_ask macro-F1 0.9975 over 230 pairs, 0 invalid DSL, 3-run
  determinism, wall-clock p50 1.129 ms vs the 300 ms bar (xerj-org#1076, xerj-org#1079);
  xerj_map unknown-field 400s 20/30 guessed -> 0/30 map-informed, 100%
  vs the >=80% bar (xerj-org#1074, xerj-org#1086); calibration FiQA pair-level ECE
  0.3109 raw -> 0.0088 held-out vs the <=0.10 bar, temperature 0.3163
  published as the failing comparison arm, $0.2125 of provider calls
  over 648 queries (xerj-org#1080, xerj-org#1087).
- FAIL, published as such: the lexical judge loses to its own first
  stage everywhere measured (0.7045->0.5967, 0.3419->0.2924,
  0.2382->0.1650 vs the 0.30 FiQA bar) and stays opt-in with no quality
  claim per its own rule (xerj-org#1077, xerj-org#1090); the flywheel freezes as
  shipped (12 of 2,000 cached, xerj-org#1094) and forces 19.72% coverage at
  0.9953 accuracy vs the 80%/0.97 bars (xerj-org#1075, xerj-org#1088); the tier-2
  decide head misses SMS 0.9193/0.287 vs 0.95/0.05 and AG News 0.2599
  vs 0.85, latency 8.4 ms PASS, ships behind the flag with the numbers
  published (xerj-org#1072/xerj-org#1073, xerj-org#1088).
- SPLIT: stemming +0.0160/+0.0179 BM25 nDCG@10 PASS, zero-hit 25->15
  vs <=10 FAIL — the 15 have no stem anywhere in the corpus (xerj-org#1070,
  xerj-org#1085).
- The gate that caught the stock release binary built without the
  decide-local cargo feature (flag a no-op, stock-binary-noop.txt) is
  told with the fix queued ahead of the cut, not claimed as done.
- Honest gaps stated, not smoothed: the /_ask token arm designed not
  run, the _watcher detection gate not yet measured, no HF URL for
  xerj-decide-v1. Four issues filed from the measurements: xerj-org#1091-xerj-org#1094.

Mechanics: hand-written page at landing/blog/the-rc80-gates.html,
registered in scripts/seo/pagedata.py (published/updated 2026-09-30),
card prepended to the blog hub (hub dateModified bumped), head and
footer owned by fix_heads/fix_links, og falls back to the site card.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

agents: POST /_ask + xerj_plan MCP tool — prompt in, validated DSL out

1 participant

0