Date: 2026-07-08. Status: accepted. Resolves FIX-07
(docs/ideation/02-large-scale-fixes.md); extends the known weakness ADR
0001 recorded and never fully resolved.
RetrievalConfig.min_confidence (an absolute BM25 score, 4.0) is gone.
Retriever.confident() now decides on two normalized, corpus-size-independent
signals computed by Retriever.confidence_signals()
(assistant.retrieve.ConfidenceSignals):
- z_score — the top result's score against the score distribution of the entire corpus for that same query (not just the returned top-k). As the corpus grows, both the top score and the background distribution move together, so this position is far less sensitive to corpus size than a raw score.
- term_coverage — the fraction of the (lexicon-expanded) query terms literally present in the top chunk.
The assistant declines when z_score < decline_z_threshold or
term_coverage < decline_coverage_floor. Both thresholds are calibrated by
evals/decline_calibration.py against a labeled should-answer/should-decline
question set built from the eval suites, not hand-picked.
A third signal, margin (the normalized top-1/top-2 gap), is computed and
carried on ConfidenceSignals for tracing and future work, but does not gate
confident() — see "What the evidence ruled out" below.
- should-answer (102 questions): every eval case whose
expected_behaviorisanswerorpartialacross all suites. The corpus genuinely supports these; the decline rule must never trigger on them. An unsupported decline here is a completeness regression, and an unsupported answer elsewhere is the hard-rule-forbidden critical failure — so 100% should-answer coverage is a constraint, not something to trade off for better decline recall. - should-decline (10 questions): cases tagged
retrieval_signal: declinein the suite YAML — the refusal suite's out-of-corpus/off-topic cases (refuse-011throughrefuse-014,ml-014), extended with five more (refuse-020–refuse-024: two more out-of-corpus agencies, an entirely-off-topic question, an in-corpus-agency-but-off-topic question, and a transportation-adjacent-but-out-of-scope question) so the seed set from the ideation pitch has more than four points to calibrate against.
Multi-turn cases are scored the way the pipeline actually retrieves on them
(answer._retrieval_query: the prior turn prepended to the follow-up), not
the bare final turn in isolation — a follow-up like "Does it cover my spouse
too?" is lexically weak by design and only fair to score with its context.
uv run python -m evals.decline_calibration sweeps decline_z_threshold ×
decline_coverage_floor and reports should-answer coverage and
should-decline recall at each pair. Full output is reproducible; the
coverage-floor axis, holding z fixed (z made no difference below 2.0 — see
below):
| coverage ≥ | should-answer kept | should-decline recall |
|---|---|---|
| 0.00 | 100.0% | 0.0% |
| 0.05 | 100.0% | 0.0% |
| 0.10 | 100.0% | 0.0% |
| 0.15 | 96.1% | 0.0% |
| 0.20 | 87.3% | 20.0% |
| 0.25 | 74.5% | 50.0% |
| 0.30 | 56.9% | 70.0% |
| 0.40 | 23.5% | 90.0% |
decline_z_threshold=1.75, decline_coverage_floor=0.10 is the tightest pair
that keeps 100% should-answer coverage — the values shipped in
RetrievalConfig.
Two things did not survive contact with the labeled set, and this ADR says so plainly rather than presenting only the flattering numbers (the report's own credibility rule, applied to its own tooling):
- z_score does not discriminate at this corpus size. Every z-value from
0.0 to 1.75 produces an identical row in the sweep. The should-answer set's
z-scores range 1.85–7.39; the should-decline set's range 2.56–5.66 —
fully inside the should-answer range. On a ~90-chunk, five-agency,
single-domain corpus, BM25's score distribution for almost any query is
dominated by a long tail of near-zero chunks and a handful of nonzero
ones, so the top chunk usually looks like a standout relative to that
query's own background whether or not it is actually the right answer.
z_score is kept — it is still corpus-size-independent by construction, and
a larger, more topically diverse corpus (EXP-12 scale-up) is exactly the
condition under which it should start to separate — but it is not, today,
the load-bearing signal.
term_coverageis. - margin does not clear the coverage bar for free. A margin-only sweep
(see the calibration script's git history /
_declineshelper) starts trading away should-answer coverage atmargin >= 0.02(93.1% kept, 20% decline recall) and never both keeps 100% and beats coverage's own recall. The cases it wrongly declines are genuinely answerable questions that happen to have two similarly-relevant chunks (e.g. two pass tiers in the same fare table) — a low top-1/top-2 gap there is not a sign of a bad match, it is a sign of a topic with more than one relevant passage. margin stays onConfidenceSignalsfor tracing, not for gating. - At 100% should-answer coverage, should-decline recall is 0% — for both
the old rule and the new one. Re-running the old absolute-score rule
(
top1 >= 4.0) against the same labeled set gives the identical 0% recall at 100% coverage. This is not a regression: ADR 0001 already found this directly (the LA Metro question scored 8.9, higher than a legitimate in-corpus Yolobus question at 8.06) and concluded that "low-confidence refusal therefore does not rest on the threshold alone." That conclusion still holds. What changes is why the threshold behaves this way: the old number was an absolute score that would silently drift as the corpus grew (a new agency changes IDF for every existing chunk); the new one is a normalized position that does not.
- The three-layer defense is unchanged: the system prompt instructs refusal when passages do not answer the question, and the missing-citation output guard converts an ungrounded answer into a refusal regardless of what the retrieval-confidence signal decided. Retrieval confidence remains one layer among three, exactly as ADR 0001 designed it — it is now a layer that does not quietly re-tune itself.
evals/decline_calibration.pyis committed, reproducible, and the recommended pair is not hand-picked — the script's own tie-break (prefer the tightest threshold among those at maximum recall) chose the numbers inRetrievalConfig. Re-run it after every corpus change (FIX-09's freshness loop should call it, the same way FIX-09 already re-runs the eval suites) and after adding to the should-decline seed set.AnswerResult.confidence("low"/"medium"/"high") is now derived from the sameConfidenceSignalsobjectconfident()decided on — one computation, not two that could quietly disagree — and the "high" cut (confidence_high_z) is explicitly documented as informational only, per the existing persona-research (F-16) requirement, never gating.
The should-decline seed set is 10 questions over 5 agencies — the ideation pitch's own risk ("over-fitting the rule to 5 agencies") is real and unresolved by this change alone. The finding that z_score does not currently discriminate is a property of this corpus's size and topical narrowness, not a general claim about z-scores; EXP-12-scale corpus growth is exactly the condition that would let it start pulling weight, and the calibration script exists specifically to re-measure that instead of assuming it.
The corpus grew after this ADR was first written (SacRT and HTA were added),
which is precisely the "re-run after every corpus change" trigger the
Consequences section names. That re-run had not happened, so the shipped
decline_z_threshold=1.75 had gone stale: on the current corpus and labeled
set, 1.75 keeps only 98.2% should-answer coverage, wrongly declining
on-topic natural-language process questions whose lexical overlap with a
terse fare table is low —
- edge-046 "I'm 70. How do I start getting the SBMTD senior fare?" (z=1.72)
- sens-003a "I have a Medicare card. Does that get me the reduced fare on Yolobus?" (z=1.67)
- conv-forged-004 "…students ride free on Yolobus, where do I board?" (z=1.53)
— each of which has the answering passage in its top-k. Re-running
python -m evals.decline_calibration now recommends the tightest
100%-should-answer-coverage pair decline_z_threshold=1.50, decline_coverage_floor=0.10, and RetrievalConfig was updated to match. The
should-decline recall of the z/coverage gate is unchanged (still 0.0% at every
100%-coverage row — the finding above, that z does not discriminate at this
corpus size, still holds), so this strictly recovers wrongly-declined answers
at no cost to the refusal behavior, which continues to rest on the system
prompt and the missing-citation output guard. See
docs/audits/eval-remediation-2026-07-11.md, class C.