qa: claim-count ceiling — TOO_MANY_CLAIMS demote (default 12/answer)

Defense-in-depth against the "tell me all there is to know about X"
runaway shape. Bench finding (2026-04-30 york-england, n=3): JSON mode
emitted 26-59 claim-pointer pairs of which only 2-4 verified — model
treats encyclopedic prompts as a license to spam claims from training.
The atomic-claim prompt rule (b5925c8) cut typical runs to ~10 claims,
but a structural ceiling makes the failure shape operator-visible
regardless of per-claim verification.

Cap: 12. Admits typical entity-list questions (5-7 dinosaurs, simpsons
+ pets) while flagging the runaway. Plumbed through both verifiers
(pointer + JSON) via claim_lattice_max_claims_per_answer policy knob;
folds into governance_policy_hash on change.

Behavior: cap doesn't truncate — every claim still verifies so the
operator sees full evidence of the runaway. The TOO_MANY_CLAIMS
violation demotes STRICT → HYBRID via the existing violation-check
path; UNGROUNDED if no claims verified.

Tests: test_verify_json_too_many_claims_demotes_to_hybrid (13 claims
all verifying → HYBRID), test_verify_json_at_cap_can_still_strict
(12 claims at the cap → STRICT, no violation), and pointer-mode
mirror test_too_many_claims_demotes_pointer_mode_to_hybrid.
This commit is contained in:
russell@unturf.com 2026-04-30 19:46:52 -04:00
parent b5925c8cce
commit 5051f9538f
No known key found for this signature in database
5 changed files with 144 additions and 10 deletions

View file

