core/teaching/math_claim_signature.py
Shay 85bfa188ed
feat(ADR-0167/W2-B): lexical claim signature + dedup (#353)
Adds `teaching/math_claim_signature.py` with `lexical_claim_signature()`:
sha256 hex of a normalised lexical token, collapsing two refusal cases on
the same surface token into one teaching-corpus candidate.

Normalisation pipeline (documented in module, breaking-change surface):
  1. Lowercase surface
  2. Strip string.punctuation from both ends (!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~)
  3. Extract token from refusal_detail via r"no primitive or lexicon match for '([^']+)'"
  4. Fallback: use stripped-lowercase surface if regex doesn't match
  5. Canonical: "lexical:" + extracted_token
  6. sha256 hex of UTF-8 bytes → 64-char lowercase hex

Also adds `teaching/math_contemplation.py` (W2-A adapter included as
union-merge; W2-A worktree was not yet dispatched):
  - `audit_to_evidence()`: AuditRow iterable → MathReaderRefusalEvidence tuple
  - `audit_problem_to_evidence()`: convenience wrapper for tests and W3-A
  - Lexical evidence: claim_signature filled; evidence_hash recomputed to include it
  - Non-lexical sub_types: claim_signature stays "" (deferred per ADR-0167 §Q1)

Real-data result on audit_brief_11.json:
  - 14 distinct lexical tokens → 14 distinct signatures (no false collisions)
  - No duplicate tokens in the 50-case sample; dedup logic verified deterministic

Wave 2, parallel with W2-C/D; depends on W1-A branch.
wrong=0 verified by passing regression suite.
2026-05-27 06:56:36 -07:00

77 lines
2.7 KiB
Python

"""ADR-0167 W2-B — Deterministic claim-signature normalisation for LexicalClaim.
Produces a stable sha256 hex that collapses two GSM8K cases refusing on the
same lexical token into one teaching-corpus candidate. Only ``sub_type ==
"lexical"`` evidence gets a non-empty signature in this PR; all other sub_types
are deferred to a follow-up ADR.
Normalisation pipeline (applied in order, documented here as a breaking-change
surface — modifying these rules changes all existing signatures):
1. Lowercase the ``surface`` string.
2. Strip leading and trailing characters that are members of the fixed
punctuation set ``string.punctuation``:
``!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~``
This set is frozen; expansions are breaking changes.
3. Extract the unknown-token from ``refusal_detail`` via the canonical
regex r"no primitive or lexicon match for '([^']+)'"
4. If the regex does not match (e.g. fraction/percentage detail format),
use the stripped-lowercase ``surface`` from step 2 as the token.
5. Canonical string: ``"lexical:" + extracted_token``
6. sha256 hex of canonical string encoded as UTF-8.
Returns a 64-character lowercase hex string. Pure function. Deterministic
across machines (sha256 of well-defined UTF-8 bytes).
"""
from __future__ import annotations
import hashlib
import re
import string
# Frozen punctuation set — documented as breaking-change surface.
_PUNCT_SET: str = string.punctuation # !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
# Canonical regex matching lexicon-entry refusal details.
_LEXICON_DETAIL_RE: re.Pattern[str] = re.compile(
r"no primitive or lexicon match for '([^']+)'"
)
def lexical_claim_signature(
*,
surface: str,
refusal_detail: str,
) -> str:
"""Deterministic sha256 hex of a normalised lexical claim.
See module docstring for the full normalisation pipeline.
Parameters
----------
surface:
The raw token surface form (e.g. ``"crayons,"``).
refusal_detail:
The ``AuditRow.refusal_detail`` string verbatim.
Returns
-------
str
64-character lowercase hex.
"""
# Step 1 — lowercase
lowered = surface.lower()
# Step 2 — strip punctuation set members from both ends
stripped = lowered.strip(_PUNCT_SET)
# Step 3 — attempt canonical extraction from refusal_detail
match = _LEXICON_DETAIL_RE.search(refusal_detail)
# Step 4 — fallback to stripped surface if regex doesn't match
token = match.group(1) if match else stripped
# Step 5 — canonical string
canonical = "lexical:" + token
# Step 6 — sha256 hex
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
__all__ = ["lexical_claim_signature"]