core/teaching/math_contemplation.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

104 lines
3.4 KiB
Python

"""ADR-0167 W2-A / W2-B — Audit-to-evidence adapter + lexical claim signature.
W2-A deliverable: :func:`audit_to_evidence` and
:func:`audit_problem_to_evidence` convert :class:`AuditRow` sequences into
typed :class:`MathReaderRefusalEvidence` teaching-corridor records.
W2-B deliverable: For ``sub_type == "lexical"`` evidence, ``claim_signature``
is computed via :func:`lexical_claim_signature` and the ``evidence_hash`` is
recomputed to incorporate the signature. All other sub_types leave
``claim_signature == ""``.
Pure module. No filesystem writes, no network calls, no global mutation.
Deterministic: same inputs → byte-identical output across all reruns.
"""
from __future__ import annotations
from typing import Iterable
from generate.comprehension.audit import AuditRow, audit_problem
from teaching.math_claim_signature import lexical_claim_signature
from teaching.math_evidence import (
MathReaderRefusalEvidence,
SUB_TYPE_FOR_OPERATOR,
from_audit_row,
)
def audit_to_evidence(
audit_rows: Iterable[AuditRow],
) -> tuple[MathReaderRefusalEvidence, ...]:
"""Convert audit rows into typed teaching-corridor evidence records.
Pure function. Deterministic. No filesystem, no network, no mutation.
Sub-type assignment from :data:`teaching.math_evidence.SUB_TYPE_FOR_OPERATOR`.
Skips rows with ``missing_operator=None`` (no sub_type → no candidate).
For ``sub_type == "lexical"``, :func:`lexical_claim_signature` fills the
``claim_signature`` field and the ``evidence_hash`` incorporates it.
For all other sub_types, ``claim_signature`` remains ``""`` (deferred).
Input order is preserved.
Parameters
----------
audit_rows:
Iterable of :class:`AuditRow` instances (e.g. from :func:`audit_problem`).
Returns
-------
tuple[MathReaderRefusalEvidence, ...]
One record per row whose ``missing_operator`` maps to a known sub_type.
"""
results: list[MathReaderRefusalEvidence] = []
for row in audit_rows:
if row.missing_operator is None:
continue
sub_type = SUB_TYPE_FOR_OPERATOR.get(row.missing_operator)
if sub_type is None:
continue
if sub_type == "lexical":
sig = lexical_claim_signature(
surface=row.token_text,
refusal_detail=row.refusal_detail,
)
else:
sig = ""
evidence = from_audit_row(row, sub_type, claim_signature=sig)
results.append(evidence)
return tuple(results)
def audit_problem_to_evidence(
problem_text: str,
*,
case_id: str,
) -> tuple[MathReaderRefusalEvidence, ...]:
"""Run the reader over *problem_text* and return evidence records.
Convenience wrapper that calls :func:`audit_problem` and pipes the
resulting :class:`AuditRow` list through :func:`audit_to_evidence`.
Useful for tests and downstream pipeline work (W3-A).
Parameters
----------
problem_text:
Raw GSM8K-style problem string.
case_id:
Identifier attached to every :class:`AuditRow` (e.g. ``"probe"``).
Returns
-------
tuple[MathReaderRefusalEvidence, ...]
Evidence records for any refusals encountered. Empty tuple on full
admission or if the text produced no sentences.
"""
_result, rows = audit_problem(problem_text, case_id=case_id)
return audit_to_evidence(rows)
__all__ = [
"audit_to_evidence",
"audit_problem_to_evidence",
]