Wires deterministic, refusal-first dimensional analysis into the binding-graph adapter. Every BoundEquation emitted by bind_math_problem_graph now carries either admissibility_status='admitted' + populated unit_proof or admissibility_status='refused' + typed refusal_reason. No silent coercion; no invented units; no solver. Adds: - generate/binding_graph/units.py — pure unit algebra over a 6-dim integer exponent vector (length, time, mass, money, count, temperature). Closed vocabulary loaded once from en_units_v1 (ADR-0127) and memoized; composite "<num>_per_<denom>" resolved recursively; conservative depluralization; refusal-first. - generate/binding_graph/admissibility.py — check_admissibility with per-operation-kind dispatch over the closed 8-string vocab, typed AdmissibilityError (closed reason set), frozen UnitProof. - ADR-0134 documenting the contract, invariants, and Phase 4-5 deferrals. Adapter changes are surgical: synthesizes operand-literal symbols where the verifier needs them (op<NNN>__multiplicand / __divisor / __rate), then stamps each equation via check_admissibility. Input/output types unchanged; bind_math_problem_graph still byte-equal across runs. Tests: 226 total in the binding-graph lane (110 Phase 1+2 still pass; 47 units + 40 admissibility + 29 adapter-units new). Pyright clean on all new files. No runtime wiring outside generate/binding_graph/. Phase 4 (question-target binding) and Phase 5 (B3 / bounded grammar) remain deferred per the brief.
8.2 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 dividerequires 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.