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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ All notable changes to vouch are documented here. Format follows
artifact the caller could not already retrieve, and it touches no write path.

### Fixed
- **`kb.experts` no longer leaks out-of-scope claims into entity rankings**
(#714): `rank_experts` aggregated evidence density over every claim in
the KB with no viewer/scope filtering at all, unlike every sibling
claim-aggregating read surface (`context.py`, `graph.py`, `digest.py`,
`health.py`, `compile.py`, and `themes.detect_themes`, the closest
shape-wise sibling). A `project`- or `agent`-scoped claim the caller
cannot otherwise retrieve still inflated `claim_count`, `citation_count`,
and `score`, and could surface verbatim in `top_claim_ids` — handing the
caller a claim id it cannot fetch. `rank_experts` now takes an optional
`viewer` (defaulting to `scoping.viewer_from(...)`, matching
`detect_themes`) and filters through `scoping.is_visible` before a claim
can contribute anything, with the FTS candidate fetch run through
`scoping.scoped_fetch_limit` so a mostly-out-of-scope KB doesn't starve
the candidate pool before the filter runs.
- **`vouch render-wiki` drops archived pages** (#695):
`render_wiki_cmd` passed every on-disk page into index/MOC, so retired
titles kept wiki links after archive. the CLI now filters to the same
Expand Down
22 changes: 19 additions & 3 deletions src/vouch/experts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from . import index_db
from .models import Claim, ClaimStatus, utcnow
from .salience import _substring_entity_ids
from .scoping import ViewerContext, is_visible, scoped_fetch_limit, viewer_from
from .storage import KBStore

# A superseded / archived / redacted claim is not live evidence and must never
Expand Down Expand Up @@ -44,24 +45,37 @@ def rank_experts(
limit: int = 10,
min_claims: int = 1,
weight: str = "count",
viewer: ViewerContext | None = None,
) -> list[dict[str, Any]]:
"""Return entities ranked by evidence density on ``topic``.

``weight`` is one of ``count`` | ``recency`` | ``citation``; an unknown
value falls back to ``count`` (never raises), matching the defensive-config
style used elsewhere. Ordered by descending score with a stable tie-break
on ``entity_id``.
on ``entity_id``. ``viewer`` defaults to the config-resolved context, so a
KB read with no explicit viewer reads as its own project (see
``scoping.viewer_from``) — matching ``themes.detect_themes``, the sibling
read surface this mirrors. Claims the viewer cannot retrieve never
contribute to ``claim_count``, ``citation_count``, or ``score``, and never
appear in ``top_claim_ids``: scoring after filtering, not just scrubbing
the id list, is what keeps a mostly-private entity from outranking one
the viewer can actually read.
"""
if weight not in _VALID_WEIGHTS:
weight = "count"
if viewer is None:
viewer = viewer_from(config_path=store.config_path)

entities = store.list_entities()
by_id = {ent.id: ent for ent in entities}
topic_entity_ids = set(_substring_entity_ids(entities, topic))

# Candidate claims: FTS hits on the topic, plus every claim that references
# an entity whose name/alias matches the topic.
fetch = max(limit * 5, 50)
# an entity whose name/alias matches the topic. Over-fetched via
# scoped_fetch_limit so a viewer-scoped KB whose top FTS hits are mostly
# private doesn't starve the candidate pool before the scope filter below
# ever runs.
fetch = scoped_fetch_limit(max(limit * 5, 50), viewer)
fts_claim_ids = {
cid
for kind, cid, _snip, _score in index_db.search(store.kb_dir, topic, limit=fetch)
Expand All @@ -77,6 +91,8 @@ def rank_experts(
for claim in store.list_claims():
if claim.status in _EXCLUDED_STATUSES:
continue
if not is_visible(claim.scope, viewer):
continue
matched = claim.id in fts_claim_ids or bool(
set(claim.entities) & topic_entity_ids
)
Expand Down
48 changes: 47 additions & 1 deletion tests/test_experts.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

from vouch.experts import rank_experts
from vouch.jsonl_server import handle_request
from vouch.models import Claim, ClaimStatus, Entity, EntityType
from vouch.models import ArtifactScope, Claim, ClaimStatus, Entity, EntityType, Visibility
from vouch.scoping import ViewerContext
from vouch.storage import KBStore


Expand Down Expand Up @@ -127,6 +128,51 @@ def test_deterministic_tie_break_on_entity_id(store: KBStore) -> None:
assert tied == ["a1", "a2"] # equal score -> ascending entity_id


def test_scopes_ranking_to_the_viewer(store: KBStore) -> None:
"""A viewer outside a claim's project must not have that claim inflate
claim_count/citation_count/score, and must never see its id surface in
top_claim_ids — scoring after filtering, not just scrubbing the id list,
is what keeps a mostly-private entity from outranking one the viewer can
actually read."""
src = store.put_source(b"y")
store.put_entity(Entity(id="jwt", name="JWT", type=EntityType.CONCEPT))
store.put_entity(Entity(id="alice", name="alice", type=EntityType.PERSON))
for i in range(3):
store.put_claim(
Claim(
id=f"priv{i}",
text=f"jwt fact {i} by alice",
evidence=[src.id],
entities=["jwt", "alice"],
scope=ArtifactScope(visibility=Visibility.PROJECT, project="secret-project"),
)
)
store.put_claim(
Claim(
id="pub",
text="jwt public fact by alice",
evidence=[src.id],
entities=["jwt", "alice"],
scope=ArtifactScope(visibility=Visibility.PUBLIC),
)
)

foreign = ViewerContext(project="other-project", agent=None)
rows = rank_experts(store, "JWT", viewer=foreign)
alice = next(r for r in rows if r["entity_id"] == "alice")
assert alice["claim_count"] == 1
assert alice["top_claim_ids"] == ["pub"]
for row in rows:
assert "priv0" not in row["top_claim_ids"]
assert "priv1" not in row["top_claim_ids"]
assert "priv2" not in row["top_claim_ids"]

owner = ViewerContext(project="secret-project", agent=None)
owner_rows = rank_experts(store, "JWT", viewer=owner)
owner_alice = next(r for r in owner_rows if r["entity_id"] == "alice")
assert owner_alice["claim_count"] == 4


def test_jsonl_experts_envelope_success(store: KBStore, monkeypatch) -> None:
# kb.experts over the JSONL contract: a well-formed request returns the
# {id, ok, result} envelope with the ranking under result["experts"].
Expand Down
Loading