mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
7f878f7ad2
* 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>
457 lines
20 KiB
Python
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
|
|
)
|