Files
imbad0202__academic-researc…/scripts/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

457 lines
20 KiB
Python

"""ARS v3.8 claim-faithfulness finalizer — 8-row matrix annotations + Stage 6 histogram.
Implements the orchestrator `§3.6 Claim-Faithfulness Audit Gate (v3.8)` matrix
table in `pipeline_orchestrator_agent.md`, plus the Stage 6 reflection
report histogram (renders when ≥5 completed audit entries exist).
The module is invoked by the orchestrator at the Stage 4 → Stage 5 boundary,
AFTER the v3.7.1 Cite-Time Provenance Finalizer resolves anchor presence,
and BEFORE `formatter_agent` runs its hard gate. The classify_* functions
project each passport aggregate row to `{annotation, tier, gate_refuse}`;
`apply_finalizer` reduces over the full passport to produce the
passport-level gate decision + reason list that the formatter consumes.
Spec:
docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md §5 (8-row
matrix), §"Manifest cross-reference (D6)" (drift / constraint routing),
§3.6 (orchestrator prose).
This module deliberately does NO file I/O — it operates on Python dicts
matching the passport aggregate schemas. The orchestrator handles passport
assembly; the formatter handles refusal surfacing. Single-responsibility
keeps the matrix logic unit-testable.
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any
from scripts._claim_audit_constants import (
INV14_FAULT_CLASS_TAGS,
SENTINEL_MANIFEST_ID,
)
# ---------------------------------------------------------------------------
# Severity tiers (per spec §5 finalizer matrix).
# ---------------------------------------------------------------------------
TIER_NONE = "none"
TIER_LOW_WARN = "low_warn"
TIER_MED_WARN = "med_warn"
TIER_HIGH_WARN = "high_warn"
# ---------------------------------------------------------------------------
# Annotation literals (canonical wording per spec §5 table + §"uncited" /
# §"constraint_violation" / §"claim_drifts" subsections + §"audit_sampling").
#
# These literals are the contract surface between the orchestrator and the
# formatter; `formatter_agent.md` REFUSE list reads them via string match.
# Changing a literal here MUST coordinate with the formatter prose update.
# ---------------------------------------------------------------------------
# #512 PDF read-integrity: pipeline-side tag substring (keep in lockstep with
# claim_audit_pipeline.PDF_READ_INTEGRITY_TAG) + the advisory annotation it drives.
PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]"
ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY = "[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]"
ANNOTATION_CLAIM_AUDIT_AMBIGUOUS = "[CLAIM-AUDIT-AMBIGUOUS]"
ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED = "[HIGH-WARN-CLAIM-NOT-SUPPORTED]"
ANNOTATION_HIGH_WARN_NEGATIVE_CONSTRAINT_VIOLATION = (
"[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION ({violated_constraint_id})]"
)
ANNOTATION_HIGH_WARN_FABRICATED_REFERENCE = "[HIGH-WARN-FABRICATED-REFERENCE]"
ANNOTATION_HIGH_WARN_ANCHORLESS = (
"[HIGH-WARN-CLAIM-AUDIT-ANCHORLESS — v3.7.3 R-L3-1-A VIOLATION REACHED AUDIT]"
)
ANNOTATION_LOW_WARN_UNVERIFIED = (
"[CLAIM-AUDIT-UNVERIFIED — REFERENCE FULL-TEXT NOT RETRIEVABLE]"
)
ANNOTATION_MED_WARN_TOOL_FAILURE = "[CLAIM-AUDIT-TOOL-FAILURE — {fault_class}]"
# v3.8.2 / #118 — UAF aggregate annotation. Same fault-class enum as the
# cited-path INV-14 row but routed through `uncited_audit_failures[]`
# because claim_audit_result.ref_slug is required.
ANNOTATION_MED_WARN_TOOL_FAILURE_UNCITED = "[CLAIM-AUDIT-TOOL-FAILURE-UNCITED — {fault_class}]"
ANNOTATION_UNCITED_ASSERTION = "[UNCITED-ASSERTION]"
ANNOTATION_HIGH_WARN_CONSTRAINT_VIOLATION_UNCITED = (
"[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED ({violated_constraint_id})]"
)
ANNOTATION_LOW_WARN_CLAIM_DRIFT = "[LOW-WARN-CLAIM-DRIFT — kind={drift_kind}]"
ANNOTATION_SAMPLING = (
"[CLAIM-AUDIT-SAMPLED — {audited_count}/{total_citation_count} audited]"
)
ANNOTATION_MANIFEST_MISSING = (
"[CLAIM-AUDIT-MANIFEST-MISSING — audit ran without pre-commitment baseline]"
)
# Set of annotation prefixes that `/ars-mark-read` CANNOT clear — structural
# verdicts on prose faithfulness rather than acknowledgement-eligible trust
# states. Mirrors v3.7.3 R-L3-1-A asymmetry (locator is structural).
_UNCLEARABLE_HIGH_WARN_PREFIXES: tuple[str, ...] = (
"[HIGH-WARN-CLAIM-NOT-SUPPORTED]",
"[HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION",
"[HIGH-WARN-FABRICATED-REFERENCE]",
"[HIGH-WARN-CLAIM-AUDIT-ANCHORLESS",
"[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED",
)
# Permitted defect_stages for the source-level UNSUPPORTED row (T-F1c).
_UNSUPPORTED_SOURCE_LEVEL_DEFECTS: frozenset[str] = frozenset(
{"source_description", "metadata", "citation_anchor", "synthesis_overclaim"}
)
# Rationale prefix regex for audit_tool_failure rows — the fault-class tag is
# the leading colon-terminated token per INV-14 (spec §"Error handling").
_RATIONALE_FAULT_CLASS_RE = re.compile(r"^([a-z_]+):")
def _classify_retrieval_failed(
*,
defect_stage: str | None,
ref_retrieval_method: str,
rationale: str,
) -> dict[str, Any]:
"""Discriminate the three (RETRIEVAL_FAILED, not_applicable) rows by method.
Raises ValueError on (defect_stage, ref_retrieval_method) combinations not
covered by the §5 matrix rows. The spec §6 consistency lint
(`check_claim_audit_consistency.py`) is the authoritative upstream gate
— it rejects malformed rows BEFORE they reach the finalizer. A raise
here signals that an out-of-contract row escaped lint (e.g. INV-10 /
INV-11 / INV-14 violation, or a passport assembled without lint
validation). Coercing such rows to a default tier would silently mask
the upstream bug; raising surfaces it.
"""
if defect_stage == "retrieval_existence" and ref_retrieval_method == "not_found":
return {
"annotation": ANNOTATION_HIGH_WARN_FABRICATED_REFERENCE,
"tier": TIER_HIGH_WARN,
"gate_refuse": True,
}
if defect_stage == "not_applicable":
if ref_retrieval_method == "not_attempted":
return {
"annotation": ANNOTATION_HIGH_WARN_ANCHORLESS,
"tier": TIER_HIGH_WARN,
"gate_refuse": True,
}
if ref_retrieval_method == "failed":
return {
"annotation": ANNOTATION_LOW_WARN_UNVERIFIED,
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
if ref_retrieval_method == "audit_tool_failure":
match = _RATIONALE_FAULT_CLASS_RE.match(rationale)
fault_class = (
match.group(1)
if match and match.group(1) in INV14_FAULT_CLASS_TAGS
else "retrieval_api_error"
)
return {
"annotation": ANNOTATION_MED_WARN_TOOL_FAILURE.format(fault_class=fault_class),
"tier": TIER_MED_WARN,
"gate_refuse": False,
}
raise ValueError(
f"unexpected RETRIEVAL_FAILED row: defect_stage={defect_stage!r} "
f"ref_retrieval_method={ref_retrieval_method!r}"
)
def classify_claim_audit_result(entry: dict[str, Any]) -> dict[str, Any]:
"""Apply the §5 8-row matrix to a single claim_audit_result row.
Returns `{"annotation": str | None, "tier": str, "gate_refuse": bool}`.
`annotation=None` indicates the SUPPORTED pass row (no formatter output).
"""
judgment = entry["judgment"]
defect_stage = entry.get("defect_stage")
ref_retrieval_method = entry.get("ref_retrieval_method", "not_attempted")
rationale = entry.get("rationale", "")
if judgment == "SUPPORTED":
# #512: a SUPPORTED row that reached support through an unverified local-PDF
# page anchor still surfaces an advisory — without this branch the pipeline's
# rationale tag would be invisible at the formatter for the expected common
# case (content-based fallback finds support). Advisory only, never a gate.
if PDF_READ_INTEGRITY_TAG in rationale:
return {
"annotation": ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY,
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
return {"annotation": None, "tier": TIER_NONE, "gate_refuse": False}
if judgment == "AMBIGUOUS":
# Spec §3.1 INV-3 permits {source_description, citation_anchor,
# synthesis_overclaim, null} on AMBIGUOUS — the §6 consistency lint is
# the authoritative gate for the allowed-matrix invariant. The matrix
# row, however, ALWAYS emits the same annotation + tier regardless of
# which permitted defect_stage the judge picked, so the local
# `defect_stage` value never affects the return. The previous version
# of this branch reassigned out-of-set values to None but never read
# them back — dead code per Step 8 codex /simplify advisory.
# Validation stays the lint's responsibility (§6 rule 5); this branch
# produces the LOW-WARN advisory and trusts the schema-validated row.
return {
"annotation": ANNOTATION_CLAIM_AUDIT_AMBIGUOUS,
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
if judgment == "UNSUPPORTED":
if defect_stage == "negative_constraint_violation":
return {
"annotation": ANNOTATION_HIGH_WARN_NEGATIVE_CONSTRAINT_VIOLATION.format(
violated_constraint_id=entry.get("violated_constraint_id", "?")
),
"tier": TIER_HIGH_WARN,
"gate_refuse": True,
}
if defect_stage in _UNSUPPORTED_SOURCE_LEVEL_DEFECTS:
return {
"annotation": ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED,
"tier": TIER_HIGH_WARN,
"gate_refuse": True,
}
raise ValueError(
f"UNSUPPORTED row carries unexpected defect_stage={defect_stage!r}; "
"spec §3.1 INV-2 permits source_description / metadata / citation_anchor / "
"synthesis_overclaim / negative_constraint_violation only"
)
if judgment == "RETRIEVAL_FAILED":
return _classify_retrieval_failed(
defect_stage=defect_stage,
ref_retrieval_method=ref_retrieval_method,
rationale=rationale,
)
raise ValueError(f"unknown judgment: {judgment!r}")
def classify_uncited_assertion(entry: dict[str, Any]) -> dict[str, Any]:
"""LOW-WARN advisory `[UNCITED-ASSERTION]` for every uncited_assertions[] row."""
return {
"annotation": ANNOTATION_UNCITED_ASSERTION,
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
def classify_constraint_violation(entry: dict[str, Any]) -> dict[str, Any]:
"""HIGH-WARN gate-refuse for uncited claim that violates MNC/NC scope."""
return {
"annotation": ANNOTATION_HIGH_WARN_CONSTRAINT_VIOLATION_UNCITED.format(
violated_constraint_id=entry.get("violated_constraint_id", "?")
),
"tier": TIER_HIGH_WARN,
"gate_refuse": True,
}
def classify_claim_drift(entry: dict[str, Any]) -> dict[str, Any]:
"""LOW-WARN advisory for drift findings (per D4-a; never gate-refuses)."""
return {
"annotation": ANNOTATION_LOW_WARN_CLAIM_DRIFT.format(drift_kind=entry["drift_kind"]),
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
def classify_uncited_audit_failure(entry: dict[str, Any]) -> dict[str, Any]:
"""MED-WARN advisory for uncited-path judge outage (v3.8.2 / #118).
Mirrors INV-14 semantics on the uncited path: emits
`[CLAIM-AUDIT-TOOL-FAILURE-UNCITED — <fault-class>]` next to the
offending sentence. Gate passes — retry-next-pass remediation.
UAF-INV-5 (lint) guarantees `fault_class` is one of the seven
INV14_FAULT_CLASS_TAGS values; we surface the row's literal here.
The `or "?"` fallback covers both missing-key (KeyError equivalent)
and explicit-null (`"fault_class": null`) cases — without it a
malformed row with explicit null would render as `[...— None]`
(Gemini R2 P3, 2026-05-17). Schema validation rejects either form,
but a defensive renderer is one less thing to think about.
"""
return {
"annotation": ANNOTATION_MED_WARN_TOOL_FAILURE_UNCITED.format(
fault_class=entry.get("fault_class") or "?",
),
"tier": TIER_MED_WARN,
"gate_refuse": False,
}
def classify_audit_sampling_summary(entry: dict[str, Any]) -> dict[str, Any]:
"""Paper-level LOW-WARN annotation when audited_count < total_citation_count (S-INV-3)."""
if entry["audited_count"] >= entry["total_citation_count"]:
return {"annotation": None, "tier": TIER_NONE, "gate_refuse": False}
return {
"annotation": ANNOTATION_SAMPLING.format(
audited_count=entry["audited_count"],
total_citation_count=entry["total_citation_count"],
),
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
def apply_finalizer(passport: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""Run the matrix across every passport aggregate; reduce to a gate decision.
Returns:
{
"annotations": list of {entry_ref, annotation, tier},
"gate_refuse": bool — True if any row has gate_refuse=True,
"gate_refuse_reasons": list[str] — annotations that triggered refuse,
}
"""
annotations: list[dict[str, Any]] = []
gate_refuse_reasons: list[str] = []
routing: tuple[tuple[str, Any], ...] = (
("claim_audit_results", classify_claim_audit_result),
("uncited_assertions", classify_uncited_assertion),
("constraint_violations", classify_constraint_violation),
("claim_drifts", classify_claim_drift),
# v3.8.2 / #118 — UAF aggregate routes to MED-WARN advisory.
# Placed BEFORE audit_sampling_summaries so sentence-level
# annotations group with the other line-item checks; the
# paper-level sampling summary belongs at the tail (Gemini R2
# P3, 2026-05-17). Without this entry, the schema/lint accept
# UAF rows but the finalizer never surfaces them and the
# formatter never sees the [CLAIM-AUDIT-TOOL-FAILURE-UNCITED — ...]
# annotation (Codex R1 P2-1, 2026-05-17).
("uncited_audit_failures", classify_uncited_audit_failure),
("audit_sampling_summaries", classify_audit_sampling_summary),
)
for aggregate_key, classifier in routing:
for entry in passport.get(aggregate_key, []):
result = classifier(entry)
if result["annotation"] is None:
continue
annotations.append(
{
"aggregate": aggregate_key,
"entry": entry,
"annotation": result["annotation"],
"tier": result["tier"],
}
)
if result["gate_refuse"]:
gate_refuse_reasons.append(result["annotation"])
# MANIFEST-MISSING paper-level advisory (spec §9 acceptance criterion;
# Step 8 codex R1 P2 closure). Fires when the audit ran without a
# pre-commitment baseline — both `claim_intent_manifests[]` is empty
# AND at least one claim_audit_result carries the sentinel scope. The
# second condition prevents firing on an empty passport (where there's
# nothing to surface a warning about). Always advisory; never
# gate-refuses (the audit completed, it just lacks the drift /
# constraint-inheritance signal a manifest would have provided).
if not passport.get("claim_intent_manifests"):
has_sentinel_row = any(
r.get("scoped_manifest_id") == SENTINEL_MANIFEST_ID
for r in passport.get("claim_audit_results", [])
)
if has_sentinel_row:
annotations.append(
{
"aggregate": "paper_level",
"entry": None,
"annotation": ANNOTATION_MANIFEST_MISSING,
"tier": TIER_LOW_WARN,
}
)
return {
"annotations": annotations,
"gate_refuse": bool(gate_refuse_reasons),
"gate_refuse_reasons": gate_refuse_reasons,
}
def render_stage6_histogram(
claim_audit_results: list[dict[str, Any]],
*,
threshold: int = 5,
) -> str | None:
"""Render the per-defect_stage histogram for the AI Self-Reflection Report.
Threshold is on `audit_status == "completed"` rows (per spec §"Outputs
feeding Stage 6 self-reflection" literal "≥ 5 completed entries"). When
fewer than `threshold` completed rows exist, returns None.
When the threshold is met, the histogram counts rows by `defect_stage`,
excluding null values (SUPPORTED rows). If every completed row is
SUPPORTED (zero defects to plot), the histogram still emits — it
surfaces "No defect stages recorded across N completed entries." so
the Stage 6 appendix stays consistent and the user sees that the audit
ran clean rather than wondering whether the histogram was suppressed.
Step 8 codex R4 P2-1 closure: prior implementation gated the threshold
on completed-with-defect rows, suppressing the appendix when the
paper had ≥5 completed audits but ≤4 defect_stage entries. Spec
literal is "≥ 5 completed entries"; common mostly-SUPPORTED papers
must still surface the reflection block.
The output is a stable plain-text rendering keyed by defect_stage; the
orchestrator embeds it under the Stage 6 reflection appendix. Stage-6
formatting (markdown headings, separators) is the orchestrator's
responsibility; this module emits only the histogram block.
"""
completed = [r for r in claim_audit_results if r.get("audit_status") == "completed"]
if len(completed) < threshold:
return None
defects = [r["defect_stage"] for r in completed if r.get("defect_stage") is not None]
n_completed = len(completed)
if not defects:
return (
f"Claim-faithfulness defect_stage histogram (n={n_completed} completed entries):"
f"\n - No defect stages recorded across {n_completed} completed entries."
)
counts = Counter(defects)
lines = [f"Claim-faithfulness defect_stage histogram (n={n_completed} completed entries):"]
for stage in sorted(counts):
lines.append(f" - {stage}: {counts[stage]}")
return "\n".join(lines)
def ars_mark_read_clears(*, annotation: str, tier: str) -> bool:
"""Return True iff `/ars-mark-read` can promote the annotation to cleared state.
HIGH-WARN classes covering structural verdicts on prose faithfulness CANNOT
be cleared by acknowledgement (T-F3 asymmetry, mirrors v3.7.3 R-L3-1-A).
LOW-WARN paywall / advisory rows CAN — the user has accepted the
unverifiable state and chosen to ship.
The MED-WARN audit_tool_failure row is NOT acknowledgement-clearable
either (the remediation is retry on next pipeline pass, not
acknowledgement); it stays surfaced until a fresh audit pass resolves
the underlying infrastructure problem or downgrades to LOW-WARN paywall.
"""
if tier == TIER_HIGH_WARN:
return False
if tier == TIER_MED_WARN:
return False
# The HIGH-WARN-prefix check below is defense-in-depth against
# caller bugs where a HIGH-WARN annotation arrives with mismatched
# tier=TIER_LOW_WARN (e.g. a passport hand-edit or a downstream
# consumer that lost the tier mapping). Well-formed inputs never
# trigger this branch — every HIGH-WARN prefix is produced only by
# the matrix paths that also set tier=TIER_HIGH_WARN. Keeping the
# check rather than dropping it preserves the safety surface; a
# silent True return on a mistyped HIGH-WARN would acknowledge a
# gate-refuse-class violation as cleared.
return tier == TIER_LOW_WARN and not any(
annotation.startswith(prefix) for prefix in _UNCLEARABLE_HIGH_WARN_PREFIXES
)