feat(binding-graph): Phase 4 question-target binding (ADR-0135) (#179)
Refines BoundUnknown from "the symbol whose value the solver determines"
to "the symbol at a specific temporal/state index with a specific
question-form". Two new required fields on BoundUnknown — state_index
(initial/terminal/Operation(operation_index)) and question_form
(count/rate/total/difference/ratio/identity) — populated by the new
pure-function resolver in generate/binding_graph/question_target.py.
The adapter (ADR-0133) now delegates Unknown -> BoundUnknown construction
to bound_unknown_from_math_problem_graph. No runtime wiring, no solver
invocation. Phase 5 (bounded-grammar / B3 integration) remains deferred.
Refusal-first via the new QuestionTargetError (sibling of AdapterError /
AdmissibilityError). Closed reason vocab: not_a_math_problem_graph,
unknown_entity_not_in_entities, apply_rate_unit_mismatch,
unmappable_question_form. Closed precedence rule on question_form
documented in ADR-0135 (compare_multiplicative > compare_additive >
apply_rate{numerator|denominator unit-match} > count); ambiguity refuses.
SemanticSymbolicBindingGraph.__post_init__ gains a cross-collection
guard: Operation(operation_index) must satisfy operation_index <
len(equations). canonical_string emission widened to include
state=... form=... tokens (hash differs from Phase 3 main by design —
not a regression; byte-equal across runs preserved).
Parents: ADR-0132 / ADR-0133 / ADR-0134.
Tests: +70 new (45 unit in test_binding_graph_question_target.py +
25 integration in test_binding_graph_adapter_question_target.py); 5
Phase 1+3 BoundUnknown fixtures migrated. Total binding-graph lane
295/1 pass (1 pre-existing test_symbol_binding_uses_slots failure on
Python 3.14, unrelated to Phase 4 — exists on origin/main). Pyright
clean on new and modified files. No edits to algebra/, chat/, core/,
or runtime hot path. Field invariant untouched.
This commit is contained in:
parent
6cbaa74076
commit
3b30eb248a
8 changed files with 1550 additions and 28 deletions
130
docs/decisions/ADR-0135-binding-graph-question-target.md
Normal file
130
docs/decisions/ADR-0135-binding-graph-question-target.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# ADR-0135 — Binding Graph Phase 4: question-target binding refinement
|
||||||
|
|
||||||
|
**Status:** Accepted.
|
||||||
|
**Parents:** [ADR-0132](ADR-0132-binding-graph-data-model.md),
|
||||||
|
[ADR-0133](ADR-0133-binding-graph-adapter.md),
|
||||||
|
[ADR-0134](ADR-0134-binding-graph-admissibility.md).
|
||||||
|
**Phase 5 (bounded-grammar / B3 integration):** deferred.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phases 1–3 produced a `SemanticSymbolicBindingGraph` containing a
|
||||||
|
`BoundUnknown` that named the symbol the question targets — but only
|
||||||
|
the symbol *identity*, not its *temporal index* nor the *shape of the
|
||||||
|
expected answer*.
|
||||||
|
|
||||||
|
For "How many apples does Tina have?" with a three-operation
|
||||||
|
trajectory, the adapter's `BoundUnknown` did not distinguish between
|
||||||
|
Tina's apples at `t=0`, after the second operation, or at the terminal
|
||||||
|
state. Three different symbols, one ambiguous question.
|
||||||
|
|
||||||
|
Likewise, "How much faster did Tina run?" and "How many apples does
|
||||||
|
Tina have?" both compile down to a `BoundUnknown` pointing at a symbol,
|
||||||
|
but the *verdict shape* a downstream consumer should produce
|
||||||
|
(`difference` vs. `count`) is qualitatively different.
|
||||||
|
|
||||||
|
Phase 4 closes that ambiguity *deterministically* at the binding-graph
|
||||||
|
layer, without invoking a solver or grammar.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Two new required fields on `BoundUnknown`
|
||||||
|
|
||||||
|
- `state_index: StateIndex` — a closed tagged union:
|
||||||
|
`Literal["initial", "terminal"]` or `Operation(operation_index: int)`,
|
||||||
|
where `Operation` is a frozen dataclass (not a string) so the index
|
||||||
|
is type-checked at construction.
|
||||||
|
- `question_form: Literal["count", "rate", "total", "difference",
|
||||||
|
"ratio", "identity"]`.
|
||||||
|
|
||||||
|
Both fields are **required** — no defaults. Phase 1–3 fixtures that
|
||||||
|
construct `BoundUnknown` directly have been updated in the same PR.
|
||||||
|
|
||||||
|
### Pure-function resolvers — `generate/binding_graph/question_target.py`
|
||||||
|
|
||||||
|
- `resolve_state_index(g)` — `"terminal"` when `g.operations` is
|
||||||
|
non-empty, `"initial"` otherwise. The `Operation` variant is reserved
|
||||||
|
for future intermediate-state queries; this resolver does not yet
|
||||||
|
produce it.
|
||||||
|
- `infer_question_form(g)` — closed dispatch over operations that
|
||||||
|
*touch* the unknown's entity (actor / target / `Comparison`
|
||||||
|
`reference_actor` matches). Precedence rule (deterministic):
|
||||||
|
|
||||||
|
1. No operations touch → `"identity"`.
|
||||||
|
2. Any `compare_multiplicative` touches → `"ratio"`.
|
||||||
|
3. Any `compare_additive` touches → `"difference"`.
|
||||||
|
4. Any `apply_rate` touches → `"total"` when `unknown.unit ==
|
||||||
|
numerator_unit`; `"rate"` when `unknown.unit == denominator_unit`;
|
||||||
|
otherwise refuse (`apply_rate_unit_mismatch`).
|
||||||
|
5. All touching kinds in `{add, subtract, transfer, multiply,
|
||||||
|
divide}` → `"count"`.
|
||||||
|
6. Anything else → refuse (`unmappable_question_form`).
|
||||||
|
|
||||||
|
The precedence is **closed and ordered**, not a heuristic. A graph
|
||||||
|
mixing `compare_additive` with `add` returns `"difference"` because
|
||||||
|
the comparison establishes the question's shape regardless of any
|
||||||
|
downstream arithmetic.
|
||||||
|
|
||||||
|
- `bound_unknown_from_math_problem_graph(g)` — composes the above and
|
||||||
|
yields a fully-populated `BoundUnknown`. The adapter (ADR-0133) calls
|
||||||
|
this in place of its old ad-hoc mapping.
|
||||||
|
|
||||||
|
### Refusal-first via `QuestionTargetError`
|
||||||
|
|
||||||
|
Sibling of `AdapterError` and `AdmissibilityError`. Reasons are a
|
||||||
|
closed set:
|
||||||
|
|
||||||
|
- `not_a_math_problem_graph`
|
||||||
|
- `unknown_entity_not_in_entities`
|
||||||
|
- `apply_rate_unit_mismatch`
|
||||||
|
- `unmappable_question_form`
|
||||||
|
|
||||||
|
The resolver never silently coerces to a default — ambiguity refuses.
|
||||||
|
|
||||||
|
### Cross-collection guard
|
||||||
|
|
||||||
|
`SemanticSymbolicBindingGraph.__post_init__` adds: if any
|
||||||
|
`BoundUnknown.state_index` is an `Operation`, its `operation_index`
|
||||||
|
must be `< len(equations)`. Standalone `BoundUnknown` construction
|
||||||
|
checks only the sign (`>= 0`).
|
||||||
|
|
||||||
|
## Invariants preserved
|
||||||
|
|
||||||
|
- `bind_math_problem_graph(g)` remains pure and deterministic.
|
||||||
|
Byte-equal across runs on a given `g`. The canonical-string emission
|
||||||
|
changes shape (now includes `state=…` and `form=…` tokens), so
|
||||||
|
hashes differ from Phase 3 main *by design* — not a regression.
|
||||||
|
- No runtime wiring. No solver coupling. No mutation of
|
||||||
|
`MathProblemGraph`. No `algebra/`, `chat/`, `core/` edits.
|
||||||
|
- Field-invariant `versor_condition(F) < 1e-6` untouched.
|
||||||
|
|
||||||
|
## Phase 5 — deferred
|
||||||
|
|
||||||
|
Bounded-grammar / B3 integration remains out of scope. Phase 4 only
|
||||||
|
determines *which symbol* the question targets and *what form* the
|
||||||
|
answer takes; producing surface verdicts from those signals belongs to
|
||||||
|
a later phase.
|
||||||
|
|
||||||
|
## Trust boundary
|
||||||
|
|
||||||
|
`question_target.py` reads only frozen, validated `MathProblemGraph`
|
||||||
|
input. No filesystem access, no dynamic import, no user-controlled
|
||||||
|
formatting. All refusals are typed.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/test_binding_graph_question_target.py` — unit-lane (~45 tests):
|
||||||
|
every operation-kind family, precedence rule, refusal paths, typed
|
||||||
|
`Operation` state-index guards.
|
||||||
|
- `tests/test_binding_graph_adapter_question_target.py` — integration
|
||||||
|
(~25 tests): adapter round-trip, hash stability, refusal propagation,
|
||||||
|
cross-collection bounds guard.
|
||||||
|
- Phase 1+2+3 fixtures updated where they constructed `BoundUnknown`
|
||||||
|
directly (~5 sites).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-0132 — data model.
|
||||||
|
- ADR-0133 — adapter.
|
||||||
|
- ADR-0134 — unit-aware admissibility.
|
||||||
|
- `generate/math_problem_graph.py` — input shape (ADR-0115).
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""ADR-0132/0133 — Semantic-Symbolic Binding Graph.
|
"""ADR-0132/0133/0134/0135 — Semantic-Symbolic Binding Graph.
|
||||||
|
|
||||||
Phase 1 (ADR-0132): pure data layer (frozen dataclasses, deterministic
|
Phase 1 (ADR-0132): pure data layer (frozen dataclasses, deterministic
|
||||||
allocator, refusal-first construction; no I/O, no parser, no algebra,
|
allocator, refusal-first construction; no I/O, no parser, no algebra,
|
||||||
|
|
@ -10,8 +10,13 @@ Phase 2 (ADR-0133): pure-function adapter
|
||||||
:class:`generate.math_problem_graph.MathProblemGraph` (ADR-0115) into a
|
:class:`generate.math_problem_graph.MathProblemGraph` (ADR-0115) into a
|
||||||
:class:`SemanticSymbolicBindingGraph`. Still no runtime wiring.
|
:class:`SemanticSymbolicBindingGraph`. Still no runtime wiring.
|
||||||
|
|
||||||
Phases 3-5 (unit-aware equation binding / question-target binding /
|
Phase 3 (ADR-0134): unit-aware admissibility on equations.
|
||||||
bounded-grammar integration) are deferred to follow-up PRs.
|
|
||||||
|
Phase 4 (ADR-0135): question-target binding refinement —
|
||||||
|
:class:`BoundUnknown` now carries ``state_index`` + ``question_form``
|
||||||
|
fields resolved by :func:`bound_unknown_from_math_problem_graph`.
|
||||||
|
|
||||||
|
Phase 5 (bounded-grammar / B3 integration) deferred.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -32,16 +37,27 @@ from .admissibility import (
|
||||||
from .allocation import allocate_symbols
|
from .allocation import allocate_symbols
|
||||||
from .model import (
|
from .model import (
|
||||||
ADMISSIBILITY_STATUSES,
|
ADMISSIBILITY_STATUSES,
|
||||||
|
QUESTION_FORMS,
|
||||||
SEMANTIC_ROLES,
|
SEMANTIC_ROLES,
|
||||||
|
STATE_INDEX_LABELS,
|
||||||
BindingGraphError,
|
BindingGraphError,
|
||||||
BoundConstraint,
|
BoundConstraint,
|
||||||
BoundEquation,
|
BoundEquation,
|
||||||
BoundFact,
|
BoundFact,
|
||||||
BoundUnknown,
|
BoundUnknown,
|
||||||
|
Operation,
|
||||||
SemanticSymbolicBindingGraph,
|
SemanticSymbolicBindingGraph,
|
||||||
SourceSpanLink,
|
SourceSpanLink,
|
||||||
|
StateIndex,
|
||||||
SymbolBinding,
|
SymbolBinding,
|
||||||
)
|
)
|
||||||
|
from .question_target import (
|
||||||
|
QUESTION_TARGET_REASONS,
|
||||||
|
QuestionTargetError,
|
||||||
|
bound_unknown_from_math_problem_graph,
|
||||||
|
infer_question_form,
|
||||||
|
resolve_state_index,
|
||||||
|
)
|
||||||
from .units import (
|
from .units import (
|
||||||
BASE_DIMENSIONS,
|
BASE_DIMENSIONS,
|
||||||
DIMENSIONLESS,
|
DIMENSIONLESS,
|
||||||
|
|
@ -60,8 +76,11 @@ __all__ = (
|
||||||
"BASE_DIMENSIONS",
|
"BASE_DIMENSIONS",
|
||||||
"DIMENSIONLESS",
|
"DIMENSIONLESS",
|
||||||
"INTRODUCED_BY",
|
"INTRODUCED_BY",
|
||||||
|
"QUESTION_FORMS",
|
||||||
|
"QUESTION_TARGET_REASONS",
|
||||||
"REFUSED_UNIT_PROOF",
|
"REFUSED_UNIT_PROOF",
|
||||||
"SEMANTIC_ROLES",
|
"SEMANTIC_ROLES",
|
||||||
|
"STATE_INDEX_LABELS",
|
||||||
"SYNTHETIC_SOURCE_ID",
|
"SYNTHETIC_SOURCE_ID",
|
||||||
"AdapterError",
|
"AdapterError",
|
||||||
"AdmissibilityError",
|
"AdmissibilityError",
|
||||||
|
|
@ -70,16 +89,22 @@ __all__ = (
|
||||||
"BoundEquation",
|
"BoundEquation",
|
||||||
"BoundFact",
|
"BoundFact",
|
||||||
"BoundUnknown",
|
"BoundUnknown",
|
||||||
|
"Operation",
|
||||||
|
"QuestionTargetError",
|
||||||
"SemanticSymbolicBindingGraph",
|
"SemanticSymbolicBindingGraph",
|
||||||
"SourceSpanLink",
|
"SourceSpanLink",
|
||||||
|
"StateIndex",
|
||||||
"SymbolBinding",
|
"SymbolBinding",
|
||||||
"UnitAlgebraError",
|
"UnitAlgebraError",
|
||||||
"UnitProof",
|
"UnitProof",
|
||||||
"UnitVector",
|
"UnitVector",
|
||||||
"allocate_symbols",
|
"allocate_symbols",
|
||||||
"bind_math_problem_graph",
|
"bind_math_problem_graph",
|
||||||
|
"bound_unknown_from_math_problem_graph",
|
||||||
"check_admissibility",
|
"check_admissibility",
|
||||||
|
"infer_question_form",
|
||||||
"parse_unit",
|
"parse_unit",
|
||||||
|
"resolve_state_index",
|
||||||
"unit_inverse",
|
"unit_inverse",
|
||||||
"unit_product",
|
"unit_product",
|
||||||
"unit_quotient",
|
"unit_quotient",
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,11 @@ from .model import (
|
||||||
BindingGraphError,
|
BindingGraphError,
|
||||||
BoundEquation,
|
BoundEquation,
|
||||||
BoundFact,
|
BoundFact,
|
||||||
BoundUnknown,
|
|
||||||
SemanticSymbolicBindingGraph,
|
SemanticSymbolicBindingGraph,
|
||||||
SourceSpanLink,
|
SourceSpanLink,
|
||||||
SymbolBinding,
|
SymbolBinding,
|
||||||
)
|
)
|
||||||
|
from .question_target import bound_unknown_from_math_problem_graph
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants — locked mapping discipline (read these before editing logic).
|
# Constants — locked mapping discipline (read these before editing logic).
|
||||||
|
|
@ -126,14 +126,6 @@ def _op_result_symbol_id(idx: int) -> str:
|
||||||
return f"op_{idx:03d}_result"
|
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:
|
def _span(text: str) -> SourceSpanLink:
|
||||||
"""Synthesize a deterministic ``SourceSpanLink`` for ``text``.
|
"""Synthesize a deterministic ``SourceSpanLink`` for ``text``.
|
||||||
|
|
||||||
|
|
@ -424,14 +416,18 @@ def bind_math_problem_graph(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Unknown → synthesized unknown symbol + BoundUnknown --------------
|
# ---- Unknown → synthesized unknown symbol + BoundUnknown --------------
|
||||||
|
# ADR-0135: ``bound_unknown_from_math_problem_graph`` now owns the
|
||||||
|
# refined ``BoundUnknown`` (symbol_id / question_span / state_index /
|
||||||
|
# question_form / expected_unit). The adapter only retains responsibility
|
||||||
|
# for the matching ``SymbolBinding`` (``semantic_role='unknown'``).
|
||||||
unk = g.unknown
|
unk = g.unknown
|
||||||
unk_sid = _unknown_symbol_id(unk.entity, unk.unit)
|
|
||||||
unk_text = (
|
unk_text = (
|
||||||
f"{unk.entity}|{unk.unit}" if unk.entity is not None else f"total|{unk.unit}"
|
f"{unk.entity}|{unk.unit}" if unk.entity is not None else f"total|{unk.unit}"
|
||||||
)
|
)
|
||||||
|
bound_unknown = bound_unknown_from_math_problem_graph(g)
|
||||||
_add(
|
_add(
|
||||||
SymbolBinding(
|
SymbolBinding(
|
||||||
symbol_id=unk_sid,
|
symbol_id=bound_unknown.symbol_id,
|
||||||
name=unk_text,
|
name=unk_text,
|
||||||
semantic_role="unknown",
|
semantic_role="unknown",
|
||||||
source_span=_span(unk_text),
|
source_span=_span(unk_text),
|
||||||
|
|
@ -440,13 +436,7 @@ def bind_math_problem_graph(
|
||||||
unit=unk.unit,
|
unit=unk.unit,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
unknowns = (
|
unknowns = (bound_unknown,)
|
||||||
BoundUnknown(
|
|
||||||
symbol_id=unk_sid,
|
|
||||||
question_span=_span(unk_text),
|
|
||||||
expected_unit=unk.unit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return SemanticSymbolicBindingGraph(
|
return SemanticSymbolicBindingGraph(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ substrate.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Final
|
from typing import Final, Literal, Union
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public errors
|
# Public errors
|
||||||
|
|
@ -56,6 +56,52 @@ ADMISSIBILITY_STATUSES: Final[frozenset[str]] = frozenset(
|
||||||
{"admitted", "pending", "refused"}
|
{"admitted", "pending", "refused"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ADR-0135 — closed ``BoundUnknown.question_form`` vocabulary. Never coerce
|
||||||
|
# silently to a default; refuse with ``QuestionTargetError`` on unmappable
|
||||||
|
# graph shapes. Extending this set is a future ADR (not a one-liner).
|
||||||
|
QUESTION_FORMS: Final[frozenset[str]] = frozenset(
|
||||||
|
{"count", "rate", "total", "difference", "ratio", "identity"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ADR-0135 — closed ``BoundUnknown.state_index`` literal labels. The
|
||||||
|
# tagged-union ``StateIndex`` (below) is either one of these strings or
|
||||||
|
# an :class:`Operation` instance carrying a typed operation index.
|
||||||
|
STATE_INDEX_LABELS: Final[frozenset[str]] = frozenset({"initial", "terminal"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Operation:
|
||||||
|
"""ADR-0135 — state-index variant pointing at a specific operation.
|
||||||
|
|
||||||
|
The ``operation_index`` is type-checked as ``int`` (not ``str``) so
|
||||||
|
typos like ``Operation("3")`` refuse at construction. Cross-collection
|
||||||
|
bounds (``operation_index < graph operation count``) are enforced by
|
||||||
|
:class:`SemanticSymbolicBindingGraph.__post_init__`, not here — at
|
||||||
|
standalone construction we only know the value must be non-negative.
|
||||||
|
"""
|
||||||
|
|
||||||
|
operation_index: int
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not isinstance(self.operation_index, int) or isinstance(
|
||||||
|
self.operation_index, bool
|
||||||
|
):
|
||||||
|
raise BindingGraphError(
|
||||||
|
f"Operation.operation_index must be int; "
|
||||||
|
f"got {type(self.operation_index).__name__}"
|
||||||
|
)
|
||||||
|
if self.operation_index < 0:
|
||||||
|
raise BindingGraphError(
|
||||||
|
f"Operation.operation_index must be >= 0; "
|
||||||
|
f"got {self.operation_index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Tagged union for the state at which a :class:`BoundUnknown`'s symbol
|
||||||
|
# is observed. ``"initial"`` / ``"terminal"`` collapse to the obvious
|
||||||
|
# endpoints; ``Operation`` references a specific intermediate step.
|
||||||
|
StateIndex = Union[Literal["initial", "terminal"], Operation]
|
||||||
|
|
||||||
|
|
||||||
def _require_non_empty_str(value: object, field_name: str) -> None:
|
def _require_non_empty_str(value: object, field_name: str) -> None:
|
||||||
if not isinstance(value, str) or value == "":
|
if not isinstance(value, str) or value == "":
|
||||||
|
|
@ -267,10 +313,20 @@ class BoundEquation:
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BoundUnknown:
|
class BoundUnknown:
|
||||||
"""The target of the question, bound to a known symbol."""
|
"""The target of the question, bound to a known symbol.
|
||||||
|
|
||||||
|
ADR-0135 widens the contract from "the symbol whose value the solver
|
||||||
|
determines" to "the symbol at a specific temporal/state index with a
|
||||||
|
specific question-form". The two new fields are *required* — no
|
||||||
|
defaults — so every construction site declares its intent.
|
||||||
|
"""
|
||||||
|
|
||||||
symbol_id: str
|
symbol_id: str
|
||||||
question_span: SourceSpanLink
|
question_span: SourceSpanLink
|
||||||
|
state_index: "StateIndex"
|
||||||
|
question_form: Literal[
|
||||||
|
"count", "rate", "total", "difference", "ratio", "identity"
|
||||||
|
]
|
||||||
expected_unit: str | None = None
|
expected_unit: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -284,6 +340,18 @@ class BoundUnknown:
|
||||||
raise BindingGraphError(
|
raise BindingGraphError(
|
||||||
"BoundUnknown.question_span must be a SourceSpanLink"
|
"BoundUnknown.question_span must be a SourceSpanLink"
|
||||||
)
|
)
|
||||||
|
if isinstance(self.state_index, Operation):
|
||||||
|
pass # bounds re-checked by the graph; standalone is sign-only
|
||||||
|
elif self.state_index not in STATE_INDEX_LABELS:
|
||||||
|
raise BindingGraphError(
|
||||||
|
f"BoundUnknown.state_index must be 'initial', 'terminal', or "
|
||||||
|
f"an Operation instance; got {self.state_index!r}"
|
||||||
|
)
|
||||||
|
if self.question_form not in QUESTION_FORMS:
|
||||||
|
raise BindingGraphError(
|
||||||
|
f"BoundUnknown.question_form must be one of "
|
||||||
|
f"{sorted(QUESTION_FORMS)}; got {self.question_form!r}"
|
||||||
|
)
|
||||||
_require_optional_str(self.expected_unit, "BoundUnknown.expected_unit")
|
_require_optional_str(self.expected_unit, "BoundUnknown.expected_unit")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -396,12 +464,20 @@ class SemanticSymbolicBindingGraph:
|
||||||
f"{dep!r} (lhs={eq.lhs_symbol_id!r})"
|
f"{dep!r} (lhs={eq.lhs_symbol_id!r})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
equation_count = len(self.equations)
|
||||||
for unk in self.unknowns:
|
for unk in self.unknowns:
|
||||||
if unk.symbol_id not in known_ids:
|
if unk.symbol_id not in known_ids:
|
||||||
raise BindingGraphError(
|
raise BindingGraphError(
|
||||||
f"BoundUnknown references unknown symbol_id "
|
f"BoundUnknown references unknown symbol_id "
|
||||||
f"{unk.symbol_id!r}"
|
f"{unk.symbol_id!r}"
|
||||||
)
|
)
|
||||||
|
if isinstance(unk.state_index, Operation):
|
||||||
|
if unk.state_index.operation_index >= equation_count:
|
||||||
|
raise BindingGraphError(
|
||||||
|
f"BoundUnknown.state_index.operation_index "
|
||||||
|
f"{unk.state_index.operation_index} >= equation_count "
|
||||||
|
f"{equation_count}"
|
||||||
|
)
|
||||||
|
|
||||||
for con in self.constraints:
|
for con in self.constraints:
|
||||||
if con.symbol_id not in known_ids:
|
if con.symbol_id not in known_ids:
|
||||||
|
|
@ -440,8 +516,13 @@ class SemanticSymbolicBindingGraph:
|
||||||
f"span={eq.source_span.to_canonical_string()}"
|
f"span={eq.source_span.to_canonical_string()}"
|
||||||
)
|
)
|
||||||
for unk in self.unknowns:
|
for unk in self.unknowns:
|
||||||
|
if isinstance(unk.state_index, Operation):
|
||||||
|
state_token = f"op({unk.state_index.operation_index})"
|
||||||
|
else:
|
||||||
|
state_token = unk.state_index
|
||||||
lines.append(
|
lines.append(
|
||||||
f"U {unk.symbol_id} expected_unit={unk.expected_unit} "
|
f"U {unk.symbol_id} expected_unit={unk.expected_unit} "
|
||||||
|
f"state={state_token} form={unk.question_form} "
|
||||||
f"qspan={unk.question_span.to_canonical_string()}"
|
f"qspan={unk.question_span.to_canonical_string()}"
|
||||||
)
|
)
|
||||||
for con in self.constraints:
|
for con in self.constraints:
|
||||||
|
|
|
||||||
274
generate/binding_graph/question_target.py
Normal file
274
generate/binding_graph/question_target.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"""ADR-0135 — Question-target binding refinement.
|
||||||
|
|
||||||
|
Pure-function resolvers that take a :class:`MathProblemGraph` and produce
|
||||||
|
the two new fields ``BoundUnknown`` requires under ADR-0135:
|
||||||
|
|
||||||
|
- :func:`resolve_state_index` — picks the temporal index at which the
|
||||||
|
unknown's symbol is observed. Current rule: ``"terminal"`` when at
|
||||||
|
least one :class:`Operation` exists in the graph, ``"initial"`` when
|
||||||
|
not. The :class:`Operation` (state-index) variant is part of the
|
||||||
|
closed vocabulary but not produced by this resolver yet — it exists
|
||||||
|
so future intermediate-state queries have a typed home.
|
||||||
|
|
||||||
|
- :func:`infer_question_form` — closed dispatch over the operations
|
||||||
|
whose actor / target / reference touches the unknown's entity. The
|
||||||
|
precedence rule (documented in ADR-0135) is deterministic; ambiguous
|
||||||
|
or unmappable shapes refuse with :class:`QuestionTargetError`.
|
||||||
|
|
||||||
|
- :func:`bound_unknown_from_math_problem_graph` — the public entry
|
||||||
|
point the adapter uses. Refusal-first, deterministic, pure.
|
||||||
|
|
||||||
|
No I/O. No solver. No mutation. The resolver only determines *which*
|
||||||
|
symbol the question targets and *what* form the answer takes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Final, Literal
|
||||||
|
|
||||||
|
from generate.math_problem_graph import (
|
||||||
|
Comparison,
|
||||||
|
MathProblemGraph,
|
||||||
|
Operation as MathOperation,
|
||||||
|
Rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .model import (
|
||||||
|
BoundUnknown,
|
||||||
|
Operation,
|
||||||
|
SourceSpanLink,
|
||||||
|
StateIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public error
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionTargetError(ValueError):
|
||||||
|
"""Raised when the question-target resolver cannot bind deterministically.
|
||||||
|
|
||||||
|
Sibling of :class:`generate.binding_graph.AdapterError` and
|
||||||
|
:class:`generate.binding_graph.AdmissibilityError`. Always carries a
|
||||||
|
typed ``reason`` token from :data:`QUESTION_TARGET_REASONS`. The
|
||||||
|
resolver never silently picks a default form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reason: str, *, detail: str = "") -> None:
|
||||||
|
if reason not in QUESTION_TARGET_REASONS:
|
||||||
|
raise ValueError(
|
||||||
|
f"QuestionTargetError.reason must be one of "
|
||||||
|
f"{sorted(QUESTION_TARGET_REASONS)}; got {reason!r}"
|
||||||
|
)
|
||||||
|
self.reason: Final[str] = reason
|
||||||
|
self.detail: Final[str] = detail
|
||||||
|
message = f"{reason}: {detail}" if detail else reason
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
QUESTION_TARGET_REASONS: Final[frozenset[str]] = frozenset(
|
||||||
|
{
|
||||||
|
"not_a_math_problem_graph",
|
||||||
|
"unknown_entity_not_in_entities",
|
||||||
|
"unmappable_question_form",
|
||||||
|
"apply_rate_unit_mismatch",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — naming locked to ``generate.binding_graph.adapter`` conventions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SLUG_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(text: str) -> str:
|
||||||
|
return _SLUG_NON_ALNUM.sub("_", text.strip().lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
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 _unknown_span(entity: str | None, unit: str) -> SourceSpanLink:
|
||||||
|
text = f"{entity}|{unit}" if entity is not None else f"total|{unit}"
|
||||||
|
return SourceSpanLink(
|
||||||
|
source_id="math_problem_graph", start=0, end=len(text), text=text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _operation_touches_unknown(
|
||||||
|
op: MathOperation, unk_entity: str | None
|
||||||
|
) -> bool:
|
||||||
|
"""An operation touches the unknown if its actor / target /
|
||||||
|
comparison reference matches the unknown's entity. When the unknown
|
||||||
|
has no entity (``entity is None``, the "total across all entities"
|
||||||
|
case), every operation is considered to touch it.
|
||||||
|
"""
|
||||||
|
if unk_entity is None:
|
||||||
|
return True
|
||||||
|
if op.actor == unk_entity:
|
||||||
|
return True
|
||||||
|
if op.target is not None and op.target == unk_entity:
|
||||||
|
return True
|
||||||
|
if isinstance(op.operand, Comparison):
|
||||||
|
if op.operand.reference_actor == unk_entity:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public resolvers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_state_index(g: object) -> StateIndex:
|
||||||
|
"""Pick the temporal index at which the unknown is observed.
|
||||||
|
|
||||||
|
Closed rule: ``"terminal"`` when the graph has at least one
|
||||||
|
operation, ``"initial"`` when not. The :class:`Operation` variant of
|
||||||
|
:data:`StateIndex` is part of the public vocabulary so future
|
||||||
|
intermediate-state queries have a typed home, but this resolver
|
||||||
|
never returns it today.
|
||||||
|
"""
|
||||||
|
if not isinstance(g, MathProblemGraph):
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"not_a_math_problem_graph",
|
||||||
|
detail=f"got {type(g).__name__}",
|
||||||
|
)
|
||||||
|
if g.operations:
|
||||||
|
return "terminal"
|
||||||
|
return "initial"
|
||||||
|
|
||||||
|
|
||||||
|
_COUNT_KINDS: Final[frozenset[str]] = frozenset(
|
||||||
|
{"add", "subtract", "transfer", "multiply", "divide"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_question_form(
|
||||||
|
g: object,
|
||||||
|
) -> Literal["count", "rate", "total", "difference", "ratio", "identity"]:
|
||||||
|
"""Closed-vocab dispatch on operation kinds touching the unknown.
|
||||||
|
|
||||||
|
Precedence (deterministic; documented in ADR-0135). Evaluate in
|
||||||
|
order, return on first match:
|
||||||
|
|
||||||
|
1. No operations touch the unknown's entity → ``"identity"``.
|
||||||
|
2. Any ``compare_multiplicative`` touches → ``"ratio"``.
|
||||||
|
3. Any ``compare_additive`` touches → ``"difference"``.
|
||||||
|
4. Any ``apply_rate`` touches → ``"total"`` when the unknown's
|
||||||
|
unit matches the rate's ``numerator_unit``; ``"rate"`` when it
|
||||||
|
matches the ``denominator_unit``. Otherwise refuse
|
||||||
|
(``apply_rate_unit_mismatch``).
|
||||||
|
5. Every touching operation is in ``{add, subtract, transfer,
|
||||||
|
multiply, divide}`` → ``"count"``.
|
||||||
|
6. Anything else → refuse (``unmappable_question_form``).
|
||||||
|
|
||||||
|
The precedence order resolves ambiguity: a graph mixing
|
||||||
|
``compare_additive`` with ``add`` returns ``"difference"`` because
|
||||||
|
the comparison establishes the question's shape regardless of any
|
||||||
|
downstream arithmetic. This is a closed rule, not a heuristic.
|
||||||
|
"""
|
||||||
|
if not isinstance(g, MathProblemGraph):
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"not_a_math_problem_graph",
|
||||||
|
detail=f"got {type(g).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
unk_entity = g.unknown.entity
|
||||||
|
if unk_entity is not None and unk_entity not in g.entities:
|
||||||
|
# MathProblemGraph already enforces this invariant, but defensive
|
||||||
|
# restatement keeps the resolver self-contained.
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"unknown_entity_not_in_entities",
|
||||||
|
detail=f"entity={unk_entity!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
touching: tuple[MathOperation, ...] = tuple(
|
||||||
|
op for op in g.operations if _operation_touches_unknown(op, unk_entity)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not touching:
|
||||||
|
return "identity"
|
||||||
|
|
||||||
|
kinds = {op.kind for op in touching}
|
||||||
|
|
||||||
|
if "compare_multiplicative" in kinds:
|
||||||
|
return "ratio"
|
||||||
|
if "compare_additive" in kinds:
|
||||||
|
return "difference"
|
||||||
|
|
||||||
|
if "apply_rate" in kinds:
|
||||||
|
unk_unit = g.unknown.unit
|
||||||
|
for op in touching:
|
||||||
|
if op.kind == "apply_rate" and isinstance(op.operand, Rate):
|
||||||
|
if unk_unit == op.operand.numerator_unit:
|
||||||
|
return "total"
|
||||||
|
if unk_unit == op.operand.denominator_unit:
|
||||||
|
return "rate"
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"apply_rate_unit_mismatch",
|
||||||
|
detail=(
|
||||||
|
f"unknown.unit={unk_unit!r} matches neither numerator nor "
|
||||||
|
f"denominator of any touching apply_rate"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if kinds.issubset(_COUNT_KINDS):
|
||||||
|
return "count"
|
||||||
|
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"unmappable_question_form",
|
||||||
|
detail=f"touching kinds={sorted(kinds)!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bound_unknown_from_math_problem_graph(g: object) -> BoundUnknown:
|
||||||
|
"""Build the refined :class:`BoundUnknown` for ``g``.
|
||||||
|
|
||||||
|
The adapter (ADR-0133) calls this in place of its old ad-hoc
|
||||||
|
``Unknown → BoundUnknown`` mapping. Determinism: identical ``g``
|
||||||
|
yields a byte-equal :class:`BoundUnknown`. Refusal-first: any
|
||||||
|
:class:`QuestionTargetError` from the sub-resolvers propagates.
|
||||||
|
|
||||||
|
The synthesized ``symbol_id`` / ``question_span`` mirror the
|
||||||
|
adapter's pre-ADR-0135 convention exactly, so the symbol map in the
|
||||||
|
surrounding binding graph remains the same shape.
|
||||||
|
"""
|
||||||
|
if not isinstance(g, MathProblemGraph):
|
||||||
|
raise QuestionTargetError(
|
||||||
|
"not_a_math_problem_graph",
|
||||||
|
detail=f"got {type(g).__name__}",
|
||||||
|
)
|
||||||
|
unk = g.unknown
|
||||||
|
state_index = resolve_state_index(g)
|
||||||
|
question_form = infer_question_form(g)
|
||||||
|
sid = _unknown_symbol_id(unk.entity, unk.unit)
|
||||||
|
span = _unknown_span(unk.entity, unk.unit)
|
||||||
|
return BoundUnknown(
|
||||||
|
symbol_id=sid,
|
||||||
|
question_span=span,
|
||||||
|
state_index=state_index,
|
||||||
|
question_form=question_form,
|
||||||
|
expected_unit=unk.unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Re-export ``Operation`` so callers do not need to thread the model
|
||||||
|
# import separately just to construct the state-index variant.
|
||||||
|
__all__ = (
|
||||||
|
"QUESTION_TARGET_REASONS",
|
||||||
|
"Operation",
|
||||||
|
"QuestionTargetError",
|
||||||
|
"bound_unknown_from_math_problem_graph",
|
||||||
|
"infer_question_form",
|
||||||
|
"resolve_state_index",
|
||||||
|
)
|
||||||
446
tests/test_binding_graph_adapter_question_target.py
Normal file
446
tests/test_binding_graph_adapter_question_target.py
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
"""ADR-0135 — Adapter integration tests for question-target binding.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- the adapter consumes ``bound_unknown_from_math_problem_graph`` and
|
||||||
|
emits a single :class:`BoundUnknown` per graph with populated
|
||||||
|
``state_index`` + ``question_form`` fields;
|
||||||
|
- byte-equal hash-stability across runs survives the Phase-4 wiring
|
||||||
|
(Phase 2 invariant — value may differ from Phase 3 main, by design);
|
||||||
|
- every Phase-2 / Phase-3 round-trip case still produces a
|
||||||
|
well-formed binding graph;
|
||||||
|
- intentionally-ambiguous inputs surface ``QuestionTargetError``
|
||||||
|
(resolver refusal) — not silent coercion;
|
||||||
|
- cross-collection invariant guards against bogus ``Operation``
|
||||||
|
state-index pointing past ``len(equations)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from generate.binding_graph import (
|
||||||
|
BindingGraphError,
|
||||||
|
BoundEquation,
|
||||||
|
BoundFact,
|
||||||
|
BoundUnknown,
|
||||||
|
Operation as StateIndexOperation,
|
||||||
|
QuestionTargetError,
|
||||||
|
SemanticSymbolicBindingGraph,
|
||||||
|
SourceSpanLink,
|
||||||
|
SymbolBinding,
|
||||||
|
bind_math_problem_graph,
|
||||||
|
)
|
||||||
|
from generate.math_problem_graph import (
|
||||||
|
Comparison,
|
||||||
|
InitialPossession,
|
||||||
|
MathProblemGraph,
|
||||||
|
Operation,
|
||||||
|
Quantity,
|
||||||
|
Rate,
|
||||||
|
Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _g(
|
||||||
|
*,
|
||||||
|
entities: tuple[str, ...] = ("Tina",),
|
||||||
|
initial_state: tuple[InitialPossession, ...] = (
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
),
|
||||||
|
operations: tuple[Operation, ...] = (),
|
||||||
|
unknown: Unknown = Unknown(entity="Tina", unit="apples"),
|
||||||
|
) -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=entities,
|
||||||
|
initial_state=initial_state,
|
||||||
|
operations=operations,
|
||||||
|
unknown=unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add(actor: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(actor=actor, kind="add", operand=Quantity(n, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_op(actor: str, value: float, num: str, denom: str) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="apply_rate",
|
||||||
|
operand=Rate(value=value, numerator_unit=num, denominator_unit=denom),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp_add(actor: str, ref: str, delta: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="compare_additive",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor=ref,
|
||||||
|
delta=Quantity(delta, unit),
|
||||||
|
factor=None,
|
||||||
|
direction="more",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp_mul(actor: str, ref: str, factor: float) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="compare_multiplicative",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor=ref, delta=None, factor=factor, direction="times"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Smoke: every Phase-2 round-trip case still emits a well-formed graph
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_emits_single_bound_unknown() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),)))
|
||||||
|
assert len(bg.unknowns) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_terminal_count() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),)))
|
||||||
|
bu = bg.unknowns[0]
|
||||||
|
assert bu.state_index == "terminal"
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
assert bu.expected_unit == "apples"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_initial_identity() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=()))
|
||||||
|
bu = bg.unknowns[0]
|
||||||
|
assert bu.state_index == "initial"
|
||||||
|
assert bu.question_form == "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_total_via_apply_rate() -> None:
|
||||||
|
g = _g(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, "dollars", "hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="dollars"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
assert bg.unknowns[0].question_form == "total"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_rate_via_apply_rate() -> None:
|
||||||
|
g = _g(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, "dollars", "hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="hours"),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
assert bg.unknowns[0].question_form == "rate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_difference() -> None:
|
||||||
|
g = _g(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_add("Tina", "Sam", 2),),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
assert bg.unknowns[0].question_form == "difference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_ratio() -> None:
|
||||||
|
g = _g(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_mul("Tina", "Sam", 2.0),),
|
||||||
|
)
|
||||||
|
bg = bind_math_problem_graph(g)
|
||||||
|
assert bg.unknowns[0].question_form == "ratio"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hash-stability: byte-equal across runs (Phase 2 invariant)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(bg: SemanticSymbolicBindingGraph) -> str:
|
||||||
|
return hashlib.sha256(bg.to_canonical_string().encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_byte_equal_across_runs_count() -> None:
|
||||||
|
g = _g(operations=(_add("Tina", 2), _add("Tina", 3)))
|
||||||
|
a = bind_math_problem_graph(g)
|
||||||
|
b = bind_math_problem_graph(g)
|
||||||
|
assert a.to_canonical_string() == b.to_canonical_string()
|
||||||
|
assert _digest(a) == _digest(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_byte_equal_across_runs_identity() -> None:
|
||||||
|
g = _g(operations=())
|
||||||
|
a = bind_math_problem_graph(g)
|
||||||
|
b = bind_math_problem_graph(g)
|
||||||
|
assert a.to_canonical_string() == b.to_canonical_string()
|
||||||
|
|
||||||
|
|
||||||
|
def test_byte_equal_across_runs_difference() -> None:
|
||||||
|
g = _g(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_add("Tina", "Sam", 2),),
|
||||||
|
)
|
||||||
|
assert bind_math_problem_graph(g).to_canonical_string() == bind_math_problem_graph(
|
||||||
|
g
|
||||||
|
).to_canonical_string()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_string_mentions_state_and_form() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),)))
|
||||||
|
s = bg.to_canonical_string()
|
||||||
|
assert "state=terminal" in s
|
||||||
|
assert "form=count" in s
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_string_identity_form() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=()))
|
||||||
|
s = bg.to_canonical_string()
|
||||||
|
assert "state=initial" in s
|
||||||
|
assert "form=identity" in s
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refusal-first propagation: resolver refusals surface to caller
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_propagates_apply_rate_unit_mismatch() -> None:
|
||||||
|
g = _g(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="apples"),
|
||||||
|
)
|
||||||
|
with pytest.raises(QuestionTargetError) as exc:
|
||||||
|
bind_math_problem_graph(g)
|
||||||
|
assert exc.value.reason == "apply_rate_unit_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-collection guard: Operation(operation_index) out of bounds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _span(text: str = "x") -> SourceSpanLink:
|
||||||
|
return SourceSpanLink(source_id="t", start=0, end=len(text), text=text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_refuses_operation_state_index_out_of_bounds() -> None:
|
||||||
|
# Construct a manual graph: 1 equation (so equation_count == 1), but
|
||||||
|
# the BoundUnknown points to Operation(operation_index=5) → refuse.
|
||||||
|
lhs = "op_000_result"
|
||||||
|
sym_q = SymbolBinding(
|
||||||
|
symbol_id="q_x_apples_t0",
|
||||||
|
name="x@t0",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
unit="apples",
|
||||||
|
)
|
||||||
|
sym_r = SymbolBinding(
|
||||||
|
symbol_id=lhs,
|
||||||
|
name="r",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
)
|
||||||
|
sym_u = SymbolBinding(
|
||||||
|
symbol_id="unknown_x_apples",
|
||||||
|
name="u",
|
||||||
|
semantic_role="unknown",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
unit="apples",
|
||||||
|
)
|
||||||
|
fact = BoundFact(
|
||||||
|
symbol_id="q_x_apples_t0", value="5", source_span=_span(), unit="apples"
|
||||||
|
)
|
||||||
|
eq = BoundEquation(
|
||||||
|
lhs_symbol_id=lhs,
|
||||||
|
rhs_canonical="add(x, 2 apples)",
|
||||||
|
dependencies=frozenset({"q_x_apples_t0"}),
|
||||||
|
operation_kind="add",
|
||||||
|
unit_proof="apples",
|
||||||
|
admissibility_status="admitted",
|
||||||
|
source_span=_span(),
|
||||||
|
)
|
||||||
|
bu = BoundUnknown(
|
||||||
|
symbol_id="unknown_x_apples",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index=StateIndexOperation(operation_index=5),
|
||||||
|
question_form="count",
|
||||||
|
)
|
||||||
|
with pytest.raises(BindingGraphError):
|
||||||
|
SemanticSymbolicBindingGraph(
|
||||||
|
symbols=(sym_q, sym_r, sym_u),
|
||||||
|
facts=(fact,),
|
||||||
|
equations=(eq,),
|
||||||
|
unknowns=(bu,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_accepts_operation_state_index_within_bounds() -> None:
|
||||||
|
# Same shape, but operation_index=0 is within bounds (equation_count==1).
|
||||||
|
lhs = "op_000_result"
|
||||||
|
sym_q = SymbolBinding(
|
||||||
|
symbol_id="q_x_apples_t0",
|
||||||
|
name="x@t0",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
unit="apples",
|
||||||
|
)
|
||||||
|
sym_r = SymbolBinding(
|
||||||
|
symbol_id=lhs,
|
||||||
|
name="r",
|
||||||
|
semantic_role="quantity",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
)
|
||||||
|
sym_u = SymbolBinding(
|
||||||
|
symbol_id="unknown_x_apples",
|
||||||
|
name="u",
|
||||||
|
semantic_role="unknown",
|
||||||
|
source_span=_span(),
|
||||||
|
introduced_by="test",
|
||||||
|
entity="x",
|
||||||
|
unit="apples",
|
||||||
|
)
|
||||||
|
fact = BoundFact(
|
||||||
|
symbol_id="q_x_apples_t0", value="5", source_span=_span(), unit="apples"
|
||||||
|
)
|
||||||
|
eq = BoundEquation(
|
||||||
|
lhs_symbol_id=lhs,
|
||||||
|
rhs_canonical="add(x, 2 apples)",
|
||||||
|
dependencies=frozenset({"q_x_apples_t0"}),
|
||||||
|
operation_kind="add",
|
||||||
|
unit_proof="apples",
|
||||||
|
admissibility_status="admitted",
|
||||||
|
source_span=_span(),
|
||||||
|
)
|
||||||
|
bu = BoundUnknown(
|
||||||
|
symbol_id="unknown_x_apples",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index=StateIndexOperation(operation_index=0),
|
||||||
|
question_form="count",
|
||||||
|
)
|
||||||
|
bg = SemanticSymbolicBindingGraph(
|
||||||
|
symbols=(sym_q, sym_r, sym_u),
|
||||||
|
facts=(fact,),
|
||||||
|
equations=(eq,),
|
||||||
|
unknowns=(bu,),
|
||||||
|
)
|
||||||
|
assert bg.unknowns[0].state_index == StateIndexOperation(operation_index=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MathProblemGraph is read-only (frozen) — assert in test as brief requires
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_does_not_mutate_input_graph() -> None:
|
||||||
|
g = _g(operations=(_add("Tina", 2),))
|
||||||
|
before = g.canonical_bytes()
|
||||||
|
_ = bind_math_problem_graph(g)
|
||||||
|
after = g.canonical_bytes()
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
def test_math_problem_graph_is_frozen() -> None:
|
||||||
|
g = _g()
|
||||||
|
with pytest.raises((AttributeError, Exception)):
|
||||||
|
g.unknown = Unknown(entity="Tina", unit="apples") # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Symbol binding for the unknown still emitted (Phase 2 invariant)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_symbol_binding_still_emitted() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),)))
|
||||||
|
unk_syms = [s for s in bg.symbols if s.semantic_role == "unknown"]
|
||||||
|
assert len(unk_syms) == 1
|
||||||
|
assert unk_syms[0].symbol_id == "unknown_tina_apples"
|
||||||
|
assert unk_syms[0].entity == "Tina"
|
||||||
|
assert unk_syms[0].unit == "apples"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_symbol_id_matches_bound_unknown() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 2),)))
|
||||||
|
unk_sym = next(s for s in bg.symbols if s.semantic_role == "unknown")
|
||||||
|
assert unk_sym.symbol_id == bg.unknowns[0].symbol_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parametric: question_form is one of the closed vocabulary on smoke graphs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"ops, expected_form",
|
||||||
|
[
|
||||||
|
((), "identity"),
|
||||||
|
((_add("Tina", 1),), "count"),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
Operation(actor="Tina", kind="subtract", operand=Quantity(1, "apples")),
|
||||||
|
),
|
||||||
|
"count",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
Operation(actor="Tina", kind="multiply", operand=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
"count",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
Operation(actor="Tina", kind="divide", operand=Quantity(2, "apples")),
|
||||||
|
),
|
||||||
|
"count",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_adapter_form_table(ops, expected_form) -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=ops))
|
||||||
|
assert bg.unknowns[0].question_form == expected_form
|
||||||
|
|
||||||
|
|
||||||
|
def test_adapter_bound_unknown_is_instance() -> None:
|
||||||
|
bg = bind_math_problem_graph(_g(operations=(_add("Tina", 1),)))
|
||||||
|
assert isinstance(bg.unknowns[0], BoundUnknown)
|
||||||
|
|
@ -298,19 +298,33 @@ def test_bound_equation_is_frozen() -> None:
|
||||||
|
|
||||||
def test_bound_unknown_construction() -> None:
|
def test_bound_unknown_construction() -> None:
|
||||||
unk = BoundUnknown(
|
unk = BoundUnknown(
|
||||||
symbol_id="sym_y_000", question_span=_span(), expected_unit="dollars"
|
symbol_id="sym_y_000",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index="terminal",
|
||||||
|
question_form="count",
|
||||||
|
expected_unit="dollars",
|
||||||
)
|
)
|
||||||
assert unk.expected_unit == "dollars"
|
assert unk.expected_unit == "dollars"
|
||||||
|
|
||||||
|
|
||||||
def test_bound_unknown_refuses_bad_id() -> None:
|
def test_bound_unknown_refuses_bad_id() -> None:
|
||||||
with pytest.raises(BindingGraphError):
|
with pytest.raises(BindingGraphError):
|
||||||
BoundUnknown(symbol_id="bad id", question_span=_span())
|
BoundUnknown(
|
||||||
|
symbol_id="bad id",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index="terminal",
|
||||||
|
question_form="count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bound_unknown_refuses_non_span_question() -> None:
|
def test_bound_unknown_refuses_non_span_question() -> None:
|
||||||
with pytest.raises(BindingGraphError):
|
with pytest.raises(BindingGraphError):
|
||||||
BoundUnknown(symbol_id="sym_y_000", question_span="text") # type: ignore[arg-type]
|
BoundUnknown(
|
||||||
|
symbol_id="sym_y_000",
|
||||||
|
question_span="text", # type: ignore[arg-type]
|
||||||
|
state_index="terminal",
|
||||||
|
question_form="count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -380,7 +394,12 @@ def test_graph_rejects_equation_with_unknown_dependency() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_graph_rejects_unknown_referencing_missing_symbol() -> None:
|
def test_graph_rejects_unknown_referencing_missing_symbol() -> None:
|
||||||
unk = BoundUnknown(symbol_id="sym_ghost_000", question_span=_span())
|
unk = BoundUnknown(
|
||||||
|
symbol_id="sym_ghost_000",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index="terminal",
|
||||||
|
question_form="count",
|
||||||
|
)
|
||||||
with pytest.raises(BindingGraphError):
|
with pytest.raises(BindingGraphError):
|
||||||
SemanticSymbolicBindingGraph(symbols=(_sym(),), unknowns=(unk,))
|
SemanticSymbolicBindingGraph(symbols=(_sym(),), unknowns=(unk,))
|
||||||
|
|
||||||
|
|
@ -402,7 +421,14 @@ def test_graph_full_round_trip_canonical_string_stable() -> None:
|
||||||
BoundFact(symbol_id="sym_x_000", value="5", source_span=_span(), unit="apples"),
|
BoundFact(symbol_id="sym_x_000", value="5", source_span=_span(), unit="apples"),
|
||||||
)
|
)
|
||||||
eqs = (_eq(),)
|
eqs = (_eq(),)
|
||||||
unks = (BoundUnknown(symbol_id="sym_y_000", question_span=_span()),)
|
unks = (
|
||||||
|
BoundUnknown(
|
||||||
|
symbol_id="sym_y_000",
|
||||||
|
question_span=_span(),
|
||||||
|
state_index="terminal",
|
||||||
|
question_form="count",
|
||||||
|
),
|
||||||
|
)
|
||||||
cons = (
|
cons = (
|
||||||
BoundConstraint(symbol_id="sym_x_000", predicate="x >= 0", source_span=_span()),
|
BoundConstraint(symbol_id="sym_x_000", predicate="x >= 0", source_span=_span()),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
550
tests/test_binding_graph_question_target.py
Normal file
550
tests/test_binding_graph_question_target.py
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
"""ADR-0135 — Tests for the question-target resolver.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- ``resolve_state_index`` boundary cases (0 / 1 / many operations);
|
||||||
|
- ``infer_question_form`` for every operation-kind family;
|
||||||
|
- precedence rule on mixed-kind graphs;
|
||||||
|
- refusal-first ``QuestionTargetError`` paths;
|
||||||
|
- ``bound_unknown_from_math_problem_graph`` deterministic shape;
|
||||||
|
- typed ``Operation`` (state-index variant) construction guards.
|
||||||
|
|
||||||
|
Pure unit lane — adapter integration lives in
|
||||||
|
``test_binding_graph_adapter_question_target.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from generate.binding_graph import (
|
||||||
|
BindingGraphError,
|
||||||
|
BoundUnknown,
|
||||||
|
Operation as StateIndexOperation,
|
||||||
|
QUESTION_FORMS,
|
||||||
|
QUESTION_TARGET_REASONS,
|
||||||
|
QuestionTargetError,
|
||||||
|
STATE_INDEX_LABELS,
|
||||||
|
bound_unknown_from_math_problem_graph,
|
||||||
|
infer_question_form,
|
||||||
|
resolve_state_index,
|
||||||
|
)
|
||||||
|
from generate.math_problem_graph import (
|
||||||
|
Comparison,
|
||||||
|
InitialPossession,
|
||||||
|
MathProblemGraph,
|
||||||
|
Operation,
|
||||||
|
Quantity,
|
||||||
|
Rate,
|
||||||
|
Unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(
|
||||||
|
*,
|
||||||
|
entities: tuple[str, ...] = ("Tina",),
|
||||||
|
initial_state: tuple[InitialPossession, ...] = (
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
),
|
||||||
|
operations: tuple[Operation, ...] = (),
|
||||||
|
unknown: Unknown = Unknown(entity="Tina", unit="apples"),
|
||||||
|
) -> MathProblemGraph:
|
||||||
|
return MathProblemGraph(
|
||||||
|
entities=entities,
|
||||||
|
initial_state=initial_state,
|
||||||
|
operations=operations,
|
||||||
|
unknown=unknown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add(actor: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(actor=actor, kind="add", operand=Quantity(n, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _sub(actor: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(actor=actor, kind="subtract", operand=Quantity(n, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _transfer(actor: str, target: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor, kind="transfer", operand=Quantity(n, unit), target=target
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mul(actor: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(actor=actor, kind="multiply", operand=Quantity(n, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _div(actor: str, n: int, unit: str = "apples") -> Operation:
|
||||||
|
return Operation(actor=actor, kind="divide", operand=Quantity(n, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_op(
|
||||||
|
actor: str, value: float, num: str, denom: str
|
||||||
|
) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="apply_rate",
|
||||||
|
operand=Rate(value=value, numerator_unit=num, denominator_unit=denom),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp_add(
|
||||||
|
actor: str, ref: str, delta: int, unit: str = "apples", direction: str = "more"
|
||||||
|
) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="compare_additive",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor=ref,
|
||||||
|
delta=Quantity(delta, unit),
|
||||||
|
factor=None,
|
||||||
|
direction=direction, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmp_mul(
|
||||||
|
actor: str, ref: str, factor: float, direction: str = "times"
|
||||||
|
) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
actor=actor,
|
||||||
|
kind="compare_multiplicative",
|
||||||
|
operand=Comparison(
|
||||||
|
reference_actor=ref,
|
||||||
|
delta=None,
|
||||||
|
factor=factor,
|
||||||
|
direction=direction, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# StateIndex / Operation dataclass guards
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_state_index_construction() -> None:
|
||||||
|
op = StateIndexOperation(operation_index=3)
|
||||||
|
assert op.operation_index == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_state_index_refuses_negative() -> None:
|
||||||
|
with pytest.raises(BindingGraphError):
|
||||||
|
StateIndexOperation(operation_index=-1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_state_index_refuses_bool() -> None:
|
||||||
|
with pytest.raises(BindingGraphError):
|
||||||
|
StateIndexOperation(operation_index=True) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_state_index_refuses_string() -> None:
|
||||||
|
with pytest.raises(BindingGraphError):
|
||||||
|
StateIndexOperation(operation_index="3") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_index_labels_locked() -> None:
|
||||||
|
assert STATE_INDEX_LABELS == frozenset({"initial", "terminal"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_forms_locked() -> None:
|
||||||
|
assert QUESTION_FORMS == frozenset(
|
||||||
|
{"count", "rate", "total", "difference", "ratio", "identity"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_state_index
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_state_index_no_operations_is_initial() -> None:
|
||||||
|
g = _graph(operations=())
|
||||||
|
assert resolve_state_index(g) == "initial"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_state_index_one_operation_is_terminal() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2),))
|
||||||
|
assert resolve_state_index(g) == "terminal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_state_index_many_operations_is_terminal() -> None:
|
||||||
|
g = _graph(
|
||||||
|
operations=tuple(_add("Tina", n) for n in (1, 2, 3, 4, 5)),
|
||||||
|
)
|
||||||
|
assert resolve_state_index(g) == "terminal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_state_index_refuses_non_graph() -> None:
|
||||||
|
with pytest.raises(QuestionTargetError) as exc:
|
||||||
|
resolve_state_index({"entities": ["Tina"]})
|
||||||
|
assert exc.value.reason == "not_a_math_problem_graph"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_state_index_deterministic() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2), _sub("Tina", 1)))
|
||||||
|
assert resolve_state_index(g) == resolve_state_index(g)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# infer_question_form — single-kind families
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_pure_add() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2), _add("Tina", 3)))
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_pure_subtract() -> None:
|
||||||
|
g = _graph(operations=(_sub("Tina", 2),))
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_pure_transfer() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_transfer("Tina", "Sam", 2),),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_multiply() -> None:
|
||||||
|
g = _graph(operations=(_mul("Tina", 3),))
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_divide() -> None:
|
||||||
|
g = _graph(operations=(_div("Tina", 5),))
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_count_mixed_arithmetic() -> None:
|
||||||
|
g = _graph(
|
||||||
|
operations=(_add("Tina", 4), _sub("Tina", 1), _mul("Tina", 2)),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_difference_compare_additive() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_add("Tina", "Sam", 2),),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "difference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_ratio_compare_multiplicative() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_mul("Tina", "Sam", 2.0),),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "ratio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_total_apply_rate_unknown_is_numerator() -> None:
|
||||||
|
# "How many dollars does Tina earn?" — apply_rate consumes hours,
|
||||||
|
# produces dollars. Unknown.unit == numerator_unit ⇒ total.
|
||||||
|
g = _graph(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="dollars"),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "total"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_rate_apply_rate_unknown_is_denominator() -> None:
|
||||||
|
# Unknown.unit == denominator_unit ⇒ rate.
|
||||||
|
g = _graph(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="hours"),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "rate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_identity_no_operations() -> None:
|
||||||
|
g = _graph(operations=())
|
||||||
|
assert infer_question_form(g) == "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_identity_when_no_operation_touches_unknown_entity() -> None:
|
||||||
|
# Tina is in entities (has initial possession) but the only operation
|
||||||
|
# belongs to Sam → no touching op → identity.
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_add("Sam", 2),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="apples"),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "identity"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# infer_question_form — precedence on mixed kinds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedence_compare_multiplicative_beats_additive() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
_cmp_add("Tina", "Sam", 2),
|
||||||
|
_cmp_mul("Tina", "Sam", 2.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "ratio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedence_compare_additive_beats_count() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
_add("Tina", 4),
|
||||||
|
_cmp_add("Tina", "Sam", 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "difference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedence_compare_beats_apply_rate() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(4, "hours")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
_rate_op("Tina", 12.5, "dollars", "hours"),
|
||||||
|
_cmp_add("Tina", "Sam", 2, unit="hours"),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Tina", unit="hours"),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "difference"
|
||||||
|
|
||||||
|
|
||||||
|
def test_precedence_apply_rate_beats_count() -> None:
|
||||||
|
g = _graph(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(
|
||||||
|
_add("Tina", 1, unit="hours"),
|
||||||
|
_rate_op("Tina", 12.5, "dollars", "hours"),
|
||||||
|
),
|
||||||
|
unknown=Unknown(entity="Tina", unit="dollars"),
|
||||||
|
)
|
||||||
|
assert infer_question_form(g) == "total"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# infer_question_form — refusals
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_rate_unit_mismatch_refuses() -> None:
|
||||||
|
# Unknown unit matches neither numerator nor denominator of any
|
||||||
|
# touching apply_rate → typed refusal.
|
||||||
|
g = _graph(
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(8, "hours")),
|
||||||
|
),
|
||||||
|
operations=(_rate_op("Tina", 12.5, num="dollars", denom="hours"),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="apples"),
|
||||||
|
)
|
||||||
|
with pytest.raises(QuestionTargetError) as exc:
|
||||||
|
infer_question_form(g)
|
||||||
|
assert exc.value.reason == "apply_rate_unit_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_infer_refuses_non_graph_input() -> None:
|
||||||
|
with pytest.raises(QuestionTargetError) as exc:
|
||||||
|
infer_question_form(object())
|
||||||
|
assert exc.value.reason == "not_a_math_problem_graph"
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_target_reasons_locked() -> None:
|
||||||
|
assert QUESTION_TARGET_REASONS == frozenset(
|
||||||
|
{
|
||||||
|
"not_a_math_problem_graph",
|
||||||
|
"unknown_entity_not_in_entities",
|
||||||
|
"unmappable_question_form",
|
||||||
|
"apply_rate_unit_mismatch",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_target_error_refuses_unknown_reason() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
QuestionTargetError("nonexistent_reason")
|
||||||
|
|
||||||
|
|
||||||
|
def test_question_target_error_carries_typed_reason() -> None:
|
||||||
|
exc = QuestionTargetError("unmappable_question_form", detail="weird shape")
|
||||||
|
assert exc.reason == "unmappable_question_form"
|
||||||
|
assert "weird shape" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bound_unknown_from_math_problem_graph
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_terminal_count() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2),))
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert isinstance(bu, BoundUnknown)
|
||||||
|
assert bu.symbol_id == "unknown_tina_apples"
|
||||||
|
assert bu.state_index == "terminal"
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
assert bu.expected_unit == "apples"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_initial_identity_no_ops() -> None:
|
||||||
|
g = _graph(operations=())
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.state_index == "initial"
|
||||||
|
assert bu.question_form == "identity"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_total_unknown_entity_none() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_add("Tina", 2), _sub("Sam", 1)),
|
||||||
|
unknown=Unknown(entity=None, unit="apples"),
|
||||||
|
)
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.symbol_id == "unknown_total_apples"
|
||||||
|
assert bu.state_index == "terminal"
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_deterministic_byte_equal() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2),))
|
||||||
|
a = bound_unknown_from_math_problem_graph(g)
|
||||||
|
b = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_bound_unknown_refuses_non_graph_input() -> None:
|
||||||
|
with pytest.raises(QuestionTargetError):
|
||||||
|
bound_unknown_from_math_problem_graph(123)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defensive: input is read-only
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_does_not_mutate_input() -> None:
|
||||||
|
g = _graph(operations=(_add("Tina", 2),))
|
||||||
|
before = g.canonical_bytes()
|
||||||
|
_ = bound_unknown_from_math_problem_graph(g)
|
||||||
|
after = g.canonical_bytes()
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parametric coverage of every operation kind in every question_form bucket
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kind_op",
|
||||||
|
[
|
||||||
|
_add("Tina", 2),
|
||||||
|
_sub("Tina", 1),
|
||||||
|
_mul("Tina", 2),
|
||||||
|
_div("Tina", 2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_count_kinds_single_op_terminal_count(kind_op: Operation) -> None:
|
||||||
|
g = _graph(operations=(kind_op,))
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
assert bu.state_index == "terminal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_count_form_for_actor() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_transfer("Tina", "Sam", 1),),
|
||||||
|
unknown=Unknown(entity="Tina", unit="apples"),
|
||||||
|
)
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_count_form_for_target() -> None:
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_transfer("Tina", "Sam", 1),),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.question_form == "count"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unknown-entity boundary: comparison reference_actor counts as "touching"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparison_reference_actor_is_touching() -> None:
|
||||||
|
# Unknown.entity == reference_actor (Sam). The compare_additive op
|
||||||
|
# touches via the reference_actor edge → difference.
|
||||||
|
g = _graph(
|
||||||
|
entities=("Tina", "Sam"),
|
||||||
|
initial_state=(
|
||||||
|
InitialPossession(entity="Tina", quantity=Quantity(5, "apples")),
|
||||||
|
InitialPossession(entity="Sam", quantity=Quantity(3, "apples")),
|
||||||
|
),
|
||||||
|
operations=(_cmp_add("Tina", "Sam", 2),),
|
||||||
|
unknown=Unknown(entity="Sam", unit="apples"),
|
||||||
|
)
|
||||||
|
bu = bound_unknown_from_math_problem_graph(g)
|
||||||
|
assert bu.question_form == "difference"
|
||||||
Loading…
Reference in a new issue