From ec52cc49a4c36e3c492d8ab877f07bd2106c76db Mon Sep 17 00:00:00 2001 From: andyk3247 Date: Fri, 10 Jul 2026 15:44:11 +0200 Subject: [PATCH 1/3] feat(server): add kb.explain_ranking retrieval introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds a read-only method that re-runs the kb.context retrieval pipeline in an instrumented mode and reports, per candidate, the lexical (fts5) rank, the semantic (embedding) rank, the rrf contribution, the rerank delta (when the reranker extras are installed), the salience factor, and the gate outcome (kept / budget-dropped / uncited / status-filtered). it reuses _retrieve's own primitives rather than duplicating the scoring math, and is viewer-scoped identically to kb.context so it can never expose an artifact the caller couldn't already retrieve. recency and frequency factors are reported null until #317 lands; the rerank delta is populated only when #5's reranker extras are present. registered on all four surfaces — mcp (kb_explain_ranking), jsonl (kb.explain_ranking), capabilities.METHODS, and cli (vouch explain-ranking with --format text|json) — plus tests in tests/test_explain_ranking.py covering a fused-only query, each gate drop, viewer-scoping parity, and the four-site registration. closes #432 --- CHANGELOG.md | 11 ++ src/vouch/capabilities.py | 1 + src/vouch/cli.py | 66 +++++++++- src/vouch/context.py | 223 ++++++++++++++++++++++++++++++++ src/vouch/jsonl_server.py | 17 ++- src/vouch/server.py | 29 ++++- tests/test_explain_ranking.py | 237 ++++++++++++++++++++++++++++++++++ 7 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 tests/test_explain_ranking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 986dcb0e..c8eda4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] +### Added +- `kb.explain_ranking` / `vouch explain-ranking` — read-only introspection over + the retrieval pipeline. per candidate it reports the lexical (fts5) rank, the + semantic (embedding) rank, the rrf contribution, the rerank delta (when the + reranker extras are installed), the salience factor, and the gate outcome + (`kept` / `budget-dropped` / `uncited` / `status-filtered`). re-runs the same + `_retrieve` primitives `kb.context` uses in an instrumented mode rather than + duplicating the scoring math, and is viewer-scoped identically so it can't + expose an artifact the caller couldn't already retrieve. recency/frequency + factors are reported null until #317 lands. + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/capabilities.py b/src/vouch/capabilities.py index e051115d..10f1a270 100644 --- a/src/vouch/capabilities.py +++ b/src/vouch/capabilities.py @@ -35,6 +35,7 @@ "kb.search", "kb.neighbors", "kb.context", + "kb.explain_ranking", "kb.synthesize", "kb.read_page", "kb.read_claim", diff --git a/src/vouch/cli.py b/src/vouch/cli.py index c6b04ccf..f259f7a8 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -46,7 +46,7 @@ from . import vault_sync as vault_sync_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps -from .context import build_context_pack +from .context import build_context_pack, explain_ranking from .lifecycle import LifecycleError from .logging_config import configure_logging from .models import Proposal, ProposalKind, ProposalStatus @@ -2353,6 +2353,70 @@ def search( click.echo(f"{k}/{i}\t{snip} ({used})") +@cli.command(name="explain-ranking") +@click.argument("query") +@click.option("--limit", "-n", default=10, show_default=True, type=int) +@click.option("--max-chars", default=None, type=int, + help="Simulate the kb.context budget gate at this cap.") +@click.option("--require-citations/--no-require-citations", default=False, + help="Flag uncited claims as gated.") +@click.option("--rerank/--no-rerank", default=False, + help="Report the rerank delta (needs the reranker extras).") +@click.option("--session-id", default=None, + help="Overlay the entity-salience reflex for this session.") +@click.option("--project", default=None, help="Viewer project for scope filtering.") +@click.option("--agent", default=None, help="Viewer agent for scope filtering.") +@click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", + show_default=True) +def explain_ranking_cmd( + query: str, + limit: int, + max_chars: int | None, + require_citations: bool, + rerank: bool, + session_id: str | None, + project: str | None, + agent: str | None, + fmt: str, +) -> None: + """Explain why each retrieval candidate ranked where it did. + + Read-only introspection over the kb.context pipeline: per candidate it + reports the lexical/semantic rank, the RRF contribution, the rerank delta, + the salience factor, and the gate outcome. Viewer-scoped like kb.context. + """ + store = _load_store() + result = explain_ranking( + store, query=query, limit=limit, max_chars=max_chars, + require_citations=require_citations, rerank=rerank, + project=project, agent=agent, session_id=session_id, + ) + if fmt == "json": + _emit_json(result) + return + + stages = result["stages"] + enabled = [name for name, on in stages.items() if on] or ["none"] + click.echo(f"query: {result['query']} backend={result['backend']} " + f"stages={','.join(enabled)}") + for note in result.get("notes", []): + click.echo(f" note: {note}") + if not result["candidates"]: + click.echo(" (no candidates)") + return + + def _fmt(v: object) -> str: + return "-" if v is None else (f"{v:.4f}" if isinstance(v, float) else str(v)) + + for c in result["candidates"]: + click.echo( + f"[{c['gate']}] {c['kind']}/{c['id']} " + f"lex={_fmt(c['lexical_rank'])} sem={_fmt(c['semantic_rank'])} " + f"rrf={_fmt(c['rrf_score'])} rerankΔ={_fmt(c['rerank_delta'])} " + f"salience={_fmt(c['salience_factor'])} score={_fmt(c['final_score'])}" + ) + + @cli.command() @click.argument("node_id") @click.option("--depth", default=1, show_default=True, type=int) diff --git a/src/vouch/context.py b/src/vouch/context.py index fa7e3f16..05165984 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -126,6 +126,229 @@ def _retrieve( return [(k, i, s, sc, "substring") for k, i, s, sc in filtered] +GateOutcome = Literal["kept", "budget-dropped", "uncited", "status-filtered"] + + +def explain_ranking( + store: KBStore, + *, + query: str, + limit: int = 10, + max_chars: int | None = None, + require_citations: bool = False, + rerank: bool = False, + project: str | None = None, + agent: str | None = None, + session_id: str | None = None, +) -> dict[str, Any]: + """Explain why each retrieval candidate ranked where it did (read-only). + + Re-runs `_retrieve`'s primitives — semantic search, lexical FTS5 search, + RRF fusion, viewer scoping — in an instrumented mode rather than + duplicating the scoring math, then reports per candidate: its lexical + rank, semantic rank, the RRF contribution, the rerank delta (when #5 is + enabled), the recency/frequency factors (when #317 is enabled), the + salience factor, and the gate outcome that keeps or drops it + (`kept` / `budget-dropped` / `uncited` / `status-filtered`). + + Viewer scoping matches `kb.context`: hits invisible to the viewer are + dropped by `filter_hits` before instrumentation, so this can never expose + an artifact the caller couldn't already retrieve. Stages reported reflect + what's wired in — fusion always; rerank only when `rerank=True` and the + reranker extras (#5) are installed; recency/frequency (#317) is not yet + implemented and its factors are reported as null. Near-duplicate + deduplication (a presentation pass in `build_context_pack`) is not + modelled: every scoped candidate is surfaced with its own gate. + """ + viewer = viewer_from(config_path=store.config_path, project=project, agent=agent) + backend = _configured_backend(store) + fetch_limit = scoped_fetch_limit(limit, viewer) + + def _ranks(hits: list[tuple[str, str, str, float]]) -> dict[tuple[str, str], int]: + return {(k, i): r for r, (k, i, _s, _sc) in enumerate(hits, start=1)} + + sem_rank: dict[tuple[str, str], int] = {} + lex_rank: dict[tuple[str, str], int] = {} + fusion = False + used_backend = backend + filtered: list[tuple[str, str, str, float]] = [] + + if backend in ("auto", "hybrid"): + sem = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) + try: + lex = index_db.search(store.kb_dir, query, limit=fetch_limit) + except sqlite3.Error: + lex = [] + fused = rrf_fuse(sem, lex, limit=fetch_limit) + if fused: + sem_rank, lex_rank = _ranks(sem), _ranks(lex) + fusion = True + used_backend = "hybrid" + filtered = filter_hits(store, fused, viewer, limit=limit) + else: + # both retrievers empty -> substring scan (mirrors _retrieve) + used_backend = "substring" + filtered = filter_hits( + store, store.search_substring(query, limit=fetch_limit), viewer, limit=limit + ) + elif backend == "embedding": + sem = index_db.search_semantic(store.kb_dir, query, limit=fetch_limit) + sem_rank = _ranks(sem) + used_backend = "embedding" + filtered = filter_hits(store, sem, viewer, limit=limit) if sem else [] + elif backend == "fts5": + try: + lex = index_db.search(store.kb_dir, query, limit=fetch_limit) + except sqlite3.Error: + lex = [] + lex_rank = _ranks(lex) + used_backend = "fts5" + filtered = filter_hits(store, lex, viewer, limit=limit) if lex else [] + else: + used_backend = "substring" + filtered = filter_hits( + store, store.search_substring(query, limit=fetch_limit), viewer, limit=limit + ) + + candidates: list[dict[str, Any]] = [] + for fused_rank, (kind, cid, summary, score) in enumerate(filtered, start=1): + key = (kind, cid) + candidates.append({ + "kind": kind, + "id": cid, + "summary": _enrich_summary(store, kind, cid, summary), + "fused_rank": fused_rank, + "lexical_rank": lex_rank.get(key), + "semantic_rank": sem_rank.get(key), + "rrf_score": score if fusion else None, + "final_score": score, + "rerank_delta": None, + "recency_factor": None, + "frequency_factor": None, + "salience_factor": None, + "gate": cast(GateOutcome, "kept"), + }) + + notes: list[str] = [] + rerank_applied = _apply_rerank(query, candidates, notes) if rerank and candidates else False + if session_id: + _apply_salience(store, session_id, candidates) + _classify_gates(store, candidates, max_chars=max_chars, require_citations=require_citations) + + result: dict[str, Any] = { + "query": query, + "backend": used_backend, + "limit": limit, + "viewer": {"project": viewer.project, "agent": viewer.agent}, + "stages": { + "fusion": fusion, + "rerank": rerank_applied, + "recency_frequency": False, + }, + "candidates": candidates, + } + if notes: + result["notes"] = notes + return result + + +def _apply_rerank(query: str, candidates: list[dict[str, Any]], notes: list[str]) -> bool: + """Annotate `candidates` with `rerank_delta`; return whether #5 ran. + + Delta is `fused_rank - reranked_rank`: positive means the reranker moved + the candidate up. No-op returning False if the reranker extras aren't + installed. + """ + try: + from .embeddings.rerank import default_reranker + from .embeddings.rerank import rerank as do_rerank + except ImportError: + notes.append("rerank requested but reranker extras (#5) not installed; stage skipped") + return False + hits = [(c["kind"], c["id"], c["summary"], c["final_score"]) for c in candidates] + reranked = do_rerank(query=query, hits=hits, reranker=default_reranker(), top_k=len(hits)) + new_rank = {(k, i): r for r, (k, i, _s, _sc) in enumerate(reranked, start=1)} + for c in candidates: + nr = new_rank.get((c["kind"], c["id"])) + if nr is not None: + c["rerank_delta"] = c["fused_rank"] - nr + return True + + +def _apply_salience( + store: KBStore, session_id: str, candidates: list[dict[str, Any]] +) -> None: + """Overlay the entity-salience reflex weight onto matching candidates. + + Salience is a sidebar signal (see `salience` module), not a term in the + fused score; this surfaces the overlap so a tuner can see which surfaced + artifacts the reflex would also have prefetched. Best-effort. + """ + from . import salience as salience_mod + try: + cfg = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + cfg = None + _enabled, _window, top_k = salience_mod.reflex_cfg(cfg if isinstance(cfg, dict) else {}) + records = salience_mod.compute_salience(store, session_id, top_k=top_k) + ent_factor: dict[str, float] = {} + claim_factor: dict[str, float] = {} + for pos, rec in enumerate(records): + weight = round(1.0 / (1 + pos), 4) + ent_factor[rec["entity_id"]] = weight + if rec.get("top_claim_id"): + claim_factor[rec["top_claim_id"]] = weight + for c in candidates: + if c["kind"] == "entity" and c["id"] in ent_factor: + c["salience_factor"] = ent_factor[c["id"]] + elif c["kind"] == "claim" and c["id"] in claim_factor: + c["salience_factor"] = claim_factor[c["id"]] + + +def _classify_gates( + store: KBStore, + candidates: list[dict[str, Any]], + *, + max_chars: int | None, + require_citations: bool, +) -> None: + """Set each candidate's `gate`, mirroring `build_context_pack`'s pipeline. + + Priority: `status-filtered` (retracted/missing claim, dropped during item + building) > `budget-dropped` (evicted by the `max_chars` tail pop) > + `uncited` (a claim with no citations, flagged when `require_citations`) > + `kept`. + """ + survivors: list[dict[str, Any]] = [] + citations: dict[str, list[str]] = {} + for c in candidates: + if c["kind"] == "claim": + try: + claim = store.get_claim(c["id"]) + except ArtifactNotFoundError: + c["gate"] = "status-filtered" + continue + if claim.status in _RETRACTED_CLAIM_STATUSES: + c["gate"] = "status-filtered" + continue + citations[c["id"]] = list(claim.evidence) + survivors.append(c) + + if max_chars is not None: + def _clipped_len(s: str) -> int: + return len(s[:200] + "…") if len(s) > 200 else len(s) + + if sum(_clipped_len(c["summary"]) for c in survivors) > max_chars: + while survivors and sum(_clipped_len(c["summary"]) for c in survivors) > max_chars: + survivors.pop()["gate"] = "budget-dropped" + + for c in survivors: + if require_citations and c["kind"] == "claim" and not citations.get(c["id"]): + c["gate"] = "uncited" + else: + c["gate"] = "kept" + + def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str: """Return a non-empty summary, falling back to the stored artifact text.""" if summary: diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index 3ada4b9a..bf6fc2ce 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -38,7 +38,7 @@ from . import trust as trust_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps -from .context import build_context_pack +from .context import build_context_pack, explain_ranking from .logging_config import configure_logging from .models import ProposalStatus from .page_filters import filter_pages @@ -225,6 +225,20 @@ def _h_context(p: dict) -> dict: return salience_mod.attach_salience(result, store, session_id, cfg) +def _h_explain_ranking(p: dict) -> dict: + return explain_ranking( + _store(), + query=p["query"], + limit=int(p.get("limit", 10)), + max_chars=int(p["max_chars"]) if p.get("max_chars") is not None else None, + require_citations=bool(p.get("require_citations", False)), + rerank=bool(p.get("rerank", False)), + project=p.get("project"), + agent=p.get("agent"), + session_id=p.get("session_id"), + ) + + def _h_synthesize(p: dict) -> dict: return synthesize( _store(), @@ -734,6 +748,7 @@ def _h_propose_theme(p: dict) -> dict: "kb.search": _h_search, "kb.neighbors": _h_neighbors, "kb.context": _h_context, + "kb.explain_ranking": _h_explain_ranking, "kb.synthesize": _h_synthesize, "kb.read_page": _h_read_page, "kb.read_claim": _h_read_claim, diff --git a/src/vouch/server.py b/src/vouch/server.py index f2a4482e..087c5895 100644 --- a/src/vouch/server.py +++ b/src/vouch/server.py @@ -29,7 +29,7 @@ from . import trust as trust_mod from . import verify as verify_mod from .capabilities import capabilities as build_caps -from .context import build_context_pack +from .context import build_context_pack, explain_ranking from .logging_config import configure_logging from .models import ProposalStatus from .page_filters import filter_pages @@ -260,6 +260,33 @@ def kb_context( return salience_mod.attach_salience(result, store, session_id, cfg) +@mcp.tool() +def kb_explain_ranking( + query: str, + limit: int = 10, + max_chars: int | None = None, + require_citations: bool = False, + rerank: bool = False, + project: str | None = None, + agent: str | None = None, + session_id: str | None = None, +) -> dict[str, Any]: + """Explain why each retrieval candidate ranked where it did. + + Read-only introspection over the same pipeline ``kb_context`` runs: per + candidate it returns the lexical/semantic rank, the RRF contribution, the + rerank delta (when the reranker extras are installed and ``rerank=True``), + the salience factor, and the gate outcome (``kept`` / ``budget-dropped`` / + ``uncited`` / ``status-filtered``). Viewer-scoped exactly like + ``kb_context`` — it can't reveal an artifact the caller couldn't retrieve. + """ + return explain_ranking( + _store(), query=query, limit=limit, max_chars=max_chars, + require_citations=require_citations, rerank=rerank, + project=project, agent=agent, session_id=session_id, + ) + + @mcp.tool() def kb_synthesize( query: str, diff --git a/tests/test_explain_ranking.py b/tests/test_explain_ranking.py new file mode 100644 index 00000000..a896038c --- /dev/null +++ b/tests/test_explain_ranking.py @@ -0,0 +1,237 @@ +"""kb.explain_ranking — read-time introspection over the retrieval pipeline. + +Covers the acceptance criteria for #432: a fused-only query, a gate-dropped +candidate, viewer-scoping parity with kb.context, and the four-site +registration. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from vouch import context, health +from vouch.models import ArtifactScope, Claim, Visibility +from vouch.storage import KBStore + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + return KBStore.init(tmp_path) + + +def _put(store: KBStore, cid: str, text: str, *, evidence: list[str] | None = None, + scope: ArtifactScope | None = None) -> None: + store.put_claim(Claim( + id=cid, text=text, + evidence=evidence if evidence is not None else [], + scope=scope or ArtifactScope(), + )) + + +# --- fused-only query ----------------------------------------------------- + + +def test_fused_only_query_reports_fusion_signals(store: KBStore) -> None: + """With no embeddings indexed the RRF path still runs on the FTS list: + fusion is reported active, lexical rank + rrf contribution are populated, + the semantic rank is null, and the candidate is kept.""" + src = store.put_source(b"e") + _put(store, "c1", "jwt tokens expire after one hour", evidence=[src.id]) + health.rebuild_index(store) + + result = context.explain_ranking(store, query="jwt", limit=5) + + assert result["query"] == "jwt" + assert result["backend"] == "hybrid" + assert result["stages"] == {"fusion": True, "rerank": False, "recency_frequency": False} + assert result["viewer"] == {"project": None, "agent": None} + + c1 = next(c for c in result["candidates"] if c["id"] == "c1") + assert c1["gate"] == "kept" + assert c1["lexical_rank"] == 1 + assert c1["semantic_rank"] is None + assert c1["rrf_score"] is not None + assert c1["final_score"] == c1["rrf_score"] + # #5 / #317 not wired into the context pipeline -> reported null. + assert c1["rerank_delta"] is None + assert c1["recency_factor"] is None + assert c1["frequency_factor"] is None + assert c1["salience_factor"] is None + + +def test_candidates_ordered_by_fused_rank(store: KBStore) -> None: + src = store.put_source(b"e") + for i in range(4): + _put(store, f"c{i}", f"redis caching layer note {i}", evidence=[src.id]) + health.rebuild_index(store) + + result = context.explain_ranking(store, query="redis caching", limit=10) + ranks = [c["fused_rank"] for c in result["candidates"]] + assert ranks == sorted(ranks) + assert ranks[0] == 1 + + +# --- gate outcomes -------------------------------------------------------- + + +def test_status_filtered_gate_for_retracted_claim(store: KBStore) -> None: + """An archived claim still matches the FTS index (its row survives with an + updated status) so it surfaces as a candidate — gated status-filtered, + exactly as build_context_pack would drop it.""" + from vouch import lifecycle + + src = store.put_source(b"e") + _put(store, "keep", "mongodb sharding strategy", evidence=[src.id]) + _put(store, "gone", "mongodb replica set failover", evidence=[src.id]) + health.rebuild_index(store) + lifecycle.archive(store, claim_id="gone", actor="reviewer") + + result = context.explain_ranking(store, query="mongodb", limit=10) + gates = {c["id"]: c["gate"] for c in result["candidates"]} + assert gates["gone"] == "status-filtered" + assert gates["keep"] == "kept" + + +def test_budget_dropped_gate(store: KBStore) -> None: + src = store.put_source(b"e") + for i in range(10): + _put(store, f"c{i}", + f"budget claim {i} with plenty of padding text to exceed the cap", + evidence=[src.id]) + health.rebuild_index(store) + + result = context.explain_ranking(store, query="budget", limit=10, max_chars=100) + gates = [c["gate"] for c in result["candidates"]] + assert "budget-dropped" in gates + assert "kept" in gates + # The tail is dropped: every budget-dropped candidate ranks below the last + # kept one. + kept_ranks = [c["fused_rank"] for c in result["candidates"] if c["gate"] == "kept"] + dropped_ranks = [c["fused_rank"] for c in result["candidates"] + if c["gate"] == "budget-dropped"] + assert max(kept_ranks) < min(dropped_ranks) + + +def test_require_citations_keeps_cited_claims(store: KBStore) -> None: + """A properly-cited claim is not gated when require_citations is set.""" + src = store.put_source(b"e") + _put(store, "c1", "cited claim about caches", evidence=[src.id]) + health.rebuild_index(store) + + result = context.explain_ranking(store, query="caches", require_citations=True) + c1 = next(c for c in result["candidates"] if c["id"] == "c1") + assert c1["gate"] == "kept" + + +def test_uncited_gate_classification() -> None: + """The uncited gate flags a citation-less claim when require_citations is + set. The model blocks empty-evidence claims on every write path, so this + otherwise-defensive branch (mirrored from build_context_pack) is exercised + against a stand-in claim rather than a persisted one.""" + from typing import ClassVar + + from vouch.models import ClaimStatus + + class _FakeClaim: + status = ClaimStatus.WORKING + evidence: ClassVar[list[str]] = [] + + class _FakeStore: + def get_claim(self, cid: str) -> _FakeClaim: + return _FakeClaim() + + def _candidate() -> list[dict]: + return [{"kind": "claim", "id": "u1", "summary": "x", "gate": "kept"}] + + cands = _candidate() + context._classify_gates(_FakeStore(), cands, max_chars=None, require_citations=True) + assert cands[0]["gate"] == "uncited" + + cands = _candidate() + context._classify_gates(_FakeStore(), cands, max_chars=None, require_citations=False) + assert cands[0]["gate"] == "kept" + + +# --- viewer scoping parity ------------------------------------------------ + + +def test_viewer_scoping_matches_context(store: KBStore) -> None: + """A private claim invisible to the default viewer must not appear in the + explain breakdown — same guarantee kb.context gives.""" + src = store.put_source(b"e") + _put(store, "public1", "public postgres tuning note", evidence=[src.id]) + _put(store, "secret1", "private postgres credentials note", evidence=[src.id], + scope=ArtifactScope(visibility=Visibility.PRIVATE, agent="alice")) + health.rebuild_index(store) + + # Default viewer (no agent) cannot see the private claim. + result = context.explain_ranking(store, query="postgres", limit=10) + ids = {c["id"] for c in result["candidates"]} + assert "public1" in ids + assert "secret1" not in ids + + pack = context.build_context_pack(store, query="postgres", limit=10) + assert {it["id"] for it in pack["items"]} == ids & {it["id"] for it in pack["items"]} + assert "secret1" not in {it["id"] for it in pack["items"]} + + # The owning agent sees it. + scoped = context.explain_ranking(store, query="postgres", limit=10, agent="alice") + assert "secret1" in {c["id"] for c in scoped["candidates"]} + assert scoped["viewer"]["agent"] == "alice" + + +def test_explain_ranking_is_read_only(store: KBStore) -> None: + """Introspection must not file proposals or mutate the audit log.""" + from vouch import audit + + src = store.put_source(b"e") + _put(store, "c1", "read only invariant claim", evidence=[src.id]) + health.rebuild_index(store) + + before_pending = len(store.list_proposals()) + before_audit = len(list(audit.read_events(store.kb_dir))) + context.explain_ranking(store, query="invariant", limit=5) + assert len(store.list_proposals()) == before_pending + assert len(list(audit.read_events(store.kb_dir))) == before_audit + + +def test_empty_query_returns_no_candidates(store: KBStore) -> None: + result = context.explain_ranking(store, query="nothingmatchesthis", limit=5) + assert result["candidates"] == [] + assert "backend" in result + + +# --- four-site registration ----------------------------------------------- + + +def test_registered_in_capabilities_and_handlers() -> None: + from vouch import capabilities + from vouch.jsonl_server import HANDLERS + + assert "kb.explain_ranking" in capabilities.METHODS + assert "kb.explain_ranking" in HANDLERS + + +def test_registered_as_mcp_tool() -> None: + from vouch.server import mcp + + assert "kb_explain_ranking" in mcp._tool_manager._tools + + +def test_jsonl_handler_round_trip(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + from vouch.jsonl_server import handle_request + + src = store.put_source(b"e") + _put(store, "c1", "jsonl surface claim", evidence=[src.id]) + health.rebuild_index(store) + monkeypatch.chdir(store.root) + + resp = handle_request({ + "id": "r1", "method": "kb.explain_ranking", + "params": {"query": "jsonl", "limit": 5}, + }) + assert resp["ok"] + assert resp["result"]["backend"] == "hybrid" + assert any(c["id"] == "c1" for c in resp["result"]["candidates"]) From 75977d64a8c775727d767771da10cb71034ae069 Mon Sep 17 00:00:00 2001 From: andyk3247 Date: Fri, 10 Jul 2026 16:02:10 +0200 Subject: [PATCH 2/3] test(explain-ranking): assert full jsonl envelope shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add the request-id assertion on the success round-trip and a failure-case test for a missing query param, asserting the {id, ok: false, error} envelope with code missing_param — per the coding guideline for new kb.* methods. --- tests/test_explain_ranking.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_explain_ranking.py b/tests/test_explain_ranking.py index a896038c..4ac8f469 100644 --- a/tests/test_explain_ranking.py +++ b/tests/test_explain_ranking.py @@ -232,6 +232,21 @@ def test_jsonl_handler_round_trip(store: KBStore, monkeypatch: pytest.MonkeyPatc "id": "r1", "method": "kb.explain_ranking", "params": {"query": "jsonl", "limit": 5}, }) + assert resp["id"] == "r1" assert resp["ok"] assert resp["result"]["backend"] == "hybrid" assert any(c["id"] == "c1" for c in resp["result"]["candidates"]) + + +def test_jsonl_handler_missing_query_returns_error_envelope() -> None: + """A request missing the required `query` param returns the failure + envelope `{id, ok: false, error}` — the handler's `p["query"]` raises + KeyError, which handle_request maps to code `missing_param`.""" + from vouch.jsonl_server import handle_request + + resp = handle_request({ + "id": "r2", "method": "kb.explain_ranking", "params": {}, + }) + assert resp["id"] == "r2" + assert resp["ok"] is False + assert resp["error"]["code"] == "missing_param" From cd80cd6abff8a8e723670c70936e720e5d0e3cec Mon Sep 17 00:00:00 2001 From: andyk3247 Date: Fri, 10 Jul 2026 16:10:39 +0200 Subject: [PATCH 3/3] docs(explain-ranking): docstring the new helpers and tests add docstrings to the nested retrieval helpers (_ranks, _clipped_len, _fmt), the jsonl handler, and the test helpers/cases that lacked them, clearing the 80% docstring-coverage threshold on the diff. --- src/vouch/cli.py | 1 + src/vouch/context.py | 2 ++ src/vouch/jsonl_server.py | 1 + tests/test_explain_ranking.py | 10 ++++++++++ 4 files changed, 14 insertions(+) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index f259f7a8..4badc476 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2406,6 +2406,7 @@ def explain_ranking_cmd( return def _fmt(v: object) -> str: + """Render a component score for the text table (None renders as '-').""" return "-" if v is None else (f"{v:.4f}" if isinstance(v, float) else str(v)) for c in result["candidates"]: diff --git a/src/vouch/context.py b/src/vouch/context.py index 05165984..1e43443a 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -165,6 +165,7 @@ def explain_ranking( fetch_limit = scoped_fetch_limit(limit, viewer) def _ranks(hits: list[tuple[str, str, str, float]]) -> dict[tuple[str, str], int]: + """Map each hit's (kind, id) to its 1-based position in the list.""" return {(k, i): r for r, (k, i, _s, _sc) in enumerate(hits, start=1)} sem_rank: dict[tuple[str, str], int] = {} @@ -336,6 +337,7 @@ def _classify_gates( if max_chars is not None: def _clipped_len(s: str) -> int: + """Summary length after build_context_pack's 200-char clip.""" return len(s[:200] + "…") if len(s) > 200 else len(s) if sum(_clipped_len(c["summary"]) for c in survivors) > max_chars: diff --git a/src/vouch/jsonl_server.py b/src/vouch/jsonl_server.py index bf6fc2ce..56e59803 100644 --- a/src/vouch/jsonl_server.py +++ b/src/vouch/jsonl_server.py @@ -226,6 +226,7 @@ def _h_context(p: dict) -> dict: def _h_explain_ranking(p: dict) -> dict: + """JSONL handler for kb.explain_ranking (read-only ranking introspection).""" return explain_ranking( _store(), query=p["query"], diff --git a/tests/test_explain_ranking.py b/tests/test_explain_ranking.py index 4ac8f469..ccc8e094 100644 --- a/tests/test_explain_ranking.py +++ b/tests/test_explain_ranking.py @@ -18,11 +18,13 @@ @pytest.fixture def store(tmp_path: Path) -> KBStore: + """A fresh KB rooted at a temp dir.""" return KBStore.init(tmp_path) def _put(store: KBStore, cid: str, text: str, *, evidence: list[str] | None = None, scope: ArtifactScope | None = None) -> None: + """Land a claim straight into storage (bypassing the review gate) for setup.""" store.put_claim(Claim( id=cid, text=text, evidence=evidence if evidence is not None else [], @@ -62,6 +64,7 @@ def test_fused_only_query_reports_fusion_signals(store: KBStore) -> None: def test_candidates_ordered_by_fused_rank(store: KBStore) -> None: + """Candidates come back in ascending fused_rank order, starting at 1.""" src = store.put_source(b"e") for i in range(4): _put(store, f"c{i}", f"redis caching layer note {i}", evidence=[src.id]) @@ -95,6 +98,7 @@ def test_status_filtered_gate_for_retracted_claim(store: KBStore) -> None: def test_budget_dropped_gate(store: KBStore) -> None: + """A tight max_chars evicts tail candidates, gated budget-dropped.""" src = store.put_source(b"e") for i in range(10): _put(store, f"c{i}", @@ -140,9 +144,11 @@ class _FakeClaim: class _FakeStore: def get_claim(self, cid: str) -> _FakeClaim: + """Return a citation-less stand-in claim for any id.""" return _FakeClaim() def _candidate() -> list[dict]: + """A single kept claim candidate to run the classifier over.""" return [{"kind": "claim", "id": "u1", "summary": "x", "gate": "kept"}] cands = _candidate() @@ -198,6 +204,7 @@ def test_explain_ranking_is_read_only(store: KBStore) -> None: def test_empty_query_returns_no_candidates(store: KBStore) -> None: + """A query that matches nothing returns an empty candidate list, not an error.""" result = context.explain_ranking(store, query="nothingmatchesthis", limit=5) assert result["candidates"] == [] assert "backend" in result @@ -207,6 +214,7 @@ def test_empty_query_returns_no_candidates(store: KBStore) -> None: def test_registered_in_capabilities_and_handlers() -> None: + """The method appears in capabilities.METHODS and the JSONL handler map.""" from vouch import capabilities from vouch.jsonl_server import HANDLERS @@ -215,12 +223,14 @@ def test_registered_in_capabilities_and_handlers() -> None: def test_registered_as_mcp_tool() -> None: + """The MCP surface exposes kb_explain_ranking.""" from vouch.server import mcp assert "kb_explain_ranking" in mcp._tool_manager._tools def test_jsonl_handler_round_trip(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + """A well-formed request returns the success envelope {id, ok, result}.""" from vouch.jsonl_server import handle_request src = store.put_source(b"e")