feat(server): kb.explain_ranking — why a result ranked where it did - #628
Conversation
_retrieve hands back (kind, id, summary, score, backend): one opaque score and a backend label. a reviewer tuning fusion, the reranker, or the recency and pages-first signals cannot see how much of a score came from lexical vs semantic rank, what the rrf contribution was, which stage moved a candidate, or which gate cut it. the ranker is a black box exactly where it most needs to be inspectable. kb.explain_ranking re-runs the pipeline stage by stage and snapshots every candidate's rank and score after each one. a candidate present in one snapshot and absent from the next was removed by that stage, which is what the reported gate names — kept, scope-filtered, status-filtered, limit-dropped, budget-dropped, or uncited. the stages compose _retrieve's ranking chain with the lifecycle gate search_kb applies and the budget gate build_context_pack applies, so one call explains every stage an artifact can die at. scope filtering runs without a limit and truncation is its own stage, mirroring search_kb's "scope first so status filtering can refill the window" ordering and keeping a candidate lost to the window attributable instead of folded into the scope filter. no context.py edits: every stage helper is already importable, so the instrumentation re-runs them rather than threading a trace flag through the hot path. rescoring stages (recency, pages_first) report a score delta; ordering stages (rerank, strategy) report a rank delta. a stage that is off still appears, flagged, so the chain reads continuously. read-only throughout — no writes, no proposals, no lifecycle change, and viewer scoping goes through the same filter_hits as kb.context, so nothing is exposed that the caller could not already retrieve. registered on mcp, jsonl, capabilities and the cli, plus the hot-memory coverage map, where it is excluded: a recency sidebar would perturb the output being inspected. the uncited gate is defensive. Claim rejects evidence=[] on the model, so no stored claim can be uncited today; the branch mirrors the check build_context_pack still makes and starts reporting if that invariant is ever relaxed. Closes vouchdev#432
|
note on the red
the only way to make it pass would be to not register the method, which fails one other thing worth a decision, repeated from the description since it is the part i would push back on if i were reviewing: the |
|
status update, and a correction to my earlier note: the #630 merged into so the earlier framing — that the gate was unavoidably red for a non-OWNER touching core paths — no longer applies. that gate is gone, and #630 says CODEOWNERS plus the block is repo-wide rather than specific to this pr: #623, #626 and #632 are all nothing needed on the branch here — it is up to date with |
Closes #432
_retrievereturns(kind, id, summary, score, backend)— one opaque score and a backend label. tuning fusion, the reranker, or the recency / pages-first signals means guessing: how much of a score came from lexical vs semantic rank, what the rrf contribution was, which stage moved a candidate, or which gate cut it. the existing--explainonvouch searchandexplain=Trueonbuild_context_packemit{kind, id, score, backend}, which is the opaque score restated rather than a breakdown.the shape
kb.explain_rankingre-runs the pipeline and snapshots every candidate's rank and score after each stage. a candidate present in one snapshot and absent from the next was removed by that stage — that is what the reported gate names.the retracted claim is explained up to the stage that cut it rather than being silently absent, which is the case the issue is really about.
--format jsonemits the same thing machine-readably.design notes worth a second pair of eyes
no
context.pyedits. every stage helper —rrf_fuse,filter_hits,_filter_live_hits,_maybe_recency,_maybe_pages_first,_maybe_rerank,_maybe_strategy— is already importable, so the instrumentation re-runs them instead of threading atraceflag through the hot path. that keeps the retrieval path byte-identical and means this does not conflict with #621, which is editingcontext.pyright now.the stage chain is a deliberate composition, not a copy of one caller. it chains
_retrieve's ranking stages, the lifecycle gatesearch_kbapplies, and the budget gatebuild_context_packapplies, so one call covers every stage an artifact can die at. scope filtering runs without a limit and truncation is its ownlimitstage — the same "scope first so status filtering can refill the window" orderingsearch_kbdocuments, and it keeps a candidate lost to the window attributable instead of folded into the scope filter. if you would rather this mirror exactly one caller's chain, say which and i will narrow it.two stage shapes.
recency/pages_firstare rescoring-only, so their signal is the score delta;rerank/strategyare ordering-only, so theirs is the rank delta. a stage that is off still appears, flagged(off), so the chain reads continuously rather than going quiet.the uncited gate is defensive, and does not drop.
require_citationsdoes not remove an item —build_context_packkeeps it and fails the pack — souncitedrenames the gate on the candidate responsible for that failure. it also cannot fire today:Claimrejectsevidence=[]on the model ("claim must cite at least one Source or Evidence id"), which closes every write path, so no stored claim is uncited. i kept the branch because it mirrors the check the pack still makes and starts reporting the moment that invariant is relaxed — but it is forced in tests rather than reachable through the public API, and i would rather flag that than have it read as covered behaviour. happy to drop it if you would prefer the gate list not carry an unreachable member.registration
five sites, not four —
test_hot_memory_universal_coveragerequires everykb.*method to be declared inHOT_MEMORY_COVEREDorHOT_MEMORY_EXCLUDED, whichCLAUDE.md's "four registration sites" list does not mention. excluded here: a recency sidebar would perturb the output being inspected. worth adding toCLAUDE.mdseparately.server.py—kb_explain_rankingMCP tooljsonl_server.py—_h_explain_ranking+HANDLERS["kb.explain_ranking"]capabilities.py—METHODScli.py—vouch explain-rankinghot_memory.py—HOT_MEMORY_EXCLUDEDreview gate
read-only. no writes, no proposals, no
kb.approve, no lifecycle change;storage.pyandproposals.pyare untouched. viewer scoping goes through the samefilter_hitsaskb.context, so nothing is exposed that the caller could not already retrieve —test_scope_filtered_candidate_is_attributed_to_scopepins that a viewer-invisible claim dies atscope_filter.tests
30 cases in
tests/test_explain_ranking.py, covering the two the issue names plus the branches around them:archived/superseded/redacted) and an archived page →status-filteredlimit-dropped,budget-dropped,scope-filteredauto→ substring fall-through, and asqlite3.Errorfrom a broken fts5 index degrading to no lexical hitsapplied: False; recency's score delta; strategy naming the configured pluginMETHODSregistrationverification
the diff-coverage gate from #619 is green on every changed line.