Skip to content

Commit 49cfc45

Browse files
Yurii214Yaroslav98214cursoragentplind-junior
authored
fix(context): compute require_citations gate after max_chars budget (#268)
the citation gate and uncited_items listed claims dropped by the max_chars budget, so a pack could fail require_citations for items the caller never received. evaluate uncited only over the returned item list. fixes #174. Co-authored-by: Yaroslav98214 <diakovichyaroslav30@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: plind <59729252+plind-junior@users.noreply.github.com>
1 parent 161f654 commit 49cfc45

3 files changed

Lines changed: 85 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ All notable changes to vouch are documented here. Format follows
368368
KB under `eval/fixture-kb/`, and an `eval` workflow gating retrieval changes
369369
(#226).
370370
### Fixed
371+
- `build_context_pack` now evaluates the `require_citations` gate (and
372+
`quality.uncited_items`) after the `max_chars` budget drops tail items, so
373+
the pack is never failed for uncited claims the caller did not receive.
374+
Fixes #174.
371375
- `audit.log_event` now holds an exclusive cross-process lock around read-prev-hash → derive → append, closing a TOCTOU race where two concurrent writers observed the same `prev_hash` and forked the chain — `verify_chain` then reported "previous hash mismatch" at the second concurrent event forever, breaking the tamper-evidence guarantee from #244 under ordinary multi-writer usage (`vouch serve` + concurrent CLI, multiple agents on JSONL, scripted backgrounded approvals). Uses `fcntl.flock` on POSIX and `msvcrt.locking` on Windows against a sibling `audit.log.jsonl.lock` file so the audit log itself is never opened in a mode that could truncate it. Fixes #262.
372376
- `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.
373377
- `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.

src/vouch/context.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,6 @@ def build_context_pack(
385385
budget_clipped = 0
386386
budget_omitted = 0
387387

388-
if require_citations:
389-
for it in items:
390-
if it.type == "claim" and not it.citations:
391-
uncited.append(it.id)
392-
393388
if max_chars is not None:
394389
total = sum(len(i.summary) for i in items)
395390
if total > max_chars:
@@ -403,6 +398,14 @@ def build_context_pack(
403398
items.pop()
404399
budget_omitted += 1
405400

401+
# Compute the citation gate over the items actually returned — after the
402+
# max_chars budget has dropped tail items — so the gate never fails on (or
403+
# reports in uncited_items) claims the consumer did not receive.
404+
if require_citations:
405+
uncited = [
406+
it.id for it in items if it.type == "claim" and not it.citations
407+
]
408+
406409
if len(items) < min_items:
407410
warnings.append(f"only {len(items)} items, minimum {min_items}")
408411
failed.append("min_items")

tests/test_context.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,79 @@ def test_context_pack_has_quality_metadata(store: KBStore) -> None:
3838
assert pack["quality"]["ok"] is True
3939

4040

41+
def test_require_citations_only_considers_returned_items(
42+
store: KBStore, monkeypatch: pytest.MonkeyPatch,
43+
) -> None:
44+
"""Regression for #174: uncited_items and the require_citations gate must
45+
reference only claims still present after max_chars trimming."""
46+
src = store.put_source(b"e")
47+
for i in range(20):
48+
store.put_claim(Claim(
49+
id=f"c{i}",
50+
text=f"padding claim number {i} with extra padding text",
51+
evidence=[src.id],
52+
))
53+
health.rebuild_index(store)
54+
55+
real_get_claim = store.get_claim
56+
57+
def get_claim_as_uncited(cid: str) -> Claim:
58+
return real_get_claim(cid).model_copy(update={"evidence": []})
59+
60+
monkeypatch.setattr(store, "get_claim", get_claim_as_uncited)
61+
62+
pack = context.build_context_pack(
63+
store, query="padding", max_chars=80, require_citations=True,
64+
)
65+
returned = {it["id"] for it in pack["items"]}
66+
assert pack["quality"]["uncited_items"], "expected uncited claims to be flagged"
67+
assert all(uid in returned for uid in pack["quality"]["uncited_items"])
68+
69+
70+
def test_require_citations_ok_when_budget_drops_all_uncited(
71+
store: KBStore, monkeypatch: pytest.MonkeyPatch,
72+
) -> None:
73+
"""Regression for #174: require_citations must not fail on uncited claims
74+
the max_chars budget already removed from the returned pack."""
75+
src = store.put_source(b"e")
76+
for i in range(10):
77+
store.put_claim(Claim(
78+
id=f"cited{i}",
79+
text=f"alpha cited claim {i}",
80+
evidence=[src.id],
81+
))
82+
store.put_claim(Claim(
83+
id=f"uncited{i}",
84+
text=f"beta uncited padding claim {i} with extra text",
85+
evidence=[src.id],
86+
))
87+
health.rebuild_index(store)
88+
89+
real_get_claim = store.get_claim
90+
cited_ids = {f"cited{i}" for i in range(10)}
91+
92+
def get_claim_with_citation_state(cid: str) -> Claim:
93+
claim = real_get_claim(cid)
94+
if cid in cited_ids:
95+
return claim
96+
return claim.model_copy(update={"evidence": []})
97+
98+
monkeypatch.setattr(store, "get_claim", get_claim_with_citation_state)
99+
100+
pack = context.build_context_pack(
101+
store,
102+
query="alpha",
103+
limit=10,
104+
max_chars=120,
105+
require_citations=True,
106+
)
107+
returned = {it["id"] for it in pack["items"]}
108+
assert returned, pack
109+
assert all(cid.startswith("cited") for cid in returned), pack
110+
assert pack["quality"]["uncited_items"] == []
111+
assert pack["quality"]["ok"] is True
112+
113+
41114
def test_context_pack_max_chars_omits_items(store: KBStore) -> None:
42115
src = store.put_source(b"e")
43116
# Many short claims — total summary length > 100 chars.

0 commit comments

Comments
 (0)