Files
Edward Cheng-I Wu 6b7ee6dcae fix: Astra request compat, no-delegation citation transport, hedge/quota prompt repairs, audit provenance (#823–#826) (#827)
* fix: Astra request compatibility, no-delegation citation transport, hedge/quota prompt repairs, audit provenance (#823 #824 #825 #826)

#823 — OpenAI request builders (smoke entrypoint + documented example) drop
`temperature`, which GPT-6 Astra rejects; the per-model effort vocabulary
lives in scripts/cross_model_verification/openai_effort_guard.sh, sourced by
both, and an unsupported explicit Astra value fails before curl. Hermetic
fake-curl test runs both surfaces.

#824 — the contained Codex citation transport rejects effort=ultra with
REASONING_EFFORT_REQUIRES_DELEGATION before detection/auth/tempdir/launch on
both entry paths (codex-cli 0.153.4 defines ultra as the multiAgentMode
replacement). Model-independent by design.

#825 — hedging can no longer rescue an unsupported claim (writer recovery
tree, CER fallback row, temporal rule 5 in writer + both compiler mirrors,
writer contract D2); universal prose quotas in the writer, compilers,
writing_quality_check.md, academic-paper/SKILL.md, and contract D6 become
diagnostics subordinate to author/venue requirements. Audit inventory
corrected in place; held-out seed evals/heldout/unsupported_claim_recovery
(NOT_RUN) registered.

#826 — run_codex_audit.sh pins gpt-6-astra/xhigh and records both in a new
sidecar `model` block; claim_audit_pipeline binds an unknown judge identity
to a run-local cache key (no cross-run reuse) instead of defaulting to
gpt-5.5-xhigh.

Review: /simplify (4 angles), codex gpt-5.6-sol xhigh 2 rounds (r1: 1 P1 +
1 P2 + 2 P3 fixed; r2: 0 P1/P2), /security-review 0 findings; all 102
spec-consistency steps + pytest manifest replayed locally.

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

* docs(claude): record the #824 ultra reversal in the v3.21.2 key-additions line

The v3.21.2 bullet still said the contained Codex citation transport accepts
ultra; #824 on this branch rejects it as a delegation request. Add the
reversal so the live instruction surface matches the transport.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 08:08:30 +09:00

1610 lines
74 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""claim_audit_pipeline — Python implementation of the §4 Step 1-6 pipeline.
This module is the executable face of `claim_ref_alignment_audit_agent.md`.
The agent prompt narrates the pipeline contract; this module runs it
under test so cross-field invariants and emission routing can be pinned
without dispatching the agent to a live model.
Retrieval and judge invocation are dependency-injected (`retrieve_fn` /
`judge_fn`) so tests can drive every error and decision path — paywall,
audit_tool_failure, not_found, SUPPORTED, UNSUPPORTED with each
defect_stage hint, VIOLATED. Production callers wire these to real
retrieval/judge clients in their own dispatch layer.
The full spec is in
docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md §4-§5.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
from typing import Any, Callable
# Allow both CLI invocations (`python3 scripts/claim_audit_pipeline.py`) AND
# package-style invocations (`python -m unittest scripts.test_*`) to resolve
# the shared constants module via the same import.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _claim_audit_constants import ( # noqa: E402
DRIFT_RULE_VERSION,
INV6_RATIONALE_PREFIX,
JUDGE_PROMPT_SHA256,
RE_NC_CONSTRAINT,
SAMPLING_STRATEGY,
SENTINEL_MANIFEST_ID,
UAF_RULE_VERSION,
UNCITED_RULE_VERSION,
is_emittable_partial_breakdown,
)
# Permitted UNSUPPORTED defect_stages for non-constraint paths (§3.1 matrix).
_UNSUPPORTED_NON_CONSTRAINT_DEFECTS = {
"source_description",
"metadata",
"citation_anchor",
"synthesis_overclaim",
}
# Permitted AMBIGUOUS defect_stages (§3.1 matrix).
_AMBIGUOUS_DEFECTS = {"source_description", "citation_anchor", "synthesis_overclaim", None}
# ---------------------------------------------------------------------------
# Cache helpers.
# ---------------------------------------------------------------------------
def _stable_json(value: Any) -> str:
"""JCS-style canonicalization sufficient for cache-key hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _hash_text(text: str | None) -> str:
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()
# claim_audit_result.rationale schema maxLength (#355 P2#3). A judge/retrieval
# failure detail that embeds the offending payload's repr must fit here, or the
# "clean inconclusive" fallback row it produces is itself schema-invalid. Every
# JudgeInvocationError / RetrievalInvocationError detail ends up in a row's
# rationale via `f"{fault_class}: {detail}"`, so the bound lives in those
# exceptions' constructors (single choke point) rather than at each raise site.
_RATIONALE_MAX_LEN = 2000
_RATIONALE_TRUNC_MARK = "…[truncated]"
# Widest fault-class prefix across both exception families ("retrieval_network_error: ").
_WIDEST_FAULT_PREFIX = "retrieval_network_error: "
# #512 PDF read-integrity rationale tag. Appended (never prepended — INV-6/INV-14
# startswith contracts stay intact) to a completed manual_pdf page-anchor row whose
# preflight sidecar is missing or non-PASS. The finalizer keys its advisory
# annotation on this exact substring; keep in lockstep with
# claim_audit_finalizer.PDF_READ_INTEGRITY_TAG.
PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]"
def _tag_pdf_read_integrity(entry: dict[str, Any]) -> None:
"""#512: mark a manual_pdf page-anchor row whose preflight did not vouch.
Applied at the single Step-6 emission point AFTER cache resolution, so a cache
hit cannot bypass it (the tag is run-context, never written into the judge
cache body). Idempotent; respects the rationale maxLength budget."""
rationale = entry.get("rationale") or ""
if PDF_READ_INTEGRITY_TAG in rationale:
return
if rationale:
clamped = _clamp_to_rationale_budget(
rationale, reserved=len(PDF_READ_INTEGRITY_TAG) + 1
)
entry["rationale"] = f"{clamped} {PDF_READ_INTEGRITY_TAG}"
else:
entry["rationale"] = PDF_READ_INTEGRITY_TAG
def _clamp_to_rationale_budget(text: str, *, reserved: int) -> str:
"""Clamp `text` so that `reserved + len(result)` fits the rationale maxLength.
Single length-budgeting choke point for every untrusted string that lands in
a row's `rationale` (#355 P2#3 / #360). `reserved` is the number of chars the
caller will prepend before this text reaches the rationale field:
- Failure paths emit ``f"{fault_class}: {detail}"`` → reserved = widest
fault-class prefix width, so the worst-case composed rationale still fits.
- Success paths copy a judge's own `rationale` verbatim onto the row → no
prefix → reserved = 0.
Truncation preserves the diagnostic head (which says WHAT the string is) and
marks the dropped tail, so a short string that already fits passes through
byte-for-byte.
"""
budget = _RATIONALE_MAX_LEN - reserved
if len(text) <= budget:
return text
keep = budget - len(_RATIONALE_TRUNC_MARK)
return text[:keep] + _RATIONALE_TRUNC_MARK
def _bounded_failure_detail(message: str) -> str:
"""Clamp a failure detail so ``f"{fault_class}: {detail}"`` fits the maxLength.
A malformed payload's repr embedded in `message` (a >1000-char
sub_claim_text, an over-decomposed breakdown, a giant non-string
judgment/method) can push the detail past the rationale maxLength, making the
fallback row schema-invalid (#355 P2#3). Budgets against the widest
fault-class prefix so the composed rationale fits for every fault class.
"""
return _clamp_to_rationale_budget(message, reserved=len(_WIDEST_FAULT_PREFIX))
def _bounded_judge_rationale(rationale: Any) -> str:
"""Clamp a judge-supplied `rationale` copied verbatim onto a SUCCESS-path row.
The judge is an LLM with no pre-emission length guarantee; an over-long
`rationale` makes a *clean* completed / constraint_violation row
schema-invalid (#360 — the success-path parallel to the #359 fallback fix).
No fault-class prefix is prepended on the success path, so reserved = 0.
`_validate_judge_dict` only checks that the `rationale` key is present, not
that its value is a string — a JSON-null (or otherwise non-string) rationale
passes that gate. Return "" for a non-string value so each caller's existing
fallback (`... or "(no rationale provided)"` / the constraint default) takes
over, rather than calling len() on it and aborting the audit run.
"""
if not isinstance(rationale, str):
return ""
return _clamp_to_rationale_budget(rationale, reserved=0)
def _active_constraints_for_claim(
*,
scoped_manifest_id: str,
claim_id: str,
claim_by_mc_id: dict[tuple[str, str], dict[str, Any]],
mncs_by_manifest_id: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
"""Return the manifest-scoped + claim-scoped negative-constraint set for a citation.
Reads from pre-built indexes (built once per audit run in
`run_audit_pipeline`) instead of rescanning the manifest tree per
citation — at realistic workloads (~150 citations × ~100 manifest
claims) that saves ~30k Python ops per run with no behavioral delta.
"""
constraints: list[dict[str, Any]] = []
for mnc in mncs_by_manifest_id.get(scoped_manifest_id, []):
constraints.append({"constraint_id": mnc["constraint_id"], "rule": mnc["rule"], "scope": "MNC"})
claim = claim_by_mc_id.get((scoped_manifest_id, claim_id))
if claim is not None:
for nc in claim.get("negative_constraints", []) or []:
constraints.append(
{"constraint_id": nc["constraint_id"], "rule": nc["rule"], "scope": "NC"}
)
constraints.sort(key=lambda c: c["constraint_id"])
return constraints
def _cache_key(
*,
claim_text: str,
ref_slug: str,
anchor_kind: str,
anchor_value: str,
retrieved_excerpt: str | None,
active_constraints: list[dict[str, Any]],
judge_model: str,
prompt_version: str,
) -> str:
payload = {
"claim_text_hash": _hash_text(claim_text),
"ref_slug": ref_slug,
"anchor_kind": anchor_kind,
"anchor_value_hash": _hash_text(anchor_value),
"retrieved_excerpt_hash": _hash_text(retrieved_excerpt),
"active_constraints_hash": _hash_text(
_stable_json([{"constraint_id": c["constraint_id"], "rule": c["rule"]} for c in active_constraints])
),
"judge_model": judge_model,
# #361: a judge-prompt revision partitions the keyspace — a verdict
# cached under one prompt is never served against new prompt logic.
# judge_model and prompt_version stay separate components (independent
# axes of judge behavior).
"prompt_version": prompt_version,
}
return hashlib.sha256(_stable_json(payload).encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Judge + retrieval invocation — wraps callables so transient failures become
# INV-14 audit_tool_failure rows instead of aborting the audit. Spec §4 step 2
# + INV-14 + Step 13 R1+R2 codex findings (transient errors on either external
# call must surface as MED-WARN advisory rows).
# ---------------------------------------------------------------------------
# Legal judge verdicts per path. claim_audit_result schema enum + constraint-side
# verdicts (VIOLATED / NOT_VIOLATED). Cited and uncited paths route different
# subsets — passing path-specific allow-lists into `_invoke_judge` rejects an
# off-path verdict at the invocation boundary instead of letting it propagate
# into _judge_result_entry where the ValueError would abort the audit
# (Step 13 R3 codex P2 #2).
_CITED_PATH_JUDGMENTS: frozenset[str] = frozenset(
{"SUPPORTED", "UNSUPPORTED", "AMBIGUOUS", "PARTIAL", "VIOLATED"}
)
# PARTIAL is a cited-path-only verdict (a reference can support some sub-claims
# but not others). The uncited path has no reference to be partial against, so
# it stays the constraint VIOLATED/NOT_VIOLATED binary (#213).
_UNCITED_PATH_JUDGMENTS: frozenset[str] = frozenset({"VIOLATED", "NOT_VIOLATED"})
class _AuditInvocationError(Exception):
"""Base for audit-tool invocation failures (judge / retrieval).
Carries an INV-14 fault-class tag + detail. The detail is bounded so the
`f"{fault_class}: {detail}"` rationale it becomes fits the claim_audit_result
schema maxLength (#355 P2#3) — every subclass's detail flows to a row
rationale, so the bound lives here (single choke point).
"""
def __init__(self, fault_class: str, detail: str) -> None:
detail = _bounded_failure_detail(detail)
super().__init__(f"{fault_class}: {detail}")
self.fault_class = fault_class
self.detail = detail
class JudgeInvocationError(_AuditInvocationError):
"""Raised by `_invoke_judge` when judge_fn fails or returns malformed output.
Carries the INV-14 fault-class tag + detail so the caller can emit a
`RETRIEVAL_FAILED + inconclusive + not_applicable + audit_tool_failure`
row per spec §4 step 2 + INV-14 instead of letting the exception abort the
audit pass.
"""
class RetrievalInvocationError(_AuditInvocationError):
"""Raised by `_invoke_retrieve` when retrieve_fn fails or returns malformed output.
Mirrors JudgeInvocationError but tags faults with the INV-14 retrieval_*
family (retrieval_api_error / retrieval_timeout / retrieval_network_error)
so a transient retrieval outage surfaces as audit_tool_failure rather than
aborting the audit pass (Step 13 R2 codex P2 finding).
"""
def _validate_judge_dict(
result: Any,
*,
allowed_judgments: frozenset[str],
active_constraint_ids: frozenset[str],
source: str = "judge_fn",
) -> dict[str, Any]:
"""Validate a judge-output dict (fresh or cache-hit).
Raises `JudgeInvocationError` with the appropriate fault-class tag for any
shape violation. Pulled out of `_invoke_judge` so cache hits can reuse the
same validation surface — without it a malformed cache entry would crash
`_judge_result_entry` and abort the audit (Step 13 R3 codex P2 #4).
Validation surface:
- non-dict → judge_parse_error
- missing `judgment` or `rationale` → judge_parse_error
- judgment not in `allowed_judgments` → judge_parse_error
- VIOLATED without non-blank string id → judge_parse_error
- VIOLATED id not in `active_constraint_ids`→ judge_parse_error
(Step 13 R3 codex P2 #1 — prevents formatter gate-refuse on a
hallucinated constraint the author never declared)
"""
if not isinstance(result, dict):
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned {type(result).__name__}, expected dict",
)
if "judgment" not in result or "rationale" not in result:
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned dict missing required key(s); got keys={sorted(result)}",
)
judgment = result.get("judgment")
# Step 13 R8 codex P2-3: guard isinstance(str) before set membership so a
# malformed return like {"judgment": [1, 2], ...} surfaces as a clean
# judge_parse_error instead of bubbling TypeError("unhashable type") out
# past the translation boundary and aborting the audit.
if not isinstance(judgment, str):
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned non-string judgment={judgment!r} (type={type(judgment).__name__}); expected one of {sorted(allowed_judgments)}",
)
if judgment not in allowed_judgments:
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned judgment={judgment!r}; expected one of {sorted(allowed_judgments)} on this path",
)
if judgment == "VIOLATED":
vcid = result.get("violated_constraint_id")
if not isinstance(vcid, str) or not vcid.strip():
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned VIOLATED without a valid violated_constraint_id (got {vcid!r}); INV-7 requires non-null id",
)
if vcid not in active_constraint_ids:
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned VIOLATED with violated_constraint_id={vcid!r} outside the active constraint set {sorted(active_constraint_ids)}; rejecting hallucinated id (Step 13 R3 codex P2 #1)",
)
if judgment == "PARTIAL":
# #213: a PARTIAL MUST carry a well-formed true-partial sub_claim_breakdown.
# A malformed PARTIAL is a judge-output parse failure — routing it through
# judge_parse_error yields the existing (RETRIEVAL_FAILED, inconclusive,
# not_applicable, audit_tool_failure) row, never a silent bare UNSUPPORTED
# (which would recreate the invisible-trap failure). The malformed-PARTIAL
# path has no new matrix triple; it reuses the judge_parse_error contract.
# is_emittable_*: true-partial mix AND every item schema-shaped (non-empty
# sub_claim_text + valid sub_verdict). The item-shape half is required
# because _judge_result_entry copies items onto a *completed* row; a
# mix-valid-but-malformed item (e.g. missing sub_claim_text) would emit a
# schema-invalid row instead of taking the judge_parse_error path
# (ship-gate round-2 finding).
if not is_emittable_partial_breakdown(result.get("sub_claim_breakdown")):
raise JudgeInvocationError(
"judge_parse_error",
f"{source} returned PARTIAL without an emittable true-partial sub_claim_breakdown "
f"(>=2 schema-shaped items, >=1 SUPPORTED AND >=1 non-SUPPORTED, each with a "
f"non-empty sub_claim_text); got {result.get('sub_claim_breakdown')!r}",
)
return result
def _invoke_judge(
judge_fn: Callable[..., dict[str, Any]],
*,
allowed_judgments: frozenset[str],
active_constraint_ids: frozenset[str],
**call_kwargs: Any,
) -> dict[str, Any]:
"""Invoke `judge_fn` and translate transient failures + malformed output into INV-14 tags.
Exception → fault class mapping:
- TimeoutError → judge_timeout
- json.JSONDecodeError / ValueError → judge_parse_error
- any other Exception → judge_api_error
Return-value validation delegates to `_validate_judge_dict` so cache hits
reuse the same surface (Step 13 R3 codex P2 #4).
`allowed_judgments` is path-specific: cited path passes
`_CITED_PATH_JUDGMENTS`, uncited (constraint) path passes
`_UNCITED_PATH_JUDGMENTS`. RETRIEVAL_FAILED / NOT_VIOLATED on the cited
path is rejected here instead of crashing in `_judge_result_entry`
(Step 13 R3 codex P2 #2).
`active_constraint_ids` carries the in-scope MNC/NC ids for this call
so a VIOLATED with a hallucinated id is rejected at the boundary
(Step 13 R3 codex P2 #1).
Does NOT swallow `SystemExit` / `KeyboardInterrupt`.
"""
try:
result = judge_fn(**call_kwargs)
except TimeoutError as exc:
raise JudgeInvocationError("judge_timeout", str(exc) or "judge timed out") from exc
except (json.JSONDecodeError, ValueError) as exc:
raise JudgeInvocationError("judge_parse_error", str(exc) or "judge returned malformed payload") from exc
except Exception as exc: # noqa: BLE001 — translation boundary; the source class is captured
raise JudgeInvocationError("judge_api_error", f"{type(exc).__name__}: {exc}") from exc
return _validate_judge_dict(
result,
allowed_judgments=allowed_judgments,
active_constraint_ids=active_constraint_ids,
source="judge_fn",
)
def _invoke_retrieve(
retrieve_fn: Callable[[dict[str, Any]], dict[str, Any]],
citation: dict[str, Any],
) -> dict[str, Any]:
"""Invoke `retrieve_fn` and translate transient failures + malformed output into INV-14 retrieval_* tags.
Exception → fault class mapping (mirrors `_invoke_judge`):
- TimeoutError → retrieval_timeout
- OSError / ConnectionError → retrieval_network_error
- json.JSONDecodeError / ValueError → retrieval_api_error
- any other Exception → retrieval_api_error
Return-value validation:
- non-dict → retrieval_api_error
- missing `ref_retrieval_method` key → retrieval_api_error
- unknown `ref_retrieval_method` value → retrieval_api_error
Returns the retrieval dict on success; raises RetrievalInvocationError
otherwise so the caller can map it to an audit_tool_failure row.
"""
try:
result = retrieve_fn(citation)
except TimeoutError as exc:
raise RetrievalInvocationError("retrieval_timeout", str(exc) or "retrieve_fn timed out") from exc
except (ConnectionError, OSError) as exc:
raise RetrievalInvocationError(
"retrieval_network_error",
f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__,
) from exc
except (json.JSONDecodeError, ValueError) as exc:
raise RetrievalInvocationError(
"retrieval_api_error",
str(exc) or "retrieve_fn returned malformed payload",
) from exc
except Exception as exc: # noqa: BLE001 — translation boundary
raise RetrievalInvocationError("retrieval_api_error", f"{type(exc).__name__}: {exc}") from exc
if not isinstance(result, dict):
raise RetrievalInvocationError(
"retrieval_api_error",
f"retrieve_fn returned {type(result).__name__}, expected dict",
)
method = result.get("ref_retrieval_method")
if method is None:
raise RetrievalInvocationError(
"retrieval_api_error",
f"retrieve_fn return missing ref_retrieval_method; got keys={sorted(result)}",
)
# Step 13 R8 codex P2-4: guard isinstance(str) before set membership so a
# malformed return like {"ref_retrieval_method": [...], ...} surfaces as a
# clean retrieval_api_error instead of bubbling TypeError("unhashable type")
# out past the translation boundary and aborting the audit (symmetric to
# P2-3 on the judge side).
if not isinstance(method, str):
raise RetrievalInvocationError(
"retrieval_api_error",
f"retrieve_fn returned non-string ref_retrieval_method={method!r} (type={type(method).__name__})",
)
if method not in {"api", "manual_pdf", "failed", "not_found", "audit_tool_failure"}:
raise RetrievalInvocationError(
"retrieval_api_error",
f"retrieve_fn returned unknown ref_retrieval_method={method!r}",
)
# Step 13 R3 codex P2 #3: a successful retrieval pathway MUST carry a
# non-empty excerpt — otherwise the judge would be invoked with
# `retrieved_excerpt=None`/empty and could mark a claim SUPPORTED with no
# source text. Map this shape violation to retrieval_api_error so it
# surfaces as audit_tool_failure instead of silently degrading the audit.
if method in {"api", "manual_pdf"}:
excerpt = result.get("retrieved_excerpt")
if not isinstance(excerpt, str) or not excerpt.strip():
raise RetrievalInvocationError(
"retrieval_api_error",
f"retrieve_fn returned ref_retrieval_method={method!r} with empty/missing retrieved_excerpt; successful retrievals must carry source text",
)
return result
# ---------------------------------------------------------------------------
# Emission helpers — each builds one entry dict.
# ---------------------------------------------------------------------------
def _anchorless_entry(citation: dict[str, Any], *, audit_run_id: str, now_iso: str, judge_model: str) -> dict[str, Any]:
"""§4 Step 1: anchor=none short-circuits to RETRIEVAL_FAILED+inconclusive+not_applicable+not_attempted.
INV-6 sentinel: anchor_kind=none MUST carry anchor_value="" (empty sentinel
per claim_audit_result.schema.json). We pin the empty string here rather
than passing through citation.anchor_value — a stale residual anchor like
"123" on an anchor_kind=none row violates the schema contract.
"""
return {
"claim_id": citation["claim_id"],
"scoped_manifest_id": citation.get("scoped_manifest_id", SENTINEL_MANIFEST_ID),
"claim_text": citation["claim_text"],
"ref_slug": citation["ref_slug"],
"anchor_kind": "none",
"anchor_value": "",
"judgment": "RETRIEVAL_FAILED",
"audit_status": "inconclusive",
"defect_stage": "not_applicable",
"rationale": (
f"{INV6_RATIONALE_PREFIX}: cited claim {citation['claim_id']} carries anchor=none; "
"v3.7.3 finalizer should have gate-refused upstream — defense-in-depth row."
),
"judge_model": judge_model,
"judge_run_at": now_iso,
"ref_retrieval_method": "not_attempted",
"upstream_owner_agent": citation.get("upstream_owner_agent"),
"audit_run_id": audit_run_id,
}
def _retrieval_failure_entry(
citation: dict[str, Any],
*,
method: str,
audit_run_id: str,
now_iso: str,
judge_model: str,
fault_class: str | None = None,
) -> dict[str, Any]:
"""§4 Step 2: retrieval-side failure routes that skip the judge."""
if method == "failed":
# D2 paywall — LOW-WARN advisory; INV-10.
entry = {
"judgment": "RETRIEVAL_FAILED",
"audit_status": "inconclusive",
"defect_stage": "not_applicable",
"rationale": "Reference full text not retrievable (paywall / license-restricted access).",
}
elif method == "not_found":
# Fabricated reference — HIGH-WARN; INV-12.
entry = {
"judgment": "RETRIEVAL_FAILED",
"audit_status": "completed",
"defect_stage": "retrieval_existence",
"rationale": "Retrieval API reports the cited reference does not exist (suspected fabrication).",
}
elif method == "audit_tool_failure":
# Transient infrastructure outage — MED-WARN; INV-14.
tag = fault_class or "retrieval_api_error"
entry = {
"judgment": "RETRIEVAL_FAILED",
"audit_status": "inconclusive",
"defect_stage": "not_applicable",
"rationale": f"{tag}: transient audit-infrastructure failure during retrieval; retry on next pipeline pass.",
}
else: # pragma: no cover — should be unreachable given Step 2 caller dispatch
raise ValueError(f"_retrieval_failure_entry called with non-failure method={method!r}")
return {
"claim_id": citation["claim_id"],
"scoped_manifest_id": citation.get("scoped_manifest_id", SENTINEL_MANIFEST_ID),
"claim_text": citation["claim_text"],
"ref_slug": citation["ref_slug"],
"anchor_kind": citation["anchor_kind"],
"anchor_value": citation.get("anchor_value", ""),
"judgment": entry["judgment"],
"audit_status": entry["audit_status"],
"defect_stage": entry["defect_stage"],
"rationale": entry["rationale"],
"judge_model": judge_model,
"judge_run_at": now_iso,
"ref_retrieval_method": method,
"upstream_owner_agent": citation.get("upstream_owner_agent"),
"audit_run_id": audit_run_id,
}
def _judge_result_entry(
citation: dict[str, Any],
*,
judge_result: dict[str, Any],
ref_retrieval_method: str,
audit_run_id: str,
now_iso: str,
judge_model: str,
) -> dict[str, Any]:
"""§4 Steps 5-6: route judge verdict to the right (judgment, defect_stage) row."""
verdict = judge_result["judgment"]
# #360: a judge is an LLM with no length guarantee; clamp its rationale to
# the schema maxLength before it lands on the completed row (success path).
rationale = _bounded_judge_rationale(judge_result.get("rationale", ""))
if verdict == "SUPPORTED":
judgment, defect_stage, violated_id = "SUPPORTED", None, None
elif verdict == "AMBIGUOUS":
hint = judge_result.get("defect_stage_hint")
if hint not in _AMBIGUOUS_DEFECTS:
hint = None # AMBIGUOUS+disallowed defect → coerce to null (INV-3 protection)
judgment, defect_stage, violated_id = "AMBIGUOUS", hint, None
elif verdict == "UNSUPPORTED":
hint = judge_result.get("defect_stage_hint") or "source_description"
if hint not in _UNSUPPORTED_NON_CONSTRAINT_DEFECTS:
hint = "source_description"
judgment, defect_stage, violated_id = "UNSUPPORTED", hint, None
elif verdict == "VIOLATED":
# Cited constraint violation — INV-7/INV-8 path.
judgment = "UNSUPPORTED"
defect_stage = "negative_constraint_violation"
violated_id = judge_result.get("violated_constraint_id")
elif verdict == "PARTIAL":
# #213 B1 normalization: a prompt-verdict PARTIAL becomes
# judgment=UNSUPPORTED, defect_stage=source_description, carrying the
# sub_claim_breakdown[] (the machine-readable partial signal). Routing
# to UNSUPPORTED puts the unsupported sub-claim through the same
# gate-refuse path a fully-unsupported claim takes. The breakdown shape
# was validated true-partial in _validate_judge_dict, so INV-19 holds on
# the emitted completed row.
judgment = "UNSUPPORTED"
defect_stage = "source_description"
violated_id = None
else:
raise ValueError(f"unknown judge verdict: {verdict!r}")
entry: dict[str, Any] = {
"claim_id": citation["claim_id"],
"scoped_manifest_id": citation.get("scoped_manifest_id", SENTINEL_MANIFEST_ID),
"claim_text": citation["claim_text"],
"ref_slug": citation["ref_slug"],
"anchor_kind": citation["anchor_kind"],
"anchor_value": citation.get("anchor_value", ""),
"judgment": judgment,
"audit_status": "completed",
"defect_stage": defect_stage,
"rationale": rationale or "(no rationale provided)",
"judge_model": judge_model,
"judge_run_at": now_iso,
"ref_retrieval_method": ref_retrieval_method,
"upstream_owner_agent": citation.get("upstream_owner_agent"),
"audit_run_id": audit_run_id,
}
if violated_id is not None:
entry["violated_constraint_id"] = violated_id
if verdict == "PARTIAL":
# Carry the decomposition onto the emitted row. Normalize each item to
# the schema item shape (sub_claim_text, sub_verdict, optional
# evidence_pointer), dropping any extra keys the judge added so the
# additionalProperties:false item schema holds. Presence of this field
# is the machine-readable partial-support signal (#213).
entry["sub_claim_breakdown"] = [
{
"sub_claim_text": item.get("sub_claim_text"),
"sub_verdict": item.get("sub_verdict"),
**(
{"evidence_pointer": item["evidence_pointer"]}
if "evidence_pointer" in item
else {}
),
}
for item in judge_result["sub_claim_breakdown"]
if isinstance(item, dict)
]
return entry
def _uncited_audit_failure_entry(
*,
sentence: dict[str, Any],
scoped_manifest_id: str,
manifest_claim_id: str | None,
fault_class: str,
detail: str,
finding_id: str,
judge_model: str,
now_iso: str,
) -> dict[str, Any]:
"""§3.6 (v3.8.2 / #118): uncited sentence × manifest pair where the
constraint judge raised JudgeInvocationError. Mirrors INV-14 row on the
cited path but rides in the uncited_audit_failures[] aggregate because
claim_audit_result.ref_slug is required."""
rationale = f"{fault_class}: {detail}" if detail else f"{fault_class}:"
return {
"finding_id": finding_id,
"claim_text": sentence["sentence_text"],
"section_path": sentence.get("section_path", ""),
"scoped_manifest_id": scoped_manifest_id,
"manifest_claim_id": manifest_claim_id,
"fault_class": fault_class,
"rationale": rationale,
"judge_model": judge_model,
"judge_run_at": now_iso,
"rule_version": UAF_RULE_VERSION,
"upstream_owner_agent": sentence.get("upstream_owner_agent"),
}
def _constraint_violation_entry(
*,
sentence: dict[str, Any],
judge_result: dict[str, Any],
scoped_manifest_id: str,
finding_id: str,
judge_model: str,
now_iso: str,
) -> dict[str, Any]:
"""§3.5 / §5 stream (d): uncited claim with VIOLATED judge verdict."""
violated_id = judge_result.get("violated_constraint_id")
manifest_claim_id = None
if violated_id:
nc_match = RE_NC_CONSTRAINT.match(violated_id)
if nc_match:
manifest_claim_id = f"C-{nc_match.group(1)}"
return {
"finding_id": finding_id,
"claim_text": sentence["sentence_text"],
"section_path": sentence.get("section_path", ""),
"violated_constraint_id": violated_id,
"scoped_manifest_id": scoped_manifest_id,
"manifest_claim_id": manifest_claim_id,
"judge_verdict": "VIOLATED",
# #360: clamp the judge-supplied rationale (success path) to maxLength;
# `or` default catches a missing/null/empty rationale (schema minLength=1).
"rationale": _bounded_judge_rationale(judge_result.get("rationale"))
or "Constraint violated by uncited claim.",
"judge_model": judge_model,
"judge_run_at": now_iso,
"rule_version": DRIFT_RULE_VERSION,
"upstream_owner_agent": sentence.get("upstream_owner_agent"),
}
def _uncited_assertion_entry(
*,
sentence: dict[str, Any],
finding_id: str,
now_iso: str,
trigger_tokens: list[str] | None = None,
) -> dict[str, Any]:
# Resolve trigger_tokens with strict semantics: prefer the explicit
# keyword arg, fall back to the sentence dict, and raise if both are
# absent. The prior `["uncited"]` sentinel passed U-INV-2 minItems=1
# but carried no semantic content — callers who skipped the detector
# silently emitted meaningless tokens into the passport. Raise instead
# so the contract is enforced at write-time, not discovered at audit-
# read-time (per codex R1 P1-4).
tokens = trigger_tokens or sentence.get("trigger_tokens")
if not tokens:
raise ValueError(
f"_uncited_assertion_entry: finding_id={finding_id!r} has no "
"trigger_tokens. Caller must pre-process draft sentences "
"through detect_uncited_assertions (or supply trigger_tokens "
"explicitly); the schema's U-INV-2 minItems=1 invariant is "
"an audit-quality contract, not a placeholder slot."
)
manifest_claim_id = sentence.get("manifest_claim_id")
# Step 7 codex R1 CO-3 / U-INV-4 pair rule: scoped_manifest_id is the
# disambiguator for a specific manifest claim. When no claim_id is bound
# (the uncited sentence is in scope for a manifest-level MNC but is NOT
# itself a manifest claim — runtime contract for stream-d uncited
# constraint-violation routing), the uncited_assertion row MUST drop the
# manifest scope. The companion constraint_violations[] row owns the
# manifest pointer in that case; carrying scope on both rows would fail
# U-INV-4 (manifest_claim_id null ↔ scoped_manifest_id null).
scoped_manifest_id = (
sentence.get("scoped_manifest_id") if manifest_claim_id is not None else None
)
return {
"finding_id": finding_id,
"sentence_text": sentence["sentence_text"],
"section_path": sentence.get("section_path", ""),
"trigger_tokens": tokens,
"detected_at": now_iso,
"rule_version": UNCITED_RULE_VERSION,
"upstream_owner_agent": sentence.get("upstream_owner_agent"),
"manifest_claim_id": manifest_claim_id,
"scoped_manifest_id": scoped_manifest_id,
}
def _claim_drift_entry(
*,
drift_kind: str,
claim_text: str,
finding_id: str,
now_iso: str,
manifest_claim_id: str | None = None,
scoped_manifest_id: str | None = None,
section_path: str | None = None,
) -> dict[str, Any]:
entry = {
"finding_id": finding_id,
"drift_kind": drift_kind,
"claim_text": claim_text,
"detected_at": now_iso,
"rule_version": DRIFT_RULE_VERSION,
"manifest_claim_id": manifest_claim_id,
"scoped_manifest_id": scoped_manifest_id,
"section_path": section_path,
}
return entry
# ---------------------------------------------------------------------------
# Sampling helper.
# ---------------------------------------------------------------------------
def _stratified_bucket_indices(total: int, cap: int) -> list[int]:
"""Pick `cap` indices in [0, total) via stratified buckets in document order.
Divides [0, total) into `cap` equal-ish buckets and picks the first index of
each bucket. The result is strictly ascending and has length min(cap, total).
Why two-stage fill: a naive `int(i * width)` for `width = total / cap`
silently collapses adjacent picks when `total/cap < 2` (e.g. N=101,
cap=100 → `int(99 * 1.01) == int(100 * 1.0)`-class duplicates after
dedup). The S-INV-1 invariant ties `audited_count` to `len(audited_indices)`
so a silent dedup would shrink audited_count below cap with no surface.
We dedup first, then fill the remaining slots from un-picked indices in
ascending document order — keeping the bucket-first-pick bias for spread
while honoring the contract that `audited_count == min(cap, N)` whenever
that is achievable.
"""
if total <= 0 or cap <= 0:
return []
k = min(cap, total)
width = total / k
picks: set[int] = set()
for i in range(k):
picks.add(int(i * width))
# Fill missing slots from un-picked indices in ascending order so the
# final result is exactly k strictly-ascending unique picks whenever
# k ≤ total (which is guaranteed by `k = min(cap, total)`).
if len(picks) < k:
for j in range(total):
if j not in picks:
picks.add(j)
if len(picks) == k:
break
return sorted(picks)
# ---------------------------------------------------------------------------
# Drift detection (manifest set-diff).
# ---------------------------------------------------------------------------
def _detect_drifts(
*,
manifests: list[dict[str, Any]],
emitted_citations: list[dict[str, Any]],
uncited_sentence_texts: set[str],
constraint_absorbed_claim_ids: set[tuple[str, str]],
constraint_absorbed_manifest_scopes: set[str],
now_iso: str,
next_finding_id: Callable[[], str],
) -> list[dict[str, Any]]:
"""§4 step 5 manifest set-diff producing claim_drift entries.
Precedence:
- T-P8 + Step 13 R5 codex P3: constraint-violation absorbs drift in
**full** for the violating manifest. `constraint_absorbed_claim_ids`
captures (manifest, declared-claim-id) pairs; `constraint_absorbed_manifest_scopes`
captures the manifest_id itself so a same-manifest citation with a
drifted (non-manifest) claim_id is also suppressed — preserves the
"absorbed in full" spec promise. A violation in manifest A does NOT
silence drift in manifest B.
- T-P10 / D-INV-4: uncited sentence takes precedence over drift — no
drift entry whose claim_text matches an uncited sentence_text.
MANIFEST-MISSING fallback (Step 13 R5 codex P2 #2): when no manifest
carries any claims, there's no pre-commitment baseline to drift FROM —
every emitted citation would be classified EMITTED_NOT_INTENDED, layering
spurious LOW-WARN noise on top of the MANIFEST-MISSING advisory the
formatter already surfaces. Short-circuit and return no drifts so the
fallback run remains audit-only.
"""
has_baseline = any((m.get("claims") or []) for m in manifests)
if not has_baseline:
return []
drifts: list[dict[str, Any]] = []
# Index emitted citations by (scoped_manifest_id, claim_id) — these are
# the "supported" set candidates the prose actually produced. Use
# .get(SENTINEL_MANIFEST_ID) so MANIFEST-MISSING callers that omit
# scoped_manifest_id still build a coherent emitted_pairs set per the
# sentinel fallback contract (Step 13 R4 codex P2 #3).
emitted_pairs: set[tuple[str, str]] = {
(c.get("scoped_manifest_id", SENTINEL_MANIFEST_ID), c.get("claim_id", ""))
for c in emitted_citations
}
emitted_texts = {c.get("claim_text", "") for c in emitted_citations}
# INTENDED_NOT_EMITTED — manifest claims missing from emitted set.
# D6 defines Emitted as a set of claim_text values; the dropped-claim side
# MUST mirror that. A stale or re-numbered claim_id where the claim_text
# still appears in the draft would otherwise show up as INTENDED_NOT_EMITTED
# even though the prose carries the claim — a false drift signal
# (Step 13 R2 codex P2 finding).
for m in manifests:
mid = m.get("manifest_id")
# If this manifest had any constraint violation, absorb ALL of its
# drift — Step 13 R5 codex P3. The (mid, cid) pair-level absorption
# below is preserved for backwards compat, but the scope-level skip
# is the load-bearing rule per "absorbed in full".
if mid in constraint_absorbed_manifest_scopes:
continue
for claim in m.get("claims", []) or []:
cid = claim.get("claim_id")
claim_text = claim.get("claim_text", "")
if (mid, cid) in emitted_pairs:
continue
if claim_text and claim_text in emitted_texts:
# The draft carries the claim under a different claim_id (e.g.
# claim_id was re-numbered between manifest emission and prose).
# D6 set-of-text semantics — not a drop.
continue
if (mid, cid) in constraint_absorbed_claim_ids:
continue
if claim_text in uncited_sentence_texts:
# Step 7 codex R1 CO-1 / D-INV-4 cross-aggregate exclusivity:
# when a manifest claim appears as an uncited sentence in the
# draft, the uncited_assertion row takes priority. Emitting an
# INTENDED_NOT_EMITTED drift alongside would fail the D-INV-4
# consistency lint (one finding per sentence across both
# aggregates). Mirrors the EMITTED_NOT_INTENDED skip in the
# loop below.
continue
drifts.append(
_claim_drift_entry(
drift_kind="INTENDED_NOT_EMITTED",
claim_text=claim_text,
finding_id=next_finding_id(),
now_iso=now_iso,
manifest_claim_id=cid,
scoped_manifest_id=mid,
section_path=None,
)
)
# EMITTED_NOT_INTENDED — emitted citations whose claim_text is not in any
# manifest's claim_text set. D6 defines Emitted as a SET of claim_text
# values, so a drifted claim that carries multiple citations (e.g. one
# sentence with two ref markers) MUST emit ONE drift row, not one per
# citation. We dedupe by claim_text here while keeping the first
# encountered section_path as the representative (Step 13 R1 codex P2).
all_manifest_texts: set[str] = set()
for m in manifests:
for claim in m.get("claims", []) or []:
text = claim.get("claim_text")
if text:
all_manifest_texts.add(text)
seen_drift_texts: set[str] = set()
for c in emitted_citations:
text = c.get("claim_text", "")
if text in all_manifest_texts:
continue
c_scope = c.get("scoped_manifest_id", SENTINEL_MANIFEST_ID)
c_claim_id = c.get("claim_id", "")
if c_scope in constraint_absorbed_manifest_scopes:
# Step 13 R5 codex P3 — manifest scope absorbed in full when any
# citation in it violated a negative constraint. Suppresses
# same-manifest drift for emitted citations whose claim_id was
# NOT in the manifest's declared claim set.
continue
if (c_scope, c_claim_id) in constraint_absorbed_claim_ids:
continue
if text in uncited_sentence_texts:
# Precedence rule 3 / D-INV-4 — uncited takes priority.
continue
if text in seen_drift_texts:
# D6 set semantics — one drift row per drifted claim_text.
continue
seen_drift_texts.add(text)
drifts.append(
_claim_drift_entry(
drift_kind="EMITTED_NOT_INTENDED",
claim_text=text,
finding_id=next_finding_id(),
now_iso=now_iso,
manifest_claim_id=None,
scoped_manifest_id=None,
section_path=c.get("section_path", "unknown"),
)
)
return drifts
# ---------------------------------------------------------------------------
# Public entry point.
# ---------------------------------------------------------------------------
def run_audit_pipeline(
*,
citations: list[dict[str, Any]],
manifests: list[dict[str, Any]],
# Reserved for the production retrieval-driver wiring per spec §4 step 2.
# The current implementation injects retrieval via `retrieve_fn` so the
# corpus is read inside that callback; keeping the parameter on the
# signature lets the orchestrator pass corpus through without changing
# the public API when the production retrieve_fn lands.
corpus: list[dict[str, Any]] | None = None,
config: dict[str, Any],
retrieve_fn: Callable[[dict[str, Any]], dict[str, Any]],
judge_fn: Callable[..., dict[str, Any]],
audit_run_id: str,
now_iso: str,
cache: dict[str, Any] | None = None,
uncited_sentences: list[dict[str, Any]] | None = None,
all_uncited_sentences: list[dict[str, Any]] | None = None,
pdf_preflight_sidecars: dict[str, dict[str, Any]] | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Run §4 Step 1-6 + manifest set-diff over caller-supplied inputs.
``config.judge_model`` is the caller-supplied judge identity (effort
included where it changes judgment). Omitted / null / blank / ``unknown``
is recorded as ``unknown`` and binds the cache key to this run (#826), so a
verdict can never be reused across runs under an unattributed identity.
Two uncited streams (Step 13 R4 codex P1 #2):
- `uncited_sentences`: D4-c detector positives — output of
`detect_uncited_assertions` (sentences matching the quantifier /
empirical-trigger filter). Drives `uncited_assertions[]` LOW-WARN
advisory emission only.
- `all_uncited_sentences`: the full uncited sentence set (every draft
sentence with no in-text citation marker). Drives stream (d) —
constraint judging for `constraint_violations[]` HIGH-WARN. The full
set is needed because a manifest negative constraint like "No causal
language" can be violated by a sentence ("The program caused
improvement") that the D4-c detector filters OUT (no quantifier, no
empirical trigger token). Routing constraint judging through the
D4-c-filtered subset silently drops those HIGH-WARN cases.
When `all_uncited_sentences` is omitted, it defaults to
`uncited_sentences` (legacy callers preserved — but a warning band:
those callers miss the R4 P1 expansion and should pass both).
The uncited token-rule detector
(`scripts/uncited_assertion_detector.py`, §"Uncited-assertion detector
(D4-c)" in claim_ref_alignment_audit_agent.md) is NOT invoked here.
Callers pre-process the full uncited set through
`detect_uncited_assertions` to get `uncited_sentences`; pass the raw
full set as `all_uncited_sentences`. `scripts/test_e2e_claim_audit.py`
exercises the full detector → pipeline → finalizer chain end-to-end.
Sentence-dict shape:
- `uncited_sentences[]`: `sentence_text` + `section_path` +
`trigger_tokens` (non-empty per U-INV-2). D4-c output guarantees
these fields.
- `all_uncited_sentences[]`: `sentence_text` + `section_path` only.
Constraint judging does not consult `trigger_tokens`.
`pdf_preflight_sidecars` (#512): optional map of `ref_slug` →
`pdf_read_preflight/1` sidecar dict (see `scripts/pdf_read_preflight.py`).
`None` (default) = the caller has not wired the preflight layer — legacy
byte-equivalent behavior, no tagging. When provided (even empty), every
completed `manual_pdf` row with `anchor_kind == "page"` whose sidecar is
missing or non-`PASS` gets `PDF_READ_INTEGRITY_TAG` appended to its
rationale at the Step-6 emission point, cache hit or not. Additionally,
page-anchor citations handed to `retrieve_fn` carry a
`pdf_preflight_verdict` key (`PASS`/`FAIL`/`UNAVAILABLE`/`MISSING`):
retrieve_fn implementations SHOULD locate the passage by content rather
than by the page scope when the verdict is not `PASS` — the judge must
never be fed a passage selected by an untrusted page number. Sidecar
freshness is the CALLER's contract: this function does no file I/O, so
the orchestrator re-hashes each PDF against its sidecar `sha256` before
dispatch and re-runs the preflight on mismatch (a stale `PASS` from a
replaced file must not license the new bytes).
Returns:
dict with six aggregate arrays keyed by passport-aggregate name:
claim_audit_results, uncited_assertions, claim_drifts,
constraint_violations, audit_sampling_summaries, plus
claim_intent_manifests (echoed for downstream consumption).
Raises:
ValueError: when config validation fails (e.g. max_claims_per_paper <= 0).
"""
# Intentional no-op: `corpus` is reserved for the production retrieval-driver
# wiring (spec §4 step 2). Mark as read so static analysers (ruff ARG002,
# mypy strict unused-arg) do not flag the forward-compat parameter.
_ = corpus
# ---- Config sanity ----
cap = config.get("max_claims_per_paper", 100)
if not isinstance(cap, int) or cap <= 0:
raise ValueError(
f"max_claims_per_paper must be positive integer; got {cap!r} "
"(spec §4 step 3 + S-INV-2 / T-P11 cap=0 rejected)"
)
supplied_judge_model = config.get("judge_model")
if supplied_judge_model is not None and not isinstance(supplied_judge_model, str):
raise ValueError("judge_model must be a string identity or null")
raw_judge_model = (supplied_judge_model or "").strip()
judge_identity_known = bool(raw_judge_model) and raw_judge_model.lower() != "unknown"
judge_model = raw_judge_model if judge_identity_known else "unknown"
# #826: an unattributed judge is never a cache-key component on its own —
# same fail-closed shape as the unknown prompt_version below: bind a
# run-local component so cross-run hits are impossible while within-run
# dedup of repeated citations still holds.
cache_judge_model = judge_model if judge_identity_known else f"__unknown__:{audit_run_id}"
# #361: prompt_version is a judge-cache-key component. Absent key → default
# to JUDGE_PROMPT_SHA256, the prompt's own fingerprint and the SINGLE SOURCE
# OF TRUTH for cache invalidation: check_judge_prompt_version.py keeps this
# hash in lockstep with the judge-prompt text, so any prompt edit changes the
# hash and AUTOMATICALLY invalidates stale entries (the human-readable
# JUDGE_PROMPT_VERSION label is decoupled and must NOT gate the cache).
# Present-but-None → the caller declares the prompt version UNKNOWN; fail
# CLOSED by binding a run-local component (audit_run_id is per-run unique) so
# a stale entry is never served across an unknown-version boundary — cross-run
# hits are disabled, but within-run dedup for repeated citations still holds.
prompt_version = config.get("judge_prompt_version", JUDGE_PROMPT_SHA256)
if prompt_version is None:
prompt_version = f"__unknown__:{audit_run_id}"
# Build the three lookup indexes once per run. Used by per-citation
# constraint resolution + manifest-level absorption + drift detection.
manifests_by_id: dict[str, dict[str, Any]] = {
m["manifest_id"]: m for m in manifests if m.get("manifest_id")
}
claim_by_mc_id: dict[tuple[str, str], dict[str, Any]] = {
(m["manifest_id"], claim["claim_id"]): claim
for m in manifests
if m.get("manifest_id")
for claim in (m.get("claims") or [])
if claim.get("claim_id")
}
mncs_by_manifest_id: dict[str, list[dict[str, Any]]] = {
m["manifest_id"]: list(m.get("manifest_negative_constraints") or [])
for m in manifests
if m.get("manifest_id")
}
cache = cache if cache is not None else {}
uncited_sentences = uncited_sentences or []
# Step 13 R4 codex P1 #2: constraint judging needs the FULL uncited set,
# not the D4-c-filtered subset. When the caller omits the full set we
# fall back to the D4-c subset for backwards compatibility — but that
# path silently drops constraint violations on sentences outside D4-c
# trigger tokens. New callers should pass both.
all_uncited_sentences = (
all_uncited_sentences if all_uncited_sentences is not None else list(uncited_sentences)
)
# ---- Sampling decision ----
total = len(citations)
if total > cap:
sampled_indices = _stratified_bucket_indices(total, cap)
audited_citations = [citations[i] for i in sampled_indices]
sampling_summaries = [
{
"audit_run_id": audit_run_id,
"max_claims_per_paper": cap,
"total_citation_count": total,
"audited_count": len(sampled_indices),
"audited_indices": sampled_indices,
"sampling_strategy": SAMPLING_STRATEGY,
"emitted_at": now_iso,
}
]
else:
audited_citations = list(citations)
sampling_summaries = []
# ---- Per-citation §4 Step 1-6 ----
claim_audit_results: list[dict[str, Any]] = []
constraint_violations: list[dict[str, Any]] = []
constraint_absorbed_claim_ids: set[tuple[str, str]] = set()
# Step 13 R5 codex P3: track manifest_id scopes whose drift is absorbed
# "in full". A constraint violation in manifest M suppresses ALL of M's
# drift — declared claims (by id), the violating citation's pair (which
# may itself be drifted), AND any other emitted citation in M with a
# non-manifest claim_id.
constraint_absorbed_manifest_scopes: set[str] = set()
def _written_scope_for(citation: dict[str, Any]) -> str:
"""Return the scoped_manifest_id that goes onto the claim_audit_result row.
Step 7 codex R1 CO-2: a drifted-cited citation's `claim_id` is not in
any manifest, but the citation still arrives with the active
`scoped_manifest_id` for runtime constraint resolution (so global MNCs
still apply per M-INV-3). The row written to the passport, however,
MUST carry the sentinel manifest id whenever the (scope, claim_id)
pair is not present in the manifest index — otherwise INV-15 dangling
check rejects the passport. Runtime constraint lookup stays untouched
(it reads citation.scoped_manifest_id directly); only the persisted
row is normalized.
"""
runtime_scope = citation.get("scoped_manifest_id", SENTINEL_MANIFEST_ID)
cid = citation.get("claim_id")
if runtime_scope == SENTINEL_MANIFEST_ID:
return SENTINEL_MANIFEST_ID
if (runtime_scope, cid) in claim_by_mc_id:
return runtime_scope
return SENTINEL_MANIFEST_ID
for citation in audited_citations:
anchor_kind = citation.get("anchor_kind")
scoped_manifest_id = citation.get("scoped_manifest_id", SENTINEL_MANIFEST_ID)
claim_id = citation.get("claim_id")
written_scope = _written_scope_for(citation)
# Step 1 — anchor=none firm-rule short-circuit.
if anchor_kind == "none":
entry = _anchorless_entry(
citation,
audit_run_id=audit_run_id,
now_iso=now_iso,
judge_model=judge_model,
)
entry["scoped_manifest_id"] = written_scope
claim_audit_results.append(entry)
continue
# Step 2 — retrieval. Wrap in `_invoke_retrieve` so transient failures
# surface as INV-14 retrieval_* audit_tool_failure rows instead of
# aborting the pass (Step 13 R2 codex P2 finding, symmetric to the
# R1 _invoke_judge wrapper).
# #512 r2: when the preflight layer is wired, page-anchor citations carry
# the sidecar verdict INTO retrieval so implementations can locate the
# passage by content instead of the untrusted page scope BEFORE the judge
# sees it — tagging after the fact cannot fix a judge that already read
# the wrong passage. Copy-on-write; API-path retrieve_fns ignore the key.
retrieval_citation = citation
if pdf_preflight_sidecars is not None and anchor_kind == "page":
_sc = pdf_preflight_sidecars.get(citation["ref_slug"])
retrieval_citation = dict(citation)
retrieval_citation["pdf_preflight_verdict"] = (
_sc.get("verdict") if _sc else "MISSING"
)
try:
retrieval = _invoke_retrieve(retrieve_fn, retrieval_citation)
except RetrievalInvocationError as ret_err:
entry = _retrieval_failure_entry(
citation,
method="audit_tool_failure",
audit_run_id=audit_run_id,
now_iso=now_iso,
judge_model=judge_model,
fault_class=ret_err.fault_class,
)
entry["rationale"] = f"{ret_err.fault_class}: {ret_err.detail}"
entry["scoped_manifest_id"] = written_scope
claim_audit_results.append(entry)
continue
method = retrieval["ref_retrieval_method"]
excerpt = retrieval.get("retrieved_excerpt")
if method in {"failed", "not_found", "audit_tool_failure"}:
entry = _retrieval_failure_entry(
citation,
method=method,
audit_run_id=audit_run_id,
now_iso=now_iso,
judge_model=judge_model,
fault_class=retrieval.get("fault_class"),
)
entry["scoped_manifest_id"] = written_scope
claim_audit_results.append(entry)
continue
if method not in {"api", "manual_pdf"}:
raise ValueError(f"unexpected ref_retrieval_method: {method!r}")
# Step 3 — cache lookup. Active constraints scoped by (manifest, claim).
active_constraints = _active_constraints_for_claim(
scoped_manifest_id=scoped_manifest_id,
claim_id=claim_id,
claim_by_mc_id=claim_by_mc_id,
mncs_by_manifest_id=mncs_by_manifest_id,
)
key = _cache_key(
claim_text=citation["claim_text"],
ref_slug=citation["ref_slug"],
anchor_kind=anchor_kind,
anchor_value=citation.get("anchor_value", ""),
retrieved_excerpt=excerpt,
active_constraints=active_constraints,
judge_model=cache_judge_model,
prompt_version=prompt_version,
)
# In-scope constraint ids for this call. Both fresh judge invocations
# AND cache hits validate VIOLATED ids against this set so a
# hallucinated id never reaches `_judge_result_entry` (Step 13 R3
# codex P2 #1).
active_ids: frozenset[str] = frozenset(
c["constraint_id"] for c in active_constraints if c.get("constraint_id")
)
cached = cache.get(key)
try:
if cached is not None:
# Step 13 R3 codex P2 #4: cache may carry a corrupted/partial
# dict from a prior session. Re-validate every hit through the
# same surface as fresh invocations so a stale entry surfaces
# as cache_corruption instead of crashing in
# `_judge_result_entry`.
judge_result = _validate_judge_dict(
cached,
allowed_judgments=_CITED_PATH_JUDGMENTS,
active_constraint_ids=active_ids,
source="cache",
)
else:
# Step 4-5 — passage location is implicit (excerpt is the
# located passage); invoke judge. Wrap in `_invoke_judge` so
# transient failures surface as INV-14 `audit_tool_failure`
# rows instead of aborting the audit (Step 13 R1 codex).
judge_result = _invoke_judge(
judge_fn,
allowed_judgments=_CITED_PATH_JUDGMENTS,
active_constraint_ids=active_ids,
claim_text=citation["claim_text"],
retrieved_excerpt=excerpt,
anchor_kind=anchor_kind,
anchor_value=citation.get("anchor_value", ""),
active_constraints=active_constraints,
judge_model=judge_model,
)
cache[key] = judge_result
except JudgeInvocationError as judge_err:
# Cache-hit validation failures map to cache_corruption per INV-14.
fault_class = "cache_corruption" if cached is not None else judge_err.fault_class
entry = _retrieval_failure_entry(
citation,
method="audit_tool_failure",
audit_run_id=audit_run_id,
now_iso=now_iso,
judge_model=judge_model,
fault_class=fault_class,
)
entry["rationale"] = f"{fault_class}: {judge_err.detail}"
entry["scoped_manifest_id"] = written_scope
claim_audit_results.append(entry)
continue
# Step 6 — defect_stage routing + emission.
entry = _judge_result_entry(
citation,
judge_result=judge_result,
ref_retrieval_method=method,
audit_run_id=audit_run_id,
now_iso=now_iso,
judge_model=judge_model,
)
entry["scoped_manifest_id"] = written_scope
# #512 PDF read-integrity precondition, executable path. Sits AFTER cache
# resolution so hits and fresh invocations are tagged identically (codex
# #512 P1: a cache hit must not bypass the check). `None` = caller has not
# wired the preflight layer → byte-equivalent legacy behavior; a provided
# dict (even empty) means the orchestrator ran the layer, so a missing or
# non-PASS sidecar for a manual_pdf page-anchor row is tagged.
if (
pdf_preflight_sidecars is not None
and method == "manual_pdf"
and anchor_kind == "page"
):
sidecar = pdf_preflight_sidecars.get(citation["ref_slug"])
if not (sidecar and sidecar.get("verdict") == "PASS"):
_tag_pdf_read_integrity(entry)
claim_audit_results.append(entry)
# Precedence rule 1: cited constraint violation absorbs the drift signal.
# Spec §6 lint rule 6 + §7.2 T-P8: when a citation in this manifest
# judges VIOLATED, that manifest's drift findings are absorbed in
# full — the constraint violation has already surfaced the L3
# faithfulness failure at HIGH-WARN, so layering LOW-WARN drift
# noise on top of it just reports the same paper-level problem twice.
# Absorption is manifest-scoped (not global) so VIOLATED in
# manifest A does NOT silence legitimate drift signal in manifest B.
if entry["defect_stage"] == "negative_constraint_violation":
manifest = manifests_by_id.get(scoped_manifest_id)
if manifest is not None:
for claim in manifest.get("claims", []) or []:
cid_in_manifest = claim.get("claim_id")
if cid_in_manifest:
constraint_absorbed_claim_ids.add(
(scoped_manifest_id, cid_in_manifest)
)
# Also absorb the emitted citation's own (manifest, claim) pair
# so a drifted-yet-violated citation does not produce a
# companion EMITTED_NOT_INTENDED row.
constraint_absorbed_claim_ids.add((scoped_manifest_id, claim_id))
# Step 13 R5 codex P3 — record the full manifest scope so any
# other emitted citation in M with a drifted claim_id is also
# absorbed by the drift detector.
constraint_absorbed_manifest_scopes.add(scoped_manifest_id)
# ---- Stream (d): uncited constraint judging over FULL uncited set ----
# Step 13 R4 codex P1 #2: constraint judging MUST see every uncited
# sentence (not just D4-c detector positives). An MNC like "No causal
# language" can be violated by an uncited sentence that the D4-c filter
# drops (no quantifier, no empirical trigger token), so routing
# constraint judging through the LOW-WARN advisory loop below would
# silently miss HIGH-WARN cases. We run constraint judging here on
# `all_uncited_sentences` and emit LOW-WARN uncited_assertion rows
# separately on `uncited_sentences` (the D4-c subset).
#
# Step 13 R6 codex P1: the documented Stage 4 draft sentence shape carries
# only `sentence_text` / `section_path` / optional `adjacent_text` — NOT
# a sentence-level `scoped_manifest_id`. Pre-fix this loop required the
# caller to populate scope on every sentence; absent that, the
# constraint judge was skipped and the HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED
# gate was a no-op for orchestrator callers following the contract.
# The runtime now derives a default scope set per sentence: if the caller
# provides `scoped_manifest_id` on the sentence dict, only that manifest's
# MNCs apply; otherwise the pipeline applies EVERY manifest's MNCs
# (uncited sentences have no claim-level binding, so manifest-scoped
# MNCs reach them universally per spec §3.5 D4-c stream (d) semantics).
# Per-manifest constraint sets — MNCs (manifest-wide) PLUS, when the
# sentence carries a `manifest_claim_id`, that claim's NC-C entries.
# NC-C (claim-level) is the R7 codex P1 gap: spec §3.5 D4-c stream (d)
# covers BOTH MNC and NC-C for uncited violations, but the R6 closure
# only wired MNCs. We resolve NC-C from claim_by_mc_id at call time
# when the sentence binds a claim_id.
#
# Step 13 R7 codex P2 also applies here: when MNC ids collide across
# manifests (two manifests both have "MNC-1"), passing a flat list to
# one judge call makes the returned `violated_constraint_id` ambiguous
# — we'd have to first-match-wins which mis-attributes the row to the
# wrong manifest. Solution: run the judge ONCE PER MANIFEST, with that
# manifest's MNC + NC-C set. Each return is unambiguous by construction;
# cost is N judge calls per sentence (cf. cited path where each
# citation already takes one judge call).
manifest_mncs_by_id: dict[str, list[dict[str, Any]]] = {}
for m in manifests:
mid = m.get("manifest_id")
if not mid:
continue
mncs_for_mid: list[dict[str, Any]] = []
for mnc in m.get("manifest_negative_constraints") or []:
if mnc.get("constraint_id"):
mncs_for_mid.append(
{"constraint_id": mnc["constraint_id"], "rule": mnc["rule"], "scope": "MNC"}
)
manifest_mncs_by_id[mid] = mncs_for_mid
constraint_violation_texts: set[str] = set()
cv_counter = 1
# v3.8.2 / #118 — uncited_audit_failures[] aggregate for JudgeInvocationError
# on the uncited constraint-judging path. Mirrors INV-14 audit_tool_failure
# on the cited path. Pre-v3.8.2 the failure was silently substituted as
# NOT_VIOLATED, suppressing HIGH-WARN constraint checks; the UAF aggregate
# surfaces the operational signal at MED-WARN advisory tier without
# dropping audit coverage (option 4 — re-raise and abort — was rejected
# for that exact coverage reason). See spec §3.6.
uncited_audit_failures: list[dict[str, Any]] = []
uaf_counter = 1
for sentence in all_uncited_sentences:
scoped_manifest_id_for_sentence = sentence.get("scoped_manifest_id")
sentence_claim_id = sentence.get("manifest_claim_id")
# Decide which manifests this sentence is constraint-judged against.
# Caller-pinned scope → that one manifest. Otherwise every manifest.
if scoped_manifest_id_for_sentence:
target_manifest_ids = [scoped_manifest_id_for_sentence] if scoped_manifest_id_for_sentence in manifest_mncs_by_id else []
else:
target_manifest_ids = list(manifest_mncs_by_id.keys())
if not target_manifest_ids:
continue
# One judge call per (sentence, manifest) pair so MNC id collisions
# across manifests cannot misattribute the violation (R7 codex P2).
sentence_violation_recorded = False
for mid in target_manifest_ids:
per_manifest_constraints: list[dict[str, Any]] = list(manifest_mncs_by_id[mid])
# R7 codex P1: also include NC-C for this manifest's bound claim.
# v3.8.2 / #118 codex P2-2 + R2 P2-1: only set the UAF row's
# manifest_claim_id when THIS manifest actually owns the claim
# binding AND at least one NC constraint was added. When sentence
# carries a sentence_claim_id but the current manifest doesn't
# have that claim_id, OR the claim exists but has no NC entries
# (so the failed judge call was MNC-only), the UAF row's
# manifest_claim_id MUST stay null — the failure is MNC-only at
# that point and a non-null binding would mislabel downstream
# consumers about which constraint set the outage hit.
uaf_manifest_claim_id: str | None = None
if sentence_claim_id:
claim = claim_by_mc_id.get((mid, sentence_claim_id))
if claim is not None:
for nc in claim.get("negative_constraints") or []:
cid = nc.get("constraint_id")
if cid:
per_manifest_constraints.append(
{"constraint_id": cid, "rule": nc["rule"], "scope": "NC"}
)
# Only mark NC-C binding when at least one NC
# constraint actually entered the judge call.
uaf_manifest_claim_id = sentence_claim_id
if not per_manifest_constraints:
continue
applicable_ids: frozenset[str] = frozenset(
c["constraint_id"] for c in per_manifest_constraints if c.get("constraint_id")
)
# Wrap in `_invoke_judge` so transient failures don't abort the
# uncited stream. v3.8.2 / #118 — JudgeInvocationError now routes
# to an uncited_audit_failures[] row (MED-WARN advisory, gate
# passes) instead of synthesising a NOT_VIOLATED verdict. The
# synthesis substitution shipped pre-v3.8.2 was silently
# suppressing HIGH-WARN constraint checks on transient judge
# outage — see spec §3.6 + §4 step 9 fourth bullet for routing,
# and the design memo at docs/superpowers/plans/2026-05-17-issue-118-*
# for the option 1-4 trade-off analysis.
try:
judge_result = _invoke_judge(
judge_fn,
allowed_judgments=_UNCITED_PATH_JUDGMENTS,
active_constraint_ids=applicable_ids,
claim_text=sentence["sentence_text"],
retrieved_excerpt=None,
anchor_kind=None,
anchor_value=None,
active_constraints=per_manifest_constraints,
judge_model=judge_model,
)
except JudgeInvocationError as judge_err:
uncited_audit_failures.append(
_uncited_audit_failure_entry(
sentence=sentence,
scoped_manifest_id=mid,
manifest_claim_id=uaf_manifest_claim_id,
fault_class=judge_err.fault_class,
detail=judge_err.detail,
finding_id=f"UAF-{uaf_counter:03d}",
judge_model=judge_model,
now_iso=now_iso,
)
)
uaf_counter += 1
continue # no fake NOT_VIOLATED, no CV row, skip to next manifest
if judge_result.get("judgment") == "VIOLATED":
constraint_violations.append(
_constraint_violation_entry(
sentence=sentence,
judge_result=judge_result,
scoped_manifest_id=mid,
finding_id=f"CV-{cv_counter:03d}",
judge_model=judge_model,
now_iso=now_iso,
)
)
cv_counter += 1
sentence_violation_recorded = True
if sentence_violation_recorded:
constraint_violation_texts.add(sentence["sentence_text"])
# ---- Step 6 stream (uncited_assertion LOW-WARN advisory) ----
# D4-c detector positives only — uncited_sentences is the filtered set.
uncited_assertions: list[dict[str, Any]] = []
uncited_sentence_texts: set[str] = set()
ua_counter = 1
for sentence in uncited_sentences:
uncited_sentence_texts.add(sentence["sentence_text"])
# Always emit uncited_assertion (LOW-WARN advisory). CV-INV-4
# explicitly permits a sentence to appear in both uncited_assertions[]
# and constraint_violations[] simultaneously.
uncited_assertions.append(
_uncited_assertion_entry(
sentence=sentence,
finding_id=f"UA-{ua_counter:03d}",
now_iso=now_iso,
)
)
ua_counter += 1
# Also surface uncited_sentence_texts for any constraint-violation
# sentence text that wasn't a D4-c positive but did violate an MNC.
# The drift detector reads `uncited_sentence_texts` to apply the
# D-INV-4 uncited-takes-precedence rule; without including CV-text
# entries here, a sentence outside D4-c but matching a manifest claim
# would produce both a constraint_violation row AND a drift row,
# violating D-INV-4.
uncited_sentence_texts.update(constraint_violation_texts)
# ---- Manifest set-diff drift detection ----
cd_counter = 1
def _next_cd() -> str:
nonlocal cd_counter
out = f"CD-{cd_counter:03d}"
cd_counter += 1
return out
# Step 7 codex R1 CO-4: drift detection's emitted-side index MUST use
# the FULL citation list, not the sampled subset. Sampling caps judge
# invocations (spec §4 step 3) — it does NOT shrink the prose visible to
# the manifest set-diff. Passing audited_citations here made every
# unsampled-but-present citation look dropped from manifest, producing
# false INTENDED_NOT_EMITTED rows in proportion to (total - cap).
claim_drifts = _detect_drifts(
manifests=manifests,
emitted_citations=citations,
uncited_sentence_texts=uncited_sentence_texts,
constraint_absorbed_claim_ids=constraint_absorbed_claim_ids,
constraint_absorbed_manifest_scopes=constraint_absorbed_manifest_scopes,
now_iso=now_iso,
next_finding_id=_next_cd,
)
return {
"claim_intent_manifests": manifests,
"claim_audit_results": claim_audit_results,
"uncited_assertions": uncited_assertions,
"claim_drifts": claim_drifts,
"constraint_violations": constraint_violations,
"audit_sampling_summaries": sampling_summaries,
"uncited_audit_failures": uncited_audit_failures,
}