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:
parent
b5925c8cce
commit
5051f9538f
5 changed files with 144 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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']}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue