From 4b5f8ccd7e8c80aaa9057594c1d37afc3b3e29d4 Mon Sep 17 00:00:00 2001 From: seekmistar01 Date: Sat, 6 Jun 2026 15:21:16 +0800 Subject: [PATCH] fix(context): compute require_citations gate after the max_chars budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_context_pack` computed the `uncited` claim list (and therefore the `require_citations` gate failure and the `uncited_items` reported in `quality`) over the full item list, then the `max_chars` budget popped tail items. So a ContextPack could be returned `ok=False` with `uncited_items=[...]` naming claims that are not present in `pack["items"]` at all — the consumer is told the pack failed citation requirements because of items it never received. Move the citation-gate computation to after the budget step so it only considers the items actually returned. Add a regression test asserting `uncited_items` is a subset of the returned items when `max_chars` and `require_citations` are combined. Co-Authored-By: Claude Opus 4.8 --- src/vouch/context.py | 11 ++++++----- tests/test_context.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/vouch/context.py b/src/vouch/context.py index 667fb518..aa975190 100644 --- a/src/vouch/context.py +++ b/src/vouch/context.py @@ -106,11 +106,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: @@ -124,6 +119,12 @@ 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") diff --git a/tests/test_context.py b/tests/test_context.py index 338d7301..1228d399 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -38,6 +38,26 @@ 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) -> None: + # Uncited claims (no evidence). With max_chars dropping tail items, the + # require_citations gate and uncited_items must reference only items actually + # returned — never claims the budget already dropped. Before the fix, uncited + # was computed pre-budget, so uncited_items could name absent items. + for i in range(20): + store.put_claim(Claim( + id=f"u{i}", + text=f"uncited padding claim number {i} with extra text", + evidence=[], + )) + health.rebuild_index(store) + 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_context_pack_max_chars_omits_items(store: KBStore) -> None: src = store.put_source(b"e") # Many short claims — total summary length > 100 chars.