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.
274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
"""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",
|
|
)
|