mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
d699782efa
## Summary Partitions the judge-verdict cache key by judge-prompt version so a prompt revision invalidates stale entries automatically, instead of serving a verdict cached under the old prompt until the TTL expires. The cache key previously included `judge_model` but no prompt-version component. A judge-prompt revision (e.g. #213's Step-0 sub-claim decomposition) therefore did not invalidate stale entries — a verdict cached under the old prompt was still served, silently bypassing the new prompt logic. Pre-existing cache-key design (P2#1 in the #355 post-squash review); affects every prompt revision, not just the decomposition path. ## What changed - **`_cache_key` gains a `prompt_version` component**, kept separate from `judge_model` (independent axes of judge behavior). A prompt revision partitions the keyspace; same-version entries still dedup (no regression). - **Cache invalidation keys on `JUDGE_PROMPT_SHA256`** — the SHA-256 of the canonical judge-prompt section, the single source of truth. `scripts/check_judge_prompt_version.py` keeps that hash in lockstep with the prompt text, so any prompt edit changes the key and invalidates stale entries — no reliance on a human remembering to bump a label. `JUDGE_PROMPT_VERSION` is a decoupled human-readable label for logs/diffs only. - **Fail-CLOSED on unknown version**: when the caller declares the prompt version unknown (`None`), the pipeline binds a run-local component (`__unknown__:<audit_run_id>`), so a stale entry is never served across an unknown-version boundary. Cross-run hits are disabled; within-run dedup for repeated citations still holds. - **CI backstop** `scripts/check_judge_prompt_version.py`: hashes the canonical judge-prompt section (between the `JUDGE-PROMPT-CANONICAL` markers in the agent `.md`) and fails if it drifts from the pinned `JUDGE_PROMPT_SHA256`, forcing a hash re-pin in the same change. Wired into `spec-consistency.yml` + the pytest manifest. - **Contract + docstring alignment** (second commit): the agent-prompt contract and the lint docstring described invalidation as keyed on the `JUDGE_PROMPT_VERSION` label, but the pipeline already falls back to `JUDGE_PROMPT_SHA256`. A downstream implementer following the stale contract would key the cache on a value whose bump the drift guard does not enforce, re-opening the bug. Re-attributed to the SHA256 fingerprint. ## Verification - RED→GREEN + mutation-verified: reverting the fallback to the label re-fails the hash-tracking guard test (`1 != 2`, stale entry not invalidated); restored → 6 passed. - Within-run dedup preserved under fail-closed; same-version + default-constant dedup preserved (no regression). - Lint companion: clean passes, drift fails, missing markers error out. - Full suite **2275 passed / 3 skipped** (= 2278 collected − 3 skipped). - Independent diff review: 0 P1 / 0 P2. Security review: 0 findings. Closes #361
269 lines
14 KiB
Python
269 lines
14 KiB
Python
"""Shared constants for v3.8 claim-faithfulness audit.
|
|
|
|
Single source of truth for the literals + regexes that appear in BOTH the
|
|
lint (`check_claim_audit_consistency.py`) and the pipeline runtime
|
|
(`claim_audit_pipeline.py`). Re-declaring these in both places opens a
|
|
drift hole — a spec bump that updates the lint without updating the
|
|
runtime would change one side silently. Tests cover both call sites; the
|
|
shared import binds them.
|
|
|
|
See docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md §3.1
|
|
(matrix + INV catalogue) and §4 step 3 (sampling) for canonical
|
|
definitions.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
# Canonical sentinel for the MANIFEST-MISSING fallback path (spec §3.1 INV-15).
|
|
SENTINEL_MANIFEST_ID = "M-0000-00-00T00:00:00Z-0000"
|
|
|
|
# INV-6 canonical rationale prefix (v3.7.3 R-L3-1-A firm rule).
|
|
INV6_RATIONALE_PREFIX = "v3.7.3 R-L3-1-A violation"
|
|
|
|
# INV-14 audit-tool-failure rationale fault-class tags.
|
|
INV14_FAULT_CLASS_TAGS: tuple[str, ...] = (
|
|
"judge_timeout",
|
|
"judge_api_error",
|
|
"judge_parse_error",
|
|
"cache_corruption",
|
|
"retrieval_api_error",
|
|
"retrieval_timeout",
|
|
"retrieval_network_error",
|
|
)
|
|
|
|
# Sampling strategy literal (S-INV schema constant).
|
|
SAMPLING_STRATEGY = "stratified_buckets_v1"
|
|
|
|
# Sub-claim breakdown sub_verdict enum (claim_audit_result.schema.json #213).
|
|
# The "non-SUPPORTED" set is the valid OPPOSING verdicts only — a missing or
|
|
# out-of-enum sub_verdict must NOT count as non-SUPPORTED, or a degenerate
|
|
# breakdown `[SUPPORTED, <missing>]` would masquerade as true-partial.
|
|
SUBCLAIM_VERDICTS: frozenset[str] = frozenset({"SUPPORTED", "UNSUPPORTED", "AMBIGUOUS"})
|
|
SUBCLAIM_NON_SUPPORTED: frozenset[str] = frozenset({"UNSUPPORTED", "AMBIGUOUS"})
|
|
|
|
|
|
def is_true_partial_breakdown(breakdown: object) -> bool:
|
|
"""True iff `breakdown` is a well-formed true-partial decomposition (#213).
|
|
|
|
Single source of truth for the INV-19 true-partial test, shared by the lint
|
|
(`check_claim_audit_consistency.py`), the runtime
|
|
(`claim_audit_pipeline.py` PARTIAL normalization + judge-output validation),
|
|
and the calibration subset metric (`claim_audit_calibration.py`). A list of
|
|
>=2 dict items whose sub_verdicts include >=1 SUPPORTED AND >=1 valid
|
|
non-SUPPORTED ({UNSUPPORTED, AMBIGUOUS}). A missing / out-of-enum sub_verdict
|
|
is NOT counted as non-SUPPORTED.
|
|
|
|
NOTE: this is the *content-shape* gate only. The lint additionally pins the
|
|
enclosing row's judgment/defect_stage (INV-19) and the calibration subset
|
|
metric additionally matches the breakdown against each fixture's expected
|
|
sub-claims — neither of those belongs here.
|
|
"""
|
|
if not isinstance(breakdown, list) or len(breakdown) < 2:
|
|
return False
|
|
verdicts = [item.get("sub_verdict") for item in breakdown if isinstance(item, dict)]
|
|
has_supported = any(v == "SUPPORTED" for v in verdicts)
|
|
has_non_supported = any(v in SUBCLAIM_NON_SUPPORTED for v in verdicts)
|
|
return has_supported and has_non_supported
|
|
|
|
|
|
def _is_schema_shaped_item(item: object) -> bool:
|
|
"""True iff a breakdown item satisfies the schema item shape (#213).
|
|
|
|
Each item MUST be a dict with a non-empty-string `sub_claim_text` and a
|
|
`sub_verdict` in the closed enum. The runtime needs this BEFORE it copies an
|
|
item onto an emitted row — `is_true_partial_breakdown` only checks the verdict
|
|
*mix*, so a degenerate item like `{"sub_verdict": "UNSUPPORTED"}` (no text)
|
|
passes the mix gate but would emit `sub_claim_text: None`, a schema-invalid
|
|
completed row (ship-gate round-2 finding).
|
|
|
|
Validates the fields the runtime COPIES onto the row against the
|
|
claim_audit_result.schema.json item shape: non-empty string sub_claim_text
|
|
(<=1000), sub_verdict in the enum, and evidence_pointer — if present — a
|
|
string (<=1000) or null. Extra keys are not rejected here because the runtime
|
|
copy keeps only these three; but the copied fields' TYPES must be valid or a
|
|
wrong-typed evidence_pointer (e.g. a number) would reach a completed row and
|
|
violate the schema (ship-gate round-3 finding).
|
|
"""
|
|
if not isinstance(item, dict):
|
|
return False
|
|
text = item.get("sub_claim_text")
|
|
if not isinstance(text, str) or not text.strip() or len(text) > 1000:
|
|
return False
|
|
if item.get("sub_verdict") not in SUBCLAIM_VERDICTS:
|
|
return False
|
|
if "evidence_pointer" in item:
|
|
ep = item["evidence_pointer"]
|
|
if ep is not None and (not isinstance(ep, str) or len(ep) > 1000):
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_emittable_partial_breakdown(breakdown: object) -> bool:
|
|
"""True iff `breakdown` is true-partial AND every item is schema-shaped (#213).
|
|
|
|
The runtime validation gate before a PARTIAL is normalized onto a *completed*
|
|
row: it must be a genuine partial (`is_true_partial_breakdown`) AND every item
|
|
must carry a non-empty sub_claim_text + valid sub_verdict, so the copied row
|
|
satisfies the item schema. A breakdown that is true-partial by mix but has a
|
|
malformed item is a judge parse failure, NOT a completed row.
|
|
"""
|
|
if not is_true_partial_breakdown(breakdown):
|
|
return False
|
|
return all(_is_schema_shaped_item(item) for item in breakdown)
|
|
|
|
# rule_version literals for v3.8.0 release. Future revisions bump the literal
|
|
# and require re-lint per spec §3.3 / §3.4 / §3.5.
|
|
UNCITED_RULE_VERSION = "D4-c-v1"
|
|
DRIFT_RULE_VERSION = "D4-a-v1"
|
|
# v3.8.2 / #118 — uncited_audit_failure rule_version literal (§3.6).
|
|
# Distinct prefix from D4-c-v1 (uncited_assertion D4-c detector) and
|
|
# D4-a-v1 (constraint_violation) so the lint can route by literal.
|
|
UAF_RULE_VERSION = "D4-c-v1-uaf-v1"
|
|
|
|
# #361 — judge-prompt version, a HUMAN-READABLE LABEL ONLY. The judge prompt
|
|
# text lives in academic-pipeline/agents/claim_ref_alignment_audit_agent.md
|
|
# (### Step 5 — Judge invocation) and is supplied to the pipeline via an injected
|
|
# judge_fn, so the pipeline cannot hash the prompt text itself. This literal is a
|
|
# friendly name for the current prompt revision (e.g. the #213 Step-0
|
|
# sub-claim-decomposition prompt); BUMP IT whenever the judge prompt changes so
|
|
# logs/diffs read clearly. It is NO LONGER the judge-cache-key source of truth —
|
|
# the cache key tracks JUDGE_PROMPT_SHA256 below (the prompt fingerprint), which
|
|
# the lint keeps in lockstep with the prompt text. Decoupling the two means a
|
|
# forgotten label bump can never leave stale cache entries valid.
|
|
JUDGE_PROMPT_VERSION = "step0-decomp-v1"
|
|
|
|
# #361 backstop: SHA-256 of the canonical judge-prompt section (the text between
|
|
# the JUDGE-PROMPT-CANONICAL-START/END markers in
|
|
# academic-pipeline/agents/claim_ref_alignment_audit_agent.md, stripped).
|
|
# scripts/check_judge_prompt_version.py recomputes this hash and fails CI if it
|
|
# drifts — forcing a human to update this hash whenever the prompt text changes.
|
|
# This hash DOUBLES AS the judge-cache-key prompt component (the single source of
|
|
# truth for cache invalidation): claim_audit_pipeline.py defaults
|
|
# prompt_version to this value, so because the lint pins the hash to the prompt
|
|
# text, any prompt edit automatically changes the cache key and invalidates stale
|
|
# entries — no reliance on a separate human-readable label bump.
|
|
JUDGE_PROMPT_SHA256 = "cdd5ba2d681ea6d6422a017fb122f36a9d62edb32f9d27bd98dbaad1f807b058"
|
|
|
|
# Constraint id parse rules (spec §3.2 + INV-17 canonical form).
|
|
RE_NC_CONSTRAINT = re.compile(r"^NC-C([0-9]{3,})-([0-9]+)$")
|
|
RE_MNC_CONSTRAINT = re.compile(r"^MNC-([0-9]+)$")
|
|
RE_CLAIM_ID = re.compile(r"^C-([0-9]{3,})$")
|
|
|
|
# Schema rejects this pattern, but for malformed-on-purpose fixtures the lint
|
|
# surfaces INV-17 explicitly before schema validation runs.
|
|
RE_NC_INNER_HYPHEN = re.compile(r"^NC-C-[0-9]+-[0-9]+$")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# D4-c uncited-assertion detector constants (spec §"Uncited-assertion
|
|
# detector (D4-c)" in claim_ref_alignment_audit_agent.md).
|
|
#
|
|
# Centralised here so pipeline runtime, lint, and detector share one source
|
|
# of truth — a spec bump touches one literal, not three.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Condition 1: empirical-claim verbs (case-insensitive whole-word match).
|
|
# Spec list: showed, demonstrated, observed, proved, confirmed.
|
|
UNCITED_EMPIRICAL_VERBS: frozenset[str] = frozenset(
|
|
{"showed", "demonstrated", "observed", "proved", "confirmed"}
|
|
)
|
|
|
|
# Condition 1: fuzzy English quantifier words (case-insensitive whole-word).
|
|
# Spec list: most, several, two-thirds. Kept literal; numerical / percent
|
|
# quantifiers are caught by RE_NUMERIC_QUANTIFIER below.
|
|
UNCITED_FUZZY_QUANTIFIERS: frozenset[str] = frozenset(
|
|
{"most", "several", "two-thirds"}
|
|
)
|
|
|
|
# Condition 1: numerical quantifier regex. Spec line 250 lists three numeric
|
|
# classes — `numbers / percentages / explicit quantifiers (50%, 67 of 100)`.
|
|
# All three fire D4-c condition 1; the detector then applies a guard pass
|
|
# (RE_NUMERIC_QUANTIFIER_GUARD below) that rejects matches whose surrounding
|
|
# context proves them to be years, version triples, or section numbers
|
|
# instead of quantifiers. Splitting into match-broadly + guard-narrowly is
|
|
# easier to read and to test than stuffing every exclusion into a single
|
|
# regex's negative lookaheads. The detector concatenates the matched
|
|
# substring into trigger_tokens verbatim so the schema's minItems=1
|
|
# invariant holds.
|
|
RE_NUMERIC_QUANTIFIER = re.compile(
|
|
# Order matters: longest-prefix-first so percent and "N of M" bind
|
|
# before the bare-number branch swallows the leading digits.
|
|
r"\b\d+(?:\.\d+)?%" # percent quantifier
|
|
r"|\b\d+(?:\.\d+)?\s+of\s+\d+\b" # "N of M" quantifier idiom
|
|
r"|\b\d+(?:\.\d+)*\b" # bare number, possibly dotted
|
|
# (3+ segments routed to guard
|
|
# as version/section)
|
|
)
|
|
|
|
# Condition 1 guard: rejects bare-number matches whose surrounding context
|
|
# identifies them as years, version triples, or section numbers — none of
|
|
# those are quantitative claims, and treating them as such produced
|
|
# false-positive LOW-WARN advisories before the guard landed (see codex
|
|
# R1 P1-3). Applied AFTER RE_NUMERIC_QUANTIFIER to the matched substring +
|
|
# its character offsets in the sentence; bare-number matches that satisfy
|
|
# any guard branch are dropped, percent and `N of M` matches always pass
|
|
# through.
|
|
#
|
|
# Guard branches:
|
|
# 1. Standalone 4-digit year in plausible academic range (1900-2099).
|
|
# 2. Version triple `X.Y.Z` (dotted form with 3+ segments — the broad
|
|
# regex captures only the first two segments, so we re-scan).
|
|
# 3. Dotted section number `X.Y[.Z…]` (treated as section ref, not
|
|
# quantifier). Distinguished from version by `section` / `§` /
|
|
# `chapter` / `figure` / `table` cue word within a 24-char left
|
|
# window, OR by `v` immediately preceding (version literal).
|
|
RE_BARE_NUMERIC_YEAR = re.compile(r"^(19|20)\d{2}$")
|
|
RE_DOTTED_TRIPLE_OR_MORE = re.compile(r"^\d+(?:\.\d+){2,}$")
|
|
RE_DOTTED_PAIR = re.compile(r"^\d+\.\d+$")
|
|
RE_SECTION_CUE = re.compile(
|
|
r"(?:section|chapter|figure|table|fig\.|tbl\.|step|appendix|§)\s*$",
|
|
re.IGNORECASE,
|
|
)
|
|
RE_VERSION_PREFIX = re.compile(r"v\s*$", re.IGNORECASE)
|
|
# Catches the case where Python's `\b` fails between a letter and a digit
|
|
# (both are \w characters) — e.g. `v3.7.3` has no \b between `v` and `3`,
|
|
# so RE_NUMERIC_QUANTIFIER starts matching from the SECOND segment (`7.3`)
|
|
# and the guard never sees the version-triple shape. This pattern detects
|
|
# a digit-then-dot prefix immediately attached to the left of the match.
|
|
# Requires exactly one digit-run + `.` ending the window — combined with
|
|
# the surrounding match (also dotted or bare number), this signals a
|
|
# multi-segment dotted form that should be treated as a version/section
|
|
# reference rather than a quantifier.
|
|
RE_NUMERIC_LEFT_ATTACHED = re.compile(r"\d+\.$")
|
|
|
|
# Condition 2: three-layer-citation ref-marker probe. Presence probe
|
|
# WITHIN the v3.7.3 ref-marker namespace — accepts any `<!--ref:...-->`
|
|
# shape where the slug payload begins with a non-whitespace character,
|
|
# rejects HTML comments that happen to start with `ref:` but use it as
|
|
# a label rather than a citation marker (e.g.
|
|
# `<!-- ref: $internal.notebook.cell -->`).
|
|
#
|
|
# Iteration history:
|
|
# R0 `[^-]+` — rejected hyphenated slugs.
|
|
# R1 `[A-Za-z][A-Za-z0-9_:-]* + 0-2 tokens` — rejected digit-leading
|
|
# slugs, plus-sign slugs,
|
|
# and 3+ status tokens.
|
|
# R2 `[^>]*?` — too broad; matched
|
|
# `<!-- ref: $analysis -->`
|
|
# and similar code/internal
|
|
# ref comments (codex
|
|
# R3 P2-NEW-A).
|
|
# R3 `[^\s>][^>]*?` — current. Slug must begin
|
|
# with a non-whitespace
|
|
# non-`>` character; the
|
|
# v3.7.3 strict validator
|
|
# in
|
|
# scripts/check_v3_7_3_three_layer_citation.py
|
|
# catches any remaining
|
|
# shape errors.
|
|
RE_REF_MARKER = re.compile(r"<!--\s*ref:[^\s>][^>]*?-->")
|
|
|
|
# Condition 3: definitional-phrase substrings (case-insensitive). Spec list:
|
|
# `refers to`, `is defined as`, `we define`, `for the purposes of`.
|
|
UNCITED_DEFINITION_PHRASES: tuple[str, ...] = (
|
|
"refers to",
|
|
"is defined as",
|
|
"we define",
|
|
"for the purposes of",
|
|
)
|