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..4badc476 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,71 @@ 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: + """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"]: + 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..1e43443a 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -126,6 +126,231 @@ 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]: + """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] = {} + 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: + """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: + 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..56e59803 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,21 @@ def _h_context(p: dict) -> dict: return salience_mod.attach_salience(result, store, session_id, cfg) +def _h_explain_ranking(p: dict) -> dict: + """JSONL handler for kb.explain_ranking (read-only ranking introspection).""" + 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 +749,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..ccc8e094 --- /dev/null +++ b/tests/test_explain_ranking.py @@ -0,0 +1,262 @@ +"""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: + """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 [], + 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: + """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]) + 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: + """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}", + 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 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() + 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: + """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 + + +# --- four-site registration ----------------------------------------------- + + +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 + + assert "kb.explain_ranking" in capabilities.METHODS + assert "kb.explain_ranking" in HANDLERS + + +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") + _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["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"