core/docs/decisions/ADR-0133-binding-graph-adapter.md
Shay 5b668cc866
feat(binding-graph): Phase 2 adapter from MathProblemGraph (ADR-0133) (#174)
Pure-function adapter `bind_math_problem_graph(g) ->
SemanticSymbolicBindingGraph` translating ADR-0115 `MathProblemGraph`
into the ADR-0132 binding-graph data model. Structural translation
only — no I/O, no parser/solver calls, no algebra, no numpy, no
runtime wiring.

Mapping discipline locked as module-level constants:
- each entity      -> SymbolBinding(semantic_role="entity")
- each possession  -> SymbolBinding(quantity) + BoundFact
- each Operation   -> fresh result SymbolBinding + BoundEquation
                      (operation_kind verbatim passthrough on the
                       shared closed vocab)
- Unknown          -> synthesized SymbolBinding(unknown) + BoundUnknown

Refusal-first: `g: object` boundary accepts any caller input and
refuses non-MathProblemGraph with typed AdapterError (sibling of
BindingGraphError). Cross-collection invariant failures (defensive,
should be unreachable) are re-raised as AdapterError so callers see a
single refusal type.

Phase 2 placeholders (closed in Phase 3+):
- BoundEquation.unit_proof = "deferred_to_phase_3"
- BoundEquation.admissibility_status = "pending"

Phase 3 (ADR-0134 unit-aware admissibility), Phase 4 (question-target
binding refinement), and Phase 5 (bounded-grammar / B3 integration)
explicitly deferred — see ADR.

Evidence:
- generate/binding_graph/adapter.py (pure functions)
- generate/binding_graph/__init__.py (public surface)
- tests/test_binding_graph_adapter.py — 41 tests (refusal-first, all
  8 VALID_OPERATION_KINDS round-trip, dep wiring, introduction order,
  hash-stability, frozen output, input immutability, placeholder
  constants, cross-collection invariants)
- docs/decisions/ADR-0133-binding-graph-adapter.md

Lane: tests/test_binding_graph_model.py + tests/test_binding_graph_adapter.py
      -> 110 passed, 0 failed. pyright clean on new files. Runtime
      byte-identical to main (no runtime integration yet, by design).
2026-05-23 10:45:15 -07:00

7.9 KiB
Raw Blame History

ADR-0133 — Semantic-Symbolic Binding Graph: Phase 2 adapter from MathProblemGraph

Status: Accepted (Phase 2 only; Phases 35 deferred) Date: 2026-05-23 Parent: ADR-0132 (Phase 1 data model, PR #171) Related: ADR-0115 (MathProblemGraph origin), ADR-0126 (candidate-graph parser), ADR-0127 (units pack), ADR-0131 (math-expert rebench / proof corridor), PR #170 (binding-graph proposal)


Context

ADR-0132 (PR #171) ratified Phase 1 of the binding-graph layer: a pure data model under generate/binding_graph/ with frozen dataclasses, deterministic symbol allocation, refusal-first construction, and no coupling to the symbolic substrate (Polynomial) — symbolic expressions are referenced by canonical string form only.

The proposal in PR #170 recommends shipping the binding graph in phases. With the data model locked, the next reviewable seam is a pure-function adapter from the existing ADR-0115 MathProblemGraph (the math-word-problem parser's output) into a SemanticSymbolicBindingGraph. Adding the adapter without runtime wiring keeps the abstraction reviewable in isolation before any runtime behavior depends on it.

Decision

Add generate/binding_graph/adapter.py exposing bind_math_problem_graph(g) -> SemanticSymbolicBindingGraph and a typed AdapterError (sibling of BindingGraphError — both ValueError subclasses, refusal-first by design).

The adapter is pure: no I/O, no parser calls, no solver calls, no algebra, no numpy. It performs structural translation only. Mapping discipline is locked as module-level constants:

Source (MathProblemGraph) Output (SemanticSymbolicBindingGraph)
each entity (str) one SymbolBinding with semantic_role="entity", symbol_id="entity_<slug>"
each InitialPossession one SymbolBinding (semantic_role="quantity", symbol_id="q_<entity>_<unit>_t0") plus one BoundFact(value=str(value))
each Operation (index i) one fresh SymbolBinding (symbol_id="op_{i:03d}_result") plus one BoundEquation with operation_kind verbatim from source
Unknown one synthesized SymbolBinding (semantic_role="unknown") plus one BoundUnknown referencing it

Operation-kind passthrough. The closed vocab MathProblemGraph.VALID_OPERATION_KINDS is shared by design — the adapter emits BoundEquation.operation_kind as a verbatim string passthrough. No translation, no renaming.

Dependency wiring. BoundEquation.dependencies references the t0 quantity symbol(s) of the actor (and, where applicable, of Operation.target for transfer and of Comparison.reference_actor for compare_*), keyed on the unit hint derived from the operand. When no matching t0 symbol exists (e.g. an actor that started at implicit zero), the dependency set silently omits it — Phase 2 wires only what the source graph already pins.

Synthetic source spans. MathProblemGraph carries no native source-text spans, so every span the adapter emits is synthesized with source_id == "math_problem_graph". The brief calls for "skip span fields cleanly when absent" — Phase 2 implements this by anchoring every SourceSpanLink to a deterministic surface text derived from the binding's own identity. If a future revision of MathProblemGraph carries real spans, the adapter is the natural site to propagate them.

Determinism is load-bearing. Two MathProblemGraph instances that compare equal produce two SemanticSymbolicBindingGraph instances that compare equal and serialize byte-identically via to_canonical_string(). This is asserted by tests/test_binding_graph_adapter.py via a hash-stability test that calls bind_math_problem_graph twice and compares both the structure and the canonical string.

Refusal-first. The adapter accepts object at the signature boundary and refuses anything that is not a MathProblemGraph with a typed AdapterError. Cross-collection invariants violated downstream (these can only fire if the adapter itself has a bug) are re-raised as AdapterError, so callers see a single refusal type at the adapter boundary.

Phase 3+ deferred (explicit)

The following remain out of scope for this ADR and are deferred to follow-up PRs:

  • Phase 3 — Unit-aware equation admissibility (ADR-0134). Every BoundEquation Phase 2 emits carries unit_proof == "deferred_to_phase_3" and admissibility_status == "pending". This is by design — dimensional analysis is the substance of Phase 3, not Phase 2, and the placeholder makes the gap unambiguous in artifact form. Phase 3's first commit should be a test that asserts no equation carries the placeholder anywhere except in this adapter's output, then the loop that closes the gap.
  • Phase 4 — Question-target binding refinement. Phase 2 binds the Unknown to a synthesized SymbolBinding (semantic_role="unknown") rather than to any prior t0 / result symbol. Phase 4 will resolve the unknown against the operation chain when possible.
  • Phase 5 — Bounded-grammar / B3 integration. No runtime wiring; chat/, core/, generate/intent.py, and generate/realizer.py are untouched.

Trust boundary

The adapter consumes a frozen, self-validating MathProblemGraph — already a trust-boundary-checked artifact (ADR-0115 refuses malformed input at construction time). The adapter introduces no new arbitrary-code execution, no filesystem access, no dynamic imports. The only widening of the trust boundary is at the signature: g: object accepts any caller input and the adapter is responsible for refusing non-MathProblemGraph inputs with a typed AdapterError. Tests pin this refusal.

Field invariant

Untouched. The adapter never reaches algebra/, field/, or any runtime hot-path; versor_condition cannot be affected by this change.

Evidence

  • generate/binding_graph/adapter.py — adapter (≈300 lines, pure functions, module-level mapping constants).
  • generate/binding_graph/__init__.py — public surface adds bind_math_problem_graph and AdapterError.
  • tests/test_binding_graph_adapter.py — 41 tests covering: refusal on non-graph input, all eight VALID_OPERATION_KINDS round-tripping verbatim, dependency wiring (transfer, apply_rate, compare_additive), introduction order preservation, hash-stability across runs, input immutability, frozen-output invariants, Phase-2 placeholder constants (PHASE_2_UNIT_PROOF, PHASE_2_ADMISSIBILITY), and cross-collection reference invariants. Full lane tests/test_binding_graph_model.py + tests/test_binding_graph_adapter.py: 110/0/0. pyright clean on new files.
  • Runtime byte-identical to main: no runtime integration introduced.

PR checklist

  • Capability added: typed, deterministic translation from MathProblemGraph (ADR-0115) to SemanticSymbolicBindingGraph (ADR-0132). No runtime wiring yet — Phase 2 is structural translation only.
  • Invariant proven: bind(g) == bind(g) byte-for-byte; two equal MathProblemGraphs produce two equal binding graphs (hash-stability test). All cross-collection references in the emitted binding graph resolve against the emitted symbol table.
  • CLI / suite: python3 -m pytest tests/test_binding_graph_model.py tests/test_binding_graph_adapter.py — 110 passed.
  • Avoided: hidden normalization, stochastic fallback, approximate recall, dimensional-analysis-by-stealth, Polynomial coupling.
  • Trust boundary: widened only at the adapter signature (g: object → refusal-first AdapterError); no new dynamic execution, no new filesystem access.