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:
parent
05c5cfa268
commit
65405f1128
18 changed files with 982 additions and 12 deletions
|
|
@ -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
|
||||||
|
|
@ -82,6 +82,7 @@ VALID_PREDICATE_NAMES: Final[frozenset[str]] = frozenset(
|
||||||
"operation.reference_actor_grounds",
|
"operation.reference_actor_grounds",
|
||||||
"operation.operand_shape_consistent",
|
"operation.operand_shape_consistent",
|
||||||
"operation.rate_denominator_grounds",
|
"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
|
sub-check populates the predicates_run trace so the eliminator can
|
||||||
record exactly which predicate the candidate failed.
|
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 (
|
from generate.math_roundtrip import (
|
||||||
KIND_TO_VERBS,
|
KIND_TO_VERBS,
|
||||||
_tokens, _token_in, _value_grounds, _unit_grounds,
|
_tokens, _token_in, _value_grounds, _unit_grounds,
|
||||||
|
|
@ -604,6 +605,29 @@ def _check_operation(candidate: object) -> ConstraintResult:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
run.append(("operation.operand_shape_consistent", "ok"))
|
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:
|
else:
|
||||||
if not isinstance(op.operand, Quantity):
|
if not isinstance(op.operand, Quantity):
|
||||||
run.append(("operation.operand_shape_consistent", "fail"))
|
run.append(("operation.operand_shape_consistent", "fail"))
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ from generate.math_problem_graph import (
|
||||||
Comparison,
|
Comparison,
|
||||||
InitialPossession,
|
InitialPossession,
|
||||||
Operation,
|
Operation,
|
||||||
|
PartitionChunk,
|
||||||
Quantity,
|
Quantity,
|
||||||
Unknown,
|
Unknown,
|
||||||
)
|
)
|
||||||
|
|
@ -1191,6 +1192,44 @@ def _build_compare_additive(
|
||||||
return None
|
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(
|
def _build_compare_multiplicative(
|
||||||
*,
|
*,
|
||||||
actor_raw: str,
|
actor_raw: str,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ VALID_OPERATION_KINDS: Final[frozenset[str]] = frozenset(
|
||||||
"apply_rate",
|
"apply_rate",
|
||||||
"compare_additive",
|
"compare_additive",
|
||||||
"compare_multiplicative",
|
"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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Comparison:
|
class Comparison:
|
||||||
"""A comparison between two actors' quantities (ADR-0123).
|
"""A comparison between two actors' quantities (ADR-0123).
|
||||||
|
|
@ -230,7 +279,7 @@ class Operation:
|
||||||
|
|
||||||
actor: str
|
actor: str
|
||||||
kind: str
|
kind: str
|
||||||
operand: "Quantity | Rate | Comparison"
|
operand: "Quantity | Rate | Comparison | PartitionChunk"
|
||||||
target: str | None = None
|
target: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -247,6 +296,12 @@ class Operation:
|
||||||
"Operation.operand must be a Rate when kind='apply_rate'; "
|
"Operation.operand must be a Rate when kind='apply_rate'; "
|
||||||
f"got {type(self.operand).__name__}"
|
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"):
|
elif self.kind in ("compare_additive", "compare_multiplicative"):
|
||||||
if not isinstance(self.operand, Comparison):
|
if not isinstance(self.operand, Comparison):
|
||||||
raise MathGraphError(
|
raise MathGraphError(
|
||||||
|
|
@ -465,13 +520,14 @@ def graph_from_dict(d: Mapping[str, Any]) -> MathProblemGraph:
|
||||||
|
|
||||||
def _operand_from_dict(
|
def _operand_from_dict(
|
||||||
kind: str, operand: Mapping[str, Any]
|
kind: str, operand: Mapping[str, Any]
|
||||||
) -> "Quantity | Rate | Comparison":
|
) -> "Quantity | Rate | Comparison | PartitionChunk":
|
||||||
"""Reconstruct an Operation.operand from its canonical JSON form.
|
"""Reconstruct an Operation.operand from its canonical JSON form.
|
||||||
|
|
||||||
Dispatches on ``kind``:
|
Dispatches on ``kind``:
|
||||||
|
|
||||||
- ``apply_rate`` → ``Rate`` (ADR-0122)
|
- ``apply_rate`` → ``Rate`` (ADR-0122)
|
||||||
- ``compare_additive`` / ``compare_multiplicative`` → ``Comparison`` (ADR-0123)
|
- ``compare_additive`` / ``compare_multiplicative`` → ``Comparison`` (ADR-0123)
|
||||||
|
- ``unit_partition`` → ``PartitionChunk`` (Gate A2a)
|
||||||
- every other kind → ``Quantity``
|
- every other kind → ``Quantity``
|
||||||
|
|
||||||
Payload shapes are structurally distinct (``Rate`` has
|
Payload shapes are structurally distinct (``Rate`` has
|
||||||
|
|
@ -504,4 +560,10 @@ def _operand_from_dict(
|
||||||
factor=operand.get("factor"),
|
factor=operand.get("factor"),
|
||||||
direction=operand["direction"],
|
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"])
|
return Quantity(value=operand["value"], unit=operand["unit"])
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ DIVIDE_VERBS: Final[frozenset[str]] = frozenset({
|
||||||
"split", "splits", "split",
|
"split", "splits", "split",
|
||||||
"divide", "divides", "divided",
|
"divide", "divides", "divided",
|
||||||
"share", "shares", "shared",
|
"share", "shares", "shared",
|
||||||
|
"cut", "cuts", "cutting",
|
||||||
|
"separate", "separates", "separated",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Comparison "verbs" — the surface anchor for compare_additive /
|
# Comparison "verbs" — the surface anchor for compare_additive /
|
||||||
|
|
@ -163,6 +165,7 @@ KIND_TO_VERBS: Final[Mapping[str, frozenset[str]]] = {
|
||||||
"apply_rate": RATE_ANCHORS,
|
"apply_rate": RATE_ANCHORS,
|
||||||
"compare_additive": COMPARE_ADDITIVE_ANCHORS,
|
"compare_additive": COMPARE_ADDITIVE_ANCHORS,
|
||||||
"compare_multiplicative": COMPARE_MULTIPLICATIVE_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"):
|
elif c.op.kind in ("compare_additive", "compare_multiplicative"):
|
||||||
if not isinstance(c.op.operand, Comparison):
|
if not isinstance(c.op.operand, Comparison):
|
||||||
return False
|
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:
|
else:
|
||||||
if not isinstance(c.op.operand, Quantity):
|
if not isinstance(c.op.operand, Quantity):
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ from generate.math_problem_graph import (
|
||||||
Comparison,
|
Comparison,
|
||||||
MathProblemGraph,
|
MathProblemGraph,
|
||||||
Operation,
|
Operation,
|
||||||
|
PartitionChunk,
|
||||||
Quantity,
|
Quantity,
|
||||||
Rate,
|
Rate,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|
@ -57,6 +58,7 @@ _OPERATION_REQUIRED_LEMMAS: dict[str, str] = {
|
||||||
"apply_rate": "apply_rate",
|
"apply_rate": "apply_rate",
|
||||||
"compare_additive": "compare_additive",
|
"compare_additive": "compare_additive",
|
||||||
"compare_multiplicative": "compare_multiplicative",
|
"compare_multiplicative": "compare_multiplicative",
|
||||||
|
"unit_partition": "divide",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -88,7 +90,7 @@ class SolutionStep:
|
||||||
operation_kind: str
|
operation_kind: str
|
||||||
pack_lemma_id: str
|
pack_lemma_id: str
|
||||||
actor: str
|
actor: str
|
||||||
operand: "Quantity | Rate | Comparison"
|
operand: "Quantity | Rate | Comparison | PartitionChunk"
|
||||||
target: str | None
|
target: str | None
|
||||||
before_value: float
|
before_value: float
|
||||||
after_value: float
|
after_value: float
|
||||||
|
|
@ -239,6 +241,8 @@ def _apply(
|
||||||
return _apply_compare_additive(op, index, state, pack_bindings)
|
return _apply_compare_additive(op, index, state, pack_bindings)
|
||||||
if op.kind == "compare_multiplicative":
|
if op.kind == "compare_multiplicative":
|
||||||
return _apply_compare_multiplicative(op, index, state, pack_bindings)
|
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):
|
if not isinstance(op.operand, Quantity):
|
||||||
raise SolveError(
|
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(
|
def _apply_compare_multiplicative(
|
||||||
op: Operation,
|
op: Operation,
|
||||||
index: int,
|
index: int,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ from typing import Any
|
||||||
from generate.math_problem_graph import (
|
from generate.math_problem_graph import (
|
||||||
Comparison,
|
Comparison,
|
||||||
MathProblemGraph,
|
MathProblemGraph,
|
||||||
|
PartitionChunk,
|
||||||
Quantity,
|
Quantity,
|
||||||
Rate,
|
Rate,
|
||||||
Unknown,
|
Unknown,
|
||||||
|
|
@ -249,6 +250,9 @@ def _verify_step(step: SolutionStep, state: dict[tuple[str, str], float]) -> Non
|
||||||
if step.operation_kind == "compare_multiplicative":
|
if step.operation_kind == "compare_multiplicative":
|
||||||
_verify_compare_multiplicative_step(step, state)
|
_verify_compare_multiplicative_step(step, state)
|
||||||
return
|
return
|
||||||
|
if step.operation_kind == "unit_partition":
|
||||||
|
_verify_unit_partition_step(step, state)
|
||||||
|
return
|
||||||
|
|
||||||
if not isinstance(step.operand, Quantity):
|
if not isinstance(step.operand, Quantity):
|
||||||
raise VerificationError(
|
raise VerificationError(
|
||||||
|
|
@ -426,6 +430,64 @@ def _verify_compare_additive_step(
|
||||||
state[actor_key] = fresh_after
|
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(
|
def _verify_compare_multiplicative_step(
|
||||||
step: SolutionStep, state: dict[tuple[str, str], float]
|
step: SolutionStep, state: dict[tuple[str, str], float]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ from generate.math_candidate_parser import (
|
||||||
CandidateInitial,
|
CandidateInitial,
|
||||||
CandidateOperation,
|
CandidateOperation,
|
||||||
_build_compare_multiplicative,
|
_build_compare_multiplicative,
|
||||||
|
_build_unit_partition,
|
||||||
)
|
)
|
||||||
from generate.math_problem_graph import (
|
from generate.math_problem_graph import (
|
||||||
InitialPossession,
|
InitialPossession,
|
||||||
|
|
@ -779,6 +780,78 @@ def inject_comparative_multiplicative(
|
||||||
return (cand,)
|
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"] = {
|
_INJECTORS: Mapping[ShapeCategory, "type"] = {
|
||||||
ShapeCategory.DISCRETE_COUNT_STATEMENT: inject_discrete_count_statement, # type: ignore[dict-item]
|
ShapeCategory.DISCRETE_COUNT_STATEMENT: inject_discrete_count_statement, # type: ignore[dict-item]
|
||||||
# WAVE-A — multiplicative_aggregation now has a per-category
|
# WAVE-A — multiplicative_aggregation now has a per-category
|
||||||
|
|
@ -798,6 +871,10 @@ _INJECTORS: Mapping[ShapeCategory, "type"] = {
|
||||||
# CandidateOperation(kind="compare_multiplicative") for the closed
|
# CandidateOperation(kind="compare_multiplicative") for the closed
|
||||||
# v1 multiplicative entity-comparison template family.
|
# v1 multiplicative entity-comparison template family.
|
||||||
ShapeCategory.COMPARATIVE_WITH_UNIT: inject_comparative_multiplicative, # type: ignore[dict-item]
|
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
|
# All other recognizer categories continue to route to the
|
||||||
# empty-tuple fallback (explicit "recognizer matched but produced
|
# empty-tuple fallback (explicit "recognizer matched but produced
|
||||||
# no injection" refusal in the candidate-graph). That is the
|
# no injection" refusal in the candidate-graph). That is the
|
||||||
|
|
@ -834,4 +911,5 @@ __all__ = [
|
||||||
"inject_discrete_count_statement",
|
"inject_discrete_count_statement",
|
||||||
"inject_rate_with_currency",
|
"inject_rate_with_currency",
|
||||||
"inject_comparative_multiplicative",
|
"inject_comparative_multiplicative",
|
||||||
|
"inject_unit_partition",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -814,6 +814,10 @@ def _match_discrete_count_statement(
|
||||||
# COMPARATIVE_WITH_UNIT instead of detection-only DCS fallback.
|
# COMPARATIVE_WITH_UNIT instead of detection-only DCS fallback.
|
||||||
if _is_comparative_multiplicative_v1_surface(statement):
|
if _is_comparative_multiplicative_v1_surface(statement):
|
||||||
return None
|
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)
|
anchor = _try_extract_discrete_count_anchor(statement, padded, spec)
|
||||||
if anchor is not None:
|
if anchor is not None:
|
||||||
|
|
@ -1969,6 +1973,123 @@ def _match_comparative_with_unit(
|
||||||
return ((anchor,), "compare")
|
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]] = {
|
_MATCHERS: Final[dict[ShapeCategory, Any]] = {
|
||||||
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _match_descriptive_setup_no_quantity,
|
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _match_descriptive_setup_no_quantity,
|
||||||
ShapeCategory.TEMPORAL_AGGREGATION: _match_temporal_aggregation,
|
ShapeCategory.TEMPORAL_AGGREGATION: _match_temporal_aggregation,
|
||||||
|
|
@ -1977,6 +2098,7 @@ _MATCHERS: Final[dict[ShapeCategory, Any]] = {
|
||||||
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _match_multiplicative_aggregation,
|
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _match_multiplicative_aggregation,
|
||||||
ShapeCategory.CURRENCY_AMOUNT: _match_currency_amount,
|
ShapeCategory.CURRENCY_AMOUNT: _match_currency_amount,
|
||||||
ShapeCategory.COMPARATIVE_WITH_UNIT: _match_comparative_with_unit,
|
ShapeCategory.COMPARATIVE_WITH_UNIT: _match_comparative_with_unit,
|
||||||
|
ShapeCategory.UNIT_PARTITION: _match_unit_partition,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,9 @@ def build_microscope_report(
|
||||||
"comparative_with_unit_no_injection": recognized_by_cat.get(
|
"comparative_with_unit_no_injection": recognized_by_cat.get(
|
||||||
"comparative_with_unit", 0
|
"comparative_with_unit", 0
|
||||||
),
|
),
|
||||||
|
"unit_partition_no_injection": recognized_by_cat.get(
|
||||||
|
"unit_partition", 0
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
4
teaching/admissibility_exemplars/unit_partition_v1.jsonl
Normal file
4
teaching/admissibility_exemplars/unit_partition_v1.jsonl
Normal 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}}
|
||||||
|
|
@ -63,6 +63,8 @@ _SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({
|
||||||
ShapeCategory.CURRENCY_AMOUNT,
|
ShapeCategory.CURRENCY_AMOUNT,
|
||||||
# Gate A1 (Workstream A) — multiplicative comparative injection.
|
# Gate A1 (Workstream A) — multiplicative comparative injection.
|
||||||
ShapeCategory.COMPARATIVE_WITH_UNIT,
|
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'")
|
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:
|
def _validate_currency_amount(ctx: str, graph: Mapping[str, Any]) -> None:
|
||||||
anchors = graph["quantity_anchors"]
|
anchors = graph["quantity_anchors"]
|
||||||
if not isinstance(anchors, list) or not anchors:
|
if not isinstance(anchors, list) or not anchors:
|
||||||
|
|
@ -348,6 +384,7 @@ _CATEGORY_VALIDATORS = {
|
||||||
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation,
|
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation,
|
||||||
ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount,
|
ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount,
|
||||||
ShapeCategory.COMPARATIVE_WITH_UNIT: _validate_comparative_with_unit,
|
ShapeCategory.COMPARATIVE_WITH_UNIT: _validate_comparative_with_unit,
|
||||||
|
ShapeCategory.UNIT_PARTITION: _validate_unit_partition,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":"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"}
|
{"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"}}
|
{"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"}}
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,57 @@ def _synthesize_multiplicative_aggregation(
|
||||||
return canonical_pattern, coverage
|
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(
|
def _synthesize_comparative_with_unit(
|
||||||
corpus: ExemplarCorpus,
|
corpus: ExemplarCorpus,
|
||||||
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
|
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
|
||||||
|
|
@ -441,6 +492,7 @@ _SYNTHESIZERS = {
|
||||||
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _synthesize_multiplicative_aggregation,
|
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _synthesize_multiplicative_aggregation,
|
||||||
ShapeCategory.CURRENCY_AMOUNT: _synthesize_currency_amount,
|
ShapeCategory.CURRENCY_AMOUNT: _synthesize_currency_amount,
|
||||||
ShapeCategory.COMPARATIVE_WITH_UNIT: _synthesize_comparative_with_unit,
|
ShapeCategory.COMPARATIVE_WITH_UNIT: _synthesize_comparative_with_unit,
|
||||||
|
ShapeCategory.UNIT_PARTITION: _synthesize_unit_partition,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,36 @@ def test_post_inc3_live_runner_has_zero_rate_no_injection():
|
||||||
assert cats.get("rate_with_currency", 0) == 0
|
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():
|
def test_post_gate_a1_live_runner_has_zero_comparative_no_injection():
|
||||||
"""Live train_sample: comparative_with_unit bucket closed at injector."""
|
"""Live train_sample: comparative_with_unit bucket closed at injector."""
|
||||||
import re
|
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 sum(microscope["top_buckets"].values()) == refused
|
||||||
assert microscope["closed_injector_buckets"]["rate_with_currency_no_injection"] == 0
|
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"]["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():
|
def test_inc3_connector_makes_rate_no_injection_actionable():
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ def test_live_microscope_meets_monotonic_contract_and_closed_injectors():
|
||||||
closed = summary["closed_injector_buckets"]
|
closed = summary["closed_injector_buckets"]
|
||||||
assert closed["rate_with_currency_no_injection"] == 0
|
assert closed["rate_with_currency_no_injection"] == 0
|
||||||
assert closed["comparative_with_unit_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():
|
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
|
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())
|
summary = build_microscope_report(_load_cases())
|
||||||
row = next(
|
row = next(
|
||||||
r for r in summary["refusal_table"] if r["case_id"].endswith("0002")
|
r for r in summary["refusal_table"] if r["case_id"].endswith("0002")
|
||||||
)
|
)
|
||||||
assert row["subfamily"] == "dcs_misroute_unit_partition"
|
assert "25-foot sections" not in (row.get("reason") or "")
|
||||||
assert row["candidate_next_primitive"] == "unit_partition"
|
assert summary["closed_injector_buckets"]["unit_partition_no_injection"] == 0
|
||||||
assert row["expected_movement"] == "downstream_reclassification"
|
assert row["top_refusal_bucket"] == "no_admissible_statement"
|
||||||
assert (
|
|
||||||
summary["recommended_next_ratification_candidate"]
|
|
||||||
== "Gate A2a unit_partition / chunking primitive"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
108
tests/test_math_candidate_graph_unit_partition_injection.py
Normal file
108
tests/test_math_candidate_graph_unit_partition_injection.py
Normal 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
|
||||||
171
tests/test_recognizer_unit_partition_inject.py
Normal file
171
tests/test_recognizer_unit_partition_inject.py
Normal 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
|
||||||
Loading…
Reference in a new issue