core/teaching/recognizer_synthesis.py
Shay 65405f1128
feat(derivation): Gate A2a unit partition injection (#809)
* feat(derivation): Gate A2a unit partition injection

Add typed unit_partition primitive with PartitionChunk/result_unit
contract, recognizer-injector bridge, DCS yield guard, and pronoun
lookback support. Closes unit_partition recognized_no_injection on live
train_sample (0002 partition stmt reclassifies); wrong=0 preserved.

* test(gsm8k): harden unit partition confusers

* test(gsm8k): add unit partition pronoun safety regressions

* chore(gsm8k): fix unit partition exemplar file ending

* chore(derivation): type unit partition solution step operand
2026-06-17 18:14:24 -07:00

525 lines
21 KiB
Python
Raw Permalink 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-0163 Phase C — admissibility recognizer synthesis.
Distill an :class:`~teaching.exemplar_ingest.ExemplarCorpus` into one
:class:`RecognizerSpec`: a typed shape specification consumed downstream
by the Phase D / Phase E candidate-graph admissibility surface.
Doctrine (non-negotiable)
- Deterministic: same corpus → same :class:`RecognizerSpec`,
byte-identical when re-serialized.
- Narrower, not broader, than the seeds. Observed-only sub-shapes are
named explicitly; the recognizer does not generalize to currency
symbols, window units, or per-unit measures the seeds never carried.
- Doctrine-compatible with Phase B author_notes. Each author_note is
either honored by a per-category branch *or* surfaced in
``canonical_pattern.unresolved_notes`` for Phase D review — never
silently dropped.
- No hidden normalization. Seed strings flow through verbatim.
The module is pure: rules-only, no LLM call, no embedding, no learned
classifier, no I/O beyond reading the supplied corpus dataclass.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Mapping
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from teaching.exemplar_ingest import Exemplar, ExemplarCorpus
class RecognizerSynthesisError(ValueError):
"""Raised when a corpus is structurally unsynthesizable."""
@dataclass(frozen=True, slots=True)
class RecognizerSpec:
"""The distilled, narrowest commitment that subsumes every seed.
Phase C produces the spec. Phase D's review surface is where the
operator may choose to widen any ``observed_*`` set. Phase E's
measurement re-runs the GSM8K + capability lanes with the widened
recognizer to verify ``wrong = 0`` still holds.
``canonical_pattern`` is the load-bearing field. Its keys are
per-category bespoke; consumers MUST branch on ``shape_category``
before reading.
"""
shape_category: ShapeCategory
canonical_pattern: Mapping[str, Any]
exemplar_count: int
exemplar_digest: str
coverage: Mapping[str, int]
def canonical_bytes(self) -> bytes:
"""Canonical sorted-key JSON bytes — what the proposal_id hashes."""
payload = {
"shape_category": self.shape_category.value,
"canonical_pattern": _as_jsonable(self.canonical_pattern),
"exemplar_count": self.exemplar_count,
"exemplar_digest": self.exemplar_digest,
"coverage": dict(self.coverage),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def spec_digest(self) -> str:
"""sha256 over :meth:`canonical_bytes`; identifies the spec."""
return hashlib.sha256(self.canonical_bytes()).hexdigest()
def as_dict(self) -> dict[str, Any]:
return {
"shape_category": self.shape_category.value,
"canonical_pattern": _as_jsonable(self.canonical_pattern),
"exemplar_count": self.exemplar_count,
"exemplar_digest": self.exemplar_digest,
"coverage": dict(self.coverage),
}
def _as_jsonable(payload: Any) -> Any:
"""Recursively coerce mappings/sequences to JSON-serializable dicts/lists.
Tuples become lists; frozensets become sorted lists. Used so the
``canonical_pattern`` mapping's value tree round-trips byte-identically
through :func:`json.dumps(sort_keys=True)`.
"""
if isinstance(payload, Mapping):
return {k: _as_jsonable(v) for k, v in payload.items()}
if isinstance(payload, (list, tuple)):
return [_as_jsonable(v) for v in payload]
if isinstance(payload, (set, frozenset)):
return sorted(_as_jsonable(v) for v in payload)
return payload
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _collect_author_notes(exemplars: tuple[Exemplar, ...]) -> list[str]:
"""Deduplicated, sorted author_notes — Phase B operator surface."""
notes: set[str] = set()
for ex in exemplars:
note = ex.author_note
if note:
notes.add(note)
return sorted(notes)
def _sorted_unique(values: list[Any]) -> list[Any]:
seen: set[Any] = set()
out: list[Any] = []
for v in sorted(values, key=lambda x: str(x)):
if v not in seen:
seen.add(v)
out.append(v)
return out
# ---------------------------------------------------------------------------
# Per-category synthesizers — flat aggregations, no smart generalization
# ---------------------------------------------------------------------------
def _synthesize_descriptive_setup_no_quantity(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All seeds: zero anchors, graph_intent=setup, outcome=inadmissible_by_design.
The recognizer's commitment is exactly that: a statement with no
extractable quantity must be admitted as setup context, not refused.
Narrowness rule: anchor_count is pinned at 0 (no widening).
"""
exemplars = corpus.exemplars
subjects_observed_null = sum(1 for e in exemplars if e.expected_graph.get("subject") is None)
subjects_observed_named = sum(1 for e in exemplars if e.expected_graph.get("subject"))
# Sanity: validator already pinned this; assert defensively.
for ex in exemplars:
if ex.expected_graph["quantity_anchors"] != []:
raise RecognizerSynthesisError(
f"{ex.exemplar_id}: descriptive_setup_no_quantity seed has "
"non-empty anchors — corpus is structurally invalid"
)
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY.value,
"graph_intent": "setup",
"outcome": "inadmissible_by_design",
"quantity_anchor_count": 0,
"subject_is_optional": True,
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_empty": len(exemplars),
"subject_null": subjects_observed_null,
"subject_named": subjects_observed_named,
}
return canonical_pattern, coverage
def _synthesize_temporal_aggregation(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All anchors are event_count_per_window. Capture window axis exactly."""
exemplars = corpus.exemplars
window_units: list[str] = []
window_quantifiers: list[str] = []
anchor_counts: list[int] = []
coverage_units: dict[str, int] = {}
coverage_quantifiers: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
window_units.append(a["window_unit"])
window_quantifiers.append(a["window_quantifier"])
coverage_units[a["window_unit"]] = coverage_units.get(a["window_unit"], 0) + 1
q = a["window_quantifier"]
coverage_quantifiers[q] = coverage_quantifiers.get(q, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.TEMPORAL_AGGREGATION.value,
"graph_intent": "aggregate",
"outcome": "admissible",
"anchor_kind": "event_count_per_window",
"observed_window_units": _sorted_unique(window_units),
"observed_window_quantifiers": _sorted_unique(window_quantifiers),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
# Coverage histogram: per-anchor-kind + per-axis frequencies.
coverage: dict[str, int] = {
"anchors_event_count_per_window": sum(anchor_counts),
}
for unit, n in sorted(coverage_units.items()):
coverage[f"window_unit:{unit}"] = n
for q, n in sorted(coverage_quantifiers.items()):
coverage[f"window_quantifier:{q}"] = n
return canonical_pattern, coverage
def _synthesize_rate_with_currency(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""All anchors are currency_per_unit_rate. Capture currency/unit/kind axes."""
exemplars = corpus.exemplars
currency_symbols: list[str] = []
per_units: list[str] = []
amount_kinds: list[str] = []
anchor_counts: list[int] = []
coverage_currency: dict[str, int] = {}
coverage_per_unit: dict[str, int] = {}
coverage_amount_kind: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
currency_symbols.append(a["currency_symbol"])
per_units.append(a["per_unit"])
amount_kinds.append(a["amount_kind"])
coverage_currency[a["currency_symbol"]] = (
coverage_currency.get(a["currency_symbol"], 0) + 1
)
coverage_per_unit[a["per_unit"]] = coverage_per_unit.get(a["per_unit"], 0) + 1
coverage_amount_kind[a["amount_kind"]] = (
coverage_amount_kind.get(a["amount_kind"], 0) + 1
)
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.RATE_WITH_CURRENCY.value,
"graph_intent": "rate",
"outcome": "admissible",
"anchor_kind": "currency_per_unit_rate",
"observed_currency_symbols": _sorted_unique(currency_symbols),
"observed_per_units": _sorted_unique(per_units),
"observed_amount_kinds": _sorted_unique(amount_kinds),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_currency_per_unit_rate": sum(anchor_counts),
}
for sym, n in sorted(coverage_currency.items()):
coverage[f"currency_symbol:{sym}"] = n
for u, n in sorted(coverage_per_unit.items()):
coverage[f"per_unit:{u}"] = n
for k, n in sorted(coverage_amount_kind.items()):
coverage[f"amount_kind:{k}"] = n
return canonical_pattern, coverage
def _synthesize_discrete_count_statement(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — discrete-count seeds.
Each anchor carries (count_token, count_kind, counted_noun). The
synthesizer records ``observed_count_kinds`` as a narrowness gate
(integer/word); ``observed_counted_nouns`` is coverage-only — gating
on every noun in the seed corpus would over-narrow the matcher
across the GSM8K nominal vocabulary.
"""
exemplars = corpus.exemplars
count_kinds: list[str] = []
counted_nouns: list[str] = []
anchor_counts: list[int] = []
coverage_count_kind: dict[str, int] = {}
coverage_counted_noun: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
ck = a["count_kind"]
noun = a["counted_noun"]
count_kinds.append(ck)
counted_nouns.append(noun)
coverage_count_kind[ck] = coverage_count_kind.get(ck, 0) + 1
coverage_counted_noun[noun] = coverage_counted_noun.get(noun, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.DISCRETE_COUNT_STATEMENT.value,
"graph_intent": "count",
"outcome": "admissible",
"anchor_kind": "discrete_count",
"observed_count_kinds": _sorted_unique(count_kinds),
"observed_counted_nouns": _sorted_unique(counted_nouns),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {"anchors_discrete_count": sum(anchor_counts)}
for k, n in sorted(coverage_count_kind.items()):
coverage[f"count_kind:{k}"] = n
for noun, n in sorted(coverage_counted_noun.items()):
coverage[f"counted_noun:{noun}"] = n
return canonical_pattern, coverage
def _synthesize_multiplicative_aggregation(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — multiplicative-aggregate seeds (``M outer × N inner``).
Multi-anchor cases (joined aggregations like Ella's apples) widen
``anchor_count_max`` naturally.
"""
exemplars = corpus.exemplars
outer_units: list[str] = []
inner_units: list[str] = []
anchor_counts: list[int] = []
coverage_outer: dict[str, int] = {}
coverage_inner: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
ou = a["outer_unit"]
iu = a["inner_unit"]
outer_units.append(ou)
inner_units.append(iu)
coverage_outer[ou] = coverage_outer.get(ou, 0) + 1
coverage_inner[iu] = coverage_inner.get(iu, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.MULTIPLICATIVE_AGGREGATION.value,
"graph_intent": "aggregate",
"outcome": "admissible",
"anchor_kind": "multiplicative_aggregate",
"observed_outer_units": _sorted_unique(outer_units),
"observed_inner_units": _sorted_unique(inner_units),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_multiplicative_aggregate": sum(anchor_counts),
}
for u, n in sorted(coverage_outer.items()):
coverage[f"outer_unit:{u}"] = n
for u, n in sorted(coverage_inner.items()):
coverage[f"inner_unit:{u}"] = n
return canonical_pattern, coverage
def _synthesize_unit_partition(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""Gate A2a — fixed-size measure chunking seeds."""
exemplars = corpus.exemplars
partition_verbs: list[str] = []
chunk_units: list[str] = []
counted_nouns: list[str] = []
anchor_counts: list[int] = []
coverage_verb: dict[str, int] = {}
coverage_unit: dict[str, int] = {}
coverage_noun: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
verb = a["partition_verb_token"]
unit = a["chunk_unit_token"]
noun = a["counted_noun_token"]
partition_verbs.append(verb)
chunk_units.append(unit)
counted_nouns.append(noun)
coverage_verb[verb] = coverage_verb.get(verb, 0) + 1
coverage_unit[unit] = coverage_unit.get(unit, 0) + 1
coverage_noun[noun] = coverage_noun.get(noun, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.UNIT_PARTITION.value,
"graph_intent": "partition",
"outcome": "admissible",
"anchor_kind": "unit_partition",
"observed_partition_verbs": _sorted_unique(partition_verbs),
"observed_chunk_units": _sorted_unique(chunk_units),
"observed_counted_nouns": _sorted_unique(counted_nouns),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_unit_partition": sum(anchor_counts),
}
for token, n in sorted(coverage_verb.items()):
coverage[f"verb:{token}"] = n
for token, n in sorted(coverage_unit.items()):
coverage[f"chunk_unit:{token}"] = n
for token, n in sorted(coverage_noun.items()):
coverage[f"counted_noun:{token}"] = n
return canonical_pattern, coverage
def _synthesize_comparative_with_unit(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""Gate A1 — multiplicative entity comparison seeds."""
exemplars = corpus.exemplars
factor_anchors: list[str] = []
anchor_counts: list[int] = []
coverage_factor: dict[str, int] = {}
has_numeric = False
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
fk = a.get("factor_kind", "anchor")
if fk == "numeric":
has_numeric = True
coverage_factor["numeric"] = coverage_factor.get("numeric", 0) + 1
else:
token = a["factor_token"]
factor_anchors.append(token)
coverage_factor[token] = coverage_factor.get(token, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.COMPARATIVE_WITH_UNIT.value,
"graph_intent": "compare",
"outcome": "admissible",
"anchor_kind": "comparative_multiplicative",
"observed_factor_anchors": _sorted_unique(factor_anchors),
"allows_numeric_factor": has_numeric,
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_comparative_multiplicative": sum(anchor_counts),
}
for token, n in sorted(coverage_factor.items()):
coverage[f"factor:{token}"] = n
return canonical_pattern, coverage
def _synthesize_currency_amount(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — currency-amount seeds.
Distinct from ``rate_with_currency``: NO per-unit framing. The
synthesizer records observed currency symbols + amount kinds as
narrowness gates.
"""
exemplars = corpus.exemplars
currency_symbols: list[str] = []
amount_kinds: list[str] = []
anchor_counts: list[int] = []
coverage_currency: dict[str, int] = {}
coverage_amount_kind: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
cs = a["currency_symbol"]
ak = a["amount_kind"]
currency_symbols.append(cs)
amount_kinds.append(ak)
coverage_currency[cs] = coverage_currency.get(cs, 0) + 1
coverage_amount_kind[ak] = coverage_amount_kind.get(ak, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.CURRENCY_AMOUNT.value,
"graph_intent": "amount",
"outcome": "admissible",
"anchor_kind": "currency_amount",
"observed_currency_symbols": _sorted_unique(currency_symbols),
"observed_amount_kinds": _sorted_unique(amount_kinds),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {"anchors_currency_amount": sum(anchor_counts)}
for sym, n in sorted(coverage_currency.items()):
coverage[f"currency_symbol:{sym}"] = n
for k, n in sorted(coverage_amount_kind.items()):
coverage[f"amount_kind:{k}"] = n
return canonical_pattern, coverage
_SYNTHESIZERS = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _synthesize_descriptive_setup_no_quantity,
ShapeCategory.TEMPORAL_AGGREGATION: _synthesize_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _synthesize_rate_with_currency,
ShapeCategory.DISCRETE_COUNT_STATEMENT: _synthesize_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _synthesize_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _synthesize_currency_amount,
ShapeCategory.COMPARATIVE_WITH_UNIT: _synthesize_comparative_with_unit,
ShapeCategory.UNIT_PARTITION: _synthesize_unit_partition,
}
def synthesize_recognizer(corpus: ExemplarCorpus) -> RecognizerSpec:
"""Distil *corpus* into one :class:`RecognizerSpec`.
Pure function. Per-category dispatch chooses the synthesizer; common
framing (digest, exemplar count) is bolted on uniformly.
"""
synth = _SYNTHESIZERS.get(corpus.shape_category)
if synth is None: # pragma: no cover — defensive: ingest already gates
raise RecognizerSynthesisError(
f"no synthesizer registered for shape_category="
f"{corpus.shape_category.value!r}"
)
canonical_pattern, coverage = synth(corpus)
return RecognizerSpec(
shape_category=corpus.shape_category,
canonical_pattern=canonical_pattern,
exemplar_count=len(corpus.exemplars),
exemplar_digest=corpus.corpus_digest,
coverage=coverage,
)
__all__ = [
"RecognizerSpec",
"RecognizerSynthesisError",
"synthesize_recognizer",
]