Skip to content

Commit ee3c528

Browse files
fix(search): exclude retracted claims and archived pages
kb.context already drops ARCHIVED/SUPERSEDED/REDACTED claims; kb.search did not, so lifecycle controls leaked on the detail-search surface. backends over-fetch a candidate pool before that filter so retracted top-hits cannot starve the requested result limit. Fixes #581
1 parent 1fe8fb5 commit ee3c528

3 files changed

Lines changed: 177 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ All notable changes to vouch are documented here. Format follows
66

77
## [Unreleased]
88

9+
### Fixed
10+
- **`kb.search` excludes retracted claims and archived pages** (#581):
11+
`search_kb` now drops `ARCHIVED` / `SUPERSEDED` / `REDACTED` claims and
12+
`ARCHIVED` pages the same way `kb.context` already does, so lifecycle
13+
controls are not decorative on the detail-search surface. backends
14+
over-fetch a candidate pool before that filter so retracted top-hits
15+
cannot starve the requested result limit.
16+
917
### Added
1018
- **shipped ranking champion** (`vouch.strategies.provenance`): the
1119
engine-lane winner (provenance-aware ranking — hearsay and stored

src/vouch/context.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@
2222
from . import graph, hot_memory, index_db, retrieval_events
2323
from . import strategy as strategy_mod
2424
from .embeddings.fusion import rrf_fuse
25-
from .models import ClaimStatus, ContextItem, ContextPack, ContextQuality
25+
from .models import (
26+
ClaimStatus,
27+
ContextItem,
28+
ContextPack,
29+
ContextQuality,
30+
PageStatus,
31+
)
2632
from .scoping import (
2733
ViewerContext,
2834
filter_hits,
@@ -51,6 +57,12 @@
5157
_STRATEGY_POOL_FACTOR = 5
5258
_STRATEGY_POOL_MIN = 50
5359

60+
# same sizing for kb.search lifecycle filtering: backends cap before status
61+
# filtering, so without over-fetch a window full of retracted hits under-fills
62+
# the requested limit (#581 / coderabbit).
63+
_LIFECYCLE_POOL_FACTOR = _STRATEGY_POOL_FACTOR
64+
_LIFECYCLE_POOL_MIN = _STRATEGY_POOL_MIN
65+
5466
_VALID_BACKENDS = ("auto", "hybrid", "embedding", "fts5", "substring")
5567
_RERANKER_CACHE: Any | None = None
5668

@@ -469,6 +481,11 @@ def search_kb(
469481
agent=agent,
470482
)
471483
fetch_limit = scoped_fetch_limit(limit, viewer)
484+
# over-fetch before lifecycle filtering so retracted/archived hits that
485+
# consume the backend window can be replaced by later live candidates.
486+
candidate_limit = max(
487+
fetch_limit * _LIFECYCLE_POOL_FACTOR, _LIFECYCLE_POOL_MIN,
488+
)
472489
hits: list[tuple[str, str, str, float]] = []
473490
used = backend_arg
474491

@@ -481,42 +498,45 @@ def search_kb(
481498

482499
if backend_arg in ("auto", "hybrid"):
483500
emb = index_db.search_semantic(
484-
store.kb_dir, query, limit=fetch_limit * 2, min_score=min_score,
501+
store.kb_dir, query, limit=candidate_limit * 2, min_score=min_score,
485502
)
486503
try:
487-
fts = index_db.search(store.kb_dir, query, limit=fetch_limit * 2)
504+
fts = index_db.search(store.kb_dir, query, limit=candidate_limit * 2)
488505
except sqlite3.Error:
489506
fts = []
490-
hits = rrf_fuse(emb, fts, limit=fetch_limit)
507+
hits = rrf_fuse(emb, fts, limit=candidate_limit)
491508
if emb and fts:
492509
used = "hybrid"
493510
elif emb:
494511
used = "embedding"
495512
elif fts:
496513
used = "fts5"
497514
if not hits and backend_arg == "auto":
498-
hits = store.search_substring(query, limit=fetch_limit)
515+
hits = store.search_substring(query, limit=candidate_limit)
499516
used = "substring"
500517
elif backend_arg == "embedding":
501518
hits = index_db.search_semantic(
502-
store.kb_dir, query, limit=fetch_limit, min_score=min_score,
519+
store.kb_dir, query, limit=candidate_limit, min_score=min_score,
503520
)
504521
used = "embedding"
505522
elif backend_arg == "fts5":
506523
try:
507-
hits = index_db.search(store.kb_dir, query, limit=fetch_limit)
524+
hits = index_db.search(store.kb_dir, query, limit=candidate_limit)
508525
except sqlite3.Error:
509526
hits = []
510527
used = "fts5"
511528
else: # substring
512-
hits = store.search_substring(query, limit=fetch_limit)
529+
hits = store.search_substring(query, limit=candidate_limit)
513530
used = "substring"
514531

515532
semantic_ok = index_db.semantic_search_available()
516-
scoped = filter_hits(store, hits, viewer, limit=limit)
533+
# scope first without a limit so status filtering can refill the window —
534+
# otherwise a page of retracted hits would leave search under-filled.
535+
scoped = filter_hits(store, hits, viewer, limit=None)
536+
live = _filter_live_hits(store, scoped, limit=limit)
517537
hits_list = [
518538
{"kind": k, "id": i, "snippet": sn, "score": sc, "backend": used}
519-
for k, i, sn, sc in scoped
539+
for k, i, sn, sc in live
520540
]
521541
result: dict[str, Any] = {
522542
"backend": used,
@@ -541,6 +561,40 @@ def search_kb(
541561
)
542562

543563

564+
def _filter_live_hits(
565+
store: KBStore,
566+
hits: list[tuple[str, str, str, float]],
567+
*,
568+
limit: int | None = None,
569+
) -> list[tuple[str, str, str, float]]:
570+
"""Drop retracted claims and archived pages from search hits.
571+
572+
``kb.context`` already applies ``_RETRACTED_CLAIM_STATUSES``; ``kb.search``
573+
must do the same or archive/supersede/redact become decorative on the
574+
surface agents use for detail after recall (#581).
575+
"""
576+
kept: list[tuple[str, str, str, float]] = []
577+
for kind, artifact_id, summary, score in hits:
578+
if kind == "claim":
579+
try:
580+
claim = store.get_claim(artifact_id)
581+
except ArtifactNotFoundError:
582+
continue
583+
if claim.status in _RETRACTED_CLAIM_STATUSES:
584+
continue
585+
elif kind == "page":
586+
try:
587+
page = store.get_page(artifact_id)
588+
except ArtifactNotFoundError:
589+
continue
590+
if page.status is PageStatus.ARCHIVED:
591+
continue
592+
kept.append((kind, artifact_id, summary, score))
593+
if limit is not None and len(kept) >= limit:
594+
break
595+
return kept
596+
597+
544598
def _enrich_summary(store: KBStore, kind: str, artifact_id: str, summary: str) -> str:
545599
"""Return a non-empty summary, falling back to the stored artifact text."""
546600
if summary:

tests/test_context.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,111 @@ def test_context_pack_excludes_archived_claims(store: KBStore) -> None:
172172
assert not any(it["id"] == "c1" for it in pack["items"]), pack
173173

174174

175+
def test_search_kb_excludes_retracted_claims(store: KBStore) -> None:
176+
"""Regression for #581: search_kb must apply the same retracted-status
177+
filter as build_context_pack — otherwise kb.search leaks archived /
178+
superseded / redacted claims after lifecycle controls run."""
179+
from vouch import lifecycle
180+
from vouch.models import Page, PageStatus, PageType
181+
182+
src = store.put_source(b"e")
183+
store.put_claim(Claim(
184+
id="c1", text="mongodb is faster than postgres", evidence=[src.id],
185+
))
186+
store.put_page(Page(
187+
id="p-live", title="mongodb ops", body="live page about mongodb",
188+
type=PageType.CONCEPT, sources=[src.id],
189+
))
190+
store.put_page(Page(
191+
id="p-arch", title="mongodb old", body="archived mongodb notes",
192+
type=PageType.CONCEPT, status=PageStatus.ARCHIVED, sources=[src.id],
193+
))
194+
health.rebuild_index(store)
195+
196+
before = context.search_kb(store, query="mongodb", backend="substring")
197+
ids_before = {h["id"] for h in before["hits"]}
198+
assert "c1" in ids_before, before
199+
assert "p-live" in ids_before, before
200+
assert "p-arch" not in ids_before, before
201+
202+
lifecycle.archive(store, claim_id="c1", actor="reviewer")
203+
after = context.search_kb(store, query="mongodb", backend="substring")
204+
ids_after = {h["id"] for h in after["hits"]}
205+
assert "c1" not in ids_after, after
206+
assert "p-live" in ids_after, after
207+
assert "p-arch" not in ids_after, after
208+
209+
210+
@pytest.mark.parametrize("mode", ["archived", "superseded", "redacted"])
211+
def test_search_kb_excludes_each_retracted_status(
212+
store: KBStore, mode: str,
213+
) -> None:
214+
"""#581: each retracted claim status must disappear from kb.search."""
215+
from vouch import lifecycle
216+
217+
src = store.put_source(b"e")
218+
store.put_claim(Claim(
219+
id="c1", text="mongodb is faster than postgres", evidence=[src.id],
220+
))
221+
health.rebuild_index(store)
222+
assert any(
223+
h["id"] == "c1"
224+
for h in context.search_kb(
225+
store, query="mongodb", backend="substring",
226+
)["hits"]
227+
)
228+
229+
if mode == "archived":
230+
lifecycle.archive(store, claim_id="c1", actor="reviewer")
231+
elif mode == "superseded":
232+
store.put_claim(Claim(
233+
id="c2", text="mongodb is faster than postgres v2",
234+
evidence=[src.id],
235+
))
236+
lifecycle.supersede(
237+
store, old_claim_id="c1", new_claim_id="c2", actor="reviewer",
238+
)
239+
else:
240+
lifecycle.redact(store, claim_id="c1", actor="reviewer")
241+
242+
health.rebuild_index(store)
243+
ids = {
244+
h["id"]
245+
for h in context.search_kb(
246+
store, query="mongodb", backend="substring",
247+
)["hits"]
248+
}
249+
assert "c1" not in ids, ids
250+
251+
252+
def test_search_kb_refills_limit_after_lifecycle_filter(store: KBStore) -> None:
253+
"""#581: over-fetch so archived top-hits do not starve live results."""
254+
from vouch import lifecycle
255+
256+
src = store.put_source(b"e")
257+
# high substring score (many query matches) but retracted — would fill a
258+
# tight backend window and hide the live claim without candidate over-fetch.
259+
for i in range(5):
260+
cid = f"arch-{i}"
261+
store.put_claim(Claim(
262+
id=cid,
263+
text="mongodb mongodb mongodb mongodb mongodb",
264+
evidence=[src.id],
265+
))
266+
lifecycle.archive(store, claim_id=cid, actor="reviewer")
267+
store.put_claim(Claim(
268+
id="live-mongo", text="mongodb tip", evidence=[src.id],
269+
))
270+
health.rebuild_index(store)
271+
272+
result = context.search_kb(
273+
store, query="mongodb", backend="substring", limit=1,
274+
)
275+
ids = [h["id"] for h in result["hits"]]
276+
assert ids == ["live-mongo"], result
277+
assert len(result["hits"]) == 1
278+
279+
175280
def test_context_pack_excludes_superseded_claims(store: KBStore) -> None:
176281
"""Regression for #78: supersede(old, new) must keep `new` retrievable
177282
while removing `old` from kb.context."""

0 commit comments

Comments
 (0)