PR-6c adds the divisive comparative frame: "half as many" read as EXACT INTEGER
DIVISION. It is the divisor twin of PR-5c's multiplicative frame, and moves the
independent R1 gold's r1-02-half from refused → correct.
No serving path touched. No rational/fractional answer support added. Non-exact
division refuses.
Design (ADR-0134 amended — divide made symmetric with multiply):
- `_check_divide` now admits a SINGLE-DEP divide-by-dimensionless-literal
(item / dimensionless = item), the exact twin of single-dep multiply. The
2-dep rate-divide path is untouched. This keeps the IR's "literal operands
are not deps" invariant (proven in PR-6a) uniform across Mul AND Div, so the
reader builds both without a per-op special case and WITHOUT synthesizing a
divisor symbol that would pollute the setup-oracle's unit signature.
- `Div(Symbol, Literal)` IR node: "ref / divisor", operation_kind "divide",
projects to `divide_by`. Divisor-only contract mirrors the scalar-only one.
- Reader: `_DIVISOR_WORDS={half:2}` slots into the same 8-token "<WORD> as many"
template as the factor words; graph carries only the two entities.
- Gold reconciliation: r1-02 placeholder `times_as_many factor 0.5` → exact
`divide_by divisor 2` (gold 4). Makes the INDEPENDENT gold integer-faithful.
The wrong=0 boundary — exact divisibility:
the oracle admits `divide_by` only when `base % divisor == 0`. An odd base
halved REFUSES (gold_error), never floors to a wrong integer. Divisor must be
a nonzero int (0, 0.5, 1.5, bool all refuse); divisor=1 is intentionally the
identity (pinned). admissibility proves DIMENSION; the oracle proves EXACT VALUE.
Meaningful-fail (CLAUDE.md Schema-Defined Proof Obligations), both verified red:
- drop the `% divisor` guard → test_oracle_refuses_non_exact_division fails (returns 3).
- disable the single-dep divide branch → the admissibility test AND the reader's
`half` test fail (admissibility refuses → reader refuses → half stays refused).
Gates:
R1 setup: 3 correct / 0 wrong / 7 refused
R1 answers: 3 correct / 0 wrong / 7 refused / setup_wrong 0 / gold_error 0
15-case setup: 15 / 0 / 0
91 PR-6c tests + 60 relational lanes + 56 architectural invariants + 502
binding-graph/proof-chain/adapter tests green. All 8 SHA-content lanes match
(serving unmoved; admissibility has no generate.derivation/reliability_gate consumer).
10 KiB
ADR-0134 — Binding Graph Phase 3: Unit-Aware Equation Admissibility
Status: accepted Parents: ADR-0132 (data model), ADR-0133 (adapter), ADR-0127 (units pack) Date: 2026-05-23
Context
Phase 1 (ADR-0132) shipped the binding-graph data model with
BoundEquation.unit_proof declared as a non-empty str and an
admissibility_status drawn from {admitted, pending, refused}.
Phase 2 (ADR-0133) shipped the MathProblemGraph → SemanticSymbolicBindingGraph
adapter and explicitly emitted every equation with the placeholder
unit_proof="deferred_to_phase_3" + admissibility_status="pending".
Phase 3 closes that gap. Every emitted equation must now carry either:
admissibility_status="admitted"+ a populatedunit_proofderived from dimensional analysis over the closeden_units_v1vocabulary (ADR-0127); oradmissibility_status="refused"+ a typedrefusal_reasondrawn from a closed vocabulary, withunit_proofset to a sentinel.
This is the wrong-answer firewall: the binding graph never silently admits a dimensionally inconsistent equation, and never invents or coerces a unit outside the pack.
Decision
Add three deliverables under generate/binding_graph/:
-
units.py— pure unit algebra over an integer exponent vector on six base dimensions (length, time, mass, money, count, temperature). The closed vocabulary is loaded once fromlanguage_packs/data/en_units_v1/lexicon.jsonlat first call and memoized. Composite unit ids of the form"<num>_per_<denom>"resolve recursively asunit_quotient(parse_unit(num), parse_unit(denom)).parse_unitrefuses withUnitAlgebraError("unknown_unit: …")on any other input — including after a conservative depluralization pass (apples → appleetc.). -
admissibility.py—check_admissibility(equation, *, symbols)dispatches onBoundEquation.operation_kindagainst the closed eight-string vocab:kind rule add/subtract/compare_additive/transferall dep units equal; lhs == that unit compare_multiplicativedep units cancel; lhs dimensionless multiplylhs == product of dep units dividesingle dep: divide by an implicit dimensionless literal, lhs == dividend unit ( x / dimensionless = x); two deps: one dividend + one*__divisorliteral, lhs == quotientapply_ratedep with semantic_role='rate'carriesX/Y; other dep carriesY; lhs ==XRefusal is typed: every
AdmissibilityErrorcarries areasonfromADMISSIBILITY_REASONS = {unit_mismatch, unknown_unit, unit_unbound, unknown_symbol, unknown_operation, operand_arity, rate_form_invalid}. Success returns a frozenUnitProof(operation_kind, lhs_unit, operand_units)whoseto_canonical_string()is stored inBoundEquation.unit_proof. -
adapter.py(surgical wiring) — for eachOperationthe adapter:- synthesizes any operand-literal symbols the verifier needs
(
op<NNN>__multiplicandformultiply,op<NNN>__divisorfordivide,op<NNN>__ratewithsemantic_role='rate'and unit"<num>_per_<denom>"forapply_rate); - constructs a shell
BoundEquationand callscheck_admissibility; - stamps the final equation
admitted+ proof on success, orrefused+ typedrefusal_reasononAdmissibilityError.
No new equations; no change to
bind_math_problem_graph's input/output types.compare_multiplicativedeliberately adds no synthesized symbols (Phase-2 invariant: dependencies remainfrozenset()). - synthesizes any operand-literal symbols the verifier needs
(
The public surface in generate/binding_graph/__init__.py gains
check_admissibility, UnitProof, UnitVector, parse_unit,
unit_product, unit_quotient, unit_inverse, units_equal,
AdmissibilityError, UnitAlgebraError, ADMISSIBILITY_REASONS,
BASE_DIMENSIONS, DIMENSIONLESS, and REFUSED_UNIT_PROOF. The
placeholder constants PHASE_2_UNIT_PROOF / PHASE_2_ADMISSIBILITY
are removed (their role is now served by real proofs + typed refusals).
Trust Boundaries
- Closed unit vocabulary. Every unit id used in admissibility must
resolve to a lemma in
en_units_v1(after conservative depluralization, or via theX_per_Ycomposite path). Anything else is refused withunknown_unit. There is no coercion, no invention, and no "best-effort" fallback. - Refusal-first. Dimensional mismatches never raise from the
adapter; they are stamped onto the equation's
refusal_reasonslot. The data model already reserves the slot — this ADR uses it. - Pure, no I/O at call time. The pack lexicon is read once at first
parse_unitcall and memoized into an immutable mapping. Subsequent calls do not touch the filesystem (testtest_unit_algebra_no_io_at_call_timepins this behavior). - No solver coupling. The verifier checks that the equation, if
solved, would be dimensionally consistent. It does not import
Polynomial, does not invoke any solver, and does not depend on the symbolic substrate.
Invariants
unit_product(a, b) == unit_product(b, a)byte-equal (commutativity on integer addition).unit_inverse(unit_inverse(v)) == v(involution).unit_quotient(v, v) == DIMENSIONLESS(cancellation).bind_math_problem_graph(g)is byte-equal across runs (Phase-2 invariant preserved; deterministic dep iteration via sorted symbol ids).bg.equations[i].admissibility_status ∈ {admitted, refused}for every equation produced by the adapter —pendingis no longer reachable viabind_math_problem_graph.- Phase-2 cases using units outside
en_units_v1(e.g.apples,widgets) now produce typedrefusedequations withrefusal_reason="unknown_unit". The structural shape of the binding graph (entity / fact / equation / unknown counts) is preserved.
Field Invariant
Unchanged. This ADR adds no algebra/, chat/, core/, generate/intent.py,
generate/realizer.py, or runtime-hot-path code; the field invariant
versor_condition(F) < 1e-6 is not touched.
Tests
tests/test_binding_graph_units.py(47 tests) — algebra primitives, pack-drivenparse_unit, depluralization, composite resolution, refusal coverage, no-I/O-after-warmup.tests/test_binding_graph_admissibility.py(40 tests) — per-kind dispatch (positive + negative), typed-refusal vocab,UnitProofcontract, sorted-dep determinism.tests/test_binding_graph_adapter_units.py(29 tests) — adapter Phase-3 integration: every Phase-2 case still round-trips (now with populatedunit_proofor typedrefusal_reason); pack-grounded happy paths admit with the expected dimensional surface; the eight operation kinds all carry Phase-3 admissibility status; canonical string is byte-equal across runs.tests/test_binding_graph_adapter.py(38 tests) — Phase-2 tests unchanged in structure; the two placeholder-equality tests have been rewritten to assert the Phase-3 contract (refused+ typed reason on out-of-vocab units;admitted+ populated proof on pack-grounded units).tests/test_binding_graph_model.py(61 tests) — unchanged.
Total binding-graph lane: 215 tests (110 pre-existing + 116 new;
the brief's expected ~210 is comfortably exceeded). All green;
pyright clean on all new files.
Phase 4–5 Deferred
The following remain explicitly out of scope:
- Phase 4 — question-target binding refinement. The
BoundUnknowncurrently recordsexpected_unitverbatim from the sourceUnknown. Phase 4 will reconcile this with the admitted lhs unit of the question-resolving equation chain. - Phase 5 — bounded-grammar / B3 integration. No runtime wiring of
the binding graph outside
generate/binding_graph/. The pipeline, realizer, and chat surfaces remain untouched. - Symbolic equivalence engine (issues #167, #169) — separate lane.
MathProblemGraphitself — read-only input here; its operand vocabulary (Quantity / Rate / Comparison) is unchanged.
Runtime Impact
None. The binding graph still has no runtime wiring outside
generate/binding_graph/. chat/runtime.py, the cognition eval lane,
the field invariant, the algebra backend, and every other production
hot path are unaffected. Cognition eval lane byte-equal to main.
Amendment 2026-06-07 — single-dep divide (divide by a dimensionless literal)
What changed. _check_divide now admits a single-dep form in addition
to the original two-dep dividend + *__divisor form: a quantity divided by an
implicit dimensionless literal, with lhs == dividend unit.
Why. The off-serving comprehension reader's typed expression IR
(generate/quantitative_expr.py, PR-4/5c/6a) carries literal operands inside
the IR and deliberately does not make them dependencies — a Mul(Symbol, Literal) ("twice as many") has dependencies = {ref}, and _check_multiply
already admits that single dep (item × dimensionless = item). "half as many"
(Div(Symbol, Literal(2))) is the exact divisive twin: same shape, same single
dep, the divisor is a dimensionless Literal in the IR. The original two-dep
convention (a synthesized *__divisor symbol) collides with that IR design and
would force a per-op special case in the reader plus an extra graph symbol. The
single-dep form makes divide symmetric with multiply so the IR's
"literal operands are not deps" invariant holds uniformly for both.
Safety.
- The two-dep rate-adapter path (
*__divisor) is unchanged. - Soundness: dividing a unit-bearing quantity by a dimensionless constant preserves the unit by construction — identical to the multiply twin.
- Exactness (the value, not the unit) is the answer oracle's responsibility:
evals.relational_metric.oracleadmitsdivide_byonly whenbase % divisor == 0, refusing a non-exact division (an odd base over 2) rather than flooring to a wrong integer. Admissibility proves dimension; the oracle proves exact integral value. - Off-serving:
admissibility.pyhas nogenerate.derivation/core.reliability_gateconsumer; the frozen GSM8K serving metric cannot move. - Pinned by
tests/test_binding_graph_admissibility.py(test_divide_single_dep_dimensionless_keeps_unit,test_divide_refuses_zero_or_three_deps).