# ADR-0134 — Binding Graph Phase 3: Unit-Aware Equation Admissibility **Status:** accepted **Parents:** [ADR-0132](ADR-0132-binding-graph-data-model.md) (data model), [ADR-0133](ADR-0133-binding-graph-adapter.md) (adapter), [ADR-0127](ADR-0127-units-pack-and-units-aware-parser.md) (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 populated `unit_proof` derived from dimensional analysis over the closed `en_units_v1` vocabulary (ADR-0127); or - `admissibility_status="refused"` + a typed `refusal_reason` drawn from a closed vocabulary, with `unit_proof` set 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/`: 1. **`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 from `language_packs/data/en_units_v1/lexicon.jsonl` at first call and memoized. Composite unit ids of the form `"_per_"` resolve recursively as `unit_quotient(parse_unit(num), parse_unit(denom))`. `parse_unit` refuses with `UnitAlgebraError("unknown_unit: …")` on any other input — including after a conservative depluralization pass (`apples → apple` etc.). 2. **`admissibility.py`** — `check_admissibility(equation, *, symbols)` dispatches on `BoundEquation.operation_kind` against the closed eight-string vocab: | kind | rule | |---|---| | `add` / `subtract` / `compare_additive` / `transfer` | all dep units equal; lhs == that unit | | `compare_multiplicative` | dep units cancel; lhs dimensionless | | `multiply` | lhs == product of dep units | | `divide` | **single dep**: divide by an implicit dimensionless literal, lhs == dividend unit (`x / dimensionless = x`); **two deps**: one dividend + one `*__divisor` literal, lhs == quotient | | `apply_rate` | dep with `semantic_role='rate'` carries `X/Y`; other dep carries `Y`; lhs == `X` | Refusal is typed: every `AdmissibilityError` carries a `reason` from `ADMISSIBILITY_REASONS = {unit_mismatch, unknown_unit, unit_unbound, unknown_symbol, unknown_operation, operand_arity, rate_form_invalid}`. Success returns a frozen `UnitProof(operation_kind, lhs_unit, operand_units)` whose `to_canonical_string()` is stored in `BoundEquation.unit_proof`. 3. **`adapter.py`** (surgical wiring) — for each `Operation` the adapter: - synthesizes any operand-literal symbols the verifier needs (`op__multiplicand` for `multiply`, `op__divisor` for `divide`, `op__rate` with `semantic_role='rate'` and unit `"_per_"` for `apply_rate`); - constructs a shell `BoundEquation` and calls `check_admissibility`; - stamps the final equation `admitted` + proof on success, or `refused` + typed `refusal_reason` on `AdmissibilityError`. No new equations; no change to `bind_math_problem_graph`'s input/output types. `compare_multiplicative` deliberately adds no synthesized symbols (Phase-2 invariant: dependencies remain `frozenset()`). 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 the `X_per_Y` composite path). Anything else is refused with `unknown_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_reason` slot. 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_unit` call and memoized into an immutable mapping. Subsequent calls do not touch the filesystem (test `test_unit_algebra_no_io_at_call_time` pins 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 — `pending` is no longer reachable via `bind_math_problem_graph`. - Phase-2 cases using units outside `en_units_v1` (e.g. `apples`, `widgets`) now produce typed `refused` equations with `refusal_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-driven `parse_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, `UnitProof` contract, 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 populated `unit_proof` or typed `refusal_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 `BoundUnknown` currently records `expected_unit` verbatim from the source `Unknown`. 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. - **`MathProblemGraph` itself** — 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.oracle` admits `divide_by` only when `base % 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.py` has no `generate.derivation` / `core.reliability_gate` consumer; 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`).