Files
imbad0202__academic-researc…/scripts/test_check_judge_prompt_version.py
Edward Cheng-I Wu d699782efa fix(citation-gate): partition judge cache key by prompt version (#361) (#363)
## 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
2026-06-07 17:41:05 +08:00

91 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""Tests for check_judge_prompt_version (#361 drift guard).
The clean fixture must PASS; a prompt edit that does not re-pin the hash must
FAIL (the core #361 backstop); a correct re-pin must PASS again; a removed
marker must error out (so the guard cannot be silently disabled).
"""
from __future__ import annotations
import hashlib
import tempfile
import unittest
from pathlib import Path
import scripts.check_judge_prompt_version as lint
_PROMPT_BODY = "> CLAIM: {claim_text}\n> Output ONE verdict from {SUPPORTED, UNSUPPORTED}."
def _agent_md(prompt_body: str, *, with_markers: bool = True) -> str:
if with_markers:
section = (
"<!-- JUDGE-PROMPT-CANONICAL-START (#361): note -->\n\n"
f"{prompt_body}\n\n"
"<!-- JUDGE-PROMPT-CANONICAL-END (#361) -->"
)
else:
section = prompt_body
return f"### Step 5 — Judge invocation\n\n{section}\n\n### Step 6 — next\n"
def _constants_src(pinned_hash: str) -> str:
return (
'JUDGE_PROMPT_VERSION = "step0-decomp-v1"\n'
f'JUDGE_PROMPT_SHA256 = "{pinned_hash}"\n'
)
def _hash_of(prompt_body: str) -> str:
# Compute the expected hash via the PRODUCTION extraction path, not a shadow
# copy of the regex — so an extraction bug fails test_clean_fixture_passes
# instead of hiding behind a matching wrong answer on both sides.
section = lint._extract_prompt_section(_agent_md(prompt_body))
assert section is not None
return hashlib.sha256(section.encode("utf-8")).hexdigest()
class CheckJudgePromptVersionTest(unittest.TestCase):
def _write_root(self, agent_md: str, constants_src: str) -> Path:
root = Path(tempfile.mkdtemp())
agent = root / lint._AGENT_REL
agent.parent.mkdir(parents=True, exist_ok=True)
agent.write_text(agent_md, encoding="utf-8")
constants = root / lint._CONSTANTS_REL
constants.parent.mkdir(parents=True, exist_ok=True)
constants.write_text(constants_src, encoding="utf-8")
return root
def test_clean_fixture_passes(self) -> None:
root = self._write_root(_agent_md(_PROMPT_BODY), _constants_src(_hash_of(_PROMPT_BODY)))
self.assertEqual(lint.check(root), 0)
def test_prompt_edit_without_repin_fails(self) -> None:
# Prompt changed; pinned hash still points at the OLD prompt → drift → fail.
root = self._write_root(
_agent_md(_PROMPT_BODY + "\n> EXTRA LINE that changes behavior"),
_constants_src(_hash_of(_PROMPT_BODY)),
)
self.assertEqual(lint.check(root), 1)
def test_prompt_edit_with_correct_repin_passes(self) -> None:
# The correct bump flow: prompt changed AND hash re-pinned → pass.
new_body = _PROMPT_BODY + "\n> EXTRA LINE that changes behavior"
root = self._write_root(_agent_md(new_body), _constants_src(_hash_of(new_body)))
self.assertEqual(lint.check(root), 0)
def test_missing_markers_errors(self) -> None:
root = self._write_root(
_agent_md(_PROMPT_BODY, with_markers=False),
_constants_src(_hash_of(_PROMPT_BODY)),
)
self.assertEqual(lint.check(root), 2)
def test_missing_pinned_constant_errors(self) -> None:
root = self._write_root(_agent_md(_PROMPT_BODY), 'JUDGE_PROMPT_VERSION = "x"\n')
self.assertEqual(lint.check(root), 2)
if __name__ == "__main__":
unittest.main()