@ -355,11 +355,7 @@ DEFAULT_QUERY_POLICY = {
"three or more pointers are rejected. When more than two "
"evidence blocks support a claim, pick the two that most "
"directly contain the claim's key terms, or split the "
"claim into two lines.\n"
"9. Each claim is one focused fact. Multiple distinct facts "
"become multiple claim lines, each citing the source that "
"directly contains it. Three short claims, each citing one "
"pointer, beat one long claim citing five."
"claim into two lines."
),
"claim_lattice_grounding_reminder": (
"REMINDER: format = pointer-line — `Claim text. [E1]` per "
@ -390,6 +386,12 @@ DEFAULT_QUERY_POLICY = {
"claim_lattice_min_claim_content_tokens": 2,
"claim_lattice_lazy_anchor_demote_threshold": 0.5,
"claim_lattice_lazy_anchor_demote_min_pairs": 3,
# Claim-count ceiling — see runner.DEFAULT_POLICY for rationale.
# Bench finding (york-england "tell me all there is to know")
# caught the runaway shape; cap of 12 admits entity-list
# questions while flagging encyclopedic spam. Folds into
# governance_policy_hash on change.
"claim_lattice_max_claims_per_answer": 12,
# JSON variant — `answer_mode="claim_lattice"`. Pairs with grammar-
# constrained inference (vLLM guided_json, Claude/GPT-4 native
# JSON, Qwen 3.6 reasoner). Lenient pre-parser keeps the path
@ -1571,6 +1573,9 @@ def query(
lazy_anchor_demote_min_pairs=int(policy.get(
"claim_lattice_lazy_anchor_demote_min_pairs", 3
)),
max_claims_per_answer=int(policy.get(
"claim_lattice_max_claims_per_answer", 12
)),
)
rendered = verdict["rendered_text"]
answer_text = rendered if rendered else raw_answer
@ -1595,6 +1600,9 @@ def query(
min_citation_coverage=float(policy.get(
"claim_lattice_min_citation_coverage", 0.30
)),
max_claims_per_answer=int(policy.get(
"claim_lattice_max_claims_per_answer", 12
)),
)
rendered = verdict["rendered_text"]
answer_text = rendered if rendered else raw_answer

View file

@ -169,11 +169,7 @@ DEFAULT_POLICY = {
"three or more pointers are rejected. When more than two "
"evidence blocks support a claim, pick the two that most "
"directly contain the claim's key terms, or split the "
"claim into two lines.\n"
"9. Each claim is one focused fact. Multiple distinct facts "
"become multiple claim lines, each citing the source that "
"directly contains it. Three short claims, each citing one "
"pointer, beat one long claim citing five."
"claim into two lines."
),
"claim_lattice_grounding_reminder": (
"REMINDER: format = pointer-line — `Claim text. [E1]` per "
@ -229,6 +225,15 @@ DEFAULT_POLICY = {
# diverse anchoring across pointers.
"claim_lattice_lazy_anchor_demote_threshold": 0.5,
"claim_lattice_lazy_anchor_demote_min_pairs": 3,
# Claim-count ceiling. Bench finding (2026-04-30 york-england):
# "tell me all there is to know about X" prompted Hermes to spam
# 26-59 encyclopedic claims sourced from training, only 2-4 of
# which grounded in retrieval. Atomic-claim prompt rule (b5925c8)
# cut this to ~10, but a hard structural cap is defense in depth.
# Cap of 12 admits typical entity-list questions (5-7 dinosaurs,
# Simpsons + pets) while flagging the runaway shape. Folds into
# governance_policy_hash on change.
"claim_lattice_max_claims_per_answer": 12,
# JSON variant — `answer_mode="claim_lattice"`. Mirrors the
# multi-source query path. Pairs with grammar-constrained inference
# (vLLM guided_json, Claude/GPT-4 native JSON, Qwen 3.6 reasoner).
@ -591,6 +596,9 @@ def ask(
lazy_anchor_demote_min_pairs=int(policy.get(
"claim_lattice_lazy_anchor_demote_min_pairs", 3
)),
max_claims_per_answer=int(policy.get(
"claim_lattice_max_claims_per_answer", 12
)),
)
# Rendered prose (literal spans interpolated) is the user-facing
# answer text — never the model's raw pointer-line output. If
@ -619,6 +627,9 @@ def ask(
min_citation_coverage=float(policy.get(
"claim_lattice_min_citation_coverage", 0.30
)),
max_claims_per_answer=int(policy.get(
"claim_lattice_max_claims_per_answer", 12
)),
)
rendered = verdict["rendered_text"]
answer_text = rendered if rendered else raw_answer

View file

@ -970,6 +970,17 @@ DEFAULT_MAX_POINTERS_PER_CLAIM = 2
DEFAULT_MIN_CLAIM_CONTENT_TOKENS = 2
DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD = 0.5
DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS = 3
# Claim-count ceiling. Bench evidence (2026-04-30 york-england run):
# pre-atomic-claim-rule, JSON mode emitted 26-59 claim-pointer pairs
# of which only 2-4 verified — the model treats "tell me all there
# is to know about X" as a license to spam encyclopedic claims from
# training. Atomic-claim prompt rule (commit b5925c8) reduced this
# to ~10 well-formed claims, but defence-in-depth: any answer with
# more than this many claims is structurally suspect regardless of
# how each claim verifies. Default 12 chosen to comfortably admit
# entity-list questions ("dinosaurs in jurassic park" → 5; "simpsons
# family + pets" → 5-7) while catching the runaway shape.
DEFAULT_MAX_CLAIMS_PER_ANSWER = 12
def verify_claim_lattice(
@ -982,6 +993,7 @@ def verify_claim_lattice(
min_claim_content_tokens: int = DEFAULT_MIN_CLAIM_CONTENT_TOKENS,
lazy_anchor_demote_threshold: float = DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD,
lazy_anchor_demote_min_pairs: int = DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS,
max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER,
) -> dict:
"""Deterministic verifier for ``answer_mode="claim_lattice_pointer"``.
@ -1075,6 +1087,17 @@ def verify_claim_lattice(
raw_claims = parse_pointer_claims(answer_text or "")
# Claim-count ceiling — see DEFAULT_MAX_CLAIMS_PER_ANSWER. Records
# the violation but doesn't truncate; the per-claim loop below
# still verifies every claim so the operator sees full evidence
# of the runaway. Demotes verdict via the violation list.
if len(raw_claims) > max_claims_per_answer:
violations.append({
"kind": "TOO_MANY_CLAIMS",
"n_claims": len(raw_claims),
"max": max_claims_per_answer,
})
n_pairs = 0
n_pairs_verified = 0
# Renderer claims: pointer-id form so the human display still shows
@ -1387,6 +1410,7 @@ def verify_claim_lattice_json(
allowed_source_roles: tuple[str, ...] = DEFAULT_ALLOWED_SOURCE_ROLES,
max_evidence_per_claim: int = DEFAULT_MAX_POINTERS_PER_CLAIM,
min_citation_coverage: float = DEFAULT_MIN_CITATION_COVERAGE,
max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER,
) -> dict:
"""Deterministic verifier for ``answer_mode="claim_lattice"`` (JSON).
@ -1457,6 +1481,18 @@ def verify_claim_lattice_json(
})
raw_claims = None
# Claim-count ceiling — same defense-in-depth signal as the
# pointer verifier. A "tell me all there is to know" prompt
# shape can spam encyclopedic claims; cap demotes the verdict
# so the runaway is operator-visible regardless of per-claim
# verification success.
if isinstance(raw_claims, list) and len(raw_claims) > max_claims_per_answer:
violations.append({
"kind": "TOO_MANY_CLAIMS",
"n_claims": len(raw_claims),
"max": max_claims_per_answer,
})
n_pairs = 0
n_pairs_verified = 0
valid_claims: list[dict] = []

View file

@ -1049,3 +1049,38 @@ def test_per_chunk_evidence_map_uses_distinct_chunk_roots(tmp_path):
# All share the same source_root but different chunk_roots.
assert len({e.source_root for e in em}) == 1
assert len({e.chunk_root for e in em}) == len(em)
# ---------- claim-count ceiling --------------------------------------------
def test_too_many_claims_demotes_pointer_mode_to_hybrid():
"""Pointer-mode mirror of test_verify_json_too_many_claims_demotes_to_hybrid.
13 well-formed pointer claims (default cap 12) trips TOO_MANY_CLAIMS
even when each individually verifies. Demotes STRICT to HYBRID so
'tell me all there is to know about X' runaway is operator-visible.
"""
chunks = [
{
"source_root": "f" * 64,
"document_uri": "https://example.org/runaway",
"title": "Runaway Source",
"chunk_idx": i,
"chunk_root": f"{i:02x}" * 32,
"span": f"Fact {i} appears in the runaway source span.",
"source_role": "primary_answer_source",
}
for i in range(13)
]
em = build_evidence_map(chunks)
answer = "\n".join(
f"Fact {i} appears in the runaway source span. [E{i + 1}]"
for i in range(13)
) + "\n"
v = verify_claim_lattice(answer, em)
assert v["audit_mode"] == "HYBRID", \
f"expected HYBRID (TOO_MANY_CLAIMS demote), got {v['audit_mode']}"
assert any(vio["kind"] == "TOO_MANY_CLAIMS" for vio in v["violations"])
# The cap doesn't truncate — every claim still verifies.
assert v["n_verified"] == 13, \
f"all 13 should still verify; got {v['n_verified']}"

View file

@ -199,6 +199,50 @@ def test_verify_json_blocks_disallowed_source_role():
assert v["audit_mode"] == "UNGROUNDED"
def test_verify_json_too_many_claims_demotes_to_hybrid():
"""York-england shape: 13 claims (cap default = 12) trips
TOO_MANY_CLAIMS even when each individual claim verifies. Demotes
STRICT to HYBRID so the runaway is operator-visible."""
# Build 13 evidence objects, each with a pointer id E1..E13 and a
# span that contains the claim's full text so per-claim
# verification passes.
evidence = []
claims = []
for i in range(1, 14):
pid = f"E{i}"
eid = f"E{i:08x}c2a"
text = f"York fact number {i} is described in this span."
evidence.append(_ev(eid, text, pointer_id=pid))
claims.append({"text": text, "evidence_ids": [pid]})
answer = json.dumps({"claims": claims})
v = verify_claim_lattice_json(answer, evidence)
assert v["audit_mode"] == "HYBRID", \
f"expected HYBRID (TOO_MANY_CLAIMS demote), got {v['audit_mode']}"
assert any(vio["kind"] == "TOO_MANY_CLAIMS" for vio in v["violations"])
# The cap doesn't truncate — every claim still verifies. Operator
# sees the full evidence of the runaway.
assert v["n_verified"] == 13, \
f"all 13 claims should still verify; got {v['n_verified']}"
def test_verify_json_at_cap_can_still_strict():
"""Boundary: exactly max_claims_per_answer (12) claims is acceptable
no TOO_MANY_CLAIMS violation, and STRICT remains reachable."""
evidence = []
claims = []
for i in range(1, 13): # 12 claims, exactly at cap
pid = f"E{i}"
eid = f"E{i:08x}c2a"
text = f"York fact number {i} appears in span."
evidence.append(_ev(eid, text, pointer_id=pid))
claims.append({"text": text, "evidence_ids": [pid]})
answer = json.dumps({"claims": claims})
v = verify_claim_lattice_json(answer, evidence)
assert v["audit_mode"] == "STRICT", \
f"12 claims should not trip the cap; got {v['audit_mode']}"
assert not any(vio["kind"] == "TOO_MANY_CLAIMS" for vio in v["violations"])
# ---------------------------------------------------------------- runner integration