core/docs/decisions/ADR-0134-binding-graph-admissibility.md
Shay 0951d80e04 feat(comprehension): the divisive comparative frame — "half as many" as exact integer division (PR-6c)
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).
2026-06-06 20:18:39 -07:00

10 KiB
Raw Blame History

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 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 "<num>_per_<denom>" 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.pycheck_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<NNN>__multiplicand for multiply, op<NNN>__divisor for divide, op<NNN>__rate with semantic_role='rate' and unit "<num>_per_<denom>" 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 45 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).