Files
imbad0202__academic-researc…/scripts/test_check_v3_8_annotation_literal_sync.py
Edward Cheng-I Wu add51a715e v3.8 #103: claim ↔ reference faithfulness audit agent
Opt-in (default OFF) Stage 4→5 audit agent that closes the L3
claim-faithfulness gap left open by v3.7.3. Judges sampled
citations against retrieved excerpts; surfaces 5 new HIGH-WARN
annotation classes through the formatter terminal hard gate.

Adds:
- 1 new agent (academic-pipeline/agents/claim_ref_alignment_audit_agent.md)
- 5 new passport schemas + 38 cross-field invariant lints
- 8-row finalizer matrix (paywall / fabricated / anchorless /
  audit_tool_failure discriminated by ref_retrieval_method)
- Per-manifest uncited judge calls (handles colliding MNC ids)
- Calibration runner with 20-tuple gold set (T-C1 FNR<0.15 +
  FPR<0.10 gate, T-C2 per-class FNR/FPR, T-C3 shape integrity)
- Cite-finalizer / formatter / orchestrator wiring per spec §5

Test baseline post-#103: 1356 pytest passed (was 1107 pre-#103;
+249 tests), 7/7 v3.x lints PASS, 0 personal-boundary violations,
SHA-pinned schemas zero-touch.

Step 13 dual-track review: 8 rounds (R1 codex+Gemini, R2-R8
codex-only after Gemini quota). Trajectory R1 4P1+2P2 → R8
0P1+4P2 — ship gate met at R8. Per
feedback_codex_review_surface_loop_design_phase.md, design-phase
P2 noise floor does not auto-converge; user declared ship signal
at R8 with all P0/P1 closed.

Carry-over follow-ups:
- #118 — uncited path NOT_VIOLATED swallow on judge failure
- #119 — nested schema-invalid shapes crash invariant helpers
- #120 — 4 R8 P2 findings (dedupe scope / walker short-circuit /
  judgment + method type-check)

External motivation: Zhao et al. arXiv:2605.07723 (2026-05) —
146,932 hallucinated citations across arXiv / bioRxiv / SSRN /
PMC in 2025.

Spec: docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md
Decision: docs/design/2026-05-15-issue-103-claim-alignment-audit-decision.md

Closes #103.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:40:54 +08:00

263 lines
11 KiB
Python

