core/docs/briefs/parallel-2026-05-23/L5-opus2-binding-graph-phase3-units.md
Shay 1a288ad017
docs(ADR-0136): statement-layer corridor, S.0 taxonomy, 2026-05-23 brief archive (#200)
* docs(ADR-0136): statement-layer corridor + S.0 taxonomy + resolve math-file conflicts

- Add ADR-0136-statement-layer-corridor.md: corridor overview, S.0-S.4 phase table,
  taxonomy summary (23 context-filler, 4 rate-class, 5 compound, 17 long-tail),
  standing invariants (admitted_wrong==0, context-filler safety rail, honest delta).
- Restore generate/math_*.py to HEAD (main) — stash-pop conflict markers were
  cosmetic comment rewording; upstream version is authoritative.
- Stage docs/reviewers.yaml math_expert_claims addition (already authored).

* docs(briefs): archive all 2026-05-23 parallel-dispatch briefs (L1–L17 + README)
2026-05-23 20:25:23 -07:00

6.4 KiB
Raw Blame History

L5 brief — Opus#2 — Binding Graph Phase 3 (unit-aware equation admissibility)

Worktree setup (do this first, non-negotiable):

git worktree add ../core-binding-graph-p3 -b feat/binding-graph-phase3-units origin/main
cd ../core-binding-graph-p3

Scope. Phase 3 of the binding-graph layer. Phase 1 (#171, ADR-0132) shipped the data model with BoundEquation.unit_proof as a placeholder. Phase 2 (#174, ADR-0133) shipped the MathProblemGraph adapter and explicitly left unit_proof = None as a Phase-3 gap. Close it: add deterministic, refusal-first unit-aware admissibility checking that populates unit_proof and stamps admissibility_status on every BoundEquation produced by the adapter (or constructed directly).

Still no runtime wiring outside generate/binding_graph/ and no solver invocation — Phase 3 is structural and dimensional analysis only. Phase 4 (question-target binding refinement) and Phase 5 (B3 / bounded-grammar integration) remain deferred.

Reference docs (read these, only these):

  1. docs/decisions/ADR-0132-binding-graph-data-model.md + docs/decisions/ADR-0133-binding-graph-adapter.md — your prior phases. The Phase-1 dataclass shape (BoundEquation.unit_proof: UnitProof | None, admissibility_status: Literal[...]) is the contract you're filling in.
  2. packs/en_units_v1/ (loaded into the runtime via ADR-0127) — the canonical unit vocabulary. Your unit algebra must be expressed in those units only; do not invent new ones. Read the pack's lexicon + relation files to identify the closed set of units and their dimensional families (length, time, mass, currency, count, rate-of-X-per-Y, etc.).

What to ship:

  • generate/binding_graph/units.py — pure unit algebra. Functions:
    • parse_unit(canonical_id: str) -> UnitVector — convert a closed-vocab unit id into a frozen UnitVector (dimension exponents over the base dimensions in the units pack).
    • unit_product(a: UnitVector, b: UnitVector) -> UnitVector, unit_quotient, unit_inverse — algebra primitives.
    • units_equal(a, b) — strict equality on the dimension-exponent vector.
    • All pure, frozen, deterministic. No I/O at call time.
  • generate/binding_graph/admissibility.pycheck_admissibility(equation: BoundEquation, *, symbols: Mapping[str, SymbolBinding]) -> AdmissibilityResult. Returns a UnitProof on success; a typed AdmissibilityError reason on refusal. Dispatch is on operation_kind — the closed eight-string vocab is your full case table:
    • add / subtract / compare_additive: lhs unit ≡ rhs unit ≡ each operand unit; refuse if any disagree.
    • multiply / divide: lhs unit = product / quotient of operand units; no equality requirement among operands.
    • transfer: actor and target must share the same unit; lhs unit = that unit.
    • apply_rate: lhs unit = rate-unit × duration-unit (or equivalent); the rate must have form X/Y where Y is the operand's unit.
    • compare_multiplicative: lhs is dimensionless (ratio); operand units must be equal so they cancel.
    • Any operand whose unit is None (unbound) → refuse with unit_unbound.
    • Any equation whose symbols lookup is missing → refuse with unknown_symbol.
  • generate/binding_graph/adapter.py (modify, surgically): wire check_admissibility into the adapter so every BoundEquation it produces carries either admissibility_status="admissible" + populated unit_proof, or admissibility_status="refused" + populated refusal_reason. Do not change the adapter's input/output types or signature. Existing 41 Phase-2 tests must still pass.
  • Public surface: add check_admissibility, UnitVector, UnitProof, AdmissibilityError, parse_unit (and minimal algebra primitives) to generate/binding_graph/__init__.py.
  • tests/test_binding_graph_units.py — 3040 tests covering the algebra primitives (commutativity, inverse-of-inverse, refusal on unknown unit-id).
  • tests/test_binding_graph_admissibility.py — 5070 tests covering each operation kind's admissibility rules with both positive and negative cases, plus the typed-refusal contract.
  • tests/test_binding_graph_adapter_units.py — 2030 integration tests covering the adapter's Phase-3 behavior: every Phase-2 case still round-trips, but now unit_proof is populated (or refusal is typed); a few intentionally unit-mismatched MathProblemGraph inputs produce admissibility_status="refused" with the correct reason.
  • docs/decisions/ADR-0134-binding-graph-admissibility.md — short ADR; cite ADR-0132 + ADR-0133 parents and ADR-0127 (units pack). Explicit "Phase 45 deferred" section.

Hard constraints:

  • Closed unit vocabulary. Every unit id used in algebra must already exist in en_units_v1. If you find yourself wanting to invent or coerce, stop and refuse with unknown_unit.
  • Refusal-first. Unit mismatches never silently coerce. The BoundEquation exits the adapter with admissibility_status="refused" and a typed refusal_reason. Never raise from the adapter on a unit mismatch — the data-model has a slot for it; use the slot.
  • Determinism. unit_product(a, b) == unit_product(b, a) byte-equal; bind_math_problem_graph(g) byte-equal across runs (Phase-2 invariant must not regress).
  • Pure, no I/O. Unit-pack data must be loaded at module import (or lazily memoized) and frozen; no per-call filesystem reads.
  • No solver coupling. You're not solving equations. You're verifying the equation, if solved, would be dimensionally consistent.
  • Field invariant untouched. Stay out of algebra/, chat/, core/, generate/intent.py, generate/realizer.py, runtime hot path.

Out of scope (do not touch — Phase 4+):

  • Question-target binding refinement (Phase 4).
  • Bounded-grammar / B3 integration (Phase 5).
  • MathProblemGraph itself (input type — read-only here).
  • Symbolic equivalence engine (#167, #169 — separate lane).

Target branch. PR against main. Title: feat(binding-graph): Phase 3 unit-aware admissibility (ADR-0134). Body must reference ADR-0132, ADR-0133, ADR-0127 (units pack); list Phase 45 deferred; report test count (expect ~210 total: 110 from Phase 1+2 still passing, +100ish new).

Exit criterion. PR opens with CI green, all new + existing binding-graph tests pass, pyright clean on new files, ADR-0134 included. Runtime behavior byte-identical to main (no integration yet).

Do not stack on another agent's branch. Target main directly.