Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ All notable changes to vouch are documented here. Format follows
KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes
(#226).
### Fixed
- `build_context_pack` now evaluates the `require_citations` gate (and
`quality.uncited_items`) after the `max_chars` budget drops tail items, so
the pack is never failed for uncited claims the caller did not receive.
Fixes #174.
- `parse_since` (the `--since` parser behind `vouch metrics`/`vouch audit`) now raises a clean `MetricsError` for a duration too large to represent (e.g. `--since 1000000000000d`), instead of letting an uncaught `OverflowError` traceback escape — restoring the documented "clean error, not a traceback" contract.
- `sync_apply` now loads the sync source exactly once and passes the same `_SyncSource` instance into `sync_check`, closing a TOCTOU window where a bundle replaced on disk between the two `_load_source` calls could cause the validation and write phases to operate on different snapshots. Also eliminates redundant directory walks (KB sources) and triple tarball opens (bundle sources). Fixes #217.
- `vault_to_kb` now passes `slug_hint=page_id` to `propose_page` so vault edit proposals target the existing page id from frontmatter instead of a slugified copy of the title (fixes #219).
Expand Down
13 changes: 8 additions & 5 deletions src/vouch/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ def build_context_pack(
budget_clipped = 0
budget_omitted = 0

if require_citations:
for it in items:
if it.type == "claim" and not it.citations:
uncited.append(it.id)

if max_chars is not None:
total = sum(len(i.summary) for i in items)
if total > max_chars:
Expand All @@ -100,6 +95,14 @@ def build_context_pack(
items.pop()
budget_omitted += 1

# Compute the citation gate over the items actually returned — after the
# max_chars budget has dropped tail items — so the gate never fails on (or
# reports in uncited_items) claims the consumer did not receive.
if require_citations:
uncited = [
it.id for it in items if it.type == "claim" and not it.citations
]

if len(items) < min_items:
warnings.append(f"only {len(items)} items, minimum {min_items}")
failed.append("min_items")
Expand Down
73 changes: 73 additions & 0 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,79 @@ def test_context_pack_has_quality_metadata(store: KBStore) -> None:
assert pack["quality"]["ok"] is True


def test_require_citations_only_considers_returned_items(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression for #174: uncited_items and the require_citations gate must
reference only claims still present after max_chars trimming."""
src = store.put_source(b"e")
for i in range(20):
store.put_claim(Claim(
id=f"c{i}",
text=f"padding claim number {i} with extra padding text",
evidence=[src.id],
))
health.rebuild_index(store)

real_get_claim = store.get_claim

def get_claim_as_uncited(cid: str) -> Claim:
return real_get_claim(cid).model_copy(update={"evidence": []})

monkeypatch.setattr(store, "get_claim", get_claim_as_uncited)

pack = context.build_context_pack(
store, query="padding", max_chars=80, require_citations=True,
)
returned = {it.id for it in pack.items}
assert pack.quality.uncited_items, "expected uncited claims to be flagged"
assert all(uid in returned for uid in pack.quality.uncited_items)


def test_require_citations_ok_when_budget_drops_all_uncited(
store: KBStore, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression for #174: require_citations must not fail on uncited claims
the max_chars budget already removed from the returned pack."""
src = store.put_source(b"e")
for i in range(10):
store.put_claim(Claim(
id=f"cited{i}",
text=f"alpha cited claim {i}",
evidence=[src.id],
))
store.put_claim(Claim(
id=f"uncited{i}",
text=f"beta uncited padding claim {i} with extra text",
evidence=[src.id],
))
health.rebuild_index(store)

real_get_claim = store.get_claim
cited_ids = {f"cited{i}" for i in range(10)}

def get_claim_with_citation_state(cid: str) -> Claim:
claim = real_get_claim(cid)
if cid in cited_ids:
return claim
return claim.model_copy(update={"evidence": []})

monkeypatch.setattr(store, "get_claim", get_claim_with_citation_state)

pack = context.build_context_pack(
store,
query="alpha",
limit=10,
max_chars=120,
require_citations=True,
)
returned = {it.id for it in pack.items}
assert returned, pack
assert all(cid.startswith("cited") for cid in returned), pack
assert pack.quality.uncited_items == []
assert pack.quality.ok is True


def test_context_pack_max_chars_omits_items(store: KBStore) -> None:
src = store.put_source(b"e")
# Many short claims — total summary length > 100 chars.
Expand Down
Loading