Files
imbad0202__academic-researc…/scripts/test_claim_audit_finalizer.py
T
Edward Cheng-I Wu 7f878f7ad2 feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors (#566)
* feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors

Closes the local-extraction-channel gap between v3.7.3 locator presence
and the #182 existence gate: a page anchor derived from a silently
truncated/mispaginated PDF read passes every existing gate.

- scripts/pdf_read_preflight.py: three independent page-count signals
  (raw root /Count, own cycle-guarded /Kids walk, pypdf page list) ->
  PASS/FAIL/UNAVAILABLE JSON sidecar with file sha256 + parser warnings.
  pypdf-backed with the verify_submission_package ImportError precedent.
- R-L3-1-D firm rule in the three v3.7.3 emitters (+ agents/ mirrors):
  local-PDF page anchors require a PASS sidecar in context, else
  anchor:none or an independently-visible locator + explicit warning.
- claim_ref_alignment_audit_agent Step 4: precondition bound to
  ref_retrieval_method == manual_pdf, sidecars joined on ref_slug
  (sha256 confirmatory until #513), [pdf_read_integrity_unverified]
  advisory tag - never UNSUPPORTED on this basis alone.
- pipeline_orchestrator_agent §3.6: preflight once per locally-read
  corpus PDF, upstream of the writers; #528 content lock re-pinned.
- 15-test synthetic-PDF suite (in-test assembly, no binary fixtures).

Closes #512

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-1 cross-model review closures (3 P1 + 2 P2)

- preflight: trailing-data-after-final-%%EOF veto (truncated incremental
  update otherwise PASSes on the older revision's agreeing counts);
  parser warnings survive early exits (appended in capture finally)
- orchestrator: preflight moved to Stage 1 corpus intake, independent of
  the opt-in audit mode (audit-gated preflight left default runs
  sidecar-less at R-L3-1-D, gate-refusing valid citations); #528 content
  lock re-pinned
- executable path: run_audit_pipeline(pdf_preflight_sidecars=...) tags
  manual_pdf page-anchor rows at the Step-6 emission point after cache
  resolution (cache hits cannot bypass; tag never enters cache body)
- finalizer: [LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED] advisory on
  SUPPORTED rows carrying the tag (content-based-fallback support no
  longer renders the advisory invisible)
- tests: preflight 15→18, +8 pipeline, +3 finalizer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-2 cross-model review closures (7 P1)

- preflight: xref-coverage cross-check (stale startxref pointing at a
  previous revision's xref with its own %%EOF now vetoes PASS); /Count
  must be a real integer object (float/string coercion rejected)
- R-L3-1-D: FAIL vs UNAVAILABLE split - positive truncation evidence
  refuses the page anchor; absence of verification (standalone dispatch,
  no-Python installs, unpreflighted files) is an explicit-warning
  advisory, never a manufactured refusal (mirrors detection-vs-
  terminality precedent)
- pipeline: retrieve_fn receives pdf_preflight_verdict on page-anchor
  citations so passage selection can go content-based BEFORE the judge
  reads a page-scoped passage; freshness (sha256 re-check) documented as
  the orchestrator's contract
- orchestrator: sha256 re-check before dispatch; cross-runtime coverage
  (standalone deep-research/academic-paper dispatch, skipped Stage 1);
  tagged-SUPPORTED row added to the operational finalizer matrix;
  content lock re-pinned
- tests: preflight 18→20, pipeline +1 (verdict-passing contract)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-3 cross-model review closures (2 P1)

- xref-coverage: redefined-object variant caught - the newest raw copy
  of every directly-stored object must be the copy the active xref
  chain references (calibration guard skips offset-shifted files
  rather than mass-flagging)
- trailing-data predicate uses ISO 32000 PDF whitespace: NUL padding
  after %%EOF passes, vertical tab is data
- tests: preflight 20→23

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-4 cross-model review closure (1 P1)

Object-header scan recognizes bare-CR line boundaries (ISO 32000 permits
CR-only line endings; Python's (?m)^ does not treat CR as a line start,
blinding both xref-coverage checks on CR-only files). Tests 23→25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-5 cross-model review closures (2 P1)

- header scan boundary/separator class extended to full ISO 32000
  whitespace (NUL-preceded replacement headers are seen)
- compressed-object variant: a direct raw replacement of an object whose
  active copy lives in an object stream, appended after its container
  with a stale startxref, vetoes PASS; a raw copy before the container
  (legitimate superseded-into-objstm update) stays clean
- new ObjStm + cross-reference-stream fixture; tests 25→28

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-6 cross-model review closure (1 P1)

Header scan accepts ten-digit object numbers (\d{1,9} blinded the
coverage checks to replacements with object IDs >= 1e9). Tests 28→29.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-7 cross-model review closure (1 P1)

Header-scan separators implement the full ISO 32000 lexer model:
%-comments-to-EOL are token separators, so comment-obfuscated headers
(e.g. '2 0%note\nobj') no longer hide from the coverage checks. This
closes the lexical-variant family structurally rather than per-case.
Tests 29→30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-8 cross-model review closure (1 P1)

Header-scan numeric tokens implement the full ISO 32000 integer form:
optional sign and leading-zero padding (pypdf coerces via int(), so
'+2 0 obj' / '00000000002 0 obj' are valid headers). Together with r7's
separator model, both halves of the lexer are now structurally complete.
Tests 30→31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:50:06 +08:00

980 lines
39 KiB
Python

"""Finalizer integration tests for v3.8 claim_ref_alignment_audit_agent (T-F1a..h + T-F2..T-F5).
Per spec §7.5 in
docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md.
These tests pin the contract of `scripts/claim_audit_finalizer.py`, the
Python module that implements the §5 orchestrator §3.6 8-row matrix and
the Stage 6 reflection-report histogram. The matrix discriminates the
three `(RETRIEVAL_FAILED, not_applicable)` paths by `ref_retrieval_method`
— anchorless (not_attempted) is HIGH-WARN gate-refuse defense-in-depth,
paywall (failed) is LOW-WARN advisory pass, audit_tool_failure is
MED-WARN advisory pass. A test keyed only on (judgment, defect_stage)
would let an implementation collapse these three into one and apply the
wrong gate behavior.
In addition to T-F1..T-F5, this file pins the four Step 7 carry-over
fixes that ride the Step 8 commit cluster (per memory
`project_ars_v3_8_103_design_shipped`):
- T-CO-1: uncited sentence_text == manifest claim_text MUST suppress the
companion INTENDED_NOT_EMITTED drift row (D-INV-4).
- T-CO-2: drifted-cited claim (EMITTED_NOT_INTENDED with `<!--ref:slug-->`)
whose `claim_id` is not in any manifest MUST carry
`scoped_manifest_id=SENTINEL_MANIFEST_ID` in the claim_audit_result row
so INV-15 passes.
- T-CO-3: uncited sentence with MNC scope but no `manifest_claim_id` MUST
emit `scoped_manifest_id=None` on its uncited_assertion entry (U-INV-4
pair rule) while the companion constraint_violation row carries the
manifest pointer.
- T-CO-4: under sampling, `_detect_drifts` MUST use the full
`citations` set as the emitted side, not the sampled `audited_citations`
subset.
Spec §7 names the file `tests/test_claim_audit_finalizer.py`. Repo
convention places tests under `scripts/test_*.py` (CI uses
`python -m unittest scripts.test_*`); the spec-named stem is preserved.
Run:
python -m unittest scripts.test_claim_audit_finalizer -v
"""
from __future__ import annotations
import unittest
from typing import Any, Callable
try:
from scripts.claim_audit_finalizer import (
TIER_NONE,
TIER_LOW_WARN,
TIER_MED_WARN,
TIER_HIGH_WARN,
classify_claim_audit_result,
classify_uncited_assertion,
classify_constraint_violation,
classify_claim_drift,
classify_audit_sampling_summary,
apply_finalizer,
render_stage6_histogram,
ars_mark_read_clears,
)
_FINALIZER_IMPORT_ERR: Exception | None = None
# Step 8 codex R2 P2-2 closure: narrow the except clause to the genuine
# RED-phase signal (`ModuleNotFoundError` on the module itself). Once
# `claim_audit_finalizer` ships, an ImportError caused by a missing
# export (e.g. a renamed function in the module) or a dependency import
# failure SHOULD surface as a test-time error rather than silently
# skipping every test in this file. The annotation-sync lint covers
# constant-name drift; this except handles only the absent-module case.
except ModuleNotFoundError as exc: # pragma: no cover — RED phase only
_FINALIZER_IMPORT_ERR = exc
TIER_NONE = TIER_LOW_WARN = TIER_MED_WARN = TIER_HIGH_WARN = None # type: ignore[assignment]
def _stub(*args: Any, **kwargs: Any) -> Any: # pragma: no cover
raise _FINALIZER_IMPORT_ERR # type: ignore[misc]
classify_claim_audit_result = _stub # type: ignore[assignment]
classify_uncited_assertion = _stub # type: ignore[assignment]
classify_constraint_violation = _stub # type: ignore[assignment]
classify_claim_drift = _stub # type: ignore[assignment]
classify_audit_sampling_summary = _stub # type: ignore[assignment]
apply_finalizer = _stub # type: ignore[assignment]
render_stage6_histogram = _stub # type: ignore[assignment]
ars_mark_read_clears = _stub # type: ignore[assignment]
try:
from scripts.claim_audit_pipeline import run_audit_pipeline
from scripts._claim_audit_constants import SENTINEL_MANIFEST_ID
_PIPELINE_IMPORT_ERR: Exception | None = None
except Exception as exc: # pragma: no cover
_PIPELINE_IMPORT_ERR = exc
SENTINEL_MANIFEST_ID = "M-0000-00-00T00:00:00Z-0000"
def run_audit_pipeline(*args: Any, **kwargs: Any) -> Any:
raise _PIPELINE_IMPORT_ERR # type: ignore[misc]
MANIFEST_ID = "M-2026-05-15T10:00:00Z-a1b2"
AUDIT_RUN_ID = "2026-05-15T10:10:00Z-9f8e"
NOW = "2026-05-15T10:11:00Z"
def _result(
*,
judgment: str,
defect_stage: str | None,
ref_retrieval_method: str,
audit_status: str = "completed",
violated_constraint_id: str | None = None,
claim_id: str = "C-001",
scoped_manifest_id: str = MANIFEST_ID,
rationale: str = "test rationale",
) -> dict[str, Any]:
"""Build a synthetic claim_audit_result row for matrix-row tests."""
entry: dict[str, Any] = {
"claim_id": claim_id,
"scoped_manifest_id": scoped_manifest_id,
"claim_text": "Test claim.",
"ref_slug": "smith2024",
"anchor_kind": "page",
"anchor_value": "12",
"judgment": judgment,
"audit_status": audit_status,
"defect_stage": defect_stage,
"rationale": rationale,
"judge_model": "gpt-5.5-xhigh",
"judge_run_at": NOW,
"ref_retrieval_method": ref_retrieval_method,
"upstream_owner_agent": None,
"audit_run_id": AUDIT_RUN_ID,
}
if violated_constraint_id is not None:
entry["violated_constraint_id"] = violated_constraint_id
return entry
class _FinalizerTestBase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
if _FINALIZER_IMPORT_ERR is not None:
raise unittest.SkipTest(
f"scripts.claim_audit_finalizer not importable yet: {_FINALIZER_IMPORT_ERR!r} "
"(expected during RED phase — implementation lands in spec §13 step 8)"
)
# ---------------------------------------------------------------------------
# T-F1a — SUPPORTED + null + any → no annotation, pass.
# ---------------------------------------------------------------------------
class TF1aSupported(_FinalizerTestBase):
def test_supported_emits_no_annotation(self) -> None:
for method in ("api", "manual_pdf"):
with self.subTest(ref_retrieval_method=method):
out = classify_claim_audit_result(
_result(judgment="SUPPORTED", defect_stage=None, ref_retrieval_method=method)
)
self.assertIsNone(out["annotation"])
self.assertEqual(out["tier"], TIER_NONE)
self.assertFalse(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1b — AMBIGUOUS + {source_description, citation_anchor, synthesis_overclaim, null}
# + any → [CLAIM-AUDIT-AMBIGUOUS], LOW-WARN, pass.
# ---------------------------------------------------------------------------
class TF1bAmbiguous(_FinalizerTestBase):
def test_ambiguous_emits_low_warn_advisory(self) -> None:
for ds in ("source_description", "citation_anchor", "synthesis_overclaim", None):
with self.subTest(defect_stage=ds):
out = classify_claim_audit_result(
_result(judgment="AMBIGUOUS", defect_stage=ds, ref_retrieval_method="api")
)
self.assertEqual(out["annotation"], "[CLAIM-AUDIT-AMBIGUOUS]")
self.assertEqual(out["tier"], TIER_LOW_WARN)
self.assertFalse(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1c — UNSUPPORTED + {source_description, metadata, citation_anchor, synthesis_overclaim}
# + any → [HIGH-WARN-CLAIM-NOT-SUPPORTED], HIGH-WARN, gate-refuse.
# ---------------------------------------------------------------------------
class TF1cUnsupportedSourceLevel(_FinalizerTestBase):
def test_unsupported_source_level_gate_refuses(self) -> None:
for ds in ("source_description", "metadata", "citation_anchor", "synthesis_overclaim"):
with self.subTest(defect_stage=ds):
out = classify_claim_audit_result(
_result(judgment="UNSUPPORTED", defect_stage=ds, ref_retrieval_method="api")
)
self.assertEqual(out["annotation"], "[HIGH-WARN-CLAIM-NOT-SUPPORTED]")
self.assertEqual(out["tier"], TIER_HIGH_WARN)
self.assertTrue(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1d — UNSUPPORTED + negative_constraint_violation + any
# → [HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION (id)], HIGH-WARN, gate-refuse.
# ---------------------------------------------------------------------------
class TF1dUnsupportedConstraintViolation(_FinalizerTestBase):
def test_unsupported_constraint_violation_gate_refuses(self) -> None:
out = classify_claim_audit_result(
_result(
judgment="UNSUPPORTED",
defect_stage="negative_constraint_violation",
ref_retrieval_method="api",
violated_constraint_id="MNC-1",
)
)
self.assertEqual(
out["annotation"],
"[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION (MNC-1)]",
)
self.assertEqual(out["tier"], TIER_HIGH_WARN)
self.assertTrue(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1e — RETRIEVAL_FAILED + retrieval_existence + not_found
# → [HIGH-WARN-FABRICATED-REFERENCE], HIGH-WARN, gate-refuse.
# ---------------------------------------------------------------------------
class TF1eFabricated(_FinalizerTestBase):
def test_fabricated_reference_gate_refuses(self) -> None:
out = classify_claim_audit_result(
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="retrieval_existence",
ref_retrieval_method="not_found",
audit_status="completed",
)
)
self.assertEqual(out["annotation"], "[HIGH-WARN-FABRICATED-REFERENCE]")
self.assertEqual(out["tier"], TIER_HIGH_WARN)
self.assertTrue(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1f — RETRIEVAL_FAILED + not_applicable + not_attempted
# → [HIGH-WARN-CLAIM-AUDIT-ANCHORLESS], HIGH-WARN, gate-refuse.
# ---------------------------------------------------------------------------
class TF1fAnchorless(_FinalizerTestBase):
def test_anchorless_defense_in_depth_gate_refuses(self) -> None:
out = classify_claim_audit_result(
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="not_applicable",
ref_retrieval_method="not_attempted",
audit_status="inconclusive",
)
)
self.assertEqual(
out["annotation"],
"[HIGH-WARN-CLAIM-AUDIT-ANCHORLESS — v3.7.3 R-L3-1-A VIOLATION REACHED AUDIT]",
)
self.assertEqual(out["tier"], TIER_HIGH_WARN)
self.assertTrue(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1g — RETRIEVAL_FAILED + not_applicable + failed → LOW-WARN, pass (paywall).
# ---------------------------------------------------------------------------
class TF1gPaywall(_FinalizerTestBase):
def test_paywall_low_warn_passes(self) -> None:
out = classify_claim_audit_result(
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="not_applicable",
ref_retrieval_method="failed",
audit_status="inconclusive",
)
)
self.assertEqual(
out["annotation"],
"[CLAIM-AUDIT-UNVERIFIED — REFERENCE FULL-TEXT NOT RETRIEVABLE]",
)
self.assertEqual(out["tier"], TIER_LOW_WARN)
self.assertFalse(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F1h — RETRIEVAL_FAILED + not_applicable + audit_tool_failure → MED-WARN, pass.
# ---------------------------------------------------------------------------
class TF1hAuditToolFailure(_FinalizerTestBase):
def test_audit_tool_failure_med_warn_passes(self) -> None:
out = classify_claim_audit_result(
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="not_applicable",
ref_retrieval_method="audit_tool_failure",
audit_status="inconclusive",
rationale="judge_timeout: judge model failed to respond within 60s",
)
)
self.assertEqual(
out["annotation"],
"[CLAIM-AUDIT-TOOL-FAILURE — judge_timeout]",
)
self.assertEqual(out["tier"], TIER_MED_WARN)
self.assertFalse(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-F2 — HIGH-WARN-CLAIM-NOT-SUPPORTED triggers terminal gate refuse.
# ---------------------------------------------------------------------------
class TF2TerminalGateRefuse(_FinalizerTestBase):
def test_high_warn_propagates_to_apply_finalizer(self) -> None:
passport = {
"claim_audit_results": [
_result(
judgment="UNSUPPORTED",
defect_stage="source_description",
ref_retrieval_method="api",
),
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="api",
claim_id="C-002",
),
],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
}
out = apply_finalizer(passport)
self.assertTrue(
out["gate_refuse"],
"any HIGH-WARN row must flip passport-level gate_refuse",
)
self.assertIn("[HIGH-WARN-CLAIM-NOT-SUPPORTED]", out["gate_refuse_reasons"])
# ---------------------------------------------------------------------------
# T-F3 — /ars-mark-read does NOT clear HIGH-WARN-CLAIM-NOT-SUPPORTED (asymmetry).
# ---------------------------------------------------------------------------
class TF3MarkReadAsymmetry(_FinalizerTestBase):
def test_mark_read_cannot_clear_high_warn_not_supported(self) -> None:
# HIGH-WARN-CLAIM-NOT-SUPPORTED is a structural verdict, not an
# acknowledgement-eligible trust state. Mirrors v3.7.3 R-L3-1-A.
self.assertFalse(
ars_mark_read_clears(
annotation="[HIGH-WARN-CLAIM-NOT-SUPPORTED]",
tier=TIER_HIGH_WARN,
)
)
self.assertFalse(
ars_mark_read_clears(
annotation="[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION (MNC-1)]",
tier=TIER_HIGH_WARN,
)
)
def test_mark_read_clears_low_warn_advisory(self) -> None:
# LOW-WARN paywall is acknowledgement-eligible (the user has
# accepted the unverifiable state and chosen to ship).
self.assertTrue(
ars_mark_read_clears(
annotation="[CLAIM-AUDIT-UNVERIFIED — REFERENCE FULL-TEXT NOT RETRIEVABLE]",
tier=TIER_LOW_WARN,
)
)
# ---------------------------------------------------------------------------
# T-F4 — LOW-WARN-CLAIM-AUDIT-UNVERIFIED passes gate.
# ---------------------------------------------------------------------------
class TF4LowWarnPasses(_FinalizerTestBase):
def test_low_warn_only_passport_passes_gate(self) -> None:
passport = {
"claim_audit_results": [
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="not_applicable",
ref_retrieval_method="failed",
audit_status="inconclusive",
),
],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
}
out = apply_finalizer(passport)
self.assertFalse(
out["gate_refuse"],
"paywall LOW-WARN-only passport must pass the gate",
)
self.assertEqual(out["gate_refuse_reasons"], [])
# ---------------------------------------------------------------------------
# T-F5 — Stage 6 reflection report renders histogram when ≥5 completed entries.
# ---------------------------------------------------------------------------
class TF5Stage6Histogram(_FinalizerTestBase):
def test_histogram_renders_when_five_or_more_completed(self) -> None:
results = [
_result(
judgment="UNSUPPORTED",
defect_stage="source_description",
ref_retrieval_method="api",
claim_id=f"C-{i:03d}",
)
for i in range(3)
]
results += [
_result(
judgment="UNSUPPORTED",
defect_stage="citation_anchor",
ref_retrieval_method="api",
claim_id=f"C-{i+3:03d}",
)
for i in range(2)
]
report = render_stage6_histogram(results)
self.assertIsNotNone(report, "≥5 completed entries MUST render histogram")
self.assertIn("source_description", report)
self.assertIn("citation_anchor", report)
self.assertIn("3", report, "source_description count = 3")
self.assertIn("2", report, "citation_anchor count = 2")
def test_histogram_suppressed_below_threshold(self) -> None:
results = [
_result(
judgment="UNSUPPORTED",
defect_stage="source_description",
ref_retrieval_method="api",
claim_id=f"C-{i:03d}",
)
for i in range(4)
]
report = render_stage6_histogram(results)
self.assertIsNone(report, "<5 completed entries MUST suppress histogram")
def test_histogram_renders_for_all_supported_papers(self) -> None:
# Step 8 codex R4 P2-1 closure: spec literal is "≥5 completed
# entries". A mostly-SUPPORTED paper (5 completed citations,
# zero defect_stages) MUST still emit the histogram block so the
# Stage 6 appendix stays consistent. Prior implementation filtered
# to completed-with-defect rows, suppressing this common case.
results = [
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="api",
claim_id=f"C-{i:03d}",
)
for i in range(5)
]
report = render_stage6_histogram(results)
self.assertIsNotNone(
report,
"5 SUPPORTED completed entries MUST emit histogram per spec "
"literal '≥5 completed entries'",
)
self.assertIn("n=5", report)
self.assertIn("No defect stages recorded", report)
def test_histogram_excludes_inconclusive_rows(self) -> None:
# 5 inconclusive + 0 completed → still suppressed (spec: "≥5 completed").
results = [
_result(
judgment="RETRIEVAL_FAILED",
defect_stage="not_applicable",
ref_retrieval_method="failed",
audit_status="inconclusive",
claim_id=f"C-{i:03d}",
)
for i in range(5)
]
report = render_stage6_histogram(results)
self.assertIsNone(
report,
"histogram threshold counts completed rows only, not inconclusive",
)
# ---------------------------------------------------------------------------
# T-F6 — MANIFEST-MISSING fallback emits a paper-level advisory annotation.
# ---------------------------------------------------------------------------
class TF6ManifestMissingAdvisory(_FinalizerTestBase):
"""T-F6 (Step 8 codex R1 P2 closure): apply_finalizer surfaces the
MANIFEST-MISSING advisory when claim_intent_manifests is empty AND any
claim_audit_result row carries the sentinel scoped_manifest_id.
Spec §9 acceptance bullet: `claim_intent_manifest absent →
MANIFEST-MISSING advisory + fallback flow exercised in test`. Step 7
T-M2 pinned the pipeline-side invariants (sentinel propagation, all
six defect_stage paths still emit); the advisory surface itself lives
in the finalizer per spec §13 step 8 deliverable. Before this row, an
all-SUPPORTED MANIFEST-MISSING passport produced zero annotations —
the user lost the required warning that the audit ran without
pre-commitment baseline.
"""
def test_empty_manifests_with_sentinel_rows_emits_advisory(self) -> None:
passport = {
"claim_intent_manifests": [],
"claim_audit_results": [
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="api",
scoped_manifest_id=SENTINEL_MANIFEST_ID,
)
],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
}
out = apply_finalizer(passport)
manifest_missing_advisories = [
a for a in out["annotations"] if "MANIFEST-MISSING" in a["annotation"]
]
self.assertEqual(
len(manifest_missing_advisories),
1,
"MANIFEST-MISSING fallback MUST emit exactly one paper-level advisory "
"when no manifests are present and at least one audit row carries the "
"sentinel scope (spec §9 acceptance criterion)",
)
advisory = manifest_missing_advisories[0]
self.assertEqual(advisory["tier"], TIER_LOW_WARN)
self.assertFalse(
out["gate_refuse"],
"MANIFEST-MISSING is advisory only — never gate-refuses",
)
def test_present_manifests_suppress_advisory(self) -> None:
# When manifests are present, the audit ran with a pre-commitment
# baseline — no MANIFEST-MISSING advisory should fire even if
# individual rows happen to carry sentinel scope (drifted-cited
# claims that the CO-2 fix sentinel-normalizes per row).
passport = {
"claim_intent_manifests": [
{
"manifest_version": "1.0",
"manifest_id": "M-2026-05-15T10:00:00Z-a1b2",
"emitted_by": "synthesis_agent",
"emitted_at": "2026-05-15T09:55:00Z",
"claims": [],
"manifest_negative_constraints": [],
}
],
"claim_audit_results": [
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="api",
scoped_manifest_id=SENTINEL_MANIFEST_ID,
)
],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
}
out = apply_finalizer(passport)
manifest_missing_advisories = [
a for a in out["annotations"] if "MANIFEST-MISSING" in a["annotation"]
]
self.assertEqual(
manifest_missing_advisories,
[],
"MANIFEST-MISSING advisory MUST NOT fire when manifests are present "
"(even with individual sentinel rows from CO-2 normalization)",
)
def test_empty_manifests_no_results_no_advisory(self) -> None:
# MANIFEST-MISSING fires on the (empty manifests + sentinel rows
# exist) pair. If there are no audit rows at all, there's nothing
# to surface a warning about — the gate just sees an empty
# passport.
passport = {
"claim_intent_manifests": [],
"claim_audit_results": [],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
}
out = apply_finalizer(passport)
self.assertEqual(out["annotations"], [])
self.assertFalse(out["gate_refuse"])
# ---------------------------------------------------------------------------
# T-CO-1 — uncited sentence_text == manifest claim_text suppresses INTENDED_NOT_EMITTED.
# ---------------------------------------------------------------------------
class _PipelineCarryoverTestBase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
if _PIPELINE_IMPORT_ERR is not None:
raise unittest.SkipTest(
f"scripts.claim_audit_pipeline not importable yet: {_PIPELINE_IMPORT_ERR!r}"
)
def _config(self, **overrides: Any) -> dict[str, Any]:
base = {"max_claims_per_paper": 100, "judge_model": "gpt-5.5-xhigh"}
base.update(overrides)
return base
class TCO1UncitedSuppressesIntendedDrift(_PipelineCarryoverTestBase):
def test_uncited_text_matches_manifest_claim_no_companion_drift(self) -> None:
text = "Sample preprints accounted for 67% of corpus."
manifest = {
"manifest_version": "1.0",
"manifest_id": MANIFEST_ID,
"emitted_by": "synthesis_agent",
"emitted_at": "2026-05-15T09:55:00Z",
"claims": [
{
"claim_id": "C-001",
"claim_text": text,
"intended_evidence_kind": "empirical",
"planned_refs": [],
}
],
"manifest_negative_constraints": [],
}
uncited = [
{
"sentence_text": text,
"section_path": "3. Results",
"trigger_tokens": ["67%"],
"manifest_claim_id": "C-001",
"scoped_manifest_id": MANIFEST_ID,
}
]
out = run_audit_pipeline(
citations=[],
manifests=[manifest],
corpus=[],
config=self._config(),
retrieve_fn=lambda c: {}, # never called — no citations
judge_fn=lambda **k: {"judgment": "SUPPORTED", "rationale": "x"},
audit_run_id=AUDIT_RUN_ID,
now_iso=NOW,
uncited_sentences=uncited,
)
self.assertEqual(len(out["uncited_assertions"]), 1, "uncited entry MUST emit")
companion = [
d
for d in out["claim_drifts"]
if d["drift_kind"] == "INTENDED_NOT_EMITTED" and d["claim_text"] == text
]
self.assertEqual(
companion,
[],
"INTENDED_NOT_EMITTED drift MUST be suppressed when uncited sentence shares the text "
"(D-INV-4 cross-aggregate exclusivity, Step 7 codex R1 carry-over CO-1)",
)
# ---------------------------------------------------------------------------
# T-CO-2 — drifted cited claim emits with scoped_manifest_id=SENTINEL.
# ---------------------------------------------------------------------------
class TCO2DriftedCitedScopedToSentinel(_PipelineCarryoverTestBase):
def test_drifted_cited_claim_uses_sentinel_scope(self) -> None:
manifest = {
"manifest_version": "1.0",
"manifest_id": MANIFEST_ID,
"emitted_by": "synthesis_agent",
"emitted_at": "2026-05-15T09:55:00Z",
"claims": [
{
"claim_id": "C-001",
"claim_text": "Original manifest claim text.",
"intended_evidence_kind": "empirical",
"planned_refs": [],
}
],
"manifest_negative_constraints": [],
}
# Drifted cited claim — has a ref marker but claim_id (C-100) is
# NOT in manifest. The pipeline currently writes scoped_manifest_id
# = MANIFEST_ID to the row; INV-15 then rejects the resulting
# passport because (MANIFEST_ID, C-100) is not in the manifest index.
drifted_citation = {
"claim_id": "C-100",
"scoped_manifest_id": MANIFEST_ID,
"claim_text": "Drifted prose introduced a new claim.",
"ref_slug": "smith2024",
"anchor_kind": "page",
"anchor_value": "14",
"section_path": "3. Results",
}
out = run_audit_pipeline(
citations=[drifted_citation],
manifests=[manifest],
corpus=[],
config=self._config(),
retrieve_fn=lambda c: {"ref_retrieval_method": "api", "retrieved_excerpt": "ok"},
judge_fn=lambda **k: {"judgment": "SUPPORTED", "rationale": "supported"},
audit_run_id=AUDIT_RUN_ID,
now_iso=NOW,
)
self.assertEqual(len(out["claim_audit_results"]), 1)
entry = out["claim_audit_results"][0]
self.assertEqual(
entry["scoped_manifest_id"],
SENTINEL_MANIFEST_ID,
"drifted cited claim (claim_id not in manifest) MUST carry sentinel scope "
"so INV-15 dangling check passes (Step 7 codex R1 carry-over CO-2)",
)
# ---------------------------------------------------------------------------
# T-CO-3 — MNC-only uncited sentence emits scoped_manifest_id=None on uncited row.
# ---------------------------------------------------------------------------
class TCO3MncOnlyUncitedScope(_PipelineCarryoverTestBase):
def test_mnc_only_uncited_drops_manifest_scope(self) -> None:
manifest = {
"manifest_version": "1.0",
"manifest_id": MANIFEST_ID,
"emitted_by": "synthesis_agent",
"emitted_at": "2026-05-15T09:55:00Z",
"claims": [], # no claim-level entries
"manifest_negative_constraints": [
{"constraint_id": "MNC-1", "rule": "No unqualified causal language."}
],
}
uncited = [
{
"sentence_text": "Treatment caused outcome.",
"section_path": "3. Results",
"trigger_tokens": ["caused"],
"manifest_claim_id": None,
"scoped_manifest_id": MANIFEST_ID,
}
]
out = run_audit_pipeline(
citations=[],
manifests=[manifest],
corpus=[],
config=self._config(),
retrieve_fn=lambda c: {},
judge_fn=lambda **k: {
"judgment": "VIOLATED",
"violated_constraint_id": "MNC-1",
"rationale": "constraint violated",
},
audit_run_id=AUDIT_RUN_ID,
now_iso=NOW,
uncited_sentences=uncited,
)
self.assertEqual(len(out["uncited_assertions"]), 1)
ua = out["uncited_assertions"][0]
self.assertIsNone(
ua["scoped_manifest_id"],
"uncited_assertion MUST drop manifest scope when manifest_claim_id is None "
"(U-INV-4 pair rule, Step 7 codex R1 carry-over CO-3)",
)
self.assertIsNone(ua["manifest_claim_id"])
# Companion constraint_violation row still carries the manifest pointer.
self.assertEqual(len(out["constraint_violations"]), 1)
cv = out["constraint_violations"][0]
self.assertEqual(cv["scoped_manifest_id"], MANIFEST_ID)
self.assertEqual(cv["violated_constraint_id"], "MNC-1")
# ---------------------------------------------------------------------------
# T-CO-4 — sampling does not truncate the emitted set used for drift detection.
# ---------------------------------------------------------------------------
class TCO4SamplingPreservesEmittedSet(_PipelineCarryoverTestBase):
def test_unsampled_citations_not_flagged_as_dropped_from_manifest(self) -> None:
N = 30
CAP = 10
claims = [
{
"claim_id": f"C-{i:03d}",
"claim_text": f"Manifest claim {i}.",
"intended_evidence_kind": "empirical",
"planned_refs": [f"ref{i}"],
}
for i in range(N)
]
manifest = {
"manifest_version": "1.0",
"manifest_id": MANIFEST_ID,
"emitted_by": "synthesis_agent",
"emitted_at": "2026-05-15T09:55:00Z",
"claims": claims,
"manifest_negative_constraints": [],
}
citations = [
{
"claim_id": f"C-{i:03d}",
"scoped_manifest_id": MANIFEST_ID,
"claim_text": f"Manifest claim {i}.",
"ref_slug": f"ref{i}",
"anchor_kind": "page",
"anchor_value": str(i),
"section_path": "s",
}
for i in range(N)
]
out = run_audit_pipeline(
citations=citations,
manifests=[manifest],
corpus=[],
config=self._config(max_claims_per_paper=CAP),
retrieve_fn=lambda c: {
"ref_retrieval_method": "api",
"retrieved_excerpt": "ok",
},
judge_fn=lambda **k: {"judgment": "SUPPORTED", "rationale": "ok"},
audit_run_id=AUDIT_RUN_ID,
now_iso=NOW,
)
intended_not_emitted = [
d for d in out["claim_drifts"] if d["drift_kind"] == "INTENDED_NOT_EMITTED"
]
self.assertEqual(
intended_not_emitted,
[],
"drift detection MUST see the full emitted set, not the sampled subset "
f"(Step 7 codex R1 carry-over CO-4); got {len(intended_not_emitted)} false "
f"INTENDED_NOT_EMITTED rows for N={N} cap={CAP}",
)
# Sampling MUST still produce a summary row.
self.assertEqual(len(out["audit_sampling_summaries"]), 1)
self.assertEqual(out["audit_sampling_summaries"][0]["audited_count"], CAP)
# ---------------------------------------------------------------------------
# T-UAF-F1 — v3.8.2 / #118 finalizer wiring for uncited_audit_failures[].
# Codex cross-model review (2026-05-17) flagged that the UAF aggregate was
# emitted by the pipeline but never surfaced by apply_finalizer, so the
# operational signal stayed silent in the final output. This test pins the
# routing entry + classifier + annotation so a future refactor cannot
# accidentally drop the UAF row from the finalizer dispatch table.
# ---------------------------------------------------------------------------
class TUAFFinalizerRouting(unittest.TestCase):
def test_uaf_row_emits_med_warn_annotation(self) -> None:
passport = {
"claim_audit_results": [],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
"uncited_audit_failures": [
{
"finding_id": "UAF-001",
"claim_text": "Uncited sentence under MNC scope.",
"section_path": "3. Results > 3.1",
"scoped_manifest_id": "M-2026-05-15T10:00:00Z-a1b2",
"manifest_claim_id": None,
"fault_class": "judge_timeout",
"rationale": "judge_timeout: judge timed out after 30s",
"judge_model": "gpt-5.5-xhigh",
"judge_run_at": "2026-05-15T10:14:00Z",
"rule_version": "D4-c-v1-uaf-v1",
}
],
}
out = apply_finalizer(passport)
annotations = [a["annotation"] for a in out["annotations"]]
self.assertIn(
"[CLAIM-AUDIT-TOOL-FAILURE-UNCITED — judge_timeout]",
annotations,
"UAF row must produce a MED-WARN annotation via apply_finalizer; "
"without the routing entry the operational signal stays silent.",
)
self.assertFalse(
out["gate_refuse"],
"UAF is MED-WARN advisory; gate must NOT refuse on it.",
)
def test_uaf_row_each_fault_class_renders(self) -> None:
"""Every INV14 fault-class value must render through the annotation template."""
from scripts._claim_audit_constants import INV14_FAULT_CLASS_TAGS
for fault_class in INV14_FAULT_CLASS_TAGS:
with self.subTest(fault_class=fault_class):
passport = {
"claim_audit_results": [],
"uncited_assertions": [],
"claim_drifts": [],
"constraint_violations": [],
"audit_sampling_summaries": [],
"uncited_audit_failures": [
{
"finding_id": "UAF-001",
"claim_text": "Uncited sentence.",
"section_path": "3. Results",
"scoped_manifest_id": "M-2026-05-15T10:00:00Z-a1b2",
"manifest_claim_id": None,
"fault_class": fault_class,
"rationale": f"{fault_class}: synthetic",
"judge_model": "gpt-5.5-xhigh",
"judge_run_at": "2026-05-15T10:14:00Z",
"rule_version": "D4-c-v1-uaf-v1",
}
],
}
out = apply_finalizer(passport)
expected = f"[CLAIM-AUDIT-TOOL-FAILURE-UNCITED — {fault_class}]"
annotations = [a["annotation"] for a in out["annotations"]]
self.assertIn(expected, annotations)
# T-512 — SUPPORTED row carrying the PDF read-integrity tag surfaces the advisory.
class T512PdfReadIntegrityAdvisory(unittest.TestCase):
def test_supported_with_tag_gets_low_warn_advisory(self) -> None:
from scripts.claim_audit_finalizer import (
ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY,
PDF_READ_INTEGRITY_TAG,
)
out = classify_claim_audit_result(
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="manual_pdf",
rationale=f"supported via content match {PDF_READ_INTEGRITY_TAG}",
)
)
self.assertEqual(out["annotation"], ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY)
self.assertEqual(out["tier"], "low_warn")
self.assertFalse(out["gate_refuse"])
def test_supported_without_tag_unchanged(self) -> None:
out = classify_claim_audit_result(
_result(judgment="SUPPORTED", defect_stage=None, ref_retrieval_method="manual_pdf")
)
self.assertIsNone(out["annotation"])
def test_tag_constants_in_lockstep_with_pipeline(self) -> None:
from scripts.claim_audit_finalizer import PDF_READ_INTEGRITY_TAG as fin_tag
from scripts.claim_audit_pipeline import PDF_READ_INTEGRITY_TAG as pipe_tag
self.assertEqual(fin_tag, pipe_tag)
if __name__ == "__main__": # pragma: no cover
unittest.main()