"""Unit tests for v3.8 annotation-literal sync lint.
The lint enforces that every `ANNOTATION_HIGH_WARN_*` constant in
`scripts/claim_audit_finalizer.py` has a matching bracket-prefix in
`academic-paper/agents/formatter_agent.md` REFUSE list. The tests cover:
1. The prefix-extraction helper handles the three annotation literal
shapes correctly (plain closed, parenthesized variable, em-dash
contextual suffix).
2. The lint reports PASS on the current repo state.
3. The lint reports FAIL when a deliberately mutated finalizer module
adds an extra HIGH-WARN constant that the formatter doesn't carry.
Run:
python -m unittest scripts.test_check_v3_8_annotation_literal_sync -v
"""
from __future__ import annotations
import subprocess
import sys
import textwrap
import unittest
from pathlib import Path
from scripts.check_v3_8_annotation_literal_sync import (
_annotation_prefix,
_extract_refuse_block,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
LINT_SCRIPT = REPO_ROOT / "scripts" / "check_v3_8_annotation_literal_sync.py"
class AnnotationPrefixHelperTest(unittest.TestCase):
def test_plain_closed_literal_keeps_closing_bracket(self) -> None:
# Step 8 codex R2 P2-1 closure: closed literals require exact match
# INCLUDING the closing bracket. Cutting at `]` left the prefix without
# the bracket, so a renamed formatter rule
# `[HIGH-WARN-FABRICATED-REFERENCE-RENAMED]` would still satisfy the
# check for finalizer literal `[HIGH-WARN-FABRICATED-REFERENCE]`.
self.assertEqual(
_annotation_prefix("[HIGH-WARN-CLAIM-NOT-SUPPORTED]"),
"[HIGH-WARN-CLAIM-NOT-SUPPORTED]",
)
def test_interpolated_parenthesized_variable_cuts_at_space_paren(self) -> None:
self.assertEqual(
_annotation_prefix(
"[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION ({violated_constraint_id})]"
),
"[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION",
)
def test_em_dash_contextual_suffix_cuts_at_space_emdash(self) -> None:
self.assertEqual(
_annotation_prefix(
"[HIGH-WARN-CLAIM-AUDIT-ANCHORLESS — v3.7.3 R-L3-1-A VIOLATION REACHED AUDIT]"
),
"[HIGH-WARN-CLAIM-AUDIT-ANCHORLESS",
)
def test_interpolated_uncited_constraint_violation_prefix(self) -> None:
self.assertEqual(
_annotation_prefix(
"[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED ({violated_constraint_id})]"
),
"[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED",
)
def test_fabricated_reference_plain_literal(self) -> None:
# Closed plain literal keeps the closing bracket for exact match
# (Step 8 codex R2 P2-1 closure).
self.assertEqual(
_annotation_prefix("[HIGH-WARN-FABRICATED-REFERENCE]"),
"[HIGH-WARN-FABRICATED-REFERENCE]",
)
class RefuseBlockExtractorTest(unittest.TestCase):
"""Step 8 codex R1 P3 closure: lint scope restricted to REFUSE-rules block."""
def test_extracts_block_starting_at_refuse_marker(self) -> None:
sample = textwrap.dedent(
"""\
## Cite-Time Provenance Hard Gate
Some intro prose mentioning HIGH-WARN-CLAIM-NOT-SUPPORTED here.
**REFUSE to emit final output** when the draft contains any of:
1. Rule one.
6. A literal `[HIGH-WARN-CLAIM-NOT-SUPPORTED]` annotation.
## Output Format
Mentions HIGH-WARN-CLAIM-NOT-SUPPORTED outside the REFUSE block.
"""
)
block = _extract_refuse_block(sample)
self.assertIsNotNone(block)
self.assertIn("[HIGH-WARN-CLAIM-NOT-SUPPORTED]", block)
# The intro mention before the REFUSE marker MUST be excluded.
self.assertNotIn(
"Some intro prose mentioning HIGH-WARN-CLAIM-NOT-SUPPORTED here.", block
)
# The Output Format mention after the next H2 MUST be excluded.
self.assertNotIn(
"Mentions HIGH-WARN-CLAIM-NOT-SUPPORTED outside the REFUSE block.", block
)
def test_returns_none_when_refuse_marker_absent(self) -> None:
sample = "## Some heading\n\nNo REFUSE-list marker anywhere.\n"
self.assertIsNone(_extract_refuse_block(sample))
class LintScriptTest(unittest.TestCase):
def test_lint_passes_on_current_repo_state(self) -> None:
# The lint MUST pass on the shipped repo — Step 8 commit cluster
# closed the §3.6 + REFUSE list rules 6-10. A failure here means a
# subsequent edit drifted the finalizer literals away from the
# formatter prose without updating both sides.
result = subprocess.run(
[sys.executable, str(LINT_SCRIPT)],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
)
self.assertEqual(
result.returncode,
0,
f"lint exited with code {result.returncode}; stdout={result.stdout!r}; "
f"stderr={result.stderr!r}",
)
self.assertIn("PASS", result.stdout)
def test_lint_detects_dynamic_literal_substring_match_attack(self) -> None:
# Step 8 codex R3 P2 closure: interpolated/em-dash literals MUST
# also require a boundary character following the token in formatter
# prose, not raw substring match. A rename that appends text
# (`[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION-RENAMED`) would have
# passed the R1/R2 lint via prefix-substring containment.
import tempfile
import shutil
with tempfile.TemporaryDirectory() as td:
tmp_root = Path(td) / "repo"
shutil.copytree(REPO_ROOT, tmp_root, ignore=shutil.ignore_patterns(
".git", "node_modules", "__pycache__", "*.pyc", "venv*", ".venv"
))
tmp_formatter = tmp_root / "academic-paper" / "agents" / "formatter_agent.md"
text = tmp_formatter.read_text()
mutated = text.replace(
"`[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION`",
"`[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION-RENAMED`",
)
self.assertNotEqual(text, mutated, "mutation should replace the literal")
tmp_formatter.write_text(mutated)
result = subprocess.run(
[sys.executable, str(tmp_root / "scripts" / "check_v3_8_annotation_literal_sync.py")],
capture_output=True,
text=True,
cwd=str(tmp_root),
)
self.assertEqual(
result.returncode,
1,
f"expected lint to fail on dynamic-literal rename; "
f"stdout={result.stdout!r}",
)
self.assertIn(
"HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION",
result.stdout,
)
def test_lint_detects_closed_literal_substring_match_attack(self) -> None:
# Step 8 codex R2 P2-1 closure: the lint MUST NOT pass when the
# formatter prose contains a SUPERSTRING of the finalizer literal
# (e.g. finalizer says `[HIGH-WARN-FABRICATED-REFERENCE]` but
# formatter REFUSE list was edited to `[HIGH-WARN-FABRICATED-
# REFERENCE-RENAMED]`). The closing bracket is the contract
# terminator for closed literals.
import tempfile
import shutil
with tempfile.TemporaryDirectory() as td:
tmp_root = Path(td) / "repo"
shutil.copytree(REPO_ROOT, tmp_root, ignore=shutil.ignore_patterns(
".git", "node_modules", "__pycache__", "*.pyc", "venv*", ".venv"
))
tmp_formatter = tmp_root / "academic-paper" / "agents" / "formatter_agent.md"
text = tmp_formatter.read_text()
# Replace the closed literal in the formatter REFUSE rule with a
# superstring that PRESERVES the original as a prefix. A naive
# `prefix in text` check would still pass; the exact-closing-
# bracket contract catches it.
mutated = text.replace(
"[HIGH-WARN-FABRICATED-REFERENCE]",
"[HIGH-WARN-FABRICATED-REFERENCE-RENAMED]",
)
self.assertNotEqual(text, mutated, "mutation should replace the literal")
tmp_formatter.write_text(mutated)
result = subprocess.run(
[sys.executable, str(tmp_root / "scripts" / "check_v3_8_annotation_literal_sync.py")],
capture_output=True,
text=True,
cwd=str(tmp_root),
)
self.assertEqual(
result.returncode,
1,
f"expected lint to fail on superstring formatter literal; "
f"stdout={result.stdout!r}",
)
self.assertIn("HIGH-WARN-FABRICATED-REFERENCE", result.stdout)
def test_lint_detects_renamed_constant_missing_from_formatter(self) -> None:
# Simulate the canonical drift scenario: the finalizer renames an
# existing HIGH-WARN constant's literal (e.g. CLAIM-NOT-SUPPORTED →
# CLAIM-UNSUPPORTED) without coordinating with the formatter prose.
# The lint MUST exit 1 and surface the renamed prefix as missing.
# Renaming (not adding) keeps the count at 5 so the constant-count
# gate doesn't fire first — the real failure mode is the literal-
# match gate, which is what we want this test to pin.
import tempfile
import shutil
with tempfile.TemporaryDirectory() as td:
tmp_root = Path(td) / "repo"
shutil.copytree(REPO_ROOT, tmp_root, ignore=shutil.ignore_patterns(
".git", "node_modules", "__pycache__", "*.pyc", "venv*", ".venv"
))
tmp_finalizer = tmp_root / "scripts" / "claim_audit_finalizer.py"
text = tmp_finalizer.read_text()
# Rename the CLAIM-NOT-SUPPORTED literal in place — the formatter
# prose still says NOT-SUPPORTED, so the lint must surface the
# divergence on the renamed literal.
mutated = text.replace(
'ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED = "[HIGH-WARN-CLAIM-NOT-SUPPORTED]"',
'ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED = "[HIGH-WARN-CLAIM-UNSUPPORTED-RENAMED]"',
)
self.assertNotEqual(text, mutated, "mutation should have replaced the literal")
tmp_finalizer.write_text(mutated)
result = subprocess.run(
[sys.executable, str(tmp_root / "scripts" / "check_v3_8_annotation_literal_sync.py")],
capture_output=True,
text=True,
cwd=str(tmp_root),
)
self.assertEqual(
result.returncode,
1,
f"expected lint to fail on mutated finalizer; stdout={result.stdout!r}",
)
self.assertIn("HIGH-WARN-CLAIM-UNSUPPORTED-RENAMED", result.stdout)
if __name__ == "__main__": # pragma: no cover
unittest.main()