Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to vouch are documented here. Format follows

## [Unreleased]

### Added
- **`kb.explain_ranking` — why a result ranked where it did** (#432): a
read-only breakdown of the retrieval pipeline. Per candidate it reports the
lexical (FTS5) rank, the semantic rank, the RRF contribution, a row for every
stage — fusion, scope and status filters, recency, pages-first, rerank, the
pluggable strategy, the limit window, and the optional budget/citation gates
— with the rank and score delta that stage caused, plus the gate that kept or
dropped it (`kept` / `scope-filtered` / `status-filtered` / `limit-dropped` /
`budget-dropped` / `uncited`). Registered on MCP, JSONL and the CLI
(`vouch explain-ranking "<query>" [--format text|json]`). Viewer-scoped
through the same `filter_hits` as `kb.context`, so it cannot expose an
artifact the caller could not already retrieve, and it touches no write path.

### Fixed
- **salience reflex excludes retracted claims**: `compute_salience` scanned
every claim regardless of status, so the `_meta.vouch_salience` sidebar
Expand Down
1 change: 1 addition & 0 deletions src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"kb.activity",
"kb.digest",
"kb.search",
"kb.explain_ranking",
"kb.neighbors",
"kb.experts",
"kb.context",
Expand Down
71 changes: 71 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3456,6 +3456,77 @@ def search(
click.echo(f"{k}/{i}\t{snip} ({used})")


@cli.command("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="Also explain kb.context's budget gate at this character cap.")
@click.option("--require-citations", is_flag=True,
help="Also explain the uncited-claim gate.")
@click.option("--format", "fmt", type=click.Choice(["text", "json"]),
default="text", show_default=True)
@click.option("--project", default=None, help="Viewer project for scope filtering.")
@click.option("--agent", default=None, help="Viewer agent for scope filtering.")
def explain_ranking_cmd(
query: str,
limit: int,
max_chars: int | None,
require_citations: bool,
fmt: str,
project: str | None,
agent: str | None,
) -> None:
"""Explain why each candidate for QUERY ranked where it did."""
from .explain_ranking import explain_ranking

store = _load_store()
with _cli_errors():
result = explain_ranking(
store,
query=query,
limit=limit,
max_chars=max_chars,
require_citations=require_citations,
project=project,
agent=agent,
)

if fmt == "json":
_emit_json(result)
return

retrieval = result["retrieval"]
stages = retrieval["stages"]
click.echo(
f"backend: {retrieval['used']} (configured {retrieval['configured']}, "
f"semantic {'available' if retrieval['semantic_available'] else 'unavailable'})"
)
active = [name for name in ("fusion", "recency", "pages_first", "rerank")
if stages.get(name)]
if stages.get("strategy"):
active.append(f"strategy={stages['strategy']}")
click.echo(f"stages active: {', '.join(active) if active else 'none'}")

for cand in result["candidates"]:
click.echo(
f"\n{cand['kind']}/{cand['id']} gate={cand['gate']}"
f" lexical={cand['lexical_rank']} semantic={cand['semantic_rank']}"
f" rrf={cand['rrf_contribution']}"
)
for row in cand["stages"]:
# a stage that did not run is shown so the chain reads continuously,
# flagged rather than silently absent.
flag = "" if row["applied"] else " (off)"
drank = row["rank_delta"]
dscore = row["score_delta"]
moved = "" if not drank else f" rank{drank:+d}"
shifted = "" if not dscore else f" score{dscore:+.6f}"
click.echo(
f" {row['stage']:<14} rank={row['rank']} "
f"score={row['score']:.6f}{moved}{shifted}{flag}"
)


@cli.command()
@click.argument("node_id")
@click.option("--depth", default=1, show_default=True, type=int)
Expand Down
Loading
Loading