core/tests/test_math_evidence_e2e.py
Shay cc6f13a939
feat(ADR-0167/W3-A): e2e determinism + cognition regression — LexicalClaim slice closed (#357)
Wave 3, closes the LexicalClaim slice of ADR-0167.  After this PR the
math reader's refusal taxonomy is evidence, not terminus: lexical
refusals flow through audit row → typed evidence → dedup signature →
HITL ratification (W2-D) → pack write → next-audit-pass-resolves.

Deliverables
------------
- tests/test_math_evidence_e2e.py (new, 7 tests):
  * test_full_pipeline_from_audit_to_evidence
  * test_e2e_replay_equivalence
  * test_lexical_ratification_advances_unknown_word_row (case 0040 'sees')
  * test_e2e_determinism_across_processes
  * test_cognition_teaching_corridor_unaffected
  * test_evidence_dedup_via_claim_signature
  * test_audit_artifact_round_trip_with_signatures
- evals/gsm8k_math/train_sample/v1/audit_brief_11.md: Post-W2 baseline
  table + cognition regression line + case 0050 hazard status + pointer
  to the new e2e regression module.
- tests/test_candidate_domain_partition.py: minimal allowlist patch to
  test_existing_cognition_tests_untouched so that future ADR-0167 PRs
  can add their own evidence test files without tripping a structurally
  brittle hard-coded whitelist (W2-C partition risk; recorded in PR body).

Hard constraints held
---------------------
- wrong == 0: case 0050 hazard still refuses at sentence_index 0
  after the tmpdir-pack 'sees' ratification; no admission introduced.
- Cognition regression: zero modifications to cognition test bodies;
  only the W2-C whitelist assertion was loosened.
- Determinism: in-process and cross-process evidence_hash byte-identical.
- No real-pack mutation: a per-test digest fixture asserts
  language_packs/data/en_core_math_v1/ is byte-identical before and
  after each test.

Out of scope
------------
- Frame/Composition/Reference/Slot ratification handlers (follow-up ADRs).
- Workbench v1 wiring of math candidates (ADR-0167 §Q4).
- Auto-ratification — HITL only, forever.
- The two partition risks Gemini flagged in W2-C (cognition pack indexing,
  replay-gate default) remain follow-up.

With this PR merged the engine can ratify math-domain lexical claims
from its own refusal evidence through the existing HITL teaching
corridor — the thesis claim of ADR-0167 becomes a concrete green test.
2026-05-27 07:27:24 -07:00

511 lines
19 KiB
Python

"""ADR-0167 W3-A — End-to-end determinism + cognition regression.
Closes the LexicalClaim-first slice: refusal → adapter → signature →
ratification → re-audit → row movement.
Pure tests. Every ratification uses a tmpdir pack copy; the real
``language_packs/data/en_core_math_v1/`` is byte-identical before and
after the suite runs.
"""
from __future__ import annotations
import functools
import hashlib
import json
import shutil
import subprocess
import sys
from dataclasses import asdict
from pathlib import Path
import pytest
from core.protocol import canonical_bytes
from generate.comprehension import lexicon as comprehension_lexicon
from generate.comprehension import lifecycle
from generate.comprehension.audit import AuditRow, audit_problem
from generate.comprehension.state import ReaderRefusal
from teaching.discovery import DiscoveryCandidate
from teaching.math_claim_signature import lexical_claim_signature
from teaching.math_contemplation import audit_to_evidence
from teaching.math_evidence import (
SUB_TYPE_FOR_OPERATOR,
MathReaderRefusalEvidence,
from_audit_row,
)
from teaching.math_lexical_ratification import apply_lexical_claim
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).resolve().parents[1]
AUDIT_ARTIFACT_PATH = (
REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "audit_brief_11.json"
)
CASES_PATH = (
REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
)
PACK_ROOT = REPO_ROOT / "language_packs" / "data" / "en_core_math_v1"
PACK_HAZARD_CASE_ID = "gsm8k-train-sample-v1-0050"
RATIFICATION_TARGET_CASE_ID = "gsm8k-train-sample-v1-0040"
RATIFICATION_TARGET_SURFACE = "sees"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _row_from_artifact_case(case: dict[str, object]) -> AuditRow:
return AuditRow(
case_id=str(case["case_id"]),
sentence_index=int(case["sentence_index"]),
token_index=int(case["token_index"]),
token_text=str(case["token_text"]),
recognized_terms=tuple(str(t) for t in case["recognized_terms"]), # type: ignore[arg-type]
skipped_frame=(
None if case["skipped_frame"] is None else str(case["skipped_frame"])
),
missing_operator=(
None
if case["missing_operator"] is None
else str(case["missing_operator"])
),
refusal_reason=str(case["refusal_reason"]),
refusal_detail=str(case["refusal_detail"]),
)
def _load_artifact_rows() -> list[AuditRow]:
artifact = json.loads(AUDIT_ARTIFACT_PATH.read_text(encoding="utf-8"))
return [_row_from_artifact_case(case) for case in artifact["per_case"]]
def _load_cases() -> list[dict]:
return [
json.loads(line)
for line in CASES_PATH.read_text(encoding="utf-8").splitlines()
if line.strip()
]
def _real_pack_digest() -> str:
"""Recursive sha256 of all bytes in the real math pack (sorted paths)."""
digest = hashlib.sha256()
for path in sorted(PACK_ROOT.rglob("*")):
if path.is_file():
digest.update(path.relative_to(PACK_ROOT).as_posix().encode("utf-8"))
digest.update(b"\x00")
digest.update(path.read_bytes())
digest.update(b"\x00")
return digest.hexdigest()
@pytest.fixture()
def pack_copy(tmp_path: Path):
"""Copy the real math pack to tmpdir; clear lexicon caches around use."""
target = tmp_path / "en_core_math_v1"
shutil.copytree(PACK_ROOT, target)
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
yield target
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
@pytest.fixture(autouse=True)
def _real_pack_untouched():
"""Hard assertion: the real math pack must be byte-identical after each test."""
before = _real_pack_digest()
yield
after = _real_pack_digest()
assert after == before, "real en_core_math_v1 pack was mutated by a test"
def _use_pack_for_reader(monkeypatch: pytest.MonkeyPatch, pack_root: Path) -> None:
comprehension_lexicon._CACHE.clear()
@functools.cache
def _tmp_lexicon():
return comprehension_lexicon.load_lexicon(pack_root)
monkeypatch.setattr(lifecycle, "_get_lexicon", _tmp_lexicon)
# ---------------------------------------------------------------------------
# Test 1 — full pipeline from audit to evidence
# ---------------------------------------------------------------------------
def test_full_pipeline_from_audit_to_evidence() -> None:
rows = _load_artifact_rows()
evidence = audit_to_evidence(rows)
expected = sum(
1
for row in rows
if row.missing_operator is not None
and row.missing_operator in SUB_TYPE_FOR_OPERATOR
)
assert len(evidence) == expected
for record in evidence:
assert isinstance(record, MathReaderRefusalEvidence)
assert len(record.evidence_hash) == 64
assert all(c in "0123456789abcdef" for c in record.evidence_hash)
if record.sub_type == "lexical":
assert len(record.claim_signature) == 64
assert all(c in "0123456789abcdef" for c in record.claim_signature)
else:
assert record.claim_signature == ""
# audit_row round-trips intact
rebuilt = from_audit_row(
record.audit_row,
record.sub_type,
claim_signature=record.claim_signature,
)
assert rebuilt.evidence_hash == record.evidence_hash
# ---------------------------------------------------------------------------
# Test 2 — replay equivalence (in-process)
# ---------------------------------------------------------------------------
def test_e2e_replay_equivalence() -> None:
rows = _load_artifact_rows()
first = audit_to_evidence(rows)
second = audit_to_evidence(rows)
assert len(first) == len(second)
for a, b in zip(first, second):
assert a == b
assert a.evidence_hash == b.evidence_hash
assert a.to_canonical_bytes() == b.to_canonical_bytes()
# ---------------------------------------------------------------------------
# Test 3 — load-bearing integration test: lexical ratification advances a row
# ---------------------------------------------------------------------------
def test_lexical_ratification_advances_unknown_word_row(
pack_copy: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ADR-0167 thesis-claim test: engine teaches itself in math domain.
Steps (per W3-A brief):
1. Run audit on case 0040 with tmpdir pack → refusal at 'sees'
2. Wrap refusal in MathReaderRefusalEvidence via audit_to_evidence
3. apply_lexical_claim with category='drain_token', tmpdir pack
4. Re-audit case 0040 with tmpdir pack → refusal still happens
5. Assert previously-blocking token resolves (no unknown_word at 'sees')
6. Assert refusal moved away from 'lexicon_entry'
7. Assert wrong == 0 holds (no admission)
8. Assert case 0050 hazard still pinned at sentence_index 0
"""
cases = _load_cases()
case_0040 = next(
c for c in cases if c["case_id"] == RATIFICATION_TARGET_CASE_ID
)
case_0050 = next(c for c in cases if c["case_id"] == PACK_HAZARD_CASE_ID)
# Step 1: initial audit produces a lexicon_entry refusal at 'sees'.
_use_pack_for_reader(monkeypatch, pack_copy)
result_before, rows_before = audit_problem(
case_0040["question"], case_id=case_0040["case_id"]
)
assert isinstance(result_before, ReaderRefusal)
assert rows_before, "expected at least one audit row from case 0040"
first_refusal = rows_before[0]
assert first_refusal.missing_operator == "lexicon_entry"
assert first_refusal.token_text == RATIFICATION_TARGET_SURFACE
# Step 2: wrap as evidence.
evidence = audit_to_evidence([first_refusal])
assert len(evidence) == 1
claim = evidence[0]
assert claim.sub_type == "lexical"
assert claim.claim_signature # non-empty for lexical sub_type
# Step 3: ratify into tmpdir drain_token.jsonl.
receipt = apply_lexical_claim(
claim=claim,
category="drain_token",
reviewer="w3a_test",
pack_root=pack_copy,
)
assert receipt.lemma == RATIFICATION_TARGET_SURFACE
assert receipt.category == "drain_token"
assert receipt.file_sha256_before != receipt.file_sha256_after
# Step 4 + 5 + 6: re-audit; previously-blocking token resolves; refusal moved.
comprehension_lexicon._CACHE.clear()
lifecycle._get_lexicon.cache_clear()
_use_pack_for_reader(monkeypatch, pack_copy)
result_after, rows_after = audit_problem(
case_0040["question"], case_id=case_0040["case_id"]
)
# Lexicon resolves 'sees' as drain_token in the tmpdir pack.
lex = comprehension_lexicon.load_lexicon(pack_copy)
resolved = comprehension_lexicon.lookup(lex, RATIFICATION_TARGET_SURFACE)
assert resolved is not None
assert resolved.category == "drain_token"
# Refusal still happens (case 0040 has many barriers) but not on 'sees'
# and not as a lexicon_entry miss on that token.
assert isinstance(result_after, ReaderRefusal)
if rows_after:
new_refusal = rows_after[0]
# Either we advanced past sentence 0, OR we hit a different token,
# OR a different missing_operator class entirely.
progressed = (
new_refusal.token_text != RATIFICATION_TARGET_SURFACE
or new_refusal.missing_operator != "lexicon_entry"
or new_refusal.sentence_index != first_refusal.sentence_index
)
assert progressed, (
"ratification of 'sees' did not move the refusal class for case 0040: "
f"before={first_refusal.missing_operator!r}@s{first_refusal.sentence_index}"
f"/{first_refusal.token_text!r}, "
f"after={new_refusal.missing_operator!r}@s{new_refusal.sentence_index}"
f"/{new_refusal.token_text!r}"
)
# Step 7: wrong == 0 — case 0040 still refuses (not admitted).
assert not _is_admission(result_after)
# Step 8: case 0050 hazard still pinned.
result_hazard, _hazard_rows = audit_problem(
case_0050["question"], case_id=case_0050["case_id"]
)
assert isinstance(result_hazard, ReaderRefusal)
assert result_hazard.sentence_index == 0
def _is_admission(result: object) -> bool:
"""Return True iff the reader admitted (produced a MathProblemGraph)."""
from generate.math_problem_graph import MathProblemGraph
return isinstance(result, MathProblemGraph)
# ---------------------------------------------------------------------------
# Test 4 — cross-process determinism
# ---------------------------------------------------------------------------
def test_e2e_determinism_across_processes() -> None:
rows = _load_artifact_rows()
in_process = audit_to_evidence(rows)
assert in_process, "expected at least one evidence record"
expected_hash = in_process[0].evidence_hash
expected_case_id = in_process[0].case_id
script = (
"import json, sys\n"
"from pathlib import Path\n"
"from generate.comprehension.audit import AuditRow\n"
"from teaching.math_contemplation import audit_to_evidence\n"
f"artifact = json.loads(Path({str(AUDIT_ARTIFACT_PATH)!r}).read_text())\n"
"def to_row(c):\n"
" return AuditRow(\n"
" case_id=str(c['case_id']),\n"
" sentence_index=int(c['sentence_index']),\n"
" token_index=int(c['token_index']),\n"
" token_text=str(c['token_text']),\n"
" recognized_terms=tuple(str(t) for t in c['recognized_terms']),\n"
" skipped_frame=None if c['skipped_frame'] is None else str(c['skipped_frame']),\n"
" missing_operator=None if c['missing_operator'] is None else str(c['missing_operator']),\n"
" refusal_reason=str(c['refusal_reason']),\n"
" refusal_detail=str(c['refusal_detail']),\n"
" )\n"
"rows = [to_row(c) for c in artifact['per_case']]\n"
"ev = audit_to_evidence(rows)\n"
"print(ev[0].case_id)\n"
"print(ev[0].evidence_hash)\n"
)
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
)
assert proc.returncode == 0, f"subprocess failed: {proc.stderr}"
out_lines = [line for line in proc.stdout.splitlines() if line.strip()]
assert out_lines[0] == expected_case_id
assert out_lines[1] == expected_hash
# ---------------------------------------------------------------------------
# Test 5 — cognition teaching corridor unaffected
# ---------------------------------------------------------------------------
def _cognition_candidate() -> DiscoveryCandidate:
return DiscoveryCandidate(
candidate_id="w3a_cognition_probe",
proposed_chain={
"subject": "wisdom",
"intent": "cause",
"connective": None,
"object": None,
},
trigger="would_have_grounded",
source_turn_trace="t_w3a",
pack_consistent=True,
boundary_clean=True,
)
def test_cognition_teaching_corridor_unaffected() -> None:
cand = _cognition_candidate()
assert cand.domain == "cognition"
# Canonical-bytes invariant (per W2-C contract: domain is serialised in
# the canonical bytes; only as_dict() omits the key for cognition).
cb = canonical_bytes(cand)
assert b'"domain":"cognition"' in cb
assert b'"domain":"math"' not in cb
# Replay-equivalent across reruns.
assert canonical_bytes(_cognition_candidate()) == cb
# Round-trip through as_dict/from_dict without raising.
payload = cand.as_dict()
assert "domain" not in payload # cognition omits per W2-C
restored = DiscoveryCandidate.from_dict(payload)
assert restored.domain == "cognition"
assert canonical_bytes(restored) == cb
# Defensive: math candidate canonical bytes differ.
math_cand = DiscoveryCandidate(
candidate_id="w3a_math_probe",
proposed_chain={
"subject": "crayons",
"intent": "lexical_claim",
"connective": None,
"object": None,
},
trigger="would_have_grounded",
source_turn_trace="t_w3a_math",
pack_consistent=True,
boundary_clean=True,
domain="math",
)
assert canonical_bytes(math_cand) != cb
assert b'"domain":"math"' in canonical_bytes(math_cand)
# ---------------------------------------------------------------------------
# Test 6 — evidence dedup via claim_signature
# ---------------------------------------------------------------------------
def test_evidence_dedup_via_claim_signature() -> None:
"""Two cases refusing on the same surface collapse under signature dedup."""
shared_surface = "crayons"
refusal_detail = f"no primitive or lexicon match for '{shared_surface}'"
row_a = AuditRow(
case_id="case-A",
sentence_index=0,
token_index=4,
token_text=shared_surface,
recognized_terms=("Ava", "has", "5"),
skipped_frame=None,
missing_operator="lexicon_entry",
refusal_reason="unknown_word",
refusal_detail=refusal_detail,
)
row_b = AuditRow(
case_id="case-B",
sentence_index=0,
token_index=4,
token_text=shared_surface,
recognized_terms=("Ava", "has", "5"),
skipped_frame=None,
missing_operator="lexicon_entry",
refusal_reason="unknown_word",
refusal_detail=refusal_detail,
)
evidence = audit_to_evidence([row_a, row_b])
assert len(evidence) == 2
e_a, e_b = evidence
# Signatures match (dedup key)
assert e_a.claim_signature == e_b.claim_signature
assert (
e_a.claim_signature
== lexical_claim_signature(surface=shared_surface, refusal_detail=refusal_detail)
)
# Per-record hashes differ (case_id is part of canonical bytes)
assert e_a.evidence_hash != e_b.evidence_hash
# Future Workbench dedup logic — pin the contract via a minimal snippet.
deduped: dict[str, MathReaderRefusalEvidence] = {}
for record in evidence:
deduped.setdefault(record.claim_signature, record)
assert len(deduped) == 1
# ---------------------------------------------------------------------------
# Test 7 — audit-artifact round-trip with signatures
# ---------------------------------------------------------------------------
def test_audit_artifact_round_trip_with_signatures(tmp_path: Path) -> None:
rows = _load_artifact_rows()
evidence = audit_to_evidence(rows)
out_path = tmp_path / "evidence.jsonl"
with out_path.open("w", encoding="utf-8") as fh:
for record in evidence:
payload = {
"case_id": record.case_id,
"sentence_index": record.sentence_index,
"token_index": record.token_index,
"refusal_reason": record.refusal_reason,
"missing_operator": record.missing_operator,
"claim_signature": record.claim_signature,
"evidence_hash": record.evidence_hash,
"sub_type": record.sub_type,
"audit_row": asdict(record.audit_row),
}
fh.write(
json.dumps(payload, sort_keys=True, ensure_ascii=False) + "\n"
)
# Reload, rebuild evidence from the audit_row, and check byte-equality.
rebuilt: list[MathReaderRefusalEvidence] = []
with out_path.open("r", encoding="utf-8") as fh:
for line in fh:
payload = json.loads(line)
audit_row = AuditRow(
case_id=payload["audit_row"]["case_id"],
sentence_index=payload["audit_row"]["sentence_index"],
token_index=payload["audit_row"]["token_index"],
token_text=payload["audit_row"]["token_text"],
recognized_terms=tuple(payload["audit_row"]["recognized_terms"]),
skipped_frame=payload["audit_row"]["skipped_frame"],
missing_operator=payload["audit_row"]["missing_operator"],
refusal_reason=payload["audit_row"]["refusal_reason"],
refusal_detail=payload["audit_row"]["refusal_detail"],
)
rebuilt.append(
from_audit_row(
audit_row,
payload["sub_type"],
claim_signature=payload["claim_signature"],
)
)
assert len(rebuilt) == len(evidence)
for original, restored in zip(evidence, rebuilt):
assert original.evidence_hash == restored.evidence_hash
assert original.to_canonical_bytes() == restored.to_canonical_bytes()