mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
1649 lines
70 KiB
Python
1649 lines
70 KiB
Python
"""Schema-validation tests for v3.8 claim_ref_alignment_audit_agent (T-S1..T-S8).
|
|
|
|
Per spec §7.1 in
|
|
docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md.
|
|
|
|
The test plan section §7.1 lists T-S1..T-S8 keyed to claim_audit_result
|
|
(§3.1) and claim_intent_manifest (§3.2) and uncited_assertion (§3.3).
|
|
§6 lint coverage is broader: it includes claim_drift D-INV-1..4 (§3.4),
|
|
constraint_violation CV-INV-1..4 (§3.5) and audit_sampling_summary
|
|
S-INV-1..4 (§4 step 3). This file follows §6 because that is the lint
|
|
contract under test — drift / constraint-violation / sampling
|
|
invariants get their own pos/neg fixtures alongside INV / M-INV / U-INV
|
|
so the lint's full 38-invariant surface is covered before the agent
|
|
prompt ships in Step 5.
|
|
|
|
Spec §7 names the test file `tests/test_claim_audit_schema.py`. This
|
|
repo's CI workflows discover tests under `scripts/test_*.py` and the
|
|
30+ existing test files all live there; we honor the repo convention
|
|
and keep the spec name's stem (`test_claim_audit_schema`) so anyone
|
|
greppping the spec lands at the right file.
|
|
|
|
Run:
|
|
python -m unittest scripts.test_claim_audit_schema -v
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tests.test_helpers import (
|
|
build_schema_validator,
|
|
load_json_schema,
|
|
run_script,
|
|
)
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
PASSPORT = REPO / "shared/contracts/passport"
|
|
LINT = REPO / "scripts/check_claim_audit_consistency.py"
|
|
|
|
SCHEMA_PATHS: dict[str, Path] = {
|
|
"claim_audit_result": PASSPORT / "claim_audit_result.schema.json",
|
|
"claim_intent_manifest": PASSPORT / "claim_intent_manifest.schema.json",
|
|
"uncited_assertion": PASSPORT / "uncited_assertion.schema.json",
|
|
"claim_drift": PASSPORT / "claim_drift.schema.json",
|
|
"constraint_violation": PASSPORT / "constraint_violation.schema.json",
|
|
"uncited_audit_failure": PASSPORT / "uncited_audit_failure.schema.json",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Canonical fixture builders. Each helper returns a fresh dict so individual
|
|
# tests can mutate one field without leaking to siblings.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MANIFEST_ID = "M-2026-05-15T10:00:00Z-a1b2"
|
|
MANIFEST_ID_OTHER = "M-2026-05-15T10:05:00Z-c3d4"
|
|
SENTINEL_MANIFEST_ID = "M-0000-00-00T00:00:00Z-0000"
|
|
AUDIT_RUN_ID = "2026-05-15T10:10:00Z-9f8e"
|
|
|
|
|
|
def supported_entry() -> dict[str, Any]:
|
|
"""Minimal SUPPORTED row — INV-1 positive baseline."""
|
|
return {
|
|
"claim_id": "C-001",
|
|
"scoped_manifest_id": MANIFEST_ID,
|
|
"claim_text": "Sample preprints accounted for 67% of corpus.",
|
|
"ref_slug": "smith2024preprints",
|
|
"anchor_kind": "page",
|
|
"anchor_value": "12",
|
|
"judgment": "SUPPORTED",
|
|
"audit_status": "completed",
|
|
"defect_stage": None,
|
|
"rationale": "The cited page reports the 67% figure verbatim.",
|
|
"judge_model": "gpt-5.5-xhigh",
|
|
"judge_run_at": "2026-05-15T10:11:00Z",
|
|
"ref_retrieval_method": "api",
|
|
"audit_run_id": AUDIT_RUN_ID,
|
|
}
|
|
|
|
|
|
def manifest_entry(
|
|
manifest_id: str = MANIFEST_ID,
|
|
*,
|
|
emitted_by: str = "synthesis_agent",
|
|
claims: list[dict[str, Any]] | None = None,
|
|
mncs: list[dict[str, str]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a minimal claim_intent_manifest entry."""
|
|
return {
|
|
"manifest_version": "1.0",
|
|
"manifest_id": manifest_id,
|
|
"emitted_by": emitted_by,
|
|
"emitted_at": "2026-05-15T09:55:00Z",
|
|
"claims": claims
|
|
if claims is not None
|
|
else [
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "Sample preprints accounted for 67% of corpus.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": ["smith2024preprints"],
|
|
}
|
|
],
|
|
"manifest_negative_constraints": mncs or [],
|
|
}
|
|
|
|
|
|
def uncited_assertion_entry() -> dict[str, Any]:
|
|
"""Minimal U-INV-* positive baseline."""
|
|
return {
|
|
"finding_id": "UA-001",
|
|
"sentence_text": "Half of all submissions showed positive results.",
|
|
"section_path": "3. Results > 3.1 Overview",
|
|
"trigger_tokens": ["50%", "showed"],
|
|
"detected_at": "2026-05-15T10:12:00Z",
|
|
"rule_version": "D4-c-v1",
|
|
}
|
|
|
|
|
|
def claim_drift_entry(*, drift_kind: str = "EMITTED_NOT_INTENDED") -> dict[str, Any]:
|
|
"""Minimal D-INV-* positive baseline."""
|
|
base: dict[str, Any] = {
|
|
"finding_id": "CD-001",
|
|
"drift_kind": drift_kind,
|
|
"claim_text": "Drifted prose sentence the writer added without manifesting.",
|
|
"detected_at": "2026-05-15T10:13:00Z",
|
|
"rule_version": "D4-a-v1",
|
|
}
|
|
if drift_kind == "EMITTED_NOT_INTENDED":
|
|
base["section_path"] = "4. Discussion > 4.2 Implications"
|
|
base["manifest_claim_id"] = None
|
|
base["scoped_manifest_id"] = None
|
|
else: # INTENDED_NOT_EMITTED
|
|
base["manifest_claim_id"] = "C-001"
|
|
base["scoped_manifest_id"] = MANIFEST_ID
|
|
return base
|
|
|
|
|
|
def constraint_violation_entry(
|
|
*,
|
|
constraint_id: str = "MNC-1",
|
|
manifest_claim_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Minimal CV-INV-* positive baseline."""
|
|
return {
|
|
"finding_id": "CV-001",
|
|
"claim_text": "We observed causality between A and B.",
|
|
"section_path": "4. Discussion > 4.3 Limitations",
|
|
"violated_constraint_id": constraint_id,
|
|
"scoped_manifest_id": MANIFEST_ID,
|
|
"manifest_claim_id": manifest_claim_id,
|
|
"judge_verdict": "VIOLATED",
|
|
"rationale": "The MNC bars causal language without RCT evidence.",
|
|
"judge_model": "gpt-5.5-xhigh",
|
|
"judge_run_at": "2026-05-15T10:14:00Z",
|
|
"rule_version": "D4-a-v1",
|
|
}
|
|
|
|
|
|
def sampling_summary_entry(
|
|
*,
|
|
total: int = 150,
|
|
cap: int = 100,
|
|
) -> dict[str, Any]:
|
|
"""Minimal S-INV-* positive baseline (sampled run)."""
|
|
audited = list(range(0, total, max(1, total // cap)))[:cap]
|
|
return {
|
|
"audit_run_id": AUDIT_RUN_ID,
|
|
"max_claims_per_paper": cap,
|
|
"total_citation_count": total,
|
|
"audited_count": len(audited),
|
|
"audited_indices": audited,
|
|
"sampling_strategy": "stratified_buckets_v1",
|
|
"emitted_at": "2026-05-15T10:15:00Z",
|
|
}
|
|
|
|
|
|
def uncited_audit_failure_entry(
|
|
*,
|
|
finding_id: str = "UAF-001",
|
|
fault_class: str = "judge_timeout",
|
|
manifest_claim_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Minimal UAF-INV-* positive baseline (v3.8.2 / #118)."""
|
|
return {
|
|
"finding_id": finding_id,
|
|
"claim_text": "We observed causality between A and B.",
|
|
"section_path": "4. Discussion > 4.3 Limitations",
|
|
"scoped_manifest_id": MANIFEST_ID,
|
|
"manifest_claim_id": manifest_claim_id,
|
|
"fault_class": fault_class,
|
|
"rationale": f"{fault_class}: judge invocation failed after 30s",
|
|
"judge_model": "gpt-5.5-xhigh",
|
|
"judge_run_at": "2026-05-15T10:14:00Z",
|
|
"rule_version": "D4-c-v1-uaf-v1",
|
|
}
|
|
|
|
|
|
def build_passport(
|
|
*,
|
|
manifests: list[dict[str, Any]] | None = None,
|
|
results: list[dict[str, Any]] | None = None,
|
|
uncited: list[dict[str, Any]] | None = None,
|
|
drifts: list[dict[str, Any]] | None = None,
|
|
violations: list[dict[str, Any]] | None = None,
|
|
samplings: list[dict[str, Any]] | None = None,
|
|
uaf: list[dict[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build a minimal passport JSON wrapping the seven aggregates the lint reads (six pre-v3.8.2 + uncited_audit_failures[])."""
|
|
return {
|
|
"claim_intent_manifests": manifests if manifests is not None else [manifest_entry()],
|
|
"claim_audit_results": results or [],
|
|
"uncited_assertions": uncited or [],
|
|
"claim_drifts": drifts or [],
|
|
"constraint_violations": violations or [],
|
|
"audit_sampling_summaries": samplings or [],
|
|
"uncited_audit_failures": uaf or [],
|
|
}
|
|
|
|
|
|
def write_passport(tmp: Path, body: dict[str, Any]) -> Path:
|
|
path = tmp / "passport.json"
|
|
path.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def run_lint(passport: Path) -> tuple[int, str, str]:
|
|
"""Invoke the lint subprocess; returns (exit_code, stdout, stderr)."""
|
|
proc = run_script(
|
|
LINT,
|
|
"--passport",
|
|
str(passport),
|
|
extra_env={"PYTHONPATH": str(REPO)},
|
|
)
|
|
return proc.returncode, proc.stdout, proc.stderr
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S1: Schema-shape only — minimal SUPPORTED entry validates against the
|
|
# claim_audit_result schema. No lint invocation; pure Draft 2020-12 validate.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS1ValidMinimalEntry(unittest.TestCase):
|
|
"""T-S1: Valid minimal entry validates (SUPPORTED, all required fields)."""
|
|
|
|
def test_supported_entry_validates(self) -> None:
|
|
schema = load_json_schema(SCHEMA_PATHS["claim_audit_result"])
|
|
validator = build_schema_validator(schema)
|
|
errors = list(validator.iter_errors(supported_entry()))
|
|
self.assertEqual(errors, [], msg=f"unexpected validation errors: {errors}")
|
|
|
|
def test_all_five_schemas_parse_as_draft_2020_12(self) -> None:
|
|
for name, path in SCHEMA_PATHS.items():
|
|
with self.subTest(schema=name):
|
|
load_json_schema(path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lint-driven invariant tests share a base that writes a tmp passport,
|
|
# invokes the lint subprocess, and asserts findings.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _LintTestBase(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
# Each test owns its tmpdir so concurrent unittest runners don't collide.
|
|
import tempfile
|
|
|
|
self._tmp_obj = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self._tmp_obj.cleanup)
|
|
self.tmp = Path(self._tmp_obj.name)
|
|
|
|
def assertLintFinds(
|
|
self,
|
|
passport_body: dict[str, Any],
|
|
*,
|
|
invariant: str,
|
|
msg: str | None = None,
|
|
) -> tuple[int, str]:
|
|
"""Assert lint exits non-zero AND stdout contains `invariant` tag."""
|
|
path = write_passport(self.tmp, passport_body)
|
|
code, out, err = run_lint(path)
|
|
full_msg = (
|
|
f"\nexpected lint to flag {invariant}\n"
|
|
f"--- exit={code} ---\nstdout:\n{out}\nstderr:\n{err}\n"
|
|
)
|
|
self.assertEqual(code, 1, msg=full_msg)
|
|
self.assertIn(invariant, out, msg=full_msg)
|
|
return code, out
|
|
|
|
def assertLintClean(
|
|
self,
|
|
passport_body: dict[str, Any],
|
|
*,
|
|
msg: str | None = None,
|
|
) -> None:
|
|
"""Assert lint exits 0 (no findings) on a passport that should pass."""
|
|
path = write_passport(self.tmp, passport_body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
0,
|
|
msg=(msg or "")
|
|
+ f"\nexpected lint clean\nexit={code}\nstdout:\n{out}\nstderr:\n{err}\n",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S2: INV-1..INV-18 paired positive + negative fixtures (claim_audit_result).
|
|
# Each invariant is one subTest; baseline = SUPPORTED entry, negative cases
|
|
# mutate the field combination the invariant forbids.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS2ClaimAuditInvariants(_LintTestBase):
|
|
"""T-S2: each INV-N paired positive/negative fixture."""
|
|
|
|
# ----- Positive: every INV holds when the canonical SUPPORTED row stands.
|
|
def test_inv_baseline_positive(self) -> None:
|
|
self.assertLintClean(build_passport(results=[supported_entry()]))
|
|
|
|
# ----- Negative cases per invariant.
|
|
def test_inv_1_supported_with_non_null_defect(self) -> None:
|
|
# INV-1: SUPPORTED -> defect_stage=null AND violated_constraint_id=null
|
|
e = supported_entry()
|
|
e["defect_stage"] = "source_description"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-1")
|
|
|
|
def test_inv_2_unsupported_with_null_defect(self) -> None:
|
|
# INV-2: UNSUPPORTED -> defect_stage != null
|
|
e = supported_entry()
|
|
e["judgment"] = "UNSUPPORTED"
|
|
e["defect_stage"] = None
|
|
e["rationale"] = "Mismatch found in source description."
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-2")
|
|
|
|
def test_inv_3_ambiguous_with_disallowed_defect(self) -> None:
|
|
# INV-3: AMBIGUOUS -> defect_stage NOT in {metadata, negative_constraint_violation}
|
|
e = supported_entry()
|
|
e["judgment"] = "AMBIGUOUS"
|
|
e["defect_stage"] = "metadata"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-3")
|
|
|
|
def test_inv_4_retrieval_failed_inconclusive_wrong_defect(self) -> None:
|
|
# INV-4: RETRIEVAL_FAILED + inconclusive -> defect_stage=not_applicable
|
|
e = supported_entry()
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "retrieval_existence"
|
|
e["ref_retrieval_method"] = "failed"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-4")
|
|
|
|
def test_inv_5_retrieval_failed_completed_wrong_defect(self) -> None:
|
|
# INV-5: RETRIEVAL_FAILED + completed -> defect_stage=retrieval_existence
|
|
e = supported_entry()
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "completed"
|
|
e["defect_stage"] = "source_description"
|
|
e["ref_retrieval_method"] = "not_found"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-5")
|
|
|
|
def test_inv_6_anchor_none_without_rationale_prefix(self) -> None:
|
|
# INV-6: anchor_kind=none -> rationale starts with the canonical prefix
|
|
e = supported_entry()
|
|
e["anchor_kind"] = "none"
|
|
e["anchor_value"] = ""
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "not_attempted"
|
|
e["rationale"] = "anchor missing for this claim" # no v3.7.3 prefix
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-6")
|
|
|
|
def test_inv_6_anchor_none_with_non_empty_anchor_value(self) -> None:
|
|
# INV-6: anchor_kind=none MUST carry empty sentinel anchor_value.
|
|
# A stale residual value (e.g. "123") violates the schema contract.
|
|
# Step 13 R1 Gemini finding (a832d3f).
|
|
e = supported_entry()
|
|
e["anchor_kind"] = "none"
|
|
e["anchor_value"] = "123" # stale residual — must be rejected
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "not_attempted"
|
|
e["rationale"] = (
|
|
"v3.7.3 R-L3-1-A violation: cited claim C-001 carries anchor=none; "
|
|
"v3.7.3 finalizer should have gate-refused upstream."
|
|
)
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-6")
|
|
|
|
def test_inv_7_constraint_violation_without_violated_id(self) -> None:
|
|
# INV-7: negative_constraint_violation -> violated_constraint_id != null
|
|
e = supported_entry()
|
|
e["judgment"] = "UNSUPPORTED"
|
|
e["defect_stage"] = "negative_constraint_violation"
|
|
e["rationale"] = "Violated declared negative constraint."
|
|
e["violated_constraint_id"] = None
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-7")
|
|
|
|
def test_inv_8_constraint_violation_wrong_judgment(self) -> None:
|
|
# INV-8: negative_constraint_violation -> judgment=UNSUPPORTED
|
|
e = supported_entry()
|
|
e["judgment"] = "AMBIGUOUS"
|
|
e["defect_stage"] = "negative_constraint_violation"
|
|
e["violated_constraint_id"] = "MNC-1"
|
|
e["rationale"] = "Ambiguous violation of MNC-1."
|
|
# INV-3 also fires here; we assert INV-8 specifically.
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-8")
|
|
|
|
def test_inv_9_dispute_on_not_applicable(self) -> None:
|
|
# INV-9: upstream_dispute != null -> defect_stage NOT in {null, not_applicable}
|
|
e = supported_entry()
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "failed"
|
|
e["rationale"] = "Paywalled."
|
|
e["upstream_dispute"] = "Author disputes this paywall classification."
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-9")
|
|
|
|
def test_inv_10_failed_method_wrong_state(self) -> None:
|
|
# INV-10: ref_retrieval_method=failed -> RETRIEVAL_FAILED + inconclusive + not_applicable
|
|
e = supported_entry()
|
|
e["ref_retrieval_method"] = "failed"
|
|
# judgment still SUPPORTED -> INV-10 violation
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-10")
|
|
|
|
def test_inv_11_not_attempted_without_anchor_none(self) -> None:
|
|
# INV-11: ref_retrieval_method=not_attempted iff anchor_kind=none
|
|
e = supported_entry()
|
|
e["ref_retrieval_method"] = "not_attempted"
|
|
# anchor_kind still page -> INV-11 violation
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-11")
|
|
|
|
def test_inv_12_not_found_wrong_state(self) -> None:
|
|
# INV-12: ref_retrieval_method=not_found iff fabricated-reference triple
|
|
e = supported_entry()
|
|
e["ref_retrieval_method"] = "not_found"
|
|
# judgment still SUPPORTED -> INV-12 violation
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-12")
|
|
|
|
def test_inv_13_metadata_wrong_method(self) -> None:
|
|
# INV-13: defect_stage=metadata -> ref_retrieval_method in {api, manual_pdf}
|
|
e = supported_entry()
|
|
e["judgment"] = "UNSUPPORTED"
|
|
e["defect_stage"] = "metadata"
|
|
e["rationale"] = "Author/year mismatch."
|
|
e["ref_retrieval_method"] = "failed"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-13")
|
|
|
|
def test_inv_14_audit_tool_failure_without_fault_class_prefix(self) -> None:
|
|
# INV-14: ref_retrieval_method=audit_tool_failure -> rationale begins with fault-class tag
|
|
e = supported_entry()
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "audit_tool_failure"
|
|
e["rationale"] = "Something went wrong." # missing tag
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-14")
|
|
|
|
def test_inv_15_dangling_scoped_manifest_id(self) -> None:
|
|
# INV-15: (scoped_manifest_id, claim_id) must resolve in some manifest entry
|
|
e = supported_entry()
|
|
e["scoped_manifest_id"] = "M-2099-01-01T00:00:00Z-dead" # not in manifests
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-15")
|
|
|
|
def test_inv_15_sentinel_manifest_permitted(self) -> None:
|
|
# INV-15 sentinel positive case — MANIFEST-MISSING fallback row.
|
|
e = supported_entry()
|
|
e["scoped_manifest_id"] = SENTINEL_MANIFEST_ID
|
|
passport = build_passport(manifests=[], results=[e])
|
|
self.assertLintClean(
|
|
passport,
|
|
msg="sentinel scoped_manifest_id must be accepted in MANIFEST-MISSING fallback",
|
|
)
|
|
|
|
def test_inv_16_empty_anchor_value_with_non_none_kind(self) -> None:
|
|
# INV-16: anchor_kind != none -> anchor_value non-empty after strip
|
|
e = supported_entry()
|
|
e["anchor_value"] = " " # whitespace only
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-16")
|
|
|
|
def test_inv_16_url_encoded_whitespace_does_not_bypass(self) -> None:
|
|
# INV-16: URL-encoded whitespace (%20, %09) must NOT bypass the firm
|
|
# rule — docstring + schema both require non-empty *after* URL-decode.
|
|
# Step 13 R1 Gemini finding (a832d3f).
|
|
e = supported_entry()
|
|
e["anchor_value"] = "%20%20%09"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-16")
|
|
|
|
def test_inv_16_url_encoded_non_whitespace_passes(self) -> None:
|
|
# INV-16: URL-encoded printable content must still satisfy the rule.
|
|
e = supported_entry()
|
|
e["anchor_kind"] = "quote"
|
|
e["anchor_value"] = "ten%20cited%20words" # 'ten cited words' after decode
|
|
self.assertLintClean(build_passport(results=[e]))
|
|
|
|
def test_inv_17_constraint_id_inner_hyphen_form(self) -> None:
|
|
# INV-17: NC-C{n}-{m} parse rule — NO inner hyphen between C and digits
|
|
manifest = manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "Causal claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
"negative_constraints": [
|
|
{
|
|
# malformed: NC-C-001-1 instead of NC-C001-1
|
|
"constraint_id": "NC-C-001-1",
|
|
"rule": "Must not claim causality without RCT.",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
)
|
|
# Schema pattern itself rejects this — lint surfaces INV-17 explicitly.
|
|
passport = build_passport(manifests=[manifest], results=[])
|
|
self.assertLintFinds(passport, invariant="INV-17")
|
|
|
|
def test_inv_18_inconclusive_not_applicable_wrong_method(self) -> None:
|
|
# INV-18: (RETRIEVAL_FAILED, inconclusive, not_applicable) -> method in
|
|
# {not_attempted, failed, audit_tool_failure}. `api` is forbidden.
|
|
e = supported_entry()
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "api"
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-18")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S3: anchor_kind=none + INV-6 violation paths.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS3AnchorNoneInv6(_LintTestBase):
|
|
"""T-S3: anchor=none entries that miss rationale prefix or use wrong method."""
|
|
|
|
def _none_entry(self) -> dict[str, Any]:
|
|
e = supported_entry()
|
|
e["anchor_kind"] = "none"
|
|
e["anchor_value"] = ""
|
|
e["judgment"] = "RETRIEVAL_FAILED"
|
|
e["audit_status"] = "inconclusive"
|
|
e["defect_stage"] = "not_applicable"
|
|
e["ref_retrieval_method"] = "not_attempted"
|
|
e["rationale"] = "v3.7.3 R-L3-1-A violation: no anchor on cited claim."
|
|
return e
|
|
|
|
def test_anchor_none_positive_baseline(self) -> None:
|
|
# Canonical INV-6 compliant row should pass.
|
|
self.assertLintClean(build_passport(results=[self._none_entry()]))
|
|
|
|
def test_anchor_none_missing_rationale_prefix(self) -> None:
|
|
e = self._none_entry()
|
|
e["rationale"] = "anchor missing" # no prefix
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-6")
|
|
|
|
def test_anchor_none_wrong_retrieval_method(self) -> None:
|
|
e = self._none_entry()
|
|
e["ref_retrieval_method"] = "failed" # should be not_attempted
|
|
# INV-11 ↔ INV-6 mismatch — either fires; spec couples the firm rule via INV-6/11.
|
|
self.assertLintFinds(build_passport(results=[e]), invariant="INV-6")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S4: M-INV-1 duplicate claim_id within ONE manifest.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS4ManifestInv1(_LintTestBase):
|
|
"""T-S4: duplicate claim_id within one manifest is rejected; cross-manifest collision permitted."""
|
|
|
|
def test_duplicate_claim_id_within_one_manifest_rejected(self) -> None:
|
|
manifest = manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "First claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
},
|
|
{
|
|
"claim_id": "C-001", # duplicate within ONE manifest
|
|
"claim_text": "Second collision.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
},
|
|
],
|
|
)
|
|
self.assertLintFinds(
|
|
build_passport(manifests=[manifest]),
|
|
invariant="M-INV-1",
|
|
)
|
|
|
|
def test_cross_manifest_claim_id_collision_permitted(self) -> None:
|
|
manifest_a = manifest_entry(manifest_id=MANIFEST_ID)
|
|
manifest_b = manifest_entry(
|
|
manifest_id=MANIFEST_ID_OTHER,
|
|
emitted_by="draft_writer_agent",
|
|
)
|
|
self.assertLintClean(
|
|
build_passport(manifests=[manifest_a, manifest_b]),
|
|
msg="cross-manifest C-001 collision MUST be permitted (joinable pair)",
|
|
)
|
|
|
|
def test_m_inv_4_duplicate_manifest_id_across_passport_rejected(self) -> None:
|
|
manifest_a = manifest_entry(manifest_id=MANIFEST_ID)
|
|
manifest_b = manifest_entry(
|
|
manifest_id=MANIFEST_ID, # collides with manifest_a
|
|
emitted_by="draft_writer_agent",
|
|
)
|
|
self.assertLintFinds(
|
|
build_passport(manifests=[manifest_a, manifest_b]),
|
|
invariant="M-INV-4",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S5: M-INV-2 dangling NC-C{n}-{m} (no parent claim with C-{n}).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS5ManifestInv2(_LintTestBase):
|
|
"""T-S5: NC-C{n}-{m} must scope under a claims[] entry with claim_id=C-{n}."""
|
|
|
|
def test_dangling_claim_level_nc(self) -> None:
|
|
manifest = manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "First claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
"negative_constraints": [
|
|
{
|
|
# NC scoped to C-002, but no C-002 claim exists.
|
|
"constraint_id": "NC-C002-1",
|
|
"rule": "Mismatched parent.",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
)
|
|
self.assertLintFinds(
|
|
build_passport(manifests=[manifest]),
|
|
invariant="M-INV-2",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S6: M-INV-3 claim-level NC attempting to override MNC.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS6ManifestInv3(_LintTestBase):
|
|
"""T-S6: claim-level NC cannot DROP a global MNC; ADD is permitted."""
|
|
|
|
def test_claim_level_nc_collides_with_mnc_id(self) -> None:
|
|
# M-INV-3 — claim-level constraint reusing an MNC-* id is the override
|
|
# signature the lint must reject (claim level can ADD via fresh NC-C{n}-{m}
|
|
# ids, never via re-using an MNC-* id).
|
|
manifest = manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "Causal claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
"negative_constraints": [
|
|
{
|
|
"constraint_id": "MNC-1", # disallowed at claim level
|
|
"rule": "Trying to override.",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
mncs=[
|
|
{
|
|
"constraint_id": "MNC-1",
|
|
"rule": "Must not claim causality.",
|
|
}
|
|
],
|
|
)
|
|
self.assertLintFinds(
|
|
build_passport(manifests=[manifest]),
|
|
invariant="M-INV-3",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S7: U-INV-1..U-INV-4 paired positive/negative.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS7UncitedAssertionInvariants(_LintTestBase):
|
|
"""T-S7: U-INV-1..U-INV-4 paired pos/neg fixtures."""
|
|
|
|
def test_u_baseline_positive(self) -> None:
|
|
self.assertLintClean(build_passport(uncited=[uncited_assertion_entry()]))
|
|
|
|
def test_u_inv_1_duplicate_finding_id(self) -> None:
|
|
a = uncited_assertion_entry()
|
|
b = uncited_assertion_entry() # same UA-001
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[a, b]),
|
|
invariant="U-INV-1",
|
|
)
|
|
|
|
def test_u_inv_2_empty_trigger_tokens(self) -> None:
|
|
e = uncited_assertion_entry()
|
|
e["trigger_tokens"] = []
|
|
# Schema also rejects (minItems=1); lint surfaces U-INV-2 specifically.
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[e]),
|
|
invariant="U-INV-2",
|
|
)
|
|
|
|
def test_u_inv_3_wrong_rule_version(self) -> None:
|
|
e = uncited_assertion_entry()
|
|
e["rule_version"] = "D4-c-v0"
|
|
# Schema const rejects; lint surfaces U-INV-3 specifically.
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[e]),
|
|
invariant="U-INV-3",
|
|
)
|
|
|
|
def test_u_inv_4_orphan_manifest_pointer(self) -> None:
|
|
# manifest_claim_id set without matching manifest entry -> U-INV-4
|
|
e = uncited_assertion_entry()
|
|
e["manifest_claim_id"] = "C-999"
|
|
e["scoped_manifest_id"] = MANIFEST_ID
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[e]),
|
|
invariant="U-INV-4",
|
|
)
|
|
|
|
def test_u_inv_4_null_manifest_id_with_set_claim_id(self) -> None:
|
|
# manifest_claim_id != null requires scoped_manifest_id != null.
|
|
e = uncited_assertion_entry()
|
|
e["manifest_claim_id"] = "C-001"
|
|
e["scoped_manifest_id"] = None
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[e]),
|
|
invariant="U-INV-4",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S8: (judgment, audit_status, defect_stage) matrix exhaustive coverage.
|
|
# Spec §3.1 table: 9 positive rows, lint rejects every (j, a, d) triple
|
|
# outside the table; ≥5 disallowed combinations exercised explicitly.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TS8AllowedMatrix(_LintTestBase):
|
|
"""T-S8: every allowed triple validates; ≥5 disallowed combinations rejected."""
|
|
|
|
# Positive matrix rows — each is a self-contained passport fragment.
|
|
ALLOWED_ROWS = [
|
|
# (judgment, audit_status, defect_stage, ref_retrieval_method overrides)
|
|
("SUPPORTED", "completed", None, {"ref_retrieval_method": "api"}),
|
|
("AMBIGUOUS", "completed", "source_description", {"ref_retrieval_method": "api"}),
|
|
("AMBIGUOUS", "completed", "citation_anchor", {"ref_retrieval_method": "api"}),
|
|
("AMBIGUOUS", "completed", "synthesis_overclaim", {"ref_retrieval_method": "api"}),
|
|
("AMBIGUOUS", "completed", None, {"ref_retrieval_method": "api"}),
|
|
("UNSUPPORTED", "completed", "source_description", {"ref_retrieval_method": "api"}),
|
|
("UNSUPPORTED", "completed", "metadata", {"ref_retrieval_method": "api"}),
|
|
("UNSUPPORTED", "completed", "citation_anchor", {"ref_retrieval_method": "api"}),
|
|
("UNSUPPORTED", "completed", "synthesis_overclaim", {"ref_retrieval_method": "api"}),
|
|
(
|
|
"UNSUPPORTED",
|
|
"completed",
|
|
"negative_constraint_violation",
|
|
{
|
|
"ref_retrieval_method": "api",
|
|
"violated_constraint_id": "MNC-1",
|
|
},
|
|
),
|
|
(
|
|
"RETRIEVAL_FAILED",
|
|
"completed",
|
|
"retrieval_existence",
|
|
{"ref_retrieval_method": "not_found"},
|
|
),
|
|
(
|
|
"RETRIEVAL_FAILED",
|
|
"inconclusive",
|
|
"not_applicable",
|
|
{"ref_retrieval_method": "failed"},
|
|
),
|
|
]
|
|
|
|
# Negative cases — ≥5 representative disallowed combinations.
|
|
DISALLOWED_ROWS = [
|
|
# (judgment, audit_status, defect_stage, overrides, expected_invariant)
|
|
(
|
|
"SUPPORTED",
|
|
"completed",
|
|
"source_description",
|
|
{"ref_retrieval_method": "api"},
|
|
"INV-1",
|
|
),
|
|
(
|
|
"UNSUPPORTED",
|
|
"completed",
|
|
None,
|
|
{"ref_retrieval_method": "api"},
|
|
"INV-2",
|
|
),
|
|
(
|
|
"RETRIEVAL_FAILED",
|
|
"completed",
|
|
"not_applicable",
|
|
{"ref_retrieval_method": "api"},
|
|
"matrix",
|
|
),
|
|
(
|
|
"SUPPORTED",
|
|
"inconclusive",
|
|
None,
|
|
{"ref_retrieval_method": "api"},
|
|
"matrix",
|
|
),
|
|
(
|
|
"AMBIGUOUS",
|
|
"completed",
|
|
"metadata",
|
|
{"ref_retrieval_method": "api"},
|
|
"INV-3",
|
|
),
|
|
(
|
|
"AMBIGUOUS",
|
|
"inconclusive",
|
|
None,
|
|
{"ref_retrieval_method": "api"},
|
|
"matrix",
|
|
),
|
|
]
|
|
|
|
def _build_row(self, j: str, a: str, d: Any, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
e = supported_entry()
|
|
e["judgment"] = j
|
|
e["audit_status"] = a
|
|
e["defect_stage"] = d
|
|
if d == "negative_constraint_violation":
|
|
e["rationale"] = "Violated declared negative constraint."
|
|
elif d == "retrieval_existence":
|
|
e["rationale"] = "Reference does not exist."
|
|
elif d == "not_applicable":
|
|
e["rationale"] = "Paywall — full text not retrievable."
|
|
elif d is not None:
|
|
e["rationale"] = f"Defect at {d}."
|
|
e.update(overrides)
|
|
return e
|
|
|
|
def test_every_allowed_row_passes(self) -> None:
|
|
for j, a, d, overrides in self.ALLOWED_ROWS:
|
|
with self.subTest(judgment=j, audit_status=a, defect_stage=d):
|
|
e = self._build_row(j, a, d, overrides)
|
|
self.assertLintClean(
|
|
build_passport(results=[e]),
|
|
msg=f"row ({j}, {a}, {d}) should pass",
|
|
)
|
|
|
|
def test_disallowed_rows_rejected(self) -> None:
|
|
for j, a, d, overrides, invariant in self.DISALLOWED_ROWS:
|
|
with self.subTest(judgment=j, audit_status=a, defect_stage=d):
|
|
e = self._build_row(j, a, d, overrides)
|
|
self.assertLintFinds(
|
|
build_passport(results=[e]),
|
|
invariant=invariant,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec §6 lint 4a — D-INV-1..D-INV-4 claim_drift cross-array integrity.
|
|
# Not labelled T-Sx in spec §7.1 but mandatory per §6.4a. Paired pos/neg.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TSDDriftInvariants(_LintTestBase):
|
|
"""Spec §6.4a: claim_drift D-INV-1..D-INV-4."""
|
|
|
|
def test_d_baseline_emitted_not_intended(self) -> None:
|
|
self.assertLintClean(build_passport(drifts=[claim_drift_entry()]))
|
|
|
|
def test_d_baseline_intended_not_emitted(self) -> None:
|
|
drift = claim_drift_entry(drift_kind="INTENDED_NOT_EMITTED")
|
|
self.assertLintClean(build_passport(drifts=[drift]))
|
|
|
|
def test_d_inv_1_duplicate_finding_id(self) -> None:
|
|
a = claim_drift_entry()
|
|
b = claim_drift_entry() # CD-001 collides
|
|
self.assertLintFinds(build_passport(drifts=[a, b]), invariant="D-INV-1")
|
|
|
|
def test_d_inv_2_intended_not_emitted_missing_manifest_pointer(self) -> None:
|
|
drift = claim_drift_entry(drift_kind="INTENDED_NOT_EMITTED")
|
|
drift["manifest_claim_id"] = None # MUST be non-null for INTENDED_NOT_EMITTED
|
|
drift["scoped_manifest_id"] = None
|
|
self.assertLintFinds(build_passport(drifts=[drift]), invariant="D-INV-2")
|
|
|
|
def test_d_inv_2_emitted_not_intended_with_manifest_pointer(self) -> None:
|
|
drift = claim_drift_entry(drift_kind="EMITTED_NOT_INTENDED")
|
|
drift["manifest_claim_id"] = "C-001" # MUST be null for EMITTED_NOT_INTENDED
|
|
drift["scoped_manifest_id"] = MANIFEST_ID
|
|
self.assertLintFinds(build_passport(drifts=[drift]), invariant="D-INV-2")
|
|
|
|
def test_d_inv_2_dangling_intended_not_emitted_pair(self) -> None:
|
|
drift = claim_drift_entry(drift_kind="INTENDED_NOT_EMITTED")
|
|
drift["manifest_claim_id"] = "C-999" # no such claim in manifest
|
|
drift["scoped_manifest_id"] = MANIFEST_ID
|
|
self.assertLintFinds(build_passport(drifts=[drift]), invariant="D-INV-2")
|
|
|
|
def test_d_inv_3_wrong_rule_version(self) -> None:
|
|
drift = claim_drift_entry()
|
|
drift["rule_version"] = "D4-a-v0"
|
|
self.assertLintFinds(build_passport(drifts=[drift]), invariant="D-INV-3")
|
|
|
|
def test_d_inv_4_uncited_and_drift_collision(self) -> None:
|
|
# A single sentence appears in both uncited_assertions[] and claim_drifts[].
|
|
sentence = "Half of submissions showed positive results."
|
|
uncited = uncited_assertion_entry()
|
|
uncited["sentence_text"] = sentence
|
|
drift = claim_drift_entry()
|
|
drift["claim_text"] = sentence
|
|
self.assertLintFinds(
|
|
build_passport(uncited=[uncited], drifts=[drift]),
|
|
invariant="D-INV-4",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec §6.4b — CV-INV-1..CV-INV-4 constraint_violation cross-array integrity.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TSCVConstraintViolationInvariants(_LintTestBase):
|
|
"""Spec §6.4b: constraint_violation CV-INV-1..CV-INV-4."""
|
|
|
|
def _manifest_with_mnc_and_nc(self) -> dict[str, Any]:
|
|
return manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "Causal claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
"negative_constraints": [
|
|
{
|
|
"constraint_id": "NC-C001-1",
|
|
"rule": "No causal language without RCT.",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
mncs=[{"constraint_id": "MNC-1", "rule": "Global rule."}],
|
|
)
|
|
|
|
def test_cv_baseline_mnc_violation(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[constraint_violation_entry()],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
def test_cv_baseline_nc_violation(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[
|
|
constraint_violation_entry(
|
|
constraint_id="NC-C001-1",
|
|
manifest_claim_id="C-001",
|
|
)
|
|
],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
def test_cv_inv_1_duplicate_finding_id(self) -> None:
|
|
a = constraint_violation_entry()
|
|
b = constraint_violation_entry()
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[a, b],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-1")
|
|
|
|
def test_cv_inv_2_dangling_mnc(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[constraint_violation_entry(constraint_id="MNC-99")],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-2")
|
|
|
|
def test_cv_inv_2_dangling_nc(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[
|
|
constraint_violation_entry(
|
|
constraint_id="NC-C999-1",
|
|
manifest_claim_id="C-999",
|
|
)
|
|
],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-2")
|
|
|
|
def test_cv_inv_3_mnc_with_set_manifest_claim_id(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[
|
|
constraint_violation_entry(
|
|
constraint_id="MNC-1",
|
|
manifest_claim_id="C-001", # MUST be null for MNC
|
|
)
|
|
],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-3")
|
|
|
|
def test_cv_inv_3_nc_polarity_mismatch(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[
|
|
constraint_violation_entry(
|
|
constraint_id="NC-C001-1",
|
|
manifest_claim_id="C-002", # MUST match the C-001 in NC id
|
|
)
|
|
],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-3")
|
|
|
|
def test_cv_inv_4_duplicate_per_constraint(self) -> None:
|
|
# Two violations for (same sentence, same constraint) -> dedup rule.
|
|
a = constraint_violation_entry()
|
|
b = constraint_violation_entry()
|
|
b["finding_id"] = "CV-002" # avoid CV-INV-1 collision
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[a, b],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-4")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Step 13 R8 codex P2-1 — CV-INV-4 dedupe key extension. Two manifests in
|
|
# the same passport can carry colliding MNC-* / NC-* ids; the same sentence
|
|
# text may legitimately violate both. The pre-fix dedupe key
|
|
# (section_path, claim_text_hash, violated_constraint_id) false-positives
|
|
# these as duplicates. Extend the key to scope by manifest_id as well.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TSCVDedupeManifestScope(_LintTestBase):
|
|
"""T-SCV-DEDUPE: CV-INV-4 dedupe must scope by scoped_manifest_id."""
|
|
|
|
def _two_manifests_with_colliding_mnc(self) -> list[dict[str, Any]]:
|
|
# Two manifests each carrying the SAME constraint_id "MNC-1" but
|
|
# bound to different manifests (M-INV-4 permits — manifest_id is
|
|
# the joinable scope). Schema requires claims[] non-empty so we
|
|
# add a minimal claim to each manifest; the claim itself is not
|
|
# under test.
|
|
return [
|
|
manifest_entry(
|
|
manifest_id=MANIFEST_ID,
|
|
mncs=[{"constraint_id": "MNC-1", "rule": "No comparative claim."}],
|
|
),
|
|
manifest_entry(
|
|
manifest_id=MANIFEST_ID_OTHER,
|
|
mncs=[{"constraint_id": "MNC-1", "rule": "No temporal claim."}],
|
|
),
|
|
]
|
|
|
|
def test_same_constraint_id_across_manifests_not_deduped(self) -> None:
|
|
# Two CV rows on the same sentence text + same constraint_id (MNC-1)
|
|
# but DIFFERENT scoped_manifest_id. Legitimate per M-INV-4 +
|
|
# manifest_negative_constraints scoping — dedupe must keep both.
|
|
a = constraint_violation_entry(constraint_id="MNC-1")
|
|
a["scoped_manifest_id"] = MANIFEST_ID
|
|
b = constraint_violation_entry(constraint_id="MNC-1")
|
|
b["finding_id"] = "CV-002"
|
|
b["scoped_manifest_id"] = MANIFEST_ID_OTHER
|
|
passport = build_passport(
|
|
manifests=self._two_manifests_with_colliding_mnc(),
|
|
violations=[a, b],
|
|
)
|
|
# Pre-fix: CV-INV-4 false-positives → lint reports CV-INV-4 line.
|
|
# Post-fix: lint MUST NOT fire CV-INV-4 on cross-manifest collision.
|
|
self.assertLintClean(passport)
|
|
|
|
def test_same_constraint_id_same_manifest_still_deduped(self) -> None:
|
|
# Negative test: dedupe still catches true within-manifest duplicates.
|
|
a = constraint_violation_entry(constraint_id="MNC-1")
|
|
b = constraint_violation_entry(constraint_id="MNC-1")
|
|
b["finding_id"] = "CV-002"
|
|
# Both rows reference the same manifest_id (default MANIFEST_ID).
|
|
passport = build_passport(
|
|
manifests=[self._two_manifests_with_colliding_mnc()[0]], # single manifest
|
|
violations=[a, b],
|
|
)
|
|
self.assertLintFinds(passport, invariant="CV-INV-4")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec §6.4c — S-INV-1..S-INV-4 audit_sampling_summary invariants.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TSSamplingInvariants(_LintTestBase):
|
|
"""Spec §6.4c: audit_sampling_summary S-INV-1..S-INV-4."""
|
|
|
|
def test_s_baseline(self) -> None:
|
|
self.assertLintClean(build_passport(samplings=[sampling_summary_entry()]))
|
|
|
|
def test_s_inv_1_count_vs_indices_mismatch(self) -> None:
|
|
e = sampling_summary_entry()
|
|
e["audited_count"] = e["audited_count"] - 1 # off-by-one
|
|
self.assertLintFinds(build_passport(samplings=[e]), invariant="S-INV-1")
|
|
|
|
def test_s_inv_2_count_exceeds_cap(self) -> None:
|
|
e = sampling_summary_entry(total=50, cap=10)
|
|
e["audited_count"] = 20
|
|
e["audited_indices"] = list(range(20))
|
|
self.assertLintFinds(build_passport(samplings=[e]), invariant="S-INV-2")
|
|
|
|
def test_s_inv_2_count_exceeds_total(self) -> None:
|
|
e = sampling_summary_entry(total=10, cap=100)
|
|
e["audited_count"] = 20
|
|
e["audited_indices"] = list(range(20))
|
|
self.assertLintFinds(build_passport(samplings=[e]), invariant="S-INV-2")
|
|
|
|
def test_s_inv_4_non_ascending_indices(self) -> None:
|
|
e = sampling_summary_entry(total=20, cap=10)
|
|
e["audited_indices"] = [5, 3, 7, 9, 11, 13, 15, 17, 19, 2]
|
|
e["audited_count"] = 10
|
|
self.assertLintFinds(build_passport(samplings=[e]), invariant="S-INV-4")
|
|
|
|
def test_s_inv_4_duplicate_indices(self) -> None:
|
|
e = sampling_summary_entry(total=20, cap=10)
|
|
e["audited_indices"] = [0, 1, 1, 3, 4, 5, 6, 7, 8, 9]
|
|
e["audited_count"] = 10
|
|
self.assertLintFinds(build_passport(samplings=[e]), invariant="S-INV-4")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec §6.4d (v3.8.2 / #118) — UAF-INV-1..UAF-INV-5 uncited_audit_failure
|
|
# cross-array integrity and dedup. Mirrors CV-INV pattern.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TSUAFUncitedAuditFailureInvariants(_LintTestBase):
|
|
"""Spec §6.4d: uncited_audit_failure UAF-INV-1..UAF-INV-5 (v3.8.2 / #118)."""
|
|
|
|
def _manifest_with_mnc_and_nc(self) -> dict[str, Any]:
|
|
return manifest_entry(
|
|
claims=[
|
|
{
|
|
"claim_id": "C-001",
|
|
"claim_text": "Causal claim.",
|
|
"intended_evidence_kind": "empirical",
|
|
"planned_refs": [],
|
|
"negative_constraints": [
|
|
{
|
|
"constraint_id": "NC-C001-1",
|
|
"rule": "No causal language without RCT.",
|
|
}
|
|
],
|
|
}
|
|
],
|
|
mncs=[{"constraint_id": "MNC-1", "rule": "Global rule."}],
|
|
)
|
|
|
|
# ----- Schema-shape only.
|
|
def test_uaf_schema_valid_minimal_entry(self) -> None:
|
|
schema = load_json_schema(SCHEMA_PATHS["uncited_audit_failure"])
|
|
validator = build_schema_validator(schema)
|
|
errors = list(validator.iter_errors(uncited_audit_failure_entry()))
|
|
self.assertEqual(errors, [], msg=f"unexpected validation errors: {errors}")
|
|
|
|
def test_uaf_schema_fault_class_enum_matches_constants(self) -> None:
|
|
"""The schema's fault_class enum MUST match INV14_FAULT_CLASS_TAGS exactly.
|
|
|
|
Without this guard, extending INV14_FAULT_CLASS_TAGS in the Python
|
|
constants module would silently produce UAF rows that fail schema
|
|
validation (the pipeline maps a new exception class to a new tag,
|
|
but the schema's closed enum hasn't been bumped). Per Gemini cross-
|
|
model review R2 P2 (2026-05-17): both surfaces must move together,
|
|
and a rule_version bump (D4-c-v1-uaf-v2 or later) should accompany
|
|
any enum extension.
|
|
"""
|
|
from scripts._claim_audit_constants import INV14_FAULT_CLASS_TAGS
|
|
|
|
schema = load_json_schema(SCHEMA_PATHS["uncited_audit_failure"])
|
|
schema_enum = set(schema["properties"]["fault_class"]["enum"])
|
|
constants_set = set(INV14_FAULT_CLASS_TAGS)
|
|
self.assertEqual(
|
|
schema_enum,
|
|
constants_set,
|
|
msg=(
|
|
"uncited_audit_failure.schema.json `fault_class` enum drifted "
|
|
"from INV14_FAULT_CLASS_TAGS. Either align the schema with the "
|
|
"python constant, or if intentionally extending the taxonomy "
|
|
"bump `rule_version` (e.g., D4-c-v1-uaf-v2) and document the "
|
|
"ramp in spec §3.6."
|
|
),
|
|
)
|
|
|
|
def test_uaf_schema_rejects_missing_fault_class(self) -> None:
|
|
schema = load_json_schema(SCHEMA_PATHS["uncited_audit_failure"])
|
|
validator = build_schema_validator(schema)
|
|
e = uncited_audit_failure_entry()
|
|
del e["fault_class"]
|
|
errors = list(validator.iter_errors(e))
|
|
self.assertNotEqual(errors, [], msg="missing fault_class must fail schema validation")
|
|
|
|
def test_uaf_schema_rejects_unknown_fault_class(self) -> None:
|
|
schema = load_json_schema(SCHEMA_PATHS["uncited_audit_failure"])
|
|
validator = build_schema_validator(schema)
|
|
e = uncited_audit_failure_entry()
|
|
e["fault_class"] = "made_up_class"
|
|
errors = list(validator.iter_errors(e))
|
|
self.assertNotEqual(errors, [], msg="unknown fault_class must fail schema validation")
|
|
|
|
# ----- Lint baseline: clean passport with one UAF row passes.
|
|
def test_uaf_baseline_mnc_failure(self) -> None:
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[uncited_audit_failure_entry()],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
def test_uaf_baseline_nc_failure(self) -> None:
|
|
# NC-C path: judge failed on a claim-level constraint check; manifest_claim_id set.
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[uncited_audit_failure_entry(manifest_claim_id="C-001")],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
# ----- UAF-INV-1: finding_id uniqueness.
|
|
def test_uaf_inv_1_duplicate_finding_id(self) -> None:
|
|
a = uncited_audit_failure_entry()
|
|
b = uncited_audit_failure_entry() # same finding_id "UAF-001"
|
|
# Per Gemini cross-model review P2 (2026-05-17): keep this test
|
|
# isolated to UAF-INV-1 by giving b a different claim_text so the
|
|
# passport does NOT simultaneously trip UAF-INV-4 (per-(sentence,
|
|
# manifest) dedup). One test should target one invariant cleanly.
|
|
b["claim_text"] = "Different text so UAF-INV-4 dedup does not fire."
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[a, b],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-1")
|
|
|
|
# ----- UAF-INV-2: scoped_manifest_id must resolve in claim_intent_manifests[].
|
|
def test_uaf_inv_2_dangling_manifest_id(self) -> None:
|
|
e = uncited_audit_failure_entry()
|
|
e["scoped_manifest_id"] = SENTINEL_MANIFEST_ID # not in manifests
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-2")
|
|
|
|
# ----- UAF-INV-3: (scoped_manifest_id, manifest_claim_id) pair integrity.
|
|
def test_uaf_inv_3_dangling_claim_id(self) -> None:
|
|
e = uncited_audit_failure_entry(manifest_claim_id="C-999") # no such claim in manifest
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-3")
|
|
|
|
def test_uaf_inv_3_null_claim_id_allowed_when_mnc_only(self) -> None:
|
|
# manifest_claim_id=null is legitimate when the failure was against MNCs only.
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[uncited_audit_failure_entry(manifest_claim_id=None)],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
# ----- UAF-INV-4: per-(sentence, manifest) dedup.
|
|
def test_uaf_inv_4_same_sentence_same_manifest_dup(self) -> None:
|
|
a = uncited_audit_failure_entry()
|
|
b = uncited_audit_failure_entry(finding_id="UAF-002")
|
|
# b shares (scoped_manifest_id, section_path, claim_text) with a.
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[a, b],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-4")
|
|
|
|
def test_uaf_inv_4_same_sentence_different_manifests_not_deduped(self) -> None:
|
|
# Two manifests both failing on the same sentence text emit two rows
|
|
# legitimately (mirrors CV-INV-4 cross-manifest reasoning).
|
|
other_manifest = manifest_entry(
|
|
manifest_id=MANIFEST_ID_OTHER,
|
|
mncs=[{"constraint_id": "MNC-1", "rule": "Different rule."}],
|
|
)
|
|
a = uncited_audit_failure_entry()
|
|
a["scoped_manifest_id"] = MANIFEST_ID
|
|
b = uncited_audit_failure_entry(finding_id="UAF-002")
|
|
b["scoped_manifest_id"] = MANIFEST_ID_OTHER
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc(), other_manifest],
|
|
uaf=[a, b],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
# ----- UAF-INV-5: rationale fault_class prefix.
|
|
def test_uaf_inv_5_rationale_missing_fault_prefix(self) -> None:
|
|
e = uncited_audit_failure_entry()
|
|
e["rationale"] = "Some narrative without the fault_class prefix"
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-5")
|
|
|
|
def test_uaf_inv_5_rationale_wrong_fault_prefix(self) -> None:
|
|
# Rationale starts with "made_up_class:" — schema rejected via fault_class
|
|
# enum, but UAF-INV-5 also flags rationale prefix mismatch independently.
|
|
# Here we keep fault_class valid but flip rationale to a different valid
|
|
# tag — should still trip UAF-INV-5 (prefix must match the row's
|
|
# fault_class, not just any known tag).
|
|
e = uncited_audit_failure_entry(fault_class="judge_timeout")
|
|
e["rationale"] = "judge_api_error: wrong tag for this row"
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-5")
|
|
|
|
# ----- Cross-aggregate exclusivity: UAF vs constraint_violation.
|
|
def test_uaf_cross_aggregate_exclusive_with_cv(self) -> None:
|
|
# Same (sentence, manifest) MUST NOT have both an audit_tool_failure (UAF)
|
|
# and a VIOLATED row (CV). They are mutually exclusive verdict states.
|
|
cv = constraint_violation_entry()
|
|
uaf = uncited_audit_failure_entry()
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
violations=[cv],
|
|
uaf=[uaf],
|
|
)
|
|
self.assertLintFinds(passport, invariant="UAF-INV-6")
|
|
|
|
# ----- Malformed-payload hardening (Codex R2 P2-2 + P2-3, 2026-05-17).
|
|
def test_uaf_malformed_manifest_claim_id_does_not_crash_lint(self) -> None:
|
|
# Schema validator flags type errors separately; lint walker must
|
|
# skip cleanly on unhashable values rather than raise TypeError.
|
|
e = uncited_audit_failure_entry()
|
|
e["manifest_claim_id"] = ["unhashable", "list", "as", "claim_id"]
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
# Lint should report SOMETHING (schema-shape finding), exit 1, NOT crash.
|
|
path = write_passport(self.tmp, passport)
|
|
code, _, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
1,
|
|
msg=f"expected clean lint failure on malformed manifest_claim_id, got exit={code}\nstderr:\n{err}",
|
|
)
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg="lint MUST NOT crash with a traceback on malformed manifest_claim_id (Codex R2 P2-2)",
|
|
)
|
|
|
|
def test_uaf_malformed_rationale_does_not_crash_lint(self) -> None:
|
|
e = uncited_audit_failure_entry()
|
|
e["rationale"] = ["unhashable", "rationale"]
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uaf=[e],
|
|
)
|
|
path = write_passport(self.tmp, passport)
|
|
code, _, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
1,
|
|
msg=f"expected clean lint failure on malformed rationale, got exit={code}\nstderr:\n{err}",
|
|
)
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg="lint MUST NOT crash with a traceback on malformed rationale (Codex R2 P2-3)",
|
|
)
|
|
|
|
# ----- Co-existence with uncited_assertions[] IS permitted.
|
|
def test_uaf_coexists_with_uncited_assertion(self) -> None:
|
|
# D4-c detector positive (UA) + judge failure (UAF) on the same sentence
|
|
# are independent signals; cross-aggregate exclusivity does NOT apply.
|
|
ua = uncited_assertion_entry()
|
|
uaf = uncited_audit_failure_entry()
|
|
passport = build_passport(
|
|
manifests=[self._manifest_with_mnc_and_nc()],
|
|
uncited=[ua],
|
|
uaf=[uaf],
|
|
)
|
|
self.assertLintClean(passport)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# T-S9: Defensive guard against malformed passports.
|
|
# Step 13 R4 codex P2 #4 — malformed aggregates (non-list / non-dict entries)
|
|
# must surface as schema findings rather than crash the lint with an
|
|
# AttributeError traceback.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TS9MalformedPassportGuard(_LintTestBase):
|
|
"""T-S9: passport with malformed aggregate yields schema finding, not crash."""
|
|
|
|
def test_claim_audit_results_with_non_dict_entry(self) -> None:
|
|
# Pre-R4: list of non-dict in claim_audit_results raised AttributeError
|
|
# because dict-only invariant loops ran .get() before noticing the shape.
|
|
body = build_passport()
|
|
body["claim_audit_results"] = ["this should be an object, not a string"]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
1,
|
|
msg=f"expected clean lint failure on malformed aggregate; got exit={code}\nstderr:\n{err}",
|
|
)
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg=f"lint must not raise — got traceback:\n{err}",
|
|
)
|
|
self.assertIn("schema", out, msg=f"expected schema finding tag in stdout:\n{out}")
|
|
|
|
def test_claim_intent_manifests_as_dict_instead_of_list(self) -> None:
|
|
body = build_passport()
|
|
body["claim_intent_manifests"] = {"oops": "should be a list"}
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not raise:\n{err}")
|
|
self.assertIn("schema", out)
|
|
|
|
def test_non_object_passport_body_yields_clean_finding(self) -> None:
|
|
# Step 13 R7 codex P3: `[]`, `null`, scalar top-level JSON would
|
|
# previously crash with AttributeError on `.get()`. Validate that
|
|
# each surfaces as a schema finding without traceback.
|
|
for malformed_body in ([], None, 42, "passport"):
|
|
with self.subTest(body=malformed_body):
|
|
path = self.tmp / f"malformed_{type(malformed_body).__name__}.json"
|
|
path.write_text(json.dumps(malformed_body), encoding="utf-8")
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
1,
|
|
msg=f"expected exit=1 for body={malformed_body!r}; got {code}\nstderr:\n{err}",
|
|
)
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg=f"lint must not raise for body={malformed_body!r}:\n{err}",
|
|
)
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_falsey_malformed_aggregate_does_not_bypass_schema(self) -> None:
|
|
# Step 13 R5 codex P2 #1: `body.get(k, []) or []` would silently
|
|
# convert a malformed `{}` / `null` / `0` / `""` value to an empty
|
|
# list and skip schema validation. Each falsey-but-malformed shape
|
|
# must surface as a schema finding.
|
|
for malformed in ({}, None, 0, ""):
|
|
with self.subTest(malformed=malformed):
|
|
body = build_passport()
|
|
body["claim_audit_results"] = malformed
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(
|
|
code,
|
|
1,
|
|
msg=f"expected exit=1 for malformed={malformed!r}; got {code}\nstdout:\n{out}\nstderr:\n{err}",
|
|
)
|
|
self.assertIn("schema", out, msg=f"expected schema finding for malformed={malformed!r}:\n{out}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not raise for malformed={malformed!r}:\n{err}")
|
|
|
|
# Step 13 R6 codex P2 + R8 P2-2 — nested schema-invalid shapes must not
|
|
# crash the cross-field invariant walkers. Schema validator records the
|
|
# finding; the invariant helpers then iterate the malformed nested shape
|
|
# and previously hit TypeError/AttributeError. Issue #119 + #120 P2-2.
|
|
|
|
def test_manifest_claims_as_string_does_not_crash_invariant_walker(self) -> None:
|
|
# #119 + #120 P2-2: claim_intent_manifests[0].claims is a string,
|
|
# not a list. Schema validator records the finding; the cross-field
|
|
# invariant walker (M-INV-1..M-INV-4) must NOT iterate the string
|
|
# and crash. Expect clean exit=1 with schema finding, no traceback.
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["claims"] = "should be a list"
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on nested string:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_manifest_claim_with_non_string_claim_id_does_not_crash(self) -> None:
|
|
# #119: manifest.claims[].claim_id is a non-string. Schema records
|
|
# the type mismatch; invariant walker must skip this manifest's
|
|
# cross-field checks instead of crashing on .get() / regex match.
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["claims"][0]["claim_id"] = 42
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on non-string claim_id:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_audited_indices_mixed_types_does_not_crash_sampling_walker(self) -> None:
|
|
# #119: audit_sampling_summaries[].audited_indices contains mixed
|
|
# str/int types. Schema records the type mismatch; S-INV-4 walker
|
|
# (indices[i] <= indices[i-1]) must skip instead of raising
|
|
# TypeError on '<=' between str and int.
|
|
body = build_passport()
|
|
body["audit_sampling_summaries"] = [{
|
|
"audit_run_id": AUDIT_RUN_ID,
|
|
"max_claims_per_paper": 10,
|
|
"total_citation_count": 5,
|
|
"audited_count": 2,
|
|
"audited_indices": ["a", 1],
|
|
"sampling_strategy": "stratified_buckets_v1",
|
|
"emitted_at": "2026-05-15T10:15:00Z",
|
|
}]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on mixed-type indices:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
# v3.8.1 Round 2 codex review P2 + adjacent unhashable-id surfaces.
|
|
# Schema validator records the type mismatch separately; uniqueness loops
|
|
# and dedupe key construction must not crash on unhashable nested ids.
|
|
|
|
def test_unhashable_claim_id_does_not_crash_m_inv_1(self) -> None:
|
|
# codex round 2 P2: claim.get("claim_id") returns a list/dict
|
|
# (unhashable). M-INV-1 uniqueness `cid in claim_ids` crashes with
|
|
# TypeError. Schema records the type mismatch but the invariant walker
|
|
# still raises, masking the schema findings.
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["claims"][0]["claim_id"] = ["bad", "type"]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on unhashable claim_id:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_unhashable_finding_id_does_not_crash_cv_inv_1(self) -> None:
|
|
# CV-INV-1 finding_id uniqueness `fid in seen` crashes when finding_id
|
|
# is a list / dict (schema-invalid). Same surface class as the M-INV-1
|
|
# case codex flagged; included for parity coverage across all four
|
|
# finding_id uniqueness sites (M-INV-4 / U-INV-1 / D-INV-1 / CV-INV-1).
|
|
body = build_passport()
|
|
body["constraint_violations"] = [
|
|
{
|
|
"finding_id": ["unhashable"],
|
|
"claim_text": "x",
|
|
"section_path": "Results > Findings",
|
|
"violated_constraint_id": "MNC-1",
|
|
"scoped_manifest_id": MANIFEST_ID,
|
|
"manifest_claim_id": None,
|
|
"judge_verdict": "VIOLATED",
|
|
"rationale": "rationale",
|
|
"judge_model": "gpt-5.5-xhigh",
|
|
"judge_run_at": "2026-05-15T10:14:00Z",
|
|
"rule_version": "D4-a-v1",
|
|
}
|
|
]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on unhashable finding_id:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_unhashable_mnc_constraint_id_does_not_crash_m_inv_3(self) -> None:
|
|
# codex round 3 P2: manifest_negative_constraints[].constraint_id as
|
|
# list/dict crashes the `mnc_ids = {nc.get("constraint_id") for ...}`
|
|
# set comprehension when the implementation builds an (unused) set.
|
|
# Schema records the type mismatch but the lint still raises.
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["manifest_negative_constraints"] = [
|
|
{"constraint_id": ["unhashable"], "rule": "bad"}
|
|
]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on unhashable MNC constraint_id:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_non_string_claim_text_does_not_crash_cv_inv_4_dedupe(self) -> None:
|
|
# CV-INV-4 dedupe key constructs hashlib.sha256((claim_text or "").encode())
|
|
# — if claim_text is a list / dict / int (schema-invalid), the `or ""`
|
|
# idiom passes the non-string through and .encode() raises AttributeError.
|
|
# Codex round 2 adversarial probing surfaced this same class.
|
|
body = build_passport()
|
|
body["constraint_violations"] = [
|
|
{
|
|
"finding_id": "CV-001",
|
|
"claim_text": ["not", "a", "string"],
|
|
"section_path": "Results > Findings",
|
|
"violated_constraint_id": "MNC-1",
|
|
"scoped_manifest_id": MANIFEST_ID,
|
|
"manifest_claim_id": None,
|
|
"judge_verdict": "VIOLATED",
|
|
"rationale": "rationale",
|
|
"judge_model": "gpt-5.5-xhigh",
|
|
"judge_run_at": "2026-05-15T10:14:00Z",
|
|
"rule_version": "D4-a-v1",
|
|
}
|
|
]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn("Traceback", err, msg=f"lint must not crash on non-string claim_text:\n{err}")
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
|
|
# v3.9.1 / #130 — manifest_id non-string guard in _build_manifest_index +
|
|
# _build_manifest_constraint_index. Schema validator records the type
|
|
# mismatch finding; the index builders must skip non-string IDs cleanly
|
|
# instead of raising TypeError("unhashable type") on setdefault() / [mid]=.
|
|
class TSManifestIdNonStringGuard(_LintTestBase):
|
|
"""Issue #130: manifest_id of type list/dict must not crash index builders."""
|
|
|
|
def test_manifest_id_as_list_does_not_crash(self) -> None:
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["manifest_id"] = ["oops"]
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg=f"lint must not crash on list manifest_id (unhashable in setdefault):\n{err}",
|
|
)
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
def test_manifest_id_as_dict_does_not_crash(self) -> None:
|
|
body = build_passport()
|
|
body["claim_intent_manifests"][0]["manifest_id"] = {"oops": "dict id"}
|
|
path = write_passport(self.tmp, body)
|
|
code, out, err = run_lint(path)
|
|
self.assertEqual(code, 1, msg=f"expected exit=1; got {code}\nstderr:\n{err}")
|
|
self.assertNotIn(
|
|
"Traceback",
|
|
err,
|
|
msg=f"lint must not crash on dict manifest_id (unhashable in setdefault):\n{err}",
|
|
)
|
|
self.assertIn("schema", out, msg=f"expected schema finding:\n{out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|