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.
130 lines
5.1 KiB
Markdown
130 lines
5.1 KiB
Markdown
# 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).
|