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
This commit is contained in:
Shay 2026-06-17 18:14:24 -07:00 committed by GitHub
parent 05c5cfa268
commit 65405f1128
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 982 additions and 12 deletions

View file

@ -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

View file

@ -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"))

View file

@ -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,

View file

@ -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"])

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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",
]

View file

@ -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<actor>[A-Z][a-zA-Z]+|She|He|They|It)
\s+
(?P<verb>{_UNIT_PARTITION_VERB_RE})
(?:\s+\w+){{0,4}}
\s+
into
\s+
(?P<chunk_size>\d+)
\s*-\s*
(?P<chunk_unit>foot|feet|inch|inches|yard|yards|meter|meters)
(?:\s+(?P<counted_noun>sections?|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,
}

View file

@ -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
),
},
}

View file

@ -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}}

View file

@ -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,
}

View file

@ -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"}}

View file

@ -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,
}

View file

@ -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():

View file

@ -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"

View file

@ -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

View file

@ -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