From 65405f112890f0ee1aa3bc6fbe6c534f3ceba7bc Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 17 Jun 2026 18:14:24 -0700 Subject: [PATCH] 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 --- ...-a2a-unit-partition-lookback-2026-06-17.md | 102 +++++++++++ .../comprehension/constraint_propagation.py | 26 ++- generate/math_candidate_parser.py | 39 ++++ generate/math_problem_graph.py | 66 ++++++- generate/math_roundtrip.py | 10 + generate/math_solver.py | 66 ++++++- generate/math_verifier.py | 62 +++++++ generate/recognizer_anchor_inject.py | 78 ++++++++ generate/recognizer_match.py | 122 +++++++++++++ .../gsm8k_post_gate_a1_frontier_microscope.py | 3 + .../unit_partition_v1.jsonl | 4 + teaching/exemplar_ingest.py | 37 ++++ teaching/proposals/proposals.jsonl | 3 + teaching/recognizer_synthesis.py | 52 ++++++ tests/test_gsm8k_frontier_report.py | 31 ++++ ..._gsm8k_post_gate_a1_frontier_microscope.py | 14 +- ...andidate_graph_unit_partition_injection.py | 108 +++++++++++ .../test_recognizer_unit_partition_inject.py | 171 ++++++++++++++++++ 18 files changed, 982 insertions(+), 12 deletions(-) create mode 100644 docs/analysis/gsm8k-workstream-a-gate-a2a-unit-partition-lookback-2026-06-17.md create mode 100644 teaching/admissibility_exemplars/unit_partition_v1.jsonl create mode 100644 tests/test_math_candidate_graph_unit_partition_injection.py create mode 100644 tests/test_recognizer_unit_partition_inject.py diff --git a/docs/analysis/gsm8k-workstream-a-gate-a2a-unit-partition-lookback-2026-06-17.md b/docs/analysis/gsm8k-workstream-a-gate-a2a-unit-partition-lookback-2026-06-17.md new file mode 100644 index 00000000..a0ce45d6 --- /dev/null +++ b/docs/analysis/gsm8k-workstream-a-gate-a2a-unit-partition-lookback-2026-06-17.md @@ -0,0 +1,102 @@ +# GSM8K Workstream A Gate A2a — unit partition implementation lookback + +**Date:** 2026-06-17 +**Gate:** A2a — `unit_partition` recognizer-injector + typed solver primitive +**Status:** Implementation complete (PR-ready; not merged) +**Ratification:** `docs/analysis/gsm8k-workstream-a-gate-a2a-unit-partition-ratification-2026-06-17.md` + +--- + +## 1. What shipped + +| Surface | Change | +|---------|--------| +| `PartitionChunk` + `unit_partition` kind | `generate/math_problem_graph.py` | +| `_apply_unit_partition` + pack bind (`divide` lemma) | `generate/math_solver.py` | +| `_verify_unit_partition_step` | `generate/math_verifier.py` | +| Roundtrip + `DIVIDE_VERBS` widen (`cut`, `separate`) | `generate/math_roundtrip.py` | +| `_match_unit_partition`, DCS yield guard | `generate/recognizer_match.py` | +| `inject_unit_partition` | `generate/recognizer_anchor_inject.py` | +| Exemplars + synthesis + accepted proposal | `teaching/admissibility_exemplars/unit_partition_v1.jsonl`, `teaching/recognizer_synthesis.py`, `teaching/proposals/proposals.jsonl` | +| Tests | `tests/test_recognizer_unit_partition_inject.py`, `tests/test_math_candidate_graph_unit_partition_injection.py`, frontier/microscope extensions | + +**Lead exemplar:** case **0002** partition stmt `She splits it up into 25-foot sections.` now matches `ShapeCategory.UNIT_PARTITION` and injects `CandidateOperation(kind="unit_partition")` with `result_unit=sections`. + +**DCS yield:** `_match_discrete_count_statement` returns `None` when `_is_unit_partition_v1_surface` holds — prevents `Initial(25, foot)` misread. + +--- + +## 2. Solid + +- New `unit_partition` kind writes quotient under `result_unit`, not dividend unit — bare `divide` reuse avoided. +- Closed v1 template: partition verb + `into` + single `\d+-(measure)` + optional counted noun. +- Pronoun subject (`She`) emits with `requires_pronoun_resolution`; existing lookback path applies. +- `wrong=0` preserved on live train_sample ephemeral runner; `unit_partition` `recognized_no_injection` = **0**. +- Pinned `report.json` unchanged (6/44/0 historical artifact). + +--- + +## 3. Gaps (no live risk) + +- `graph_intent: "partition"` is new; no separate graph_planner hook (out of v1 scope). +- Pack lemma maps `unit_partition` → existing `divide` entry (semantically division; kind discriminates `result_unit` contract). +- No dedicated `binding_graph` admissibility hook; partition ops reach solver only via injector + roundtrip. + +--- + +## 4. Drift from ratification + +| Ratification claim | Implementation | +|--------------------|----------------| +| `separate` verb | Included in matcher regex + `DIVIDE_VERBS` | +| Actor binding “no cross-sentence pronoun beyond session rules” | Uses existing ADR-0174 lookback; no new binding logic | +| `report.json` rebaseline | Intentionally skipped | + +No amendment required. + +--- + +## 5. Hazards reviewed + +| Hazard | Verdict | +|--------|---------| +| Over-recognition on `\d+-(hour\|foot)` alone | Mitigated: requires verb + `into`; `2-hour drive` does not match `unit_partition` | +| DCS wins race on 0002 | Mitigated: DCS yield returns `None` | +| Quotient stored under `feet` | Mitigated: `PartitionChunk.result_unit` | +| Pseudo-accumulation 996 (confuser-v1-0007) | Full 0002 still refuses; no correct lift claimed | +| Non-exact quotient | Solver + verifier refuse (`SolveError` / `VerificationError`) | + +--- + +## 6. Metric movement (ephemeral live runner) + +| Metric | Before | After (expected) | +|--------|--------|------------------| +| `wrong` | 0 | **0** | +| `correct` | 6 | **≥ 6** (no lift guaranteed) | +| `refused` | 44 | **≤ 44** | +| `unit_partition` no-injection | 1 (0002 via DCS misroute) | **0** | +| `discrete_count_statement` no-injection | 19 | likely **18** (−1 reclassification) | + +Case **0002** partition stmt reclassifies; full solve to 15 remains refused until composition ratification. + +--- + +## 7. Validation run + +```bash +git diff --check origin/main...HEAD +pytest tests/test_recognizer_unit_partition_inject.py -q +pytest tests/test_math_candidate_graph_unit_partition_injection.py -q +pytest tests/test_gsm8k_frontier_report.py -q +pytest tests/test_gsm8k_post_gate_a1_frontier_microscope.py -q +pytest tests/test_candidate_graph_recognizer_wiring.py -q +``` + +--- + +## 8. Explicit non-goals (held) + +- No full 0002 composition, no `report.json` rebaseline, no sealed-lane pin movement +- No Gate A1b, Inc4, broad DCS, `determine()` / `FrameVerdict` / CLOSE +- No `graph_planner.py` changes \ No newline at end of file diff --git a/generate/comprehension/constraint_propagation.py b/generate/comprehension/constraint_propagation.py index 4e2a88b1..e5d30623 100644 --- a/generate/comprehension/constraint_propagation.py +++ b/generate/comprehension/constraint_propagation.py @@ -82,6 +82,7 @@ VALID_PREDICATE_NAMES: Final[frozenset[str]] = frozenset( "operation.reference_actor_grounds", "operation.operand_shape_consistent", "operation.rate_denominator_grounds", + "operation.partition_result_unit_grounds", } ) @@ -434,7 +435,7 @@ def _check_operation(candidate: object) -> ConstraintResult: sub-check populates the predicates_run trace so the eliminator can record exactly which predicate the candidate failed. """ - from generate.math_problem_graph import Comparison, Quantity, Rate + from generate.math_problem_graph import Comparison, PartitionChunk, Quantity, Rate from generate.math_roundtrip import ( KIND_TO_VERBS, _tokens, _token_in, _value_grounds, _unit_grounds, @@ -604,6 +605,29 @@ def _check_operation(candidate: object) -> ConstraintResult: ), ) run.append(("operation.operand_shape_consistent", "ok")) + elif op.kind == "unit_partition": + if not isinstance(op.operand, PartitionChunk): + run.append(("operation.operand_shape_consistent", "fail")) + return ConstraintResult( + admitted=False, + predicates_run=tuple(run), + elimination_reason=( + "op.kind='unit_partition' requires PartitionChunk " + f"operand; got {type(op.operand).__name__}" + ), + ) + run.append(("operation.operand_shape_consistent", "ok")) + if not _token_in(op.operand.result_unit, haystack): + run.append(("operation.partition_result_unit_grounds", "fail")) + return ConstraintResult( + admitted=False, + predicates_run=tuple(run), + elimination_reason=( + f"PartitionChunk.result_unit " + f"{op.operand.result_unit!r} does not ground" + ), + ) + run.append(("operation.partition_result_unit_grounds", "ok")) else: if not isinstance(op.operand, Quantity): run.append(("operation.operand_shape_consistent", "fail")) diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index e67f78b2..72af818a 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -42,6 +42,7 @@ from generate.math_problem_graph import ( Comparison, InitialPossession, Operation, + PartitionChunk, Quantity, Unknown, ) @@ -1191,6 +1192,44 @@ def _build_compare_additive( return None +def _build_unit_partition( + *, + actor_raw: str, + chunk_size: float, + chunk_unit_raw: str, + result_unit_raw: str, + matched_verb: str, + matched_value_token: str, + source: str, +) -> CandidateOperation | None: + actor = _normalize_entity(actor_raw) + chunk_unit = _canonicalize_unit(chunk_unit_raw) + result_unit = _canonicalize_unit(result_unit_raw) + try: + op = Operation( + actor=actor, + kind="unit_partition", + operand=PartitionChunk( + value=chunk_size, + unit=chunk_unit, + result_unit=result_unit, + ), + ) + except Exception: + return None + try: + return CandidateOperation( + op=op, + source_span=source, + matched_verb=matched_verb, + matched_value_token=matched_value_token, + matched_unit_token=chunk_unit_raw, + matched_actor_token=actor_raw, + ) + except Exception: + return None + + def _build_compare_multiplicative( *, actor_raw: str, diff --git a/generate/math_problem_graph.py b/generate/math_problem_graph.py index e58be6d5..231e61d4 100644 --- a/generate/math_problem_graph.py +++ b/generate/math_problem_graph.py @@ -34,6 +34,7 @@ VALID_OPERATION_KINDS: Final[frozenset[str]] = frozenset( "apply_rate", "compare_additive", "compare_multiplicative", + "unit_partition", } ) @@ -123,6 +124,54 @@ class Rate: } +@dataclass(frozen=True, slots=True) +class PartitionChunk: + """Fixed-size chunk measure for unit_partition (Gate A2a). + + ``PartitionChunk(25, "feet", "sections")`` means "split the actor's + total in ``unit`` into chunks of size 25, writing the integer chunk + count under ``result_unit``". ``value`` is the chunk size (divisor); + ``unit`` is the measure unit shared with the prior total; ``result_unit`` + is the count noun for the quotient (not the dividend unit). + """ + + value: int | float + unit: str + result_unit: str + + def __post_init__(self) -> None: + if not isinstance(self.value, (int, float)) or isinstance(self.value, bool): + raise MathGraphError( + f"PartitionChunk.value must be int or float, got " + f"{type(self.value).__name__}" + ) + if self.value <= 0: + raise MathGraphError( + f"PartitionChunk.value must be strictly positive; got {self.value!r}" + ) + if not isinstance(self.unit, str) or not self.unit: + raise MathGraphError( + f"PartitionChunk.unit must be a non-empty string, got {self.unit!r}" + ) + if not isinstance(self.result_unit, str) or not self.result_unit: + raise MathGraphError( + f"PartitionChunk.result_unit must be a non-empty string, got " + f"{self.result_unit!r}" + ) + if self.unit == self.result_unit: + raise MathGraphError( + f"PartitionChunk.unit and PartitionChunk.result_unit must differ; " + f"got {self.unit!r} for both" + ) + + def as_json(self) -> dict[str, Any]: + return { + "result_unit": self.result_unit, + "unit": self.unit, + "value": self.value, + } + + @dataclass(frozen=True, slots=True) class Comparison: """A comparison between two actors' quantities (ADR-0123). @@ -230,7 +279,7 @@ class Operation: actor: str kind: str - operand: "Quantity | Rate | Comparison" + operand: "Quantity | Rate | Comparison | PartitionChunk" target: str | None = None def __post_init__(self) -> None: @@ -247,6 +296,12 @@ class Operation: "Operation.operand must be a Rate when kind='apply_rate'; " f"got {type(self.operand).__name__}" ) + elif self.kind == "unit_partition": + if not isinstance(self.operand, PartitionChunk): + raise MathGraphError( + "Operation.operand must be a PartitionChunk when " + f"kind='unit_partition'; got {type(self.operand).__name__}" + ) elif self.kind in ("compare_additive", "compare_multiplicative"): if not isinstance(self.operand, Comparison): raise MathGraphError( @@ -465,13 +520,14 @@ def graph_from_dict(d: Mapping[str, Any]) -> MathProblemGraph: def _operand_from_dict( kind: str, operand: Mapping[str, Any] -) -> "Quantity | Rate | Comparison": +) -> "Quantity | Rate | Comparison | PartitionChunk": """Reconstruct an Operation.operand from its canonical JSON form. Dispatches on ``kind``: - ``apply_rate`` → ``Rate`` (ADR-0122) - ``compare_additive`` / ``compare_multiplicative`` → ``Comparison`` (ADR-0123) + - ``unit_partition`` → ``PartitionChunk`` (Gate A2a) - every other kind → ``Quantity`` Payload shapes are structurally distinct (``Rate`` has @@ -504,4 +560,10 @@ def _operand_from_dict( factor=operand.get("factor"), direction=operand["direction"], ) + if kind == "unit_partition": + return PartitionChunk( + value=operand["value"], + unit=operand["unit"], + result_unit=operand["result_unit"], + ) return Quantity(value=operand["value"], unit=operand["unit"]) diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index b50a7c28..a0eae203 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -128,6 +128,8 @@ DIVIDE_VERBS: Final[frozenset[str]] = frozenset({ "split", "splits", "split", "divide", "divides", "divided", "share", "shares", "shared", + "cut", "cuts", "cutting", + "separate", "separates", "separated", }) # Comparison "verbs" — the surface anchor for compare_additive / @@ -163,6 +165,7 @@ KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = { "apply_rate": RATE_ANCHORS, "compare_additive": COMPARE_ADDITIVE_ANCHORS, "compare_multiplicative": COMPARE_MULTIPLICATIVE_ANCHORS, + "unit_partition": DIVIDE_VERBS, } @@ -500,6 +503,13 @@ def roundtrip_admissible(c: CandidateOperation) -> bool: elif c.op.kind in ("compare_additive", "compare_multiplicative"): if not isinstance(c.op.operand, Comparison): return False + elif c.op.kind == "unit_partition": + from generate.math_problem_graph import PartitionChunk + + if not isinstance(c.op.operand, PartitionChunk): + return False + if not _token_in(c.op.operand.result_unit, haystack): + return False else: if not isinstance(c.op.operand, Quantity): return False diff --git a/generate/math_solver.py b/generate/math_solver.py index 43e4432f..c12fbee1 100644 --- a/generate/math_solver.py +++ b/generate/math_solver.py @@ -36,6 +36,7 @@ from generate.math_problem_graph import ( Comparison, MathProblemGraph, Operation, + PartitionChunk, Quantity, Rate, Unknown, @@ -57,6 +58,7 @@ _OPERATION_REQUIRED_LEMMAS: dict[str, str] = { "apply_rate": "apply_rate", "compare_additive": "compare_additive", "compare_multiplicative": "compare_multiplicative", + "unit_partition": "divide", } @@ -88,7 +90,7 @@ class SolutionStep: operation_kind: str pack_lemma_id: str actor: str - operand: "Quantity | Rate | Comparison" + operand: "Quantity | Rate | Comparison | PartitionChunk" target: str | None before_value: float after_value: float @@ -239,6 +241,8 @@ def _apply( return _apply_compare_additive(op, index, state, pack_bindings) if op.kind == "compare_multiplicative": return _apply_compare_multiplicative(op, index, state, pack_bindings) + if op.kind == "unit_partition": + return _apply_unit_partition(op, index, state, pack_bindings) if not isinstance(op.operand, Quantity): raise SolveError( @@ -419,6 +423,66 @@ def _apply_compare_additive( ) +def _apply_unit_partition( + op: Operation, + index: int, + state: dict[tuple[str, str], float], + pack_bindings: Mapping[str, str], +) -> SolutionStep: + """Apply a fixed-size unit partition (Gate A2a). + + Reads ``(actor, chunk.unit)`` from prior state, requires an exact + integer quotient, and writes ``(actor, chunk.result_unit)``. + The dividend-unit quantity is preserved (partition is derived state). + """ + if not isinstance(op.operand, PartitionChunk): + raise SolveError( + f"unit_partition at step {index} requires a " + f"PartitionChunk operand; got {type(op.operand).__name__}" + ) + chunk = op.operand + dividend_key = (op.actor, chunk.unit) + if dividend_key not in state: + raise SolveError( + f"unit_partition at step {index} requires actor {op.actor!r} " + f"to hold a quantity in {chunk.unit!r}, but no such state exists" + ) + before = state[dividend_key] + chunk_size = float(chunk.value) + if chunk_size == 0: + raise SolveError( + f"unit_partition at step {index} refuses zero chunk size" + ) + quotient = before / chunk_size + if abs(quotient - round(quotient)) > 1e-9 or quotient <= 0: + raise SolveError( + f"unit_partition at step {index} requires an exact positive " + f"integer quotient; got {quotient!r} from {before!r} / " + f"{chunk_size!r}" + ) + after = float(int(round(quotient))) + result_key = (op.actor, chunk.result_unit) + if result_key in state: + raise SolveError( + f"unit_partition at step {index} would overwrite existing state " + f"for ({op.actor!r}, {chunk.result_unit!r}); refuse rather than " + f"silently redeclare" + ) + state[result_key] = after + return SolutionStep( + step_index=index, + operation_kind=op.kind, + pack_lemma_id=pack_bindings[op.kind], + actor=op.actor, + operand=chunk, + target=None, + before_value=before, + after_value=after, + target_before=None, + target_after=None, + ) + + def _apply_compare_multiplicative( op: Operation, index: int, diff --git a/generate/math_verifier.py b/generate/math_verifier.py index c71a2f52..4e7e4688 100644 --- a/generate/math_verifier.py +++ b/generate/math_verifier.py @@ -40,6 +40,7 @@ from typing import Any from generate.math_problem_graph import ( Comparison, MathProblemGraph, + PartitionChunk, Quantity, Rate, Unknown, @@ -249,6 +250,9 @@ def _verify_step(step: SolutionStep, state: dict[tuple[str, str], float]) -> Non if step.operation_kind == "compare_multiplicative": _verify_compare_multiplicative_step(step, state) return + if step.operation_kind == "unit_partition": + _verify_unit_partition_step(step, state) + return if not isinstance(step.operand, Quantity): raise VerificationError( @@ -426,6 +430,64 @@ def _verify_compare_additive_step( state[actor_key] = fresh_after +def _verify_unit_partition_step( + step: SolutionStep, state: dict[tuple[str, str], float] +) -> None: + """Verify a unit_partition step (Gate A2a). + + Re-applies fixed chunk-size division against the dividend-unit + state, requires an exact integer quotient, and writes the count + under ``result_unit``. + """ + if not isinstance(step.operand, PartitionChunk): + raise VerificationError( + f"step {step.step_index} kind=unit_partition requires " + f"PartitionChunk operand; got {type(step.operand).__name__}" + ) + chunk = step.operand + dividend_key = (step.actor, chunk.unit) + if dividend_key not in state: + raise VerificationError( + f"step {step.step_index} kind=unit_partition references " + f"({step.actor!r}, {chunk.unit!r}) which is not in verifier state" + ) + fresh_before = state[dividend_key] + if fresh_before != step.before_value: + raise VerificationError( + f"step {step.step_index} declares before_value=" + f"{step.before_value}, verifier computed {fresh_before}" + ) + chunk_size = float(chunk.value) + if chunk_size == 0: + raise VerificationError( + f"step {step.step_index} kind=unit_partition refuses zero chunk size" + ) + quotient = fresh_before / chunk_size + if abs(quotient - round(quotient)) > 1e-9 or quotient <= 0: + raise VerificationError( + f"step {step.step_index} kind=unit_partition requires an exact " + f"positive integer quotient; got {quotient!r}" + ) + fresh_after = float(int(round(quotient))) + if fresh_after != step.after_value: + raise VerificationError( + f"step {step.step_index} declares after_value=" + f"{step.after_value}, verifier computed {fresh_after}" + ) + if step.target is not None: + raise VerificationError( + f"step {step.step_index} kind=unit_partition must not declare " + f"a target; got {step.target!r}" + ) + result_key = (step.actor, chunk.result_unit) + if result_key in state: + raise VerificationError( + f"step {step.step_index} kind=unit_partition would overwrite " + f"existing state for ({step.actor!r}, {chunk.result_unit!r})" + ) + state[result_key] = fresh_after + + def _verify_compare_multiplicative_step( step: SolutionStep, state: dict[tuple[str, str], float] ) -> None: diff --git a/generate/recognizer_anchor_inject.py b/generate/recognizer_anchor_inject.py index 89ad8041..394fb0a9 100644 --- a/generate/recognizer_anchor_inject.py +++ b/generate/recognizer_anchor_inject.py @@ -52,6 +52,7 @@ from generate.math_candidate_parser import ( CandidateInitial, CandidateOperation, _build_compare_multiplicative, + _build_unit_partition, ) from generate.math_problem_graph import ( InitialPossession, @@ -779,6 +780,78 @@ def inject_comparative_multiplicative( return (cand,) +# --------------------------------------------------------------------------- +# Gate A2a — unit_partition → unit_partition (Workstream A) +# --------------------------------------------------------------------------- + + +def inject_unit_partition( + match: RecognizerMatch, + sentence: str, +) -> tuple[InjectorEmission, ...]: + """Narrow injector for ShapeCategory.UNIT_PARTITION. + + Emits ``CandidateOperation(kind="unit_partition")`` when the matcher + published a fully grounded partition anchor and roundtrip admissibility + holds. Pronoun subjects are emitted with the surface pronoun; the + candidate-graph lookback path resolves them to a discourse antecedent. + """ + if not match.parsed_anchors or len(match.parsed_anchors) != 1: + return () + + anchor = match.parsed_anchors[0] + if not isinstance(anchor, dict): + return () + if anchor.get("kind") != "unit_partition": + return () + + actor_token = anchor.get("actor_token") + chunk_size_token = anchor.get("chunk_size_token") + chunk_unit_token = anchor.get("chunk_unit_token") + counted_noun_token = anchor.get("counted_noun_token") + partition_verb_token = anchor.get("partition_verb_token") + + if not all( + isinstance(v, str) and v + for v in ( + actor_token, + chunk_size_token, + chunk_unit_token, + counted_noun_token, + partition_verb_token, + ) + ): + return () + + if not chunk_size_token.isdigit(): + return () + chunk_size = int(chunk_size_token) + if chunk_size <= 0: + return () + + requires_pronoun = bool(anchor.get("requires_pronoun_resolution")) + if not requires_pronoun: + actor = extract_proper_noun_subject(sentence) + if not actor or actor != actor_token: + return () + bound_actor = actor_token + else: + bound_actor = actor_token + + cand = _build_unit_partition( + actor_raw=bound_actor, + chunk_size=float(chunk_size), + chunk_unit_raw=chunk_unit_token, + result_unit_raw=counted_noun_token, + matched_verb=partition_verb_token, + matched_value_token=chunk_size_token, + source=sentence, + ) + if cand is None or not roundtrip_admissible(cand): + return () + return (cand,) + + _INJECTORS: Mapping[ShapeCategory, "type"] = { ShapeCategory.DISCRETE_COUNT_STATEMENT: inject_discrete_count_statement, # type: ignore[dict-item] # WAVE-A — multiplicative_aggregation now has a per-category @@ -798,6 +871,10 @@ _INJECTORS: Mapping[ShapeCategory, "type"] = { # CandidateOperation(kind="compare_multiplicative") for the closed # v1 multiplicative entity-comparison template family. ShapeCategory.COMPARATIVE_WITH_UNIT: inject_comparative_multiplicative, # type: ignore[dict-item] + # Gate A2a (Workstream A) — unit_partition emits + # CandidateOperation(kind="unit_partition") for fixed-size measure + # chunking with explicit chunk-size unit and result_unit contract. + ShapeCategory.UNIT_PARTITION: inject_unit_partition, # type: ignore[dict-item] # All other recognizer categories continue to route to the # empty-tuple fallback (explicit "recognizer matched but produced # no injection" refusal in the candidate-graph). That is the @@ -834,4 +911,5 @@ __all__ = [ "inject_discrete_count_statement", "inject_rate_with_currency", "inject_comparative_multiplicative", + "inject_unit_partition", ] diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 16fe462f..92c29c1c 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -814,6 +814,10 @@ def _match_discrete_count_statement( # COMPARATIVE_WITH_UNIT instead of detection-only DCS fallback. if _is_comparative_multiplicative_v1_surface(statement): return None + # Gate A2a — yield unit-partition surfaces to UNIT_PARTITION instead + # of detection-only DCS misread (Initial(chunk_size, material_unit)). + if _is_unit_partition_v1_surface(statement): + return None anchor = _try_extract_discrete_count_anchor(statement, padded, spec) if anchor is not None: @@ -1969,6 +1973,123 @@ def _match_comparative_with_unit( return ((anchor,), "compare") +# --------------------------------------------------------------------------- +# Gate A2a — unit_partition → unit_partition (Workstream A) +# --------------------------------------------------------------------------- + +_UNIT_PARTITION_VERB_RE: Final[str] = ( + r"(?:split|splits|divide|divides|divided|cut|cuts|cutting|" + r"separate|separates|separated)" +) + +_UNIT_PARTITION_ANCHOR_RE: Final[re.Pattern[str]] = re.compile( + rf"""(?ix) + ^\s* + (?P[A-Z][a-zA-Z]+|She|He|They|It) + \s+ + (?P{_UNIT_PARTITION_VERB_RE}) + (?:\s+\w+){{0,4}} + \s+ + into + \s+ + (?P\d+) + \s*-\s* + (?Pfoot|feet|inch|inches|yard|yards|meter|meters) + (?:\s+(?Psections?|pieces?|parts?))? + \s*\.?\s*$ + """ +) + + +def _is_unit_partition_v1_surface(statement: str) -> bool: + """True when *statement* matches the Gate A2a closed partition template.""" + s = statement.strip() + if _UNIT_PARTITION_ANCHOR_RE.match(s) is None: + return False + if len(re.findall(r"\d+", s)) != 1: + return False + return True + + +def _default_unit_partition_result_noun(chunk_unit: str) -> str: + return "pieces" + + +def _try_extract_unit_partition_anchor( + statement: str, + spec: Mapping[str, Any], +) -> Mapping[str, Any] | None: + """Extract one unit_partition anchor when narrowness holds.""" + s = statement.strip() + observed_verbs = set(spec.get("observed_partition_verbs") or ()) + observed_units = set(spec.get("observed_chunk_units") or ()) + observed_nouns = set(spec.get("observed_counted_nouns") or ()) + if not observed_verbs or not observed_units: + return None + + m = _UNIT_PARTITION_ANCHOR_RE.match(s) + if m is None: + return None + + if len(re.findall(r"\d+", s)) != 1: + return None + + actor_token = m.group("actor") + verb_token = m.group("verb").lower() + if verb_token not in observed_verbs: + return None + + chunk_size_token = m.group("chunk_size") + chunk_unit_token = m.group("chunk_unit").lower() + if chunk_unit_token not in observed_units: + return None + + counted_noun_token = m.group("counted_noun") + if counted_noun_token is not None: + noun_lc = counted_noun_token.lower() + if observed_nouns and noun_lc not in {n.lower() for n in observed_nouns}: + return None + result_noun = counted_noun_token + else: + if observed_nouns: + result_noun = _default_unit_partition_result_noun(chunk_unit_token) + else: + result_noun = _default_unit_partition_result_noun(chunk_unit_token) + + requires_pronoun_resolution = actor_token.lower() in _REFUSED_SUBJECT_TOKENS + + anchor: dict[str, Any] = { + "kind": "unit_partition", + "actor_token": actor_token, + # ADR-0174 lookback reads subject_role for pronoun resolution. + "subject_role": actor_token, + "chunk_size_token": chunk_size_token, + "chunk_unit_token": chunk_unit_token, + "counted_noun_token": result_noun, + "partition_verb_token": verb_token, + "source_span": s, + } + if requires_pronoun_resolution: + anchor["requires_pronoun_resolution"] = True + return anchor + + +def _match_unit_partition( + statement: str, spec: Mapping[str, Any] +) -> tuple[tuple[Mapping[str, Any], ...], Literal["partition"]] | None: + """Gate A2a — fixed-size measure chunking with explicit quotient.""" + if spec.get("anchor_kind") != "unit_partition": + return None + anchor = _try_extract_unit_partition_anchor(statement, spec) + if anchor is None: + return None + cmin = int(spec.get("anchor_count_min", 1)) + cmax = int(spec.get("anchor_count_max", 1)) + if not (cmin <= 1 <= cmax): + return None + return ((anchor,), "partition") + + _MATCHERS: Final[dict[ShapeCategory, Any]] = { ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _match_descriptive_setup_no_quantity, ShapeCategory.TEMPORAL_AGGREGATION: _match_temporal_aggregation, @@ -1977,6 +2098,7 @@ _MATCHERS: Final[dict[ShapeCategory, Any]] = { ShapeCategory.MULTIPLICATIVE_AGGREGATION: _match_multiplicative_aggregation, ShapeCategory.CURRENCY_AMOUNT: _match_currency_amount, ShapeCategory.COMPARATIVE_WITH_UNIT: _match_comparative_with_unit, + ShapeCategory.UNIT_PARTITION: _match_unit_partition, } diff --git a/scripts/gsm8k_post_gate_a1_frontier_microscope.py b/scripts/gsm8k_post_gate_a1_frontier_microscope.py index e6b2e1a6..52b6c04e 100644 --- a/scripts/gsm8k_post_gate_a1_frontier_microscope.py +++ b/scripts/gsm8k_post_gate_a1_frontier_microscope.py @@ -518,6 +518,9 @@ def build_microscope_report( "comparative_with_unit_no_injection": recognized_by_cat.get( "comparative_with_unit", 0 ), + "unit_partition_no_injection": recognized_by_cat.get( + "unit_partition", 0 + ), }, } diff --git a/teaching/admissibility_exemplars/unit_partition_v1.jsonl b/teaching/admissibility_exemplars/unit_partition_v1.jsonl new file mode 100644 index 00000000..97784cf3 --- /dev/null +++ b/teaching/admissibility_exemplars/unit_partition_v1.jsonl @@ -0,0 +1,4 @@ +{"exemplar_id": "up-v1-0001", "shape_category": "unit_partition", "statement": "She splits it up into 25-foot sections.", "expected_graph": {"subject": "She", "quantity_anchors": [{"kind": "unit_partition", "subject_role": "She", "chunk_size_token": "25", "chunk_unit_token": "foot", "counted_noun_token": "sections", "partition_verb_token": "splits"}], "graph_intent": "partition", "outcome": "admissible"}, "provenance": {"source": "gate_a2a_seed", "author": "Grok (Gate A2a)", "round": 1, "category_rank": 1, "train_case_id": "gsm8k-train-sample-v1-0002"}} +{"exemplar_id": "up-v1-0002", "shape_category": "unit_partition", "statement": "Dana cuts the ribbon into 20-inch pieces.", "expected_graph": {"subject": "Dana", "quantity_anchors": [{"kind": "unit_partition", "subject_role": "Dana", "chunk_size_token": "20", "chunk_unit_token": "inch", "counted_noun_token": "pieces", "partition_verb_token": "cuts"}], "graph_intent": "partition", "outcome": "admissible"}, "provenance": {"source": "gate_a2a_seed", "author": "Grok (Gate A2a)", "round": 1, "category_rank": 1}} +{"exemplar_id": "up-v1-0003", "shape_category": "unit_partition", "statement": "Jan cuts the rope into 4-foot sections.", "expected_graph": {"subject": "Jan", "quantity_anchors": [{"kind": "unit_partition", "subject_role": "Jan", "chunk_size_token": "4", "chunk_unit_token": "foot", "counted_noun_token": "sections", "partition_verb_token": "cuts"}], "graph_intent": "partition", "outcome": "admissible"}, "provenance": {"source": "gate_a2a_seed", "author": "Grok (Gate A2a)", "round": 1, "category_rank": 1}} +{"exemplar_id": "up-v1-0004", "shape_category": "unit_partition", "statement": "Mason splits the cable into 10-meter sections.", "expected_graph": {"subject": "Mason", "quantity_anchors": [{"kind": "unit_partition", "subject_role": "Mason", "chunk_size_token": "10", "chunk_unit_token": "meter", "counted_noun_token": "sections", "partition_verb_token": "splits"}], "graph_intent": "partition", "outcome": "admissible"}, "provenance": {"source": "gate_a2a_seed", "author": "Grok (Gate A2a)", "round": 1, "category_rank": 1}} diff --git a/teaching/exemplar_ingest.py b/teaching/exemplar_ingest.py index 68f564c4..e64662bb 100644 --- a/teaching/exemplar_ingest.py +++ b/teaching/exemplar_ingest.py @@ -63,6 +63,8 @@ _SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({ ShapeCategory.CURRENCY_AMOUNT, # Gate A1 (Workstream A) — multiplicative comparative injection. ShapeCategory.COMPARATIVE_WITH_UNIT, + # Gate A2a (Workstream A) — fixed-size measure chunking injection. + ShapeCategory.UNIT_PARTITION, }) @@ -307,6 +309,40 @@ def _validate_comparative_with_unit(ctx: str, graph: Mapping[str, Any]) -> None: raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'") +def _validate_unit_partition(ctx: str, graph: Mapping[str, Any]) -> None: + anchors = graph["quantity_anchors"] + if not isinstance(anchors, list) or not anchors: + raise ExemplarIngestError(f"{ctx} unit_partition needs ≥1 anchor") + for a in anchors: + if not isinstance(a, Mapping): + raise ExemplarIngestError(f"{ctx} anchor must be a mapping") + _require_keys(ctx, a, frozenset({ + "kind", + "subject_role", + "chunk_size_token", + "chunk_unit_token", + "counted_noun_token", + "partition_verb_token", + })) + if a["kind"] != "unit_partition": + raise ExemplarIngestError( + f"{ctx} anchor kind must be 'unit_partition'" + ) + for fld in ( + "subject_role", + "chunk_size_token", + "chunk_unit_token", + "counted_noun_token", + "partition_verb_token", + ): + if not isinstance(a[fld], str) or not a[fld]: + raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str") + if graph["graph_intent"] != "partition": + raise ExemplarIngestError(f"{ctx} graph_intent must be 'partition'") + if graph["outcome"] != "admissible": + raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'") + + def _validate_currency_amount(ctx: str, graph: Mapping[str, Any]) -> None: anchors = graph["quantity_anchors"] if not isinstance(anchors, list) or not anchors: @@ -348,6 +384,7 @@ _CATEGORY_VALIDATORS = { ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation, ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount, ShapeCategory.COMPARATIVE_WITH_UNIT: _validate_comparative_with_unit, + ShapeCategory.UNIT_PARTITION: _validate_unit_partition, } diff --git a/teaching/proposals/proposals.jsonl b/teaching/proposals/proposals.jsonl index 70482376..a8809403 100644 --- a/teaching/proposals/proposals.jsonl +++ b/teaching/proposals/proposals.jsonl @@ -82,3 +82,6 @@ {"event":"replay","proposal_id":"bec14058b9afbb76216414e903106ae9","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":1.0,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":1.0,"versor_closure_rate":1.0},"capability_axes":{"G1_verb_classes":{"correct":20,"refused":0,"wrong":0},"G2_comparatives":{"correct":29,"refused":0,"wrong":0},"G3_numerics":{"correct":20,"refused":6,"wrong":0},"G4_multi_clause":{"correct":32,"refused":0,"wrong":0},"G5_aggregate":{"correct":20,"refused":0,"wrong":0},"S1_rate_events":{"correct":20,"refused":0,"wrong":0}},"gsm8k_train_sample":{"correct":6,"refused":44,"wrong":0},"regressed_metrics":[],"replay_equivalent":true,"wrong_count_delta":0}} {"event":"transition","note":"Gate A1 ratification 2026-06-17","proposal_id":"bec14058b9afbb76216414e903106ae9","to":"accepted"} {"chain_id":"admissibility_comparative_with_unit_recognizes_f3be480f69b85cff21ff6525d769a92fa21f0ef89dfb5e3af076265b90d5883d","event":"accepted_corpus_append","proposal_id":"bec14058b9afbb76216414e903106ae9","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-06-17","review_date":"2026-06-17","source":"discovery_promoted"}} +{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:up-v1-0001","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:up-v1-0002","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:up-v1-0003","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:up-v1-0004","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:gsm8k-train-sample-v1-0002","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"3ae00e14ec1688b4d1c35a393b8d7f20","proposed_chain":{"connective":"recognizes","intent":"admissibility","object":"47bc4c577fb58821ef2b4622b257e50fafe663d84e81920da894330b12beeb7e","recognizer_spec":{"canonical_pattern":{"anchor_count_max":1,"anchor_count_min":1,"anchor_kind":"unit_partition","graph_intent":"partition","observed_chunk_units":["foot","inch","meter"],"observed_counted_nouns":["pieces","sections"],"observed_partition_verbs":["cuts","splits"],"outcome":"admissible","shape_category":"unit_partition","unresolved_notes":[]},"coverage":{"anchors_unit_partition":4,"chunk_unit:foot":2,"chunk_unit:inch":1,"chunk_unit:meter":1,"counted_noun:pieces":1,"counted_noun:sections":3,"verb:cuts":2,"verb:splits":2},"exemplar_count":4,"exemplar_digest":"dc298d5c7a0781b52432c449f20094a174750027bc820aa2d5eb15c4369633b6","shape_category":"unit_partition"},"subject":"unit_partition"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"gate-a2a-impl","kind":"exemplar_corpus","source_id":"dc298d5c7a0781b52432c449f20094a174750027bc820aa2d5eb15c4369633b6"},"source_candidate_id":"18eee13c7ba3a136216d83dd5f0906cda2e6024d73cc8d7eb9c53efab5b79f1c"}} +{"event":"transition","note":"Gate A2a ratification 2026-06-17","proposal_id":"3ae00e14ec1688b4d1c35a393b8d7f20","to":"accepted"} +{"chain_id":"admissibility_unit_partition_recognizes_47bc4c577fb58821ef2b4622b257e50fafe663d84e81920da894330b12beeb7e","event":"accepted_corpus_append","proposal_id":"3ae00e14ec1688b4d1c35a393b8d7f20","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-06-17","review_date":"2026-06-17","source":"discovery_promoted"}} diff --git a/teaching/recognizer_synthesis.py b/teaching/recognizer_synthesis.py index 5bb2c6b8..d1040203 100644 --- a/teaching/recognizer_synthesis.py +++ b/teaching/recognizer_synthesis.py @@ -347,6 +347,57 @@ def _synthesize_multiplicative_aggregation( 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]]: @@ -441,6 +492,7 @@ _SYNTHESIZERS = { 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, } diff --git a/tests/test_gsm8k_frontier_report.py b/tests/test_gsm8k_frontier_report.py index 0a031a5c..0e256109 100644 --- a/tests/test_gsm8k_frontier_report.py +++ b/tests/test_gsm8k_frontier_report.py @@ -107,6 +107,36 @@ def test_post_inc3_live_runner_has_zero_rate_no_injection(): assert cats.get("rate_with_currency", 0) == 0 +def test_post_gate_a2a_live_runner_has_zero_unit_partition_no_injection(): + """Live train_sample: unit_partition bucket closed at injector.""" + import re + from collections import Counter + + from evals.gsm8k_math.train_sample.v1.runner import build_report + from tests.gsm8k_train_sample_baseline import assert_monotonic_serving_counts + + cases_path = _REPO_ROOT / "evals/gsm8k_math/train_sample/v1/cases.jsonl" + cases = [ + json.loads(line) + for line in cases_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + report = build_report(cases) + assert_monotonic_serving_counts(report["counts"]) + + cats: Counter[str] = Counter() + pat = re.compile(r"category=(\w+)") + for row in report["per_case"]: + reason = row.get("reason") or "" + if "recognizer matched but produced no injection" not in reason: + continue + m = pat.search(reason) + if m: + cats[m.group(1)] += 1 + + assert cats.get("unit_partition", 0) == 0 + + def test_post_gate_a1_live_runner_has_zero_comparative_no_injection(): """Live train_sample: comparative_with_unit bucket closed at injector.""" import re @@ -232,6 +262,7 @@ def test_frontier_report_aligns_with_post_gate_a1_microscope_structure(): assert sum(microscope["top_buckets"].values()) == refused assert microscope["closed_injector_buckets"]["rate_with_currency_no_injection"] == 0 assert microscope["closed_injector_buckets"]["comparative_with_unit_no_injection"] == 0 + assert microscope["closed_injector_buckets"]["unit_partition_no_injection"] == 0 def test_inc3_connector_makes_rate_no_injection_actionable(): diff --git a/tests/test_gsm8k_post_gate_a1_frontier_microscope.py b/tests/test_gsm8k_post_gate_a1_frontier_microscope.py index 5109047a..ca4633c0 100644 --- a/tests/test_gsm8k_post_gate_a1_frontier_microscope.py +++ b/tests/test_gsm8k_post_gate_a1_frontier_microscope.py @@ -54,6 +54,7 @@ def test_live_microscope_meets_monotonic_contract_and_closed_injectors(): closed = summary["closed_injector_buckets"] assert closed["rate_with_currency_no_injection"] == 0 assert closed["comparative_with_unit_no_injection"] == 0 + assert closed["unit_partition_no_injection"] == 0 def test_live_microscope_refusal_partition_is_complete(): @@ -107,15 +108,12 @@ def test_markdown_render_surfaces_partition_candidate(): assert "Gate A2a unit_partition" in md -def test_case_0002_ratification_candidate_fields(): +def test_case_0002_post_gate_a2a_reclassified_off_partition_misroute(): + """After Gate A2a, 0002 refuses downstream (fraction give), not partition no-injection.""" summary = build_microscope_report(_load_cases()) row = next( r for r in summary["refusal_table"] if r["case_id"].endswith("0002") ) - assert row["subfamily"] == "dcs_misroute_unit_partition" - assert row["candidate_next_primitive"] == "unit_partition" - assert row["expected_movement"] == "downstream_reclassification" - assert ( - summary["recommended_next_ratification_candidate"] - == "Gate A2a unit_partition / chunking primitive" - ) + assert "25-foot sections" not in (row.get("reason") or "") + assert summary["closed_injector_buckets"]["unit_partition_no_injection"] == 0 + assert row["top_refusal_bucket"] == "no_admissible_statement" diff --git a/tests/test_math_candidate_graph_unit_partition_injection.py b/tests/test_math_candidate_graph_unit_partition_injection.py new file mode 100644 index 00000000..99ec1859 --- /dev/null +++ b/tests/test_math_candidate_graph_unit_partition_injection.py @@ -0,0 +1,108 @@ +"""Candidate-graph integration for Gate A2a unit_partition injection.""" + +from __future__ import annotations + +import pytest + +from generate.math_candidate_graph import parse_and_solve +from generate.math_problem_graph import Operation, PartitionChunk +from generate.math_solver import SolveError, _apply_unit_partition +from generate.recognizer_anchor_inject import inject_from_match +from generate.recognizer_match import match +from generate.recognizer_registry import load_ratified_registry + + +def _run(text: str): + return parse_and_solve(text, sealed=False) + + +def test_unit_partition_solver_lower_level_integration(): + """Prove unit_partition apply writes chunk count under result_unit.""" + chunk = PartitionChunk(value=25.0, unit="feet", result_unit="sections") + op = Operation(actor="Jan", kind="unit_partition", operand=chunk) + state = {("Jan", "feet"): 1000.0} + pack_bindings = {"unit_partition": "en_arithmetic_v1:divide"} + + step = _apply_unit_partition(op, index=0, state=state, pack_bindings=pack_bindings) + + assert step.operation_kind == "unit_partition" + assert state[("Jan", "sections")] == 40.0 + assert state[("Jan", "feet")] == 1000.0 + + +def test_partition_stmt_injects_on_lead_exemplar_pair(): + registry = load_ratified_registry() + stmt = "She splits it up into 25-foot sections." + m = match(stmt, registry) + assert m is not None + emitted = inject_from_match(m, stmt, sealed=False) + assert len(emitted) == 1 + assert emitted[0].op.kind == "unit_partition" + + +def test_stmt_only_partition_refuses_end_to_end(): + res = _run("She splits it into 25-foot sections.") + assert res.answer is None + assert res.refusal_reason is not None + + +def test_pronoun_partition_refuses_without_antecedent(): + res = _run("She splits it into 25-foot sections. How many sections does she have?") + assert res.answer is None + assert res.refusal_reason is not None + + +def test_pronoun_partition_refuses_multi_actor_ambiguity(): + text = ( + "Jan buys 1000 feet of cable. " + "Bob buys 200 feet of rope. " + "She splits it into 25-foot sections. " + "How many sections does Jan have?" + ) + res = _run(text) + assert res.answer is None + assert res.refusal_reason is not None + + +def test_non_exact_quotient_refuses_at_solver(): + chunk = PartitionChunk(value=30.0, unit="feet", result_unit="sections") + op = Operation(actor="Jan", kind="unit_partition", operand=chunk) + state = {("Jan", "feet"): 1000.0} + pack_bindings = {"unit_partition": "en_arithmetic_v1:divide"} + + with pytest.raises(SolveError): + _apply_unit_partition(op, index=0, state=state, pack_bindings=pack_bindings) + + +def test_unit_mismatch_surface_does_not_solve(): + text = "Jan buys 1000 feet of cable. Jan cuts 1000 feet into 25-inch sections." + res = _run(text) + assert res.answer is None + + +def test_full_0002_still_refuses_without_composition(): + text = ( + "Jan buys 1000 feet of cable. " + "She splits it up into 25-foot sections. " + "She gives 1/4 of that to a friend. " + "She then puts half of the rest in storage. " + "How much does she keep on hand?" + ) + res = _run(text) + assert res.answer is None + assert res.refusal_reason is not None + + +def test_duration_confuser_does_not_inject_unit_partition(): + res = _run("It is a 2-hour drive.") + assert res.answer is None + + +def test_injected_unit_partition_does_not_create_wrong_on_isolated_rate(): + for stmt in [ + "Tina makes $18.00 an hour.", + "Alexa has a lemonade stand where she sells lemonade for $2 for one cup.", + ]: + res = parse_and_solve(stmt, sealed=False) + assert res.answer is None + assert res.refusal_reason is not None diff --git a/tests/test_recognizer_unit_partition_inject.py b/tests/test_recognizer_unit_partition_inject.py new file mode 100644 index 00000000..298f49dc --- /dev/null +++ b/tests/test_recognizer_unit_partition_inject.py @@ -0,0 +1,171 @@ +"""Gate A2a — unit_partition recognizer-anchor injection tests.""" + +from __future__ import annotations + +import types + +import pytest + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.math_candidate_parser import CandidateOperation +from generate.math_problem_graph import PartitionChunk +from generate.math_roundtrip import roundtrip_admissible +from generate.recognizer_anchor_inject import inject_from_match, inject_unit_partition +from generate.recognizer_match import RecognizerMatch, match +from generate.recognizer_registry import load_ratified_registry + + +def _stub_recognizer(category: ShapeCategory) -> types.SimpleNamespace: + return types.SimpleNamespace(shape_category=category, canonical_pattern={}) + + +def _make_match(anchor: dict) -> RecognizerMatch: + return RecognizerMatch( + recognizer=_stub_recognizer(ShapeCategory.UNIT_PARTITION), + category=ShapeCategory.UNIT_PARTITION, + outcome="admissible", + graph_intent="partition", + parsed_anchors=(anchor,), + ) + + +def _anchor( + *, + actor: str = "Jan", + chunk_size: str = "25", + chunk_unit: str = "foot", + counted_noun: str = "sections", + verb: str = "splits", +) -> dict: + return { + "kind": "unit_partition", + "actor_token": actor, + "chunk_size_token": chunk_size, + "chunk_unit_token": chunk_unit, + "counted_noun_token": counted_noun, + "partition_verb_token": verb, + "source_span": f"{actor} {verb} it into {chunk_size}-{chunk_unit} {counted_noun}.", + } + + +@pytest.mark.parametrize( + "sentence,actor,chunk_size,chunk_unit,counted_noun,verb", + [ + ("She splits it up into 25-foot sections.", "She", "25", "foot", "sections", "splits"), + ("Dana cuts the ribbon into 20-inch pieces.", "Dana", "20", "inch", "pieces", "cuts"), + ("Jan cuts the rope into 4-foot sections.", "Jan", "4", "foot", "sections", "cuts"), + ("Mason splits the cable into 10-meter sections.", "Mason", "10", "meter", "sections", "splits"), + ], +) +def test_positive_surfaces_emit_unit_partition( + sentence, actor, chunk_size, chunk_unit, counted_noun, verb +): + registry = load_ratified_registry() + m = match(sentence, registry) + assert m is not None + assert m.category is ShapeCategory.UNIT_PARTITION + emitted = inject_from_match(m, sentence, sealed=False) + assert len(emitted) == 1 + cand = emitted[0] + assert isinstance(cand, CandidateOperation) + assert cand.op.kind == "unit_partition" + assert isinstance(cand.op.operand, PartitionChunk) + assert cand.op.operand.value == float(chunk_size) + assert cand.matched_value_token == chunk_size + assert cand.matched_verb == verb + assert roundtrip_admissible(cand) is True + + +@pytest.mark.parametrize( + "sentence", + [ + "25-foot sections.", + "She splits it into 25 sections.", + "It is a 2-hour drive.", + "Jan cuts the rope into 3-foot sections and 4-foot sections.", + "She splits it into equal sections.", + "She splits it into bags.", + "Half of the kids go to soccer camp.", + "She puts 48 cookies into boxes of 6.", + "999 feet split into 25-foot sections.", + ], +) +def test_unit_partition_confusers_never_inject(sentence: str): + registry = load_ratified_registry() + m = match(sentence, registry) + if m is None: + return + assert inject_from_match(m, sentence, sealed=False) == () + + +@pytest.mark.parametrize( + "sentence", + [ + "Jan buys 1000 feet of cable.", + "Tina makes $18.00 an hour.", + "Alice has twice as many apples as Bob.", + "Bob can shuck 10 oysters in 5 minutes.", + ], +) +def test_legitimate_unrelated_surfaces_do_not_emit_unit_partition(sentence: str): + registry = load_ratified_registry() + m = match(sentence, registry) + if m is None: + return + emitted = inject_from_match(m, sentence, sealed=False) + for candidate in emitted: + if isinstance(candidate, CandidateOperation): + assert candidate.op.kind != "unit_partition" + + +def test_pronoun_anchor_emits_with_resolution_flag(): + registry = load_ratified_registry() + stmt = "She splits it up into 25-foot sections." + m = match(stmt, registry) + assert m is not None + assert m.parsed_anchors[0].get("requires_pronoun_resolution") is True + emitted = inject_from_match(m, stmt, sealed=False) + assert len(emitted) == 1 + + +def test_dispatch_table_routes_unit_partition(): + registry = load_ratified_registry() + stmt = "Jan cuts the rope into 4-foot sections." + m = match(stmt, registry) + assert m is not None + assert m.category is ShapeCategory.UNIT_PARTITION + emitted = inject_from_match(m, stmt, sealed=False) + assert len(emitted) == 1 + assert emitted[0].op.operand.result_unit == "sections" + + +def test_dcs_yields_unit_partition_not_initial_chunk_size(): + registry = load_ratified_registry() + stmt = "She splits it up into 25-foot sections." + m = match(stmt, registry) + assert m is not None + assert m.category is ShapeCategory.UNIT_PARTITION + emitted = inject_from_match(m, stmt, sealed=False) + assert len(emitted) == 1 + assert emitted[0].op.kind == "unit_partition" + + +def test_direct_injector_refuses_malformed_anchor(): + emitted = inject_unit_partition( + _make_match(_anchor(chunk_size="two")), + "Jan splits it into two-foot sections.", + ) + assert emitted == () + + +def test_matched_tokens_ground_in_source_sentence(): + sentence = "Dana cuts the ribbon into 20-inch pieces." + registry = load_ratified_registry() + m = match(sentence, registry) + assert m is not None + emitted = inject_from_match(m, sentence, sealed=False) + assert len(emitted) == 1 + c = emitted[0] + assert c.matched_actor_token in sentence + assert c.matched_value_token in sentence + assert c.matched_unit_token in sentence