feat(autoindex): estimate on the user's machine, ask before a long run, index what matters first - #281
Merged
Merged
Conversation
xerj-org
force-pushed
the
feat/ux-estimate-gate
branch
from
August 10, 2026 15:34
3955ccd to
99e8b3e
Compare
…n, index what matters first
`landing/llms.txt` already told an agent driving XERJ on someone's laptop to
estimate the job, tell the person, ask if it is big, and report progress. That
was advice, and advice is ignorable. This makes the binary do it.
MOTIVATION (owner, verbatim): "search should be smart with ai agent harness
getting real important things to index first and make it clear for user that
indexing will take time if many files, so xerj autoindex if estimated more than
10min work needs to ask AI back what to do".
--- (a) estimate, measured here, never on our box -------------------------
Phase A already reads and parses every file to sniff and sample it, so it now
TIMES that work per format family and prints a range with its basis:
estimate: at least 64.2 s–64.2 s — a MEASURED FLOOR for client-side
extraction, not a prediction of the whole run …
estimate: code 500 files 749.3 MB at 11.7 MB/s measured over 500 file(s) → 64.2 s
estimate: yaml 12 files 23.8 KB at 13.1 MB/s measured over 12 file(s) → 0.0 s
New `estimate` module. A family is priced ONLY from files phase A provably read
end to end — `exact_scan_bytes` decides that from the extractor's own contract
(whole-file parsers always; streaming parsers only under the sampling byte cap
AND short of the record cap; never sqlite, never gzip, since neither pair of
(size, elapsed) describes real throughput). Everything else is listed under
`unmeasured_families` with its bytes rather than priced at some other family's
rate, and `coverage` states the share of planned bytes the number covers. No
measurement → no number, no gate, and `basis` says why. The two ends are the
classical list-scheduling bounds (Graham 1969) over the phase-B worker count
It is a FLOOR and is labelled as one on every surface (`kind`,
`Estimate::headline`, `excludes`, the help, llms.txt). It covers client-side
extraction only: measuring the server, the network or embedding would mean
writing to the index the estimate exists to ask permission for. Measured:
68 MB source tree → floor 0.1 s vs real 8.9 s; 793 MB → 64.2 s vs ~350 s. So
the gate under-asks and never over-asks, and the "not triggered" line says in
words that silence is not a promise the run is short. A multiplier would have
closed the gap with a number nobody measured.
--- (b) the 10-minute gate ------------------------------------------------
New `gate` module. Past `--max-minutes` (default 10) with no answer, autoindex
indexes nothing, writes a JSON decision request to stdout and exits 4 — its own
code, because 1 is already the catch-all for every real failure and an agent
must be able to tell "choose something" from "your endpoint is down".
The payload carries the estimate and its basis, file/byte counts, the per-band
work order, the heaviest directories with real byte counts (flagged when they
match the vendored/generated rule), and four options. `narrower` is re-costed
with the same measured rates. `fast` deliberately states NO speed-up factor: it
reports which datasets and how many files it changes and says the factor was
not measured by this run — inventing one would break the honest-claims rule.
Answer with `--approve proceed|fast|cancel` (`--yes` = proceed);
`--max-minutes 0` disables it. `--approve fast` really applies
`--no-semantic --no-graph`; `--approve narrower` is REFUSED with instructions
rather than accepted and silently ignored (#204). A person at a terminal gets
the same facts as prose plus a prompt; a piped or agent-driven run never
touches stdin, and an unanswerable prompt is a cancel, never consent.
The gate sits before the first remote mutation — no index created, no mapping
upgraded, no plan persisted, no document written.
--- (c) index what matters first ------------------------------------------
New `order` module. The queue was sorted by size alone: right about scheduling,
silent about value, so a user who stopped early got whatever was largest —
on a source tree, `node_modules`. Work now drains by value band (source and
documents → configuration → structured data → logs and line files → vendored,
generated and minified), with the old biggest-first rule kept INSIDE each band
so a large file runs alongside its band instead of becoming the tail; a
single-worker run goes smallest-first, where there is no tail to hide in. One
exception: a file whose extraction outlasts everything ranked above it
(size × workers > the rest) starts first regardless of band. Bands, counts and
the reason each sits where it does are printed with the plan and included as
`priority_order` — an unexplained order is indistinguishable from an arbitrary
one. Bytes-based progress is unaffected; reordering does not move the
denominator.
Prior art (retrieval server on :9200 was down; corpus clones read directly):
tantivy log_merge_policy.rs:94-99 (MIT) sorts Reverse(size) then buckets into
levels — adapted, our levels are value bands; quickwit tool.rs:977-1012 and
ClickHouse ProgressIndication.cpp:118-119,223-227 both report measured bytes/s
and stop short of an ETA; meilisearch milli::progress reports steps and no ETA
at all. None estimates a job before running it.
--- verified live (branch binary, real node) ------------------------------
* 1.1 GB / 747 files, --workers 1 --max-minutes 1 → exit 4, JSON payload on
stdout, and 0 `gt-*` indices and 0 brain-edge indices created.
* same run with --approve fast → gate bypassed, completes exit 0, `body`
mapped `text` not `semantic_text`, no edges index written.
* --approve cancel → exit 0, reason=cancelled, nothing indexed.
* --dry-run --max-minutes 1 → reports "a real run would STOP here and exit 4"
and does not stop; stdout is still exactly the plan JSON.
* ordering, 28 MB mixed corpus at 2 workers: first second of phase B is on
src/mod_*.rs; the 1.7 MB node_modules/** files — the largest in the corpus,
and therefore FIRST under the old rule — drain last.
* usage: --approve narrower/later, --max-minutes 10081/soon, and the flags on
`map`/`status` all exit 2 with an explanation.
Gates: cargo fmt --all --check clean; cargo clippy -p xerj-autoindex
-p xerj-server --all-targets -D warnings clean; cargo test -p xerj-autoindex
390 passed / 0 failed; cargo test -p xerj-server --bins 30 passed / 0 failed;
ES-YAML 1366 passed / 0 failed / 3 skipped.
`xerj brain` composes autoindex and has no --approve of its own, so it sets
max_minutes: 0 explicitly — arming a gate there would stop the run with
instructions the caller cannot follow.
xerj-org
force-pushed
the
feat/ux-estimate-gate
branch
2 times, most recently
from
August 10, 2026 17:25
95408c6 to
91c1f9c
Compare
The gate's design is "never block on stdin", and it held everywhere except one hole, found by measurement during PR #281's rebase verification: `--quiet` / `--progress none` made the question INVISIBLE but still BLOCKING. Root cause: the gate's prose and its prompt echo both go through the progress surface (`Progress::note`), which `Surface::Silent` drops on the floor. But `can_prompt()` tested only that stdin and stderr were TTYs — never that the question had actually been printed. So a person at a terminal who passed `--quiet` was asked nothing and then waited on `read_line` forever. Measured on the pre-fix binary (370 MB / 540 files, `--workers 1 --max-minutes 1`, real pty, real node): --progress none STILL RUNNING at 220 s, killed, **0 bytes ever printed** --quiet STILL RUNNING at 220 s, killed, **0 bytes ever printed** normal TTY prompt shown, answered 'c' → exit 0 "cancelled", 113.9 s stdin closed exit 4 with the payload on stdout, 113.9 s Same four after the fix: --progress none exit 4 at 93.6 s, 4022 B of decision-request JSON on stdout --quiet exit 4 at 93.9 s, same payload normal TTY prompt shown, answered 'c' → exit 0 "cancelled", 93.7 s stdin closed exit 4, 94.0 s --quiet --approve cancel exit 0, 0 bytes (the documented way out, silence kept) --progress json TTY, 'f' prompt still offered, answer honoured, exit 4 No `ax-*` index was created by any run: the gate still sits before the first remote mutation. Route (a), not (b) — the question is skipped rather than re-routed to stderr: * `--quiet` has a documented contract in --help ("no progress and NO terminal line, only a fatal error: line"). Route (b) would have written ~30 lines of prose plus an interactive prompt to a stream the user explicitly silenced. Accepting a flag and then not honouring it is the accepted-and-ignored class tracked in #204 — the same defect this PR refuses elsewhere with `--approve narrower`. * Route (a) is not a silent early exit. The decision request goes to **stdout**, which `--quiet` never claimed to silence ("stdout is the RESULT, stderr is PROGRESS"). The user still gets every fact, in machine-readable form, plus exit 4. Only the prose rendering is lost — and it was already opted out of. * It makes quiet runs *uniform* with agent-driven ones: one rule to document, one rule to reason about, instead of a fourth distinct behaviour. * Route (b) would also newly let a `--quiet` run block on stdin, enlarging the set of configurations that can wait for a human. That is the hazard itself. Because a silent skip still owes the user an explanation, the payload now carries `prompt_offered` and `prompt_not_offered_because`, and the prose prints the same sentence wherever a surface exists to print it. Every reason names the way out (`--approve`), so no path ends in "wait". Implementation: * `gate::NoPrompt` + `gate::prompt_blocked_by(stdin_tty, stderr_tty, question_visible)` — pure, so the whole rule is testable without a terminal, the same shape as `progress::resolve`. `question_visible` is the third condition that was missing. * `gate::answer_from_terminal(blocked, open_stdin, echo)` takes stdin as a **closure**, so on a blocked path the process's stdin is never even opened. That is the invariant, not merely "no answer was returned". * `can_prompt()` is removed rather than kept: a function with that name that ignores the surface is exactly the footgun that caused this. * Call site in `lib.rs` decides once and uses it for both halves. Regression test `gate::tests::a_question_that_cannot_be_seen_is_never_asked` hands in a reader that panics if anyone touches it. Verified to fail without the fix, on both halves of it: - classifier reverted → fails on the `QuestionSilenced` assertion - guard reverted → panics "stdin was read for a question the user was never shown" (gate.rs:800) Docs: `--help` (`--quiet` and the ESTIMATE + DECISION GATE section) and landing/llms.txt now state the three conditions, that `--quiet` is never prompted, and that autoindex never waits on stdin for a question it did not print. Gates: cargo fmt --all -- --check clean; cargo clippy -p xerj-autoindex --all-targets -- -D warnings clean; cargo test -p xerj-autoindex 475 passed / 0 failed (473 before; +2 new).
Conflicts: CHANGELOG.md (kept both release-notes sections) and engine/crates/xerj-autoindex/src/cli.rs (main's #276 ignore-rules flags + help section merged alongside this branch's estimate-gate help). The rerere replay of the cli.rs resolution left a stray semicolon that turned help_text() -> String into a unit return; removed it. Verified with scoped cargo check -p xerj-autoindex (lib + tests) and cargo fmt --check.
xerj-org
added a commit
that referenced
this pull request
Aug 12, 2026
…build on release staleness (#298) Issue #298 pins ROADMAP.md as the authoritative "where the project is going" document and advertises that parts of it are machine-checked. At HEAD (31d1fd5, the `release: cut 1.0.0-rc.16` commit) the file had drifted from every promise the issue makes: - "Last reviewed: … against v1.0.0-rc.15" while CHANGELOG.md already records 1.0.0-rc.16 as cut. - "## Next release — v1.0.0-rc.16" described the release that had just shipped: five PRs listed "in flight" (#290 #281 #261 #296 #297) had merged; #258 alone remains open. - Closed issues listed as open roadmap items: #293 (fixed by #304), and GA gate entries #270 (PR #300), #269 (PR #308), #207 (PR #261), plus the closed #204 umbrella. - The ES-YAML count read 1,365/1,368 against a measured 1,366/1,369. All 13 existing docs_capability_lists tests were green the whole time: they pin capability counts, but nothing tied the roadmap to the release history, so a cut that forgets to roll the roadmap forward passed CI. Fix, in the same shape as the #211 capability-list guard: - Two new tests in docs_capability_lists.rs pin ROADMAP.md to CHANGELOG.md's latest `## [<version>]` heading: `the_roadmap_next_release_has_not_already_been_cut` (the next-release heading must name a version strictly newer than the latest cut) and `the_roadmap_review_line_names_the_latest_cut_release` (the review line must name the latest cut release). Both FAIL before this ROADMAP.md refresh (verified: 13 passed / 2 failed at HEAD) and pass after it. Cutting rc.18 without re-reviewing ROADMAP.md now fails the build. - ROADMAP.md re-reviewed against v1.0.0-rc.16 and main: rc.16 recorded as cut with the roadmap items it retired; next-release section now points at the v1.0.0-rc.17 milestone with the real open set (#294 carried over; residuals #310 #311 #312; PRs #258 #313 #274 in flight); GA gate list drops the shipped items (#270 #269 #207) and reflects the closed #204 umbrella; conformance count updated. - The same stale 1,365/1,368 count fixed in README.md (badge + prose), landing/llms.txt and landing/llms-full.txt. The fresh number traces to a verified run: CI job 93861837729 on 31d1fd5 — "1366 passed · 0 failed · 3 skipped · 1369 total". GitHub-side, per the issue's "every open issue is triaged onto a milestone": milestone v1.0.0-rc.17 created, #294/#310/#311/#312 moved onto it, milestone v1.0.0-rc.16 closed. Gate: cargo fmt --check clean, clippy clean, docs_capability_lists 15 passed / 0 failed. No engine runtime code is touched (the new file is test-only), so the ES-YAML suite is unchanged from the green run above; PR CI re-runs it. Files: - engine/crates/xerj-engine/tests/docs_capability_lists.rs (release freshness section, end of file) - ROADMAP.md, README.md, landing/llms.txt, landing/llms-full.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
landing/llms.txtalready tells an agent driving XERJ on someone's laptop to estimate, tell them, ask if it is big, report progress. That was advice, and advice is ignorable. This makes the binary do it.Built on #240 (resource policy) and #241 (progress surface); neither is re-derived here.
(a) Estimate, measured on the user's machine
Phase A already reads and parses every file to sniff and sample it, so it now times that work per format family:
A family is priced only from files phase A provably read end to end — decided from each extractor's own contract, not assumed (whole-file parsers always; streaming parsers only under the sampling byte cap and short of the record cap; never
sqlite, never gzip). Everything else is named underunmeasured_familieswith its bytes rather than priced at another family's rate, andcoveragereports the share of planned bytes covered. No measurement → no number and no gate;basissays why. The two ends are the list-scheduling bounds (Graham 1969) over the phase-B worker count #240 chose.It is a floor, and is labelled as one everywhere (
kind,headline,excludes,--help,llms.txt). It covers client-side extraction only, because measuring the server, the network or embedding means writing to the index the estimate exists to ask permission for. Measured here: 68 MB source tree → floor 0.1 s vs real 8.9 s; 793 MB → 64.2 s vs ~350 s. So the gate under-asks and never over-asks, and the "not triggered" line says in words that silence is not a promise the run is short. Closing that gap with a multiplier would mean gating on a number nobody measured.(b) The 10-minute gate
Past
--max-minutes(default 10) with no answer: nothing is indexed, a JSON decision request goes to stdout, and the process exits 4 — its own code, because1is already the catch-all for every real failure and an agent must be able to tell "choose something" from "your endpoint is down".The payload carries the estimate and basis, file/byte counts, the per-band work order, the heaviest directories with real byte counts (flagged when they match the vendored/generated rule), and four options.
narroweris re-costed with the same measured rates.faststates no speed-up factor — it reports which datasets and how many files it changes and says plainly that this run did not measure the factor.Answer with
--approve proceed|fast|cancel(--yes= proceed);--max-minutes 0disables it.--approve fastreally applies--no-semantic --no-graph;--approve narroweris refused with instructions rather than accepted and silently ignored (#204). A person at a terminal gets the same facts as prose plus a prompt; a piped or agent-driven run never touches stdin, and an unanswerable prompt is a cancel, never consent. The gate sits before the first remote mutation.(c) Index what matters first
The queue was sorted by size alone: right about scheduling, silent about value — a user who stopped early got whatever was largest, which on a source tree is
node_modules. Work now drains by value band (source and documents → configuration → structured data → logs and line files → vendored/generated/minified), with the old biggest-first rule kept inside each band so a large file runs alongside its band instead of becoming the tail; a single-worker run goes smallest-first. One exception: a file whose extraction outlasts everything ranked above it (size × workers > the rest) starts first regardless of band. Bands, counts and the reason each sits where it does are printed with the plan and shipped aspriority_order— an unexplained order is indistinguishable from an arbitrary one. Bytes-based progress is unaffected.Verified live (branch binary, real node)
--workers 1 --max-minutes 1gt-*indices and 0 brain-edge indices created--approve fastbodymappedtextnotsemantic_text, no edges index--approve cancelreason=cancelled, nothing indexed--dry-run --max-minutes 1src/mod_*.rs; the 1.7 MBnode_modules/**files — largest in the corpus, and first under the old rule — drained last--approve narrower/later,--max-minutes 10081/soon, gate flags onmap/statusGates
cargo fmt --all -- --checkcleancargo clippy -p xerj-autoindex -p xerj-server --all-targets -- -D warningscleancargo test -p xerj-autoindex— 390 passed / 0 failedcargo test -p xerj-server --bins— 30 passed / 0 failedKnown limits (stated, not hidden)
xerj braincomposes autoindex and has no--approve, so it setsmax_minutes: 0explicitly (one hunk inxerj-server/src/brain.rs) — arming the gate there would stop the run with instructions its caller cannot follow.build,out,obj,distare ambiguous names). The cost is a file's place in the queue and nothing else — it is still indexed, still complete.Reference-coding note
The retrieval server was not reachable on
localhost:9200, and the only live node carried an unrelated networking corpus, so I read the corpus clones in~/.xerj-code/corpora/directly rather than starting or reindexing anything: tantivylog_merge_policy.rs:94-99(MIT — sortsReverse(size)then buckets into levels; adapted, our levels are value bands), quickwitquickwit-cli/src/tool.rs:977-1012and ClickHouseProgressIndication.cpp:118-119,223-227(both report measured bytes/s and stop short of an ETA), meilisearchmilli::progress(named steps, no ETA at all). None of the four estimates a job before running it.Rebased onto
main(post-#259 incremental route)mainlanded the generated/incremental reconcile path while this was in flight. Rebased and re-verified end to end on the rebased tree; all figures above were re-measured on it (exit 4, 0gt-*indices, ordering unchanged, ES-YAML 1366/0/3,cargo test -p xerj-autoindexnow 465 passed / 0 failed).One interaction worth calling out: an incremental reconcile of an already committed generation (
--no-graphre-run) publishes from a sealed snapshot and never reaches the phase-B queue the estimate prices, so--max-minutescannot apply there. Rather than accept the flag and quietly not honour it (#204), that route now prints the exemption and points at--dry-run. Both phase-A routes feed the same throughput meter.Pre-existing bug found while verifying (NOT from this PR)
A second
--no-graphrun over the same folder and state dir fails:Reproduced identically on unmodified
origin/main@ 8fb08a9 with a binary built from a clean worktree — first run exits 0, second exits 1 — so it is a defect in the newly-landed incremental route, not a regression here. No flag of this PR is involved (--max-minutes 0,--max-minutes 10and the default all reproduce, and none of the new fields enterprepared_records_identity). Worth its own issue.Fix:
--quietmade the gate's question invisible but still blockingFound by measurement during the rebase verification above, and the single worst
failure mode this feature could have: an invisible prompt is indistinguishable
from a hang, in the one feature whose entire job is to say "this will take a
while".
The gate's prose and its prompt echo both go through the progress surface
(
Progress::note), which--quiet/--progress nonesilences. Butcan_prompt()only checked that stdin and stderr were TTYs — never that thequestion had actually been printed. So a person at a terminal who passed
--quietwas asked nothing and then waited onread_lineforever. Agent-drivenruns were never affected (stdin is not a TTY, so no prompt was attempted).
Measured on a real pty against a real node, 370 MB / 540 files,
--workers 1 --max-minutes 1:--progress none--quietccancelled, 113.9 scancelled, 93.7 s--quiet --approve cancel--progress json, answerfNo
ax-*index was created by any run — the gate still sits before the firstremote mutation.
Why route (a), and not (b)
Both routes were on the table: (a) skip the prompt when the question cannot be
printed, or (b) route the gate's prose and prompt to stderr independently of
progress mode. (a).
--quiethas a documented contract in--help: "prints no progress andNO terminal line (only a fatal
error:line)". Route (b) writes ~30 lines ofprose plus an interactive prompt to a stream the user explicitly silenced.
Accepting a flag and then not honouring it is the accepted-and-ignored class
tracked in Tracking: accepted-and-ignored is the dominant bug class, six instances found in one review #204 — the same defect this PR already refuses elsewhere with
--approve narrower.which
--quietnever claimed to silence (stdout is the RESULT, stderr is PROGRESS— the help says so). The user still gets every fact, inmachine-readable form, plus exit 4. The only thing lost is the prose
rendering of information they still receive — and prose is precisely what
they opted out of.
and reason about, instead of a fourth distinct behaviour.
--quietrun block on stdin, enlarging the set ofconfigurations that can wait for a human. That is the hazard itself.
The honest objection to (a) — "a user who asked for quiet and got a silent early
exit may be equally confused" — is answered rather than waved away: the payload
now carries
prompt_offeredandprompt_not_offered_because, and the proseprints the same sentence wherever a surface exists to print it. Every reason
names the way out (
--approve …), so no path ends in "wait". Under--quietthe user sees, on stdout:
Implementation
gate::NoPrompt+gate::prompt_blocked_by(stdin_tty, stderr_tty, question_visible)—pure, so the whole rule is testable without a terminal (same shape as
progress::resolve).question_visibleis the third condition that was missing.gate::answer_from_terminal(blocked, open_stdin, echo)takes stdin as aclosure, so on a blocked path the process's stdin is never even opened.
That is the invariant — not "no answer was returned" but "stdin was not read".
can_prompt()is removed, not kept: a function with that name that ignoresthe surface is exactly the footgun that caused this.
Regression test
gate::tests::a_question_that_cannot_be_seen_is_never_askedhands in a readerthat panics if anything touches it. Verified to fail without the fix, on both
halves of it:
QuestionSilencedassertionstdin was read for a question the user was never shownDocumented
--help(the--quietentry and the ESTIMATE + DECISION GATE section) andlanding/llms.txtnow state the three conditions, that--quietis neverprompted, and that autoindex never waits on stdin for a question it did not
print. A help-text test pins all three strings.
Gates (re-run on the fixed tree)
cargo fmt --all -- --check— cleancargo clippy -p xerj-autoindex --all-targets -- -D warnings— cleancargo test -p xerj-autoindex— 475 passed / 0 failed (473 before; +2 new)