feat(comprehension): question target in the graph (PR-1) + setup-oracle lane (grade the reading)

Two coupled, additive, off-serving changes toward the typed math-comprehension organ.
No serving path touched; the relational_metric answer lane stays 15/15 wrong=0.

PR-1 — QuantQuery → BoundUnknown. comprehend_quantitative now emits the question
target as a BoundUnknown INSIDE the binding-graph (symbol_id, state_index="terminal",
question_form "count"|"total", expected_unit), so the graph is a real question-bearing
mathematical object and its canonical serialization carries the target. The external
QuantQuery is RETAINED, consistent-by-construction, so the two consumers
(to_relational_metric, realize/quantitative) are byte-identical; a follow-up rewires
them onto graph.unknowns and drops the duplicate field.

Setup-oracle lane (evals/setup_oracle) — grade the READING, not the answer. The
relational_metric lane scores answers, which can bless a semantically-wrong derivation
that coincidentally lands on the right number (the exact hazard the held-out
measurements + the 2/87 resolve_pooled probe exposed). The setup-oracle compares the
reader's comprehended STRUCTURE — a span-free signature of facts + typed equations +
the BoundUnknown target — against the INDEPENDENT gold structure (the relational_metric
cases' own relations/query, authored separately from the binding-graph reader). A
structural mismatch is setup_wrong, the wrong=0-critical count, even when the answer
would be right. v1 grades structure (units deferred — covered by admissibility). The
reader reads all 15 cases with the gold structure (setup_wrong=0); a meaningful-fail
test proves the oracle catches a right-answer/wrong-structure reading (it is not
decoration). `python -m evals.setup_oracle` exits nonzero iff setup_wrong > 0.

This is the measurement rig BEFORE investing in frame families: setup_wrong=0 is the
gate; serving must not move while setup_wrong > 0. It is the first milestone of the
math-comprehension organ, not a path to "solve GSM8K".

Verified: setup-oracle 15/15 setup_correct wrong=0; quantitative + setup-oracle unit
tests (17); realize-binding-graph + binding-graph + architectural invariants (183).
This commit is contained in:
Shay 2026-06-06 16:40:15 -07:00
parent 61ac070c91
commit 59974865ef
7 changed files with 347 additions and 1 deletions

View file

@ -0,0 +1,28 @@
"""Setup-oracle lane — grade the READING (the semantic setup), not the answer.
The relational_metric lane scores answers: `comprehend project oracle_answer
compare gold integer`. That hides a hazard the held-out measurements exposed a WRONG
reading can produce a coincidentally-correct number and still pass. The setup-oracle
closes that gap: it compares the reader's comprehended STRUCTURE (the relations it read
+ the BoundUnknown question target it emitted) against the INDEPENDENT gold structure
(the relational_metric cases' own `relations`/`query`, authored separately from the
binding-graph reader). A wrong setup is a first-class failure even when its answer is right.
`setup_wrong` is the load-bearing, wrong=0-critical count: a reading that misrepresents
the problem. v1 grades structure (facts + equations + question target/state/form); unit
modelling stays covered by the admissibility tests (a documented signature extension).
"""
from evals.setup_oracle.runner import run
from evals.setup_oracle.signature import (
gold_unknown_signature,
reader_unknown_signature,
relation_signature,
)
__all__ = [
"gold_unknown_signature",
"reader_unknown_signature",
"relation_signature",
"run",
]

View file

@ -0,0 +1,23 @@
"""CLI: print the setup-oracle report.
python -m evals.setup_oracle
Exit 0 iff ``setup_wrong == 0`` the gate the milestone rests on (a wrong reading must
never pass, and serving must not move while setup_wrong > 0).
"""
from __future__ import annotations
import json
from evals.setup_oracle.runner import run
def main() -> int:
report = run()
print(json.dumps(report, indent=2, default=str))
return 0 if report["setup_wrong"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,81 @@
"""Setup-oracle runner — grade the reader's comprehended structure vs gold structure.
For each relational_metric case: comprehend the prose into a binding-graph, project it
to relations + read its question target, and compare the (relations, target) SIGNATURE
to the case's INDEPENDENT gold (`relations` + `query`). A structural mismatch is
``setup_wrong`` the wrong=0-critical count even if the answer would be right.
This is a STRICTER gate than the relational_metric (answer) lane: it requires the
reader to have read the problem the way the gold says it reads, not merely to land on
the gold number. It is the gate every future frame family must pass before serving.
"""
from __future__ import annotations
from typing import Any
from evals.relational_metric.runner import _load_cases
from evals.setup_oracle.signature import (
gold_unknown_signature,
reader_unknown_signature,
relation_signature,
)
from generate.meaning_graph.reader import Refusal
from generate.quantitative_comprehension import comprehend_quantitative, to_relational_metric
def run() -> dict[str, Any]:
"""Score the reader's setup against the independent gold setup, structure-only."""
cases = _load_cases()
setup_correct = setup_wrong = setup_refused = 0
wrongs: list[dict[str, Any]] = []
for case in cases:
comp = comprehend_quantitative(case["text"])
if isinstance(comp, Refusal):
setup_refused += 1
continue
projected = to_relational_metric(comp)
if projected is None:
setup_refused += 1
continue
reader_relations, _reader_query = projected
reader_sig = relation_signature(reader_relations)
gold_sig = relation_signature(case["relations"])
reader_unk = reader_unknown_signature(comp.binding_graph)
gold_unk = gold_unknown_signature(case["relations"], case["query"])
if reader_sig == gold_sig and reader_unk == gold_unk:
setup_correct += 1
else:
setup_wrong += 1
wrongs.append(
{
"id": case.get("id"),
"relations_match": reader_sig == gold_sig,
"target_match": reader_unk == gold_unk,
"reader_relations": reader_sig,
"gold_relations": gold_sig,
"reader_target": reader_unk,
"gold_target": gold_unk,
}
)
return {
"lane": "setup_oracle",
"grades": "structure-only (facts + equations + question target); units deferred",
"total": len(cases),
"setup_correct": setup_correct,
"setup_wrong": setup_wrong,
"setup_refused": setup_refused,
"wrongs": wrongs,
"counts": {
"setup_correct": setup_correct,
"setup_wrong": setup_wrong,
"setup_refused": setup_refused,
},
}
__all__ = ["run"]

View file

@ -0,0 +1,76 @@
"""Span-free structural signatures for the setup-oracle.
A *signature* is a deterministic, order-independent, span-free projection of the
mathematical SETUP what the reader claims the problem says, stripped of input
offsets and surface tokens. Two readings are setup-equivalent iff their signatures
are equal. Used to compare the reader's comprehended structure against the
independent gold structure (the relational_metric cases' own `relations`/`query`).
v1 grades: facts (entity, value), equations (the typed relation shape), and the
question target (symbol, state-index, question-form). Unit modelling is intentionally
NOT in the signature yet it is covered by the admissibility tests, and a future
extension adds an expected-unit axis once the gold carries it.
"""
from __future__ import annotations
from typing import Any
from generate.binding_graph.model import SemanticSymbolicBindingGraph
def relation_signature(relations: list[dict[str, Any]]) -> tuple[tuple, ...]:
"""Canonicalize a list of relations (the ``to_relational_metric`` / gold shape)
into a sorted, order-independent tuple of typed relation tuples."""
out: list[tuple] = []
for r in relations:
kind = r["kind"]
if kind == "fact":
out.append(("fact", r["entity"], int(r["value"])))
elif kind in ("more_than", "fewer_than"):
out.append((kind, r["entity"], r["ref"], int(r["delta"])))
elif kind == "sum_of":
out.append(("sum_of", r["entity"], tuple(sorted(r["parts"]))))
else: # an unknown relation kind is itself a structural difference, not a crash
out.append(("unhandled_kind", kind, r.get("entity", "")))
return tuple(sorted(out, key=repr))
def gold_unknown_signature(
relations: list[dict[str, Any]], query: dict[str, Any]
) -> tuple[str, str, str]:
"""The expected question-target signature, derived from the INDEPENDENT gold.
A query whose target is an aggregate (the gold contains a ``sum_of`` producing it)
is a ``total`` form; otherwise a ``count``. All current cases ask the terminal state.
"""
form = "total" if any(r["kind"] == "sum_of" for r in relations) else "count"
return (query["entity"], "terminal", form)
def _state_token(state_index: Any) -> str:
if isinstance(state_index, str):
return state_index
# An Operation state-index (ADR-0135) — name it by its operation index.
return f"op{getattr(state_index, 'operation_index', '?')}"
def reader_unknown_signature(graph: SemanticSymbolicBindingGraph) -> tuple[str, str, str]:
"""The reader's question-target signature from ``graph.unknowns`` (PR-1).
A graph that does not carry exactly one unknown is itself a structural defect it
is reported as a distinguished ``MALFORMED`` signature so it can never silently
compare equal to a well-formed gold target.
"""
unknowns = graph.unknowns
if len(unknowns) != 1:
return ("MALFORMED", str(len(unknowns)), "")
u = unknowns[0]
return (u.symbol_id, _state_token(u.state_index), u.question_form)
__all__ = [
"gold_unknown_signature",
"reader_unknown_signature",
"relation_signature",
]

View file

@ -34,6 +34,7 @@ from generate.binding_graph.admissibility import AdmissibilityError, check_admis
from generate.binding_graph.model import (
BoundEquation,
BoundFact,
BoundUnknown,
SemanticSymbolicBindingGraph,
SourceSpanLink,
SymbolBinding,
@ -271,9 +272,25 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
)
)
# The question target lives INSIDE the graph (ADR-0135): a BoundUnknown bound to
# the asked symbol at the terminal state. The form is "total" for an aggregate
# query ("how many do X and Y have"), else "count". ``query`` is retained as a
# consistent-by-construction convenience for the existing relational_metric
# projection + realize path; a follow-up collapses it onto graph.unknowns.
unknown = BoundUnknown(
symbol_id=ask_entity,
question_span=_span(ask_entity),
state_index="terminal",
question_form="total" if sum_eq is not None else "count",
expected_unit=ask_unit,
)
try:
graph = SemanticSymbolicBindingGraph(
symbols=tuple(symbols), facts=bound_facts, equations=tuple(equations)
symbols=tuple(symbols),
facts=bound_facts,
equations=tuple(equations),
unknowns=(unknown,),
)
except Exception as exc: # noqa: BLE001 — surface construction refusal
return Refusal("invalid_binding_graph", repr(exc))

View file

@ -35,6 +35,29 @@ def test_fact_and_more_than_build_binding_graph() -> None:
assert comp.query.entity == "mia"
def test_question_target_is_a_bound_unknown_in_the_graph() -> None:
# PR-1: the question target lives INSIDE the graph (a BoundUnknown at the terminal
# state), not only as the external QuantQuery.
comp = _comp("Liam has 6 stickers. Mia has 4 more stickers than Liam. How many stickers does Mia have?")
unknowns = comp.binding_graph.unknowns
assert len(unknowns) == 1
u = unknowns[0]
assert u.symbol_id == "mia"
assert u.state_index == "terminal"
assert u.question_form == "count"
assert u.expected_unit == "item"
# The graph's canonical serialization now carries the target.
assert "state=terminal" in comp.binding_graph.to_canonical_string()
# Retained convenience stays consistent with the in-graph unknown.
assert comp.query.entity == u.symbol_id
def test_sum_query_target_is_total_form_unknown() -> None:
comp = _comp("Dan has 7 coins. Eva has 9 more coins than Dan. How many coins do Dan and Eva have?")
(u,) = comp.binding_graph.unknowns
assert u.symbol_id == "total" and u.question_form == "total" and u.state_index == "terminal"
def test_count_nouns_resolve_to_item_dimension() -> None:
# Unknown sortal nouns become the count dimension (item); admissibility admits.
comp = _comp("Kim has 2 marbles. Leo has 3 more marbles than Kim. How many marbles does Leo have?")

View file

@ -0,0 +1,98 @@
"""Setup-oracle lane — grade the reading (structure), not the answer.
Two obligations:
1. The current reader reads all 15 relational_metric cases with the gold STRUCTURE
(``setup_wrong == 0``) the gate the milestone rests on.
2. The oracle MEANINGFULLY FAILS a reading that lands on the right number via the
WRONG structure is ``setup_wrong``. Without this, structure-grading would be
decoration; with it, "did we read it right?" is falsifiable.
"""
from __future__ import annotations
from evals.setup_oracle import (
gold_unknown_signature,
reader_unknown_signature,
relation_signature,
run,
)
from generate.binding_graph.model import (
BoundFact,
SemanticSymbolicBindingGraph,
SourceSpanLink,
SymbolBinding,
)
def _span() -> SourceSpanLink:
return SourceSpanLink(source_id="t", start=0, end=1, text="x")
# --------------------------------------------------------------------------- #
# Obligation 1 — the reader reads the gold structure on every case
# --------------------------------------------------------------------------- #
def test_all_cases_setup_correct_wrong_zero() -> None:
report = run()
assert report["total"] == 15
assert report["setup_correct"] == 15
assert report["setup_wrong"] == 0 # the load-bearing count
assert report["setup_refused"] == 0
# --------------------------------------------------------------------------- #
# Obligation 2 — the oracle is not decoration (it catches wrong readings)
# --------------------------------------------------------------------------- #
def test_right_answer_wrong_structure_is_caught() -> None:
# Gold: mia = liam + 4 over liam = 6 (answer 10, read as a relation).
gold = [
{"kind": "fact", "entity": "liam", "value": 6},
{"kind": "more_than", "entity": "mia", "ref": "liam", "delta": 4},
]
# A reading that lands on the SAME answer (mia = 10) but flattens the relation
# into a bare fact — the right number, the wrong reading.
wrong_structure = [{"kind": "fact", "entity": "mia", "value": 10}]
assert relation_signature(gold) != relation_signature(wrong_structure)
def test_signature_catches_wrong_operation() -> None:
more = [{"kind": "more_than", "entity": "y", "ref": "x", "delta": 6}]
fewer = [{"kind": "fewer_than", "entity": "y", "ref": "x", "delta": 6}]
assert relation_signature(more) != relation_signature(fewer)
def test_signature_is_order_independent() -> None:
a = [
{"kind": "fact", "entity": "x", "value": 1},
{"kind": "more_than", "entity": "y", "ref": "x", "delta": 2},
]
assert relation_signature(a) == relation_signature(list(reversed(a)))
def test_wrong_question_target_is_caught() -> None:
rels = [
{"kind": "fact", "entity": "dan", "value": 7},
{"kind": "more_than", "entity": "eva", "ref": "dan", "delta": 9},
{"kind": "sum_of", "entity": "total", "parts": ["dan", "eva"]},
]
# Gold asks the total; a reader that targeted "eva" instead is a different reading.
assert gold_unknown_signature(rels, {"entity": "total"}) == ("total", "terminal", "total")
assert gold_unknown_signature(rels, {"entity": "total"}) != ("eva", "terminal", "count")
def test_malformed_graph_target_never_matches_gold() -> None:
# A graph carrying no question target (pre-PR-1 shape) must report MALFORMED and
# never silently compare equal to a well-formed gold target.
graph = SemanticSymbolicBindingGraph(
symbols=(SymbolBinding(symbol_id="x", name="x", semantic_role="count",
source_span=_span(), introduced_by="t", entity="x", unit="item"),),
facts=(BoundFact(symbol_id="x", value="1", source_span=_span(), unit="item"),),
equations=(),
unknowns=(),
)
sig = reader_unknown_signature(graph)
assert sig[0] == "MALFORMED"
assert sig != ("x", "terminal", "count")