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).
This commit is contained in:
parent
ed759d1b43
commit
5b668cc866
4 changed files with 1055 additions and 14 deletions
148
docs/decisions/ADR-0133-binding-graph-adapter.md
Normal file
148
docs/decisions/ADR-0133-binding-graph-adapter.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# ADR-0133 — Semantic-Symbolic Binding Graph: Phase 2 adapter from `MathProblemGraph`
|
||||||
|
|
||||||
|
**Status:** Accepted (Phase 2 only; Phases 3–5 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
|
||||||
|
`MathProblemGraph`s 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.
|
||||||
|
|
@ -1,24 +1,29 @@
|
||||||
"""ADR-0132 — Semantic-Symbolic Binding Graph, Phase 1 (data model only).
|
"""ADR-0132/0133 — Semantic-Symbolic Binding Graph.
|
||||||
|
|
||||||
This package introduces the typed compiler boundary between natural-language
|
Phase 1 (ADR-0132): pure data layer (frozen dataclasses, deterministic
|
||||||
semantic parsing and symbolic/equational solving proposed in
|
allocator, refusal-first construction; no I/O, no parser, no algebra,
|
||||||
``docs/implementation/semantic-symbolic-binding-graph-proposal.md``.
|
no ``Polynomial`` coupling — symbolic expressions held as canonical
|
||||||
|
strings only).
|
||||||
|
|
||||||
Phase 1 is intentionally a pure data layer:
|
Phase 2 (ADR-0133): pure-function adapter
|
||||||
|
:func:`bind_math_problem_graph` translating
|
||||||
|
:class:`generate.math_problem_graph.MathProblemGraph` (ADR-0115) into a
|
||||||
|
:class:`SemanticSymbolicBindingGraph`. Still no runtime wiring.
|
||||||
|
|
||||||
- frozen dataclasses with immutable collections,
|
Phases 3-5 (unit-aware equation binding / question-target binding /
|
||||||
- deterministic symbol allocation,
|
bounded-grammar integration) are deferred to follow-up PRs.
|
||||||
- refusal-first construction (typed ``BindingGraphError``),
|
|
||||||
- no I/O, no parser calls, no algebra calls, no numpy,
|
|
||||||
- no coupling to ``generate.math_symbolic_normalizer.Polynomial``;
|
|
||||||
symbolic expressions are referenced by canonical string form only.
|
|
||||||
|
|
||||||
Phases 2-5 (adapter, unit-aware binding, question target binding, bounded
|
|
||||||
grammar integration) are deferred to follow-up PRs.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .adapter import (
|
||||||
|
INTRODUCED_BY,
|
||||||
|
PHASE_2_ADMISSIBILITY,
|
||||||
|
PHASE_2_UNIT_PROOF,
|
||||||
|
SYNTHETIC_SOURCE_ID,
|
||||||
|
AdapterError,
|
||||||
|
bind_math_problem_graph,
|
||||||
|
)
|
||||||
from .allocation import allocate_symbols
|
from .allocation import allocate_symbols
|
||||||
from .model import (
|
from .model import (
|
||||||
ADMISSIBILITY_STATUSES,
|
ADMISSIBILITY_STATUSES,
|
||||||
|
|
@ -35,7 +40,12 @@ from .model import (
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"ADMISSIBILITY_STATUSES",
|
"ADMISSIBILITY_STATUSES",
|
||||||
|
"INTRODUCED_BY",
|
||||||
|
"PHASE_2_ADMISSIBILITY",
|
||||||
|
"PHASE_2_UNIT_PROOF",
|
||||||
"SEMANTIC_ROLES",
|
"SEMANTIC_ROLES",
|
||||||
|
"SYNTHETIC_SOURCE_ID",
|
||||||
|
"AdapterError",
|
||||||
"BindingGraphError",
|
"BindingGraphError",
|
||||||
"BoundConstraint",
|
"BoundConstraint",
|
||||||
"BoundEquation",
|
"BoundEquation",
|
||||||
|
|
@ -45,4 +55,5 @@ __all__ = (
|
||||||
"SourceSpanLink",
|
"SourceSpanLink",
|
||||||
"SymbolBinding",
|
"SymbolBinding",
|
||||||
"allocate_symbols",
|
"allocate_symbols",
|
||||||
|
"bind_math_problem_graph",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
366
generate/binding_graph/adapter.py
Normal file
366
generate/binding_graph/adapter.py
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
"""ADR-0133 — Adapter: ``MathProblemGraph`` → ``SemanticSymbolicBindingGraph``.
|
||||||
|
|
||||||
|
Phase 2 of the binding-graph layer (ADR-0132). This module is a pure,
|
||||||
|
deterministic translation: it consumes a ratified
|
||||||
|
:class:`generate.math_problem_graph.MathProblemGraph` (ADR-0115) and
|
||||||
|
emits the corresponding
|
||||||
|
:class:`generate.binding_graph.SemanticSymbolicBindingGraph`. No I/O, no
|
||||||
|
parser calls, no solver calls, no algebra. The adapter is total on every
|
||||||
|
well-formed ``MathProblemGraph`` and refuses (typed :class:`AdapterError`)
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
Mapping discipline (locked at top of module — see constants):
|
||||||
|
|
||||||
|
- each entity → one ``SymbolBinding`` with ``semantic_role='entity'``,
|
||||||
|
- each ``InitialPossession`` → one ``SymbolBinding``
|
||||||
|
(``semantic_role='quantity'``) + one ``BoundFact``,
|
||||||
|
- each ``Operation`` → one fresh result ``SymbolBinding`` plus one
|
||||||
|
``BoundEquation`` whose ``operation_kind`` is a verbatim passthrough
|
||||||
|
of the source op kind (closed vocab is shared by design),
|
||||||
|
- the ``Unknown`` → one synthesized ``SymbolBinding``
|
||||||
|
(``semantic_role='unknown'``) + one ``BoundUnknown``.
|
||||||
|
|
||||||
|
Phases 3+ deferred:
|
||||||
|
|
||||||
|
- unit-aware equation admissibility (Phase 3, ADR-0134),
|
||||||
|
- question-target binding refinement (Phase 4),
|
||||||
|
- bounded-grammar / B3 integration (Phase 5).
|
||||||
|
|
||||||
|
Until Phase 3 lands, every emitted ``BoundEquation`` carries the
|
||||||
|
placeholder ``unit_proof=PHASE_2_UNIT_PROOF`` and
|
||||||
|
``admissibility_status='pending'``. This is by design — dimensional
|
||||||
|
analysis belongs in the next ADR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from generate.math_problem_graph import (
|
||||||
|
Comparison,
|
||||||
|
MathProblemGraph,
|
||||||
|
Operation,
|
||||||
|
Quantity,
|
||||||
|
Rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .model import (
|
||||||
|
BindingGraphError,
|
||||||
|
BoundEquation,
|
||||||
|
BoundFact,
|
||||||
|
BoundUnknown,
|
||||||
|
SemanticSymbolicBindingGraph,
|
||||||
|
SourceSpanLink,
|
||||||
|
SymbolBinding,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants — locked mapping discipline (read these before editing logic).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: ``source_id`` stamped onto every synthesized ``SourceSpanLink``.
|
||||||
|
#: ``MathProblemGraph`` carries no native source-span information, so
|
||||||
|
#: every span the adapter emits is synthetic and shares this id.
|
||||||
|
SYNTHETIC_SOURCE_ID: Final[str] = "math_problem_graph"
|
||||||
|
|
||||||
|
#: ``introduced_by`` stamped onto every ``SymbolBinding`` the adapter
|
||||||
|
#: emits. Replaying the adapter therefore yields byte-equal symbols.
|
||||||
|
INTRODUCED_BY: Final[str] = "bind_math_problem_graph"
|
||||||
|
|
||||||
|
#: Placeholder ``unit_proof`` for every Phase 2 ``BoundEquation``.
|
||||||
|
#: Phase 3 (ADR-0134) replaces this with a real dimensional proof token.
|
||||||
|
PHASE_2_UNIT_PROOF: Final[str] = "deferred_to_phase_3"
|
||||||
|
|
||||||
|
#: Every Phase 2 ``BoundEquation`` is emitted ``pending`` — the equation
|
||||||
|
#: is structurally valid but unit-admissibility has not yet been checked.
|
||||||
|
PHASE_2_ADMISSIBILITY: Final[str] = "pending"
|
||||||
|
|
||||||
|
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public error
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterError(ValueError):
|
||||||
|
"""Raised on malformed input to :func:`bind_math_problem_graph`.
|
||||||
|
|
||||||
|
Sibling of :class:`generate.binding_graph.BindingGraphError` —
|
||||||
|
refusal-first by design. The adapter never silently coerces an
|
||||||
|
unrecognized input type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Symbol-id helpers (pure)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(text: str) -> str:
|
||||||
|
"""ASCII-lowercase slug; non-alnum runs collapse to ``_``."""
|
||||||
|
return _SLUG_NON_ALNUM.sub("_", text.strip().lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_identifier(text: str, *, prefix: str) -> str:
|
||||||
|
"""Return ``f"{prefix}_{slug}"``, defaulting to ``prefix + '_x'`` on
|
||||||
|
empty slug. Guarantees a valid Python identifier."""
|
||||||
|
s = _slug(text)
|
||||||
|
if s == "":
|
||||||
|
s = "x"
|
||||||
|
return f"{prefix}_{s}"
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_symbol_id(entity: str) -> str:
|
||||||
|
return _safe_identifier(entity, prefix="entity")
|
||||||
|
|
||||||
|
|
||||||
|
def _quantity_symbol_id(entity: str, unit: str, tick: int) -> str:
|
||||||
|
return f"q_{_slug(entity) or 'x'}_{_slug(unit) or 'x'}_t{tick}"
|
||||||
|
|
||||||
|
|
||||||
|
def _op_result_symbol_id(idx: int) -> str:
|
||||||
|
return f"op_{idx:03d}_result"
|
||||||
|
|
||||||
|
|
||||||
|
def _unknown_symbol_id(entity: str | None, unit: str) -> str:
|
||||||
|
scope = _slug(entity) if entity is not None else "total"
|
||||||
|
if scope == "":
|
||||||
|
scope = "total"
|
||||||
|
unit_slug = _slug(unit) or "x"
|
||||||
|
return f"unknown_{scope}_{unit_slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _span(text: str) -> SourceSpanLink:
|
||||||
|
"""Synthesize a deterministic ``SourceSpanLink`` for ``text``.
|
||||||
|
|
||||||
|
``MathProblemGraph`` carries no native span information; Phase 2
|
||||||
|
therefore stamps every binding with a synthetic span anchored to
|
||||||
|
the rendered surface text. The span is byte-stable per input.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str) or text == "":
|
||||||
|
# Defensive: every caller passes non-empty text. Refuse rather
|
||||||
|
# than silently substitute.
|
||||||
|
raise AdapterError("synthetic span text must be a non-empty str")
|
||||||
|
return SourceSpanLink(
|
||||||
|
source_id=SYNTHETIC_SOURCE_ID, start=0, end=len(text), text=text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RHS canonicalization (deterministic, string-only — no Polynomial coupling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _format_quantity(q: Quantity) -> str:
|
||||||
|
return f"{q.value} {q.unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rate(r: Rate) -> str:
|
||||||
|
return f"{r.value} {r.numerator_unit}/{r.denominator_unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_comparison(c: Comparison) -> str:
|
||||||
|
if c.delta is not None:
|
||||||
|
return (
|
||||||
|
f"{c.direction}({c.reference_actor}, "
|
||||||
|
f"delta={_format_quantity(c.delta)})"
|
||||||
|
)
|
||||||
|
return f"{c.direction}({c.reference_actor}, factor={c.factor})"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_operand(operand: Quantity | Rate | Comparison) -> str:
|
||||||
|
if isinstance(operand, Quantity):
|
||||||
|
return _format_quantity(operand)
|
||||||
|
if isinstance(operand, Rate):
|
||||||
|
return _format_rate(operand)
|
||||||
|
return _format_comparison(operand)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_rhs(op: Operation) -> str:
|
||||||
|
head = f"{op.kind}({op.actor}"
|
||||||
|
if op.target is not None:
|
||||||
|
head += f"->{op.target}"
|
||||||
|
return head + f", {_format_operand(op.operand)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _operand_unit_hint(operand: Quantity | Rate | Comparison) -> str | None:
|
||||||
|
"""The most relevant unit a dependency lookup should key on.
|
||||||
|
|
||||||
|
Used only for wiring deterministic ``BoundEquation.dependencies``
|
||||||
|
against pre-existing t0 symbols. Unit-aware *admissibility* is
|
||||||
|
Phase 3.
|
||||||
|
"""
|
||||||
|
if isinstance(operand, Quantity):
|
||||||
|
return operand.unit
|
||||||
|
if isinstance(operand, Rate):
|
||||||
|
return operand.denominator_unit
|
||||||
|
if operand.delta is not None:
|
||||||
|
return operand.delta.unit
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public adapter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def bind_math_problem_graph(
|
||||||
|
g: object,
|
||||||
|
) -> SemanticSymbolicBindingGraph:
|
||||||
|
"""Translate a ``MathProblemGraph`` into a ``SemanticSymbolicBindingGraph``.
|
||||||
|
|
||||||
|
Pure function. Deterministic: ``bind_math_problem_graph(g) ==
|
||||||
|
bind_math_problem_graph(g)`` byte-for-byte, and two graphs that
|
||||||
|
compare equal (``g1 == g2``) produce two binding graphs that compare
|
||||||
|
equal. Input is never mutated (cannot be — ``MathProblemGraph`` is
|
||||||
|
frozen — but the contract is asserted by tests).
|
||||||
|
|
||||||
|
Raises :class:`AdapterError` if ``g`` is not a
|
||||||
|
:class:`MathProblemGraph`. Every well-formed ``MathProblemGraph``
|
||||||
|
produces a well-formed ``SemanticSymbolicBindingGraph`` — the
|
||||||
|
adapter is total on the input type's image.
|
||||||
|
"""
|
||||||
|
if not isinstance(g, MathProblemGraph):
|
||||||
|
raise AdapterError(
|
||||||
|
"bind_math_problem_graph requires a MathProblemGraph; "
|
||||||
|
f"got {type(g).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
symbols: list[SymbolBinding] = []
|
||||||
|
facts: list[BoundFact] = []
|
||||||
|
equations: list[BoundEquation] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
def _add(sym: SymbolBinding) -> None:
|
||||||
|
if sym.symbol_id in seen_ids:
|
||||||
|
# Idempotent — same symbol re-emitted from a different
|
||||||
|
# construction path collapses cleanly.
|
||||||
|
return
|
||||||
|
seen_ids.add(sym.symbol_id)
|
||||||
|
symbols.append(sym)
|
||||||
|
|
||||||
|
# ---- Entities (order of introduction) ---------------------------------
|
||||||
|
for entity in g.entities:
|
||||||
|
_add(
|
||||||
|
SymbolBinding(
|
||||||
|
symbol_id=_entity_symbol_id(entity),
|
||||||
|
name=entity,
|
||||||
|
semantic_role="entity",
|
||||||
|
source_span=_span(entity),
|
||||||
|
introduced_by=INTRODUCED_BY,
|
||||||
|
entity=entity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Initial state → t0 quantity symbols + grounded facts -------------
|
||||||
|
t0_index: dict[tuple[str, str], str] = {}
|
||||||
|
for poss in g.initial_state:
|
||||||
|
sid = _quantity_symbol_id(poss.entity, poss.quantity.unit, 0)
|
||||||
|
span_text = f"{poss.entity}|{poss.quantity.unit}|t0"
|
||||||
|
_add(
|
||||||
|
SymbolBinding(
|
||||||
|
symbol_id=sid,
|
||||||
|
name=f"{poss.entity}.{poss.quantity.unit}@t0",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(span_text),
|
||||||
|
introduced_by=INTRODUCED_BY,
|
||||||
|
entity=poss.entity,
|
||||||
|
unit=poss.quantity.unit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
facts.append(
|
||||||
|
BoundFact(
|
||||||
|
symbol_id=sid,
|
||||||
|
value=str(poss.quantity.value),
|
||||||
|
source_span=_span(span_text),
|
||||||
|
unit=poss.quantity.unit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
t0_index[(poss.entity, poss.quantity.unit)] = sid
|
||||||
|
|
||||||
|
# ---- Operations → fresh result symbol + bound equation ----------------
|
||||||
|
for idx, op in enumerate(g.operations):
|
||||||
|
result_sid = _op_result_symbol_id(idx)
|
||||||
|
op_span_text = f"op{idx:03d}|{op.kind}|{op.actor}"
|
||||||
|
_add(
|
||||||
|
SymbolBinding(
|
||||||
|
symbol_id=result_sid,
|
||||||
|
name=f"op{idx}.{op.kind}.{op.actor}",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(op_span_text),
|
||||||
|
introduced_by=INTRODUCED_BY,
|
||||||
|
entity=op.actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
deps: set[str] = set()
|
||||||
|
unit_hint = _operand_unit_hint(op.operand)
|
||||||
|
if unit_hint is not None:
|
||||||
|
actor_sid = t0_index.get((op.actor, unit_hint))
|
||||||
|
if actor_sid is not None:
|
||||||
|
deps.add(actor_sid)
|
||||||
|
if op.target is not None:
|
||||||
|
target_sid = t0_index.get((op.target, unit_hint))
|
||||||
|
if target_sid is not None:
|
||||||
|
deps.add(target_sid)
|
||||||
|
if isinstance(op.operand, Comparison):
|
||||||
|
ref_sid = t0_index.get(
|
||||||
|
(op.operand.reference_actor, unit_hint)
|
||||||
|
)
|
||||||
|
if ref_sid is not None:
|
||||||
|
deps.add(ref_sid)
|
||||||
|
|
||||||
|
equations.append(
|
||||||
|
BoundEquation(
|
||||||
|
lhs_symbol_id=result_sid,
|
||||||
|
rhs_canonical=_format_rhs(op),
|
||||||
|
dependencies=frozenset(deps),
|
||||||
|
operation_kind=op.kind, # passthrough — shared closed vocab
|
||||||
|
unit_proof=PHASE_2_UNIT_PROOF,
|
||||||
|
admissibility_status=PHASE_2_ADMISSIBILITY,
|
||||||
|
source_span=_span(op_span_text),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Unknown → synthesized unknown symbol + BoundUnknown --------------
|
||||||
|
unk = g.unknown
|
||||||
|
unk_sid = _unknown_symbol_id(unk.entity, unk.unit)
|
||||||
|
unk_text = (
|
||||||
|
f"{unk.entity}|{unk.unit}" if unk.entity is not None else f"total|{unk.unit}"
|
||||||
|
)
|
||||||
|
_add(
|
||||||
|
SymbolBinding(
|
||||||
|
symbol_id=unk_sid,
|
||||||
|
name=unk_text,
|
||||||
|
semantic_role="unknown",
|
||||||
|
source_span=_span(unk_text),
|
||||||
|
introduced_by=INTRODUCED_BY,
|
||||||
|
entity=unk.entity,
|
||||||
|
unit=unk.unit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unknowns = (
|
||||||
|
BoundUnknown(
|
||||||
|
symbol_id=unk_sid,
|
||||||
|
question_span=_span(unk_text),
|
||||||
|
expected_unit=unk.unit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SemanticSymbolicBindingGraph(
|
||||||
|
symbols=tuple(symbols),
|
||||||
|
facts=tuple(facts),
|
||||||
|
equations=tuple(equations),
|
||||||
|
unknowns=unknowns,
|
||||||
|
)
|
||||||
|
except BindingGraphError as exc:
|
||||||
|
# Cross-collection invariants of ``SemanticSymbolicBindingGraph``
|
||||||
|
# are stricter than ``MathProblemGraph``'s own checks. Surface
|
||||||
|
# any such failure as an ``AdapterError`` so callers see a single
|
||||||
|
# refusal type at the adapter boundary.
|
||||||
|
raise AdapterError(
|
||||||
|
f"adapter produced an invalid binding graph: {exc}"
|
||||||
|
) from exc
|
||||||
516
tests/test_binding_graph_adapter.py
Normal file
516
tests/test_binding_graph_adapter.py
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
"""ADR-0133 — Tests for the MathProblemGraph → BindingGraph adapter.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- refusal-first on malformed input (typed ``AdapterError``),
|
||||||
|
- per-operation-kind round-trip (string passthrough on the closed vocab),
|
||||||
|
- entity / quantity / unknown mapping discipline,
|
||||||
|
- deterministic introduction-order preservation,
|
||||||
|
- dependency wiring against pre-existing t0 symbols,
|
||||||
|
- byte-equal idempotency across runs (hash-stability),
|
||||||
|
- input immutability and frozen-output invariants,
|
||||||
|
- Phase-2 placeholders for ``unit_proof`` + admissibility (Phase 3+ deferred).
|
||||||
|
|
||||||
|
Pure data — no runtime, no algebra, no parser imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from generate.binding_graph import (
|
||||||
|
INTRODUCED_BY,
|
||||||
|
PHASE_2_ADMISSIBILITY,
|
||||||
|
PHASE_2_UNIT_PROOF,
|
||||||
|
SYNTHETIC_SOURCE_ID,
|
||||||
|
AdapterError,
|
||||||
|
BoundEquation,
|
||||||
|
BoundFact,
|
||||||
|
BoundUnknown,
|
||||||
|
SemanticSymbolicBindingGraph,
|
||||||
|
SymbolBinding,
|
||||||
|
bind_math_problem_graph,
|
||||||
|
)
|
||||||
|
from generate.math_problem_graph import (
|
||||||
|
VALID_OPERATION_KINDS,
|
||||||
|
Comparison,
|
||||||
|
InitialPossession,
|
||||||
|
MathProblemGraph,
|
||||||
|
Operation,
|
||||||
|
Quantity,
|
||||||
|
Rate,
|
||||||
|
Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _q(value: int | float, unit: str) -> Quantity:
|
||||||
|
return Quantity(value=value, unit=unit)
|
||||||
|
|
||||||
|
|
||||||
|
def _trivial_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam",),
|
||||||
|
initial_state=(InitialPossession(entity="Sam", quantity=_q(3, "apples")),),
|
||||||
|
operations=(),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _two_actor_add_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam", "Mary"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Sam", quantity=_q(3, "apples")),
|
||||||
|
InitialPossession(entity="Mary", quantity=_q(5, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
Operation(actor="Sam", kind="add", operand=_q(2, "apples")),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity=None, unit="apples"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _transfer_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam", "Mary"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Sam", quantity=_q(7, "apples")),
|
||||||
|
InitialPossession(entity="Mary", quantity=_q(1, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
Operation(
|
||||||
|
actor="Sam",
|
||||||
|
kind="transfer",
|
||||||
|
operand=_q(3, "apples"),
|
||||||
|
target="Mary",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Mary", unit="apples"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam",),
|
||||||
|
initial_state=(InitialPossession(entity="Sam", quantity=_q(4, "apple")),),
|
||||||
|
operations=(
|
||||||
|
Operation(
|
||||||
|
actor="Sam",
|
||||||
|
kind="apply_rate",
|
||||||
|
operand=Rate(
|
||||||
|
value=2.0, numerator_unit="dollars", denominator_unit="apple"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Sam", unit="dollars"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_additive_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam", "Mary"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Mary", quantity=_q(5, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
Operation(
|
||||||
|
actor="Sam",
|
||||||
|
kind="compare_additive",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor="Mary",
|
||||||
|
delta=_q(2, "apples"),
|
||||||
|
factor=None,
|
||||||
|
direction="more",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_multiplicative_graph() -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=("Sam", "Mary"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Mary", quantity=_q(5, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
Operation(
|
||||||
|
actor="Sam",
|
||||||
|
kind="compare_multiplicative",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor="Mary",
|
||||||
|
delta=None,
|
||||||
|
factor=3.0,
|
||||||
|
direction="times",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1-3. Refusal-first on malformed input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_refuses_non_graph_input() -> None:
|
||||||
|
with pytest.raises(AdapterError):
|
||||||
|
bind_math_problem_graph({"entities": ()}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_refuses_none() -> None:
|
||||||
|
with pytest.raises(AdapterError):
|
||||||
|
bind_math_problem_graph(None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_refuses_string() -> None:
|
||||||
|
with pytest.raises(AdapterError):
|
||||||
|
bind_math_problem_graph("not a graph") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4-7. Minimal-graph shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_trivial_graph_emits_expected_symbol_count() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
# 1 entity + 1 initial-quantity + 1 unknown synthesis
|
||||||
|
assert len(bg.symbols) == 3
|
||||||
|
assert len(bg.facts) == 1
|
||||||
|
assert len(bg.equations) == 0
|
||||||
|
assert len(bg.unknowns) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_symbol_has_entity_role_and_slug() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
entity_syms = [s for s in bg.symbols if s.semantic_role == "entity"]
|
||||||
|
assert len(entity_syms) == 1
|
||||||
|
assert entity_syms[0].symbol_id == "entity_sam"
|
||||||
|
assert entity_syms[0].name == "Sam"
|
||||||
|
assert entity_syms[0].entity == "Sam"
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_possession_emits_fact_with_str_value_and_unit() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
fact = bg.facts[0]
|
||||||
|
assert fact.symbol_id == "q_sam_apples_t0"
|
||||||
|
assert fact.value == "3"
|
||||||
|
assert fact.unit == "apples"
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_quantity_symbol_has_quantity_role_and_unit() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
quant_syms = [
|
||||||
|
s
|
||||||
|
for s in bg.symbols
|
||||||
|
if s.semantic_role == "quantity" and s.symbol_id.startswith("q_")
|
||||||
|
]
|
||||||
|
assert len(quant_syms) == 1
|
||||||
|
assert quant_syms[0].unit == "apples"
|
||||||
|
assert quant_syms[0].entity == "Sam"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8-9. Unknown handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_bound_to_synthesized_symbol() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
unk = bg.unknowns[0]
|
||||||
|
syms_by_id = {s.symbol_id: s for s in bg.symbols}
|
||||||
|
assert unk.symbol_id in syms_by_id
|
||||||
|
assert syms_by_id[unk.symbol_id].semantic_role == "unknown"
|
||||||
|
assert unk.expected_unit == "apples"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_entity_none_renders_total_scope() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
unk = bg.unknowns[0]
|
||||||
|
assert unk.symbol_id == "unknown_total_apples"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10-17. Per-operation-kind passthrough (covers all 8 in VALID_OPERATION_KINDS)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind,operand,target",
|
||||||
|
[
|
||||||
|
("add", _q(2, "apples"), None),
|
||||||
|
("subtract", _q(2, "apples"), None),
|
||||||
|
("multiply", _q(2, "apples"), None),
|
||||||
|
("divide", _q(2, "apples"), None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_simple_operation_kind_passthrough(
|
||||||
|
kind: str, operand: Quantity, target: str | None
|
||||||
|
) -> None:
|
||||||
|
g = MathProblemGraph(
|
||||||
|
entities=("Sam",),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Sam", quantity=_q(10, "apples")),
|
||||||
|
),
|
||||||
|
operations=(Operation(actor="Sam", kind=kind, operand=operand, target=target),),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
assert len(bg.equations) == 1
|
||||||
|
assert bg.equations[0].operation_kind == kind
|
||||||
|
# passthrough must match the source closed vocab verbatim
|
||||||
|
assert bg.equations[0].operation_kind in VALID_OPERATION_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_passthrough_and_dep_on_both_actors() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
eq = bg.equations[0]
|
||||||
|
assert eq.operation_kind == "transfer"
|
||||||
|
assert "q_sam_apples_t0" in eq.dependencies
|
||||||
|
assert "q_mary_apples_t0" in eq.dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_rate_passthrough_and_dep_on_denominator_unit() -> None:
|
||||||
|
bg = bind_math_problem_graph(_rate_graph())
|
||||||
|
eq = bg.equations[0]
|
||||||
|
assert eq.operation_kind == "apply_rate"
|
||||||
|
# Sam holds 'apple' (denominator); dep wires there.
|
||||||
|
assert "q_sam_apple_t0" in eq.dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_additive_passthrough_and_dep_on_reference() -> None:
|
||||||
|
bg = bind_math_problem_graph(_compare_additive_graph())
|
||||||
|
eq = bg.equations[0]
|
||||||
|
assert eq.operation_kind == "compare_additive"
|
||||||
|
assert "q_mary_apples_t0" in eq.dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_multiplicative_passthrough() -> None:
|
||||||
|
bg = bind_math_problem_graph(_compare_multiplicative_graph())
|
||||||
|
eq = bg.equations[0]
|
||||||
|
assert eq.operation_kind == "compare_multiplicative"
|
||||||
|
# multiplicative comparison has no delta-unit → no t0 dep wired
|
||||||
|
assert eq.dependencies == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_eight_operation_kinds_round_trip() -> None:
|
||||||
|
# Sanity: brief mandates the closed vocab is shared by design.
|
||||||
|
# Each kind exercised individually above; here we just assert the
|
||||||
|
# vocab itself hasn't drifted under us.
|
||||||
|
assert VALID_OPERATION_KINDS == frozenset(
|
||||||
|
{
|
||||||
|
"add",
|
||||||
|
"subtract",
|
||||||
|
"transfer",
|
||||||
|
"multiply",
|
||||||
|
"divide",
|
||||||
|
"apply_rate",
|
||||||
|
"compare_additive",
|
||||||
|
"compare_multiplicative",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 18-22. Determinism, immutability, hash-stability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_is_idempotent_on_equal_graphs() -> None:
|
||||||
|
a = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
b = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
assert a == b
|
||||||
|
assert a.to_canonical_string() == b.to_canonical_string()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_string_is_byte_equal_across_runs() -> None:
|
||||||
|
g = _transfer_graph()
|
||||||
|
s1 = bind_math_problem_graph(g).to_canonical_string()
|
||||||
|
s2 = bind_math_problem_graph(g).to_canonical_string()
|
||||||
|
assert s1.encode("utf-8") == s2.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_introduction_order_preserved_across_entities() -> None:
|
||||||
|
g = MathProblemGraph(
|
||||||
|
entities=("Zeta", "Alpha", "Mu"),
|
||||||
|
initial_state=(),
|
||||||
|
operations=(),
|
||||||
|
unknown=Unknown(entity="Alpha", unit="widgets"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
entity_syms = [s for s in bg.symbols if s.semantic_role == "entity"]
|
||||||
|
assert [s.name for s in entity_syms] == ["Zeta", "Alpha", "Mu"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_graph_not_mutated() -> None:
|
||||||
|
g = _transfer_graph()
|
||||||
|
entities_before = g.entities
|
||||||
|
ops_before = g.operations
|
||||||
|
bind_math_problem_graph(g)
|
||||||
|
# Frozen + slots makes mutation impossible; assert object identity
|
||||||
|
# of the immutable tuples as a defense-in-depth contract.
|
||||||
|
assert g.entities is entities_before
|
||||||
|
assert g.operations is ops_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_dataclasses_are_frozen() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
sym = bg.symbols[0]
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
sym.name = "mutated" # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 23-26. Phase-2 placeholders + cross-collection invariants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_equation_unit_proof_is_phase2_placeholder() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
assert bg.equations[0].unit_proof == PHASE_2_UNIT_PROOF
|
||||||
|
assert PHASE_2_UNIT_PROOF == "deferred_to_phase_3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_equation_admissibility_is_pending() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
assert bg.equations[0].admissibility_status == PHASE_2_ADMISSIBILITY
|
||||||
|
assert PHASE_2_ADMISSIBILITY == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_equation_dependencies_reference_known_symbols() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
known = {s.symbol_id for s in bg.symbols}
|
||||||
|
for eq in bg.equations:
|
||||||
|
assert eq.dependencies.issubset(known)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_symbol_id_references_known_symbol() -> None:
|
||||||
|
bg = bind_math_problem_graph(_compare_additive_graph())
|
||||||
|
known = {s.symbol_id for s in bg.symbols}
|
||||||
|
assert bg.unknowns[0].symbol_id in known
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 27-31. Provenance + constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_symbol_introduced_by_constant() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
for sym in bg.symbols:
|
||||||
|
assert sym.introduced_by == INTRODUCED_BY
|
||||||
|
|
||||||
|
|
||||||
|
def test_synthetic_source_id_on_every_span() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
for sym in bg.symbols:
|
||||||
|
assert sym.source_span.source_id == SYNTHETIC_SOURCE_ID
|
||||||
|
for fact in bg.facts:
|
||||||
|
assert fact.source_span.source_id == SYNTHETIC_SOURCE_ID
|
||||||
|
for eq in bg.equations:
|
||||||
|
assert eq.source_span.source_id == SYNTHETIC_SOURCE_ID
|
||||||
|
for unk in bg.unknowns:
|
||||||
|
assert unk.question_span.source_id == SYNTHETIC_SOURCE_ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_op_result_symbol_id_is_deterministic_and_indexed() -> None:
|
||||||
|
g = MathProblemGraph(
|
||||||
|
entities=("Sam",),
|
||||||
|
initial_state=(InitialPossession(entity="Sam", quantity=_q(1, "u")),),
|
||||||
|
operations=(
|
||||||
|
Operation(actor="Sam", kind="add", operand=_q(1, "u")),
|
||||||
|
Operation(actor="Sam", kind="add", operand=_q(2, "u")),
|
||||||
|
Operation(actor="Sam", kind="add", operand=_q(3, "u")),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Sam", unit="u"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
lhs = [eq.lhs_symbol_id for eq in bg.equations]
|
||||||
|
assert lhs == ["op_000_result", "op_001_result", "op_002_result"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rhs_canonical_contains_operation_kind() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
assert bg.equations[0].rhs_canonical.startswith("transfer(")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_is_single_target() -> None:
|
||||||
|
bg = bind_math_problem_graph(_compare_multiplicative_graph())
|
||||||
|
# Phase 2 promise: exactly one unknown binding per graph.
|
||||||
|
assert len(bg.unknowns) == 1
|
||||||
|
assert isinstance(bg.unknowns[0], BoundUnknown)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 32-34. Misc edge cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_with_zero_operations_is_well_formed() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
assert bg.equations == ()
|
||||||
|
# round-trip stays valid under SemanticSymbolicBindingGraph invariants
|
||||||
|
assert isinstance(bg, SemanticSymbolicBindingGraph)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entity_with_spaces_slugifies_into_valid_identifier() -> None:
|
||||||
|
g = MathProblemGraph(
|
||||||
|
entities=("Mary Jane",),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Mary Jane", quantity=_q(2, "apples")),
|
||||||
|
),
|
||||||
|
operations=(),
|
||||||
|
unknown=Unknown(entity="Mary Jane", unit="apples"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
entity_syms = [s for s in bg.symbols if s.semantic_role == "entity"]
|
||||||
|
assert entity_syms[0].symbol_id == "entity_mary_jane"
|
||||||
|
assert entity_syms[0].symbol_id.isidentifier()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fact_count_matches_initial_possession_count() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
assert len(bg.facts) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_outputs_for_distinct_graphs_differ() -> None:
|
||||||
|
s1 = bind_math_problem_graph(_trivial_graph()).to_canonical_string()
|
||||||
|
s2 = bind_math_problem_graph(_transfer_graph()).to_canonical_string()
|
||||||
|
assert s1 != s2
|
||||||
|
|
||||||
|
|
||||||
|
def test_symbol_table_has_no_duplicate_ids() -> None:
|
||||||
|
bg = bind_math_problem_graph(_transfer_graph())
|
||||||
|
ids = [s.symbol_id for s in bg.symbols]
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def test_equation_lhs_is_a_known_symbol() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
known = {s.symbol_id for s in bg.symbols}
|
||||||
|
for eq in bg.equations:
|
||||||
|
assert eq.lhs_symbol_id in known
|
||||||
|
|
||||||
|
|
||||||
|
def test_typeof_emitted_equation_is_bound_equation() -> None:
|
||||||
|
bg = bind_math_problem_graph(_two_actor_add_graph())
|
||||||
|
assert all(isinstance(eq, BoundEquation) for eq in bg.equations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_typeof_emitted_fact_is_bound_fact() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
assert all(isinstance(f, BoundFact) for f in bg.facts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_typeof_emitted_symbol_is_symbol_binding() -> None:
|
||||||
|
bg = bind_math_problem_graph(_trivial_graph())
|
||||||
|
assert all(isinstance(s, SymbolBinding) for s in bg.symbols)
|
||||||
Loading…
Reference in a new issue