core/teaching/math_contemplation.py
Shay 44c0aa2896
feat(ADR-0169/CC-2+CC-3): CompositionClaim ratification handler + decomposer heuristic tightening (#393)
PR-β of the CompositionClaim wave (CC-2 + CC-3 bundled per the brief
pack — CC-3's heuristic depends on CC-2's new change_kind Literal value).
Mirrors the F1 / ADR-0168 FrameClaim template 1:1 with composition-specific
substitutions.

CC-2 — handler implementation
  - teaching/math_composition_proposal.py — MathCompositionClaimProposal
    adapter per ADR-0169.1 §"Data shape". Frozen dataclass, deterministic
    proposal_id / claim_signature, source="math_audit" pin at the
    proposal layer (rejects "corpus" laundering).
  - teaching/math_composition_ratification.py — apply_composition_claim()
    handler. SAFE_COMPOSITION_CATEGORIES = {multiplicative,
    additive, subtractive}_composition per ADR-0169 §"Initial safe
    category scope". New WrongCompositionCategory exception per
    ADR-0169.1 §"Trip-wires" #8. Writes only to
    language_packs/data/en_core_math_v1/compositions/{category}.jsonl;
    no solver / parser / decomposer / runtime mutation.
  - workbench/readers.py — _HANDLER_DISPATCH now routes
    composition_reclassification → CompositionClaim; suggested_cli
    branch added for both read_math_proposal and ratify_math_proposal.
  - teaching/math_contemplation_proposal.py — ChangeKind Literal +
    _VALID_CHANGE_KINDS frozenset extended with
    composition_reclassification.
  - language_packs/data/en_core_math_v1/compositions/.gitkeep —
    reviewed-pack scaffold.
  - tests/test_math_composition_ratification.py — 22 tests including
    case 0050 hazard pin, cross-process replay equivalence, queue-order
    independence, partition, no-corpus-laundering, dispatch wire,
    Literal acceptance, JSONL round-trip.
  - tests/test_adr_0172_w1_shape_proposal.py — parametrize round-trip
    over all 5 change_kinds.
  - core/cli.py — teaching suite tuple includes new test file.

CC-3 — decomposer heuristic tightening
  - teaching/math_contemplation.py::_CHANGE_KIND_BY_PAIR:
    + (incomplete_operation, quantity_extraction)         → composition_reclassification
    + (incomplete_operation, multi_quantity_composition)  → composition_reclassification
    - (unexpected_category, multi_subject_sentence)       demoted to injector_sub_shape
      (was frame_reclassification; FrameClaim SAFE_FRAME_CATEGORIES doesn't
       cover this — needs ReferenceClaim/CompositionClaim)
    - (unexpected_category, descriptive_frame_question)   demoted to injector_sub_shape
      (was frame_reclassification; needs SlotClaim, not FrameClaim)
    Updated hypothesis-step justification text to reflect new dispatch
    table.
  - tests/test_adr_0172_w2_decomposer.py — distribution assertion
    tightened from "≥3 matcher, ≥2 frame" to exact counts:
    3 matcher / 2 composition / 3 injector / 0 frame. New
    per-pair tests for the four CC-3 dispatch changes.

Verification on real audit_brief_11.json (20 of 47 highest-leverage
refusals now routable):

  2  composition_reclassification   (12 quantity_extraction + 8 multi_quantity_composition)
  3  injector_sub_shape             (2 multi_subject + 2 descriptive_frame + 4 unattached_quantity)
  3  matcher_extension              (9 pre_frame_filler + 4 fraction_percentage + 4 pronoun)
  0  frame_reclassification         (the two prior misroutes are gone)

Workbench POST /math-proposals/{id}/ratify on either composition
proposal now returns 200/routed with a real apply_composition_claim()
command instead of 501.

Suites green:
  - core test --suite teaching -q  → 71 passed
  - core test --suite runtime -q   → 20 passed

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:15:11 -07:00

444 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ADR-0167 W2-A / W2-B + ADR-0172 W2 — Audit-corpus decomposition.
ADR-0167 W2-A deliverable: :func:`audit_to_evidence` and
:func:`audit_problem_to_evidence` convert :class:`AuditRow` sequences into
typed :class:`MathReaderRefusalEvidence` teaching-corridor records.
ADR-0167 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 == ""``.
ADR-0172 W2 deliverable: :func:`decompose_audit` reads
``audit_brief_11.json``, groups refusal rows by
``(refusal_reason, missing_operator)``, and emits one
:class:`MathReaderRefusalShapeProposal` per group of ≥2 rows. Each
proposal carries a 4-step :class:`ReasoningTrace`
(observation → grouping → hypothesis → conclusion). Pure read-only:
the audit file is not mutated, no proposal is written to disk, and no
teaching-store hook fires.
Pure module. No filesystem writes, no network calls, no global mutation.
Deterministic: same inputs → byte-identical output across all reruns.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Iterable
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.comprehension.audit import AuditRow, audit_problem
from teaching.math_claim_signature import lexical_claim_signature
from teaching.math_contemplation_proposal import (
MathReaderRefusalShapeProposal,
build_proposal,
)
from teaching.math_evidence import (
MathReaderRefusalEvidence,
SUB_TYPE_FOR_OPERATOR,
from_audit_row,
)
from teaching.math_reasoning_trace import (
ReasoningStep,
ReasoningTrace,
build_trace,
)
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)
# ---------------------------------------------------------------------------
# ADR-0172 W2 — Audit-corpus decomposer
# ---------------------------------------------------------------------------
# Widened dispatch (ADR-0172 tightening follow-up #2): the original
# single-key heuristic (refusal_reason only) collapsed every audit_brief_11
# group to injector_sub_shape because the reader's actual refusal_reasons
# (unexpected_category, unresolved_pronoun, …) never matched the legacy
# keys. The (refusal_reason × missing_operator) pair carries the
# information needed to route to the queued handlers from
# docs/handoff/ADR-0167-FOLLOWUPS.md §1.
_CHANGE_KIND_BY_PAIR: dict[tuple[str, str], str] = {
("unexpected_category", "pre_frame_filler_sentence"): "matcher_extension",
("unexpected_category", "fraction_percentage_literal"): "matcher_extension",
("unresolved_pronoun", "pronoun_resolution"): "matcher_extension",
# ADR-0169 CC-3: route CompositionClaim audit groups (20 of 47 cases —
# `quantity_extraction` = 12 + `multi_quantity_composition` = 8) to the
# new CompositionClaim handler. Previously these fell through to
# `injector_sub_shape` because no pair entry existed.
("incomplete_operation", "quantity_extraction"): "composition_reclassification",
("incomplete_operation", "multi_quantity_composition"): "composition_reclassification",
# ADR-0169 CC-3 demotion: the prior two `frame_reclassification` routes
# for (unexpected_category, multi_subject_sentence) and
# (unexpected_category, descriptive_frame_question) were proven at the
# 2026-05-27 end-to-end demo to be handler mismatches — those refusals
# need ReferenceClaim/CompositionClaim and SlotClaim respectively, not
# FrameClaim (whose SAFE_FRAME_CATEGORIES allowlist is quantity/ownership
# frames). Until SlotClaim and ReferenceClaim handlers ship, they fall
# through to `injector_sub_shape` (the catch-all) so the workbench stops
# emitting handler-mismatched FrameClaim proposals.
}
# Single-key fallback retained for completeness — covers reader refusals
# that share a refusal_reason regardless of missing_operator.
_CHANGE_KIND_BY_REFUSAL_REASON: dict[str, str] = {
"lexicon_entry": "vocabulary_addition",
"narrowness_violation": "matcher_extension",
"frame_unrecognized": "frame_reclassification",
}
def _audit_row_from_case(case: dict) -> AuditRow:
"""Reconstruct an :class:`AuditRow` from one ``per_case`` JSON entry.
Defensive about missing fields: the audit JSON only carries the
columns it had to populate. Empty/missing fields default to the
natural zero for their declared type.
"""
return AuditRow(
case_id=str(case["case_id"]),
sentence_index=int(case.get("sentence_index", 0)),
token_index=int(case.get("token_index", 0)),
token_text=str(case.get("token_text", "")),
recognized_terms=tuple(case.get("recognized_terms", ())),
skipped_frame=case.get("skipped_frame"),
missing_operator=case.get("missing_operator"),
refusal_reason=str(case.get("refusal_reason", "")),
refusal_detail=str(case.get("refusal_detail", "")),
)
def _change_kind_for_group(refusal_reason: str, missing_operator: str) -> str:
"""Dispatch on (refusal_reason, missing_operator) pair, then refusal_reason.
Per ADR-0172 tightening follow-up #2: the pair-based table covers the
GSM8K train-sample audit groups that route to queued handlers
(matcher_extension, frame_reclassification). The single-key fallback
preserves the original ADR-0172 §"Six open questions" #1 mapping for
reader refusals that share a refusal_reason regardless of operator.
"""
paired = _CHANGE_KIND_BY_PAIR.get((refusal_reason, missing_operator))
if paired is not None:
return paired
return _CHANGE_KIND_BY_REFUSAL_REASON.get(
refusal_reason, "injector_sub_shape"
)
def _modal_anchor_payload(
*,
refusal_reason: str,
missing_operator: str,
evidence: tuple[MathReaderRefusalEvidence, ...],
) -> dict:
"""Build a deterministic, JSON-safe placeholder payload for the group."""
return {
"evidence_count": len(evidence),
"group_key": {
"missing_operator": missing_operator,
"refusal_reason": refusal_reason,
},
"modal_sub_type": evidence[0].sub_type,
}
def _build_reasoning_trace(
*,
refusal_reason: str,
missing_operator: str,
evidence: tuple[MathReaderRefusalEvidence, ...],
change_kind: str,
) -> ReasoningTrace:
"""Construct the 4-step contemplation trace for one group."""
case_ids = tuple(ev.case_id for ev in evidence)
group_payload = {
"missing_operator": missing_operator,
"refusal_reason": refusal_reason,
}
observation = ReasoningStep(
step_index=0,
step_kind="observation",
input_pointers=case_ids,
claim=(
f"{len(evidence)} refusal rows share "
f"(refusal_reason={refusal_reason!r}, "
f"missing_operator={missing_operator!r})"
),
justification=(
"Decomposer iterated audit_brief_11.json per_case rows and "
"found a group whose shared key meets the ≥2-evidence floor."
),
output_payload={
"case_ids": list(case_ids),
"evidence_count": len(evidence),
},
)
grouping = ReasoningStep(
step_index=1,
step_kind="grouping",
input_pointers=case_ids,
claim=(
"Group key encodes the shared (refusal_reason, missing_operator) "
"tuple under which these rows refused."
),
justification=(
"Per ADR-0172 §'Six open questions' #1, the naive grouping is "
"exact equality on the refusal_reason × missing_operator pair."
),
output_payload=group_payload,
)
hypothesis = ReasoningStep(
step_index=2,
step_kind="hypothesis",
input_pointers=case_ids,
claim=(
f"The structural change kind for this group is {change_kind!r}."
),
justification=(
"Dispatched via the (refusal_reason, missing_operator) pair table: "
"(unexpected_category, pre_frame_filler_sentence|"
"fraction_percentage_literal) → matcher_extension; "
"(unresolved_pronoun, pronoun_resolution) → matcher_extension; "
"(incomplete_operation, quantity_extraction|"
"multi_quantity_composition) → composition_reclassification "
"(ADR-0169 CC-3). "
"(unexpected_category, multi_subject_sentence|"
"descriptive_frame_question) demoted from frame_reclassification "
"to injector_sub_shape (ADR-0169 CC-3): the FrameClaim "
"SAFE_FRAME_CATEGORIES allowlist does not cover those shapes; "
"they await ReferenceClaim/SlotClaim handlers. "
"Refusal-reason fallback: lexicon_entry → vocabulary_addition; "
"narrowness_violation → matcher_extension; "
"frame_unrecognized → frame_reclassification; "
"default → injector_sub_shape."
),
output_payload={"proposed_change_kind": change_kind},
)
conclusion = ReasoningStep(
step_index=3,
step_kind="conclusion",
input_pointers=case_ids,
claim=(
f"Propose a {change_kind!r} structural change covering "
f"{len(evidence)} evidence rows."
),
justification=(
"Evidence-only proposal; the wrong=0 surface ratification "
"handler decides whether to apply, not this decomposer."
),
output_payload={
"proposed_change_kind": change_kind,
"evidence_count": len(evidence),
},
)
return build_trace(
(observation, grouping, hypothesis, conclusion)
)
def _build_proposal_for_group(
*,
refusal_reason: str,
missing_operator: str,
evidence: tuple[MathReaderRefusalEvidence, ...],
) -> MathReaderRefusalShapeProposal:
"""Assemble one :class:`MathReaderRefusalShapeProposal` for a group."""
change_kind = _change_kind_for_group(refusal_reason, missing_operator)
payload = _modal_anchor_payload(
refusal_reason=refusal_reason,
missing_operator=missing_operator,
evidence=evidence,
)
trace = _build_reasoning_trace(
refusal_reason=refusal_reason,
missing_operator=missing_operator,
evidence=evidence,
change_kind=change_kind,
)
replay_seed = json.dumps(
{
"evidence_hashes": sorted(ev.evidence_hash for ev in evidence),
"missing_operator": missing_operator,
"refusal_reason": refusal_reason,
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
replay_equivalence_hash = hashlib.sha256(replay_seed).hexdigest()
structural_commonality = (
f"{len(evidence)} refusals share "
f"refusal_reason={refusal_reason!r}"
f"missing_operator={missing_operator!r}"
)
wrong_zero_assertion = (
"Proposal is evidence-only; ratification handler is the wrong=0 "
"surface, not this proposal."
)
# Structural gap (ADR-0172 tightening follow-up #3): the evidence record
# carries `sub_type` but not `shape_category`, so the proposal cannot
# derive shape_category from the evidence today. All groups emit
# UNCATEGORIZED until a future wave adds shape_category to
# MathReaderRefusalEvidence (or the reader's ShapeCategory inference is
# plumbed through the audit row). Do NOT invent a shape_category here.
return build_proposal(
shape_category=ShapeCategory.UNCATEGORIZED,
structural_commonality=structural_commonality,
evidence_pointers=evidence,
proposed_change_kind=change_kind,
proposed_change_payload=payload,
wrong_zero_assertion=wrong_zero_assertion,
replay_equivalence_hash=replay_equivalence_hash,
reasoning_trace=trace,
)
def decompose_audit(
audit_path: Path,
) -> tuple[MathReaderRefusalShapeProposal, ...]:
"""Decompose an audit brief into refusal-shape proposals.
Read ``audit_path`` (expected schema: ``audit_brief_11.json``), group
``per_case`` refusal rows by ``(refusal_reason, missing_operator)``,
and emit one :class:`MathReaderRefusalShapeProposal` per group with
≥2 evidence rows. Each proposal carries a 4-step
:class:`ReasoningTrace` (observation → grouping → hypothesis →
conclusion).
Determinism contract
--------------------
- Group iteration order is sorted by ``(refusal_reason,
missing_operator)``.
- Evidence per group is sorted by ``case_id``.
- Output tuple is sorted by ``proposal_id``.
- The same input file produces a byte-identical proposal stream
across every rerun.
Trust boundary
--------------
Pure read-only. ``audit_path`` is read once; no file is written.
Decomposer is teaching-layer code only — does not import from
``chat``/``field``/``generate.stream``/``algebra``.
"""
raw = audit_path.read_text(encoding="utf-8")
data = json.loads(raw)
per_case = data.get("per_case", []) or []
rows: list[AuditRow] = []
for case in per_case:
if not isinstance(case, dict):
continue
if not case.get("case_id"):
continue
rows.append(_audit_row_from_case(case))
evidence_records = audit_to_evidence(rows)
groups: dict[tuple[str, str], list[MathReaderRefusalEvidence]] = {}
for ev in evidence_records:
if ev.missing_operator is None:
continue
key = (ev.refusal_reason, ev.missing_operator)
groups.setdefault(key, []).append(ev)
proposals: list[MathReaderRefusalShapeProposal] = []
for key in sorted(groups.keys()):
refusal_reason, missing_operator = key
group_evs = tuple(sorted(groups[key], key=lambda e: e.case_id))
if len(group_evs) < 2:
continue
proposals.append(
_build_proposal_for_group(
refusal_reason=refusal_reason,
missing_operator=missing_operator,
evidence=group_evs,
)
)
return tuple(sorted(proposals, key=lambda p: p.proposal_id))
__all__ = [
"audit_to_evidence",
"audit_problem_to_evidence",
"decompose_audit",
]