From 06450928c93b3bf2961abd6b620e95e27601d23e Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 6 Jun 2026 16:57:53 -0700 Subject: [PATCH] refactor(comprehension): typed expression IR as the source of meaning (PR-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal hygiene + future-proofing. No serving path, no new capability — the 15-case reader is structurally cleaner and the projection no longer recovers meaning by re-parsing a string. - New generate/quantitative_expr.py: a typed Expr IR (Literal/Symbol/Add/Sub/SumOf) with to_canonical_string (BYTE-IDENTICAL to the legacy "ref + delta" / "ref - delta" / "a + b" format), dependencies, operation_kind, and to_relation (the structured projection). - The reader builds the Expr per equation as the SOURCE OF MEANING; rhs_canonical, dependencies, and operation_kind are all DERIVED from it (BoundEquation stays a string — the binding-graph's deliberate decoupling layer is untouched). QuantComprehension carries the IR as equation_exprs. - to_relational_metric reads the IR via to_relation — the rhs_canonical string-reparse is GONE. An unhandled equation shape refuses (None). - The dead _rhs string builder is removed. Gates held: relational_metric answer lane 15/15 wrong=0; setup-oracle 15/15 setup_wrong=0; malformed-target refusals intact; realize-binding-graph + architectural invariants green (95). rhs_canonical is byte-identical, so the binding-graph + downstream hashes are unchanged. No model change. This is the foundation PR-5's R1 frames build on — structured equations, not string parsing — and only after independent R1 gold is hand-authored. --- generate/quantitative_comprehension.py | 74 +++++++------- generate/quantitative_expr.py | 128 +++++++++++++++++++++++++ tests/test_quantitative_expr.py | 71 ++++++++++++++ 3 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 generate/quantitative_expr.py create mode 100644 tests/test_quantitative_expr.py diff --git a/generate/quantitative_comprehension.py b/generate/quantitative_comprehension.py index 59268ba8..cd026e92 100644 --- a/generate/quantitative_comprehension.py +++ b/generate/quantitative_comprehension.py @@ -41,6 +41,18 @@ from generate.binding_graph.model import ( ) from generate.binding_graph.units import UnitAlgebraError, parse_unit from generate.meaning_graph.reader import Refusal, _split_sentences +from generate.quantitative_expr import ( + Add, + Expr, + Literal, + Sub, + SumOf, + Symbol, + dependencies, + operation_kind, + to_canonical_string, + to_relation, +) _INTRODUCED_BY = "comprehend_quantitative" @@ -74,9 +86,14 @@ class QuantComprehension: sole :class:`BoundUnknown` (PR-1). Consumers read it via :func:`single_unknown`, which refuses (returns ``None``) on a graph that does not carry exactly one target rather than silently picking one. + + ``equation_exprs`` is the typed expression IR (PR-4) — the reader's SOURCE OF MEANING + for each equation, as ``(lhs_symbol_id, Expr)`` pairs. ``BoundEquation.rhs_canonical`` + is the serialization of these; the projection reads the IR, never the string. """ binding_graph: SemanticSymbolicBindingGraph + equation_exprs: tuple[tuple[str, Expr], ...] = () def single_unknown(graph: SemanticSymbolicBindingGraph) -> BoundUnknown | None: @@ -163,10 +180,6 @@ def _span(text: str) -> SourceSpanLink: return SourceSpanLink(source_id="input", start=0, end=max(1, len(text)), text=text or " ") -def _rhs(op: str, ref: str, delta: int) -> str: - return f"{ref} + {delta}" if op == "add" else f"{ref} - {delta}" - - def comprehend_quantitative(text: str, source_id: str = "input") -> QuantComprehension | Refusal: """Comprehend arithmetic prose into a binding_graph + asked entity, or refuse.""" if not text or not text.strip(): @@ -243,16 +256,22 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh for f in facts ) - # equations: shell -> REAL admissibility -> rebuild (NEVER stamp "admitted"). - eq_specs: list[tuple[str, str, frozenset[str], str]] = [ - (e.entity, _rhs(e.op, e.ref, e.delta), frozenset({e.ref}), e.op) for e in eqs + # The typed expression IR (PR-4) is the SOURCE OF MEANING; rhs_canonical / dependencies + # / operation_kind are all derived from it, never recovered by re-parsing the string. + expr_specs: list[tuple[str, Expr]] = [ + (e.entity, (Add if e.op == "add" else Sub)(Symbol(e.ref), Literal(e.delta))) + for e in eqs ] if sum_eq is not None: lhs, parts = sum_eq - eq_specs.append((lhs, " + ".join(parts), frozenset(parts), "add")) + expr_specs.append((lhs, SumOf(tuple(Symbol(p) for p in parts)))) + # equations: shell -> REAL admissibility -> rebuild (NEVER stamp "admitted"). equations: list[BoundEquation] = [] - for lhs, rhs, deps, op in eq_specs: + for lhs, expr in expr_specs: + rhs = to_canonical_string(expr) + deps = dependencies(expr) + op = operation_kind(expr) shell = BoundEquation( lhs_symbol_id=lhs, rhs_canonical=rhs, @@ -301,44 +320,29 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh except Exception as exc: # noqa: BLE001 — surface construction refusal return Refusal("invalid_binding_graph", repr(exc)) - return QuantComprehension(binding_graph=graph) + return QuantComprehension(binding_graph=graph, equation_exprs=tuple(expr_specs)) def to_relational_metric( comp: QuantComprehension, ) -> tuple[list[dict[str, Any]], dict[str, Any]] | None: - """Project the comprehended binding_graph into ``(relations, query)`` for + """Project the comprehension into ``(relations, query)`` for ``evals.relational_metric.oracle.oracle_answer``. - Reads the binding-graph itself (facts + admitted equations) — the equation's - own ``rhs_canonical`` is parsed back (a controlled round-trip of the format this - module emits) and operands are classified as symbol (a known entity) vs literal. - Facts are emitted before equations and equations in dependency order, so the - oracle's forward substitution never hits an unresolved reference. + Reads the typed expression IR (``comp.equation_exprs``) directly — meaning is NEVER + recovered by re-parsing ``rhs_canonical`` (PR-4). Facts are emitted before equations + and equations in dependency order, so the oracle's forward substitution never hits an + unresolved reference. A relation shape the projection does not handle REFUSES. """ graph = comp.binding_graph - symbol_ids = {s.symbol_id for s in graph.symbols} relations: list[dict[str, Any]] = [ {"kind": "fact", "entity": f.symbol_id, "value": int(f.value)} for f in graph.facts ] - for eq in graph.equations: - rhs = eq.rhs_canonical - if " + " in rhs: - operands = rhs.split(" + ") - if all(op in symbol_ids for op in operands): - relations.append({"kind": "sum_of", "entity": eq.lhs_symbol_id, "parts": list(operands)}) - continue - ref, literal = operands[0], operands[1] - relations.append( - {"kind": "more_than", "entity": eq.lhs_symbol_id, "ref": ref, "delta": int(literal)} - ) - elif " - " in rhs: - ref, literal = rhs.split(" - ") - relations.append( - {"kind": "fewer_than", "entity": eq.lhs_symbol_id, "ref": ref, "delta": int(literal)} - ) - else: - return None # unrecognized equation shape -> refuse + for lhs, expr in comp.equation_exprs: + rel = to_relation(lhs, expr) + if rel is None: + return None # unhandled equation shape -> refuse + relations.append(rel) if not relations: return None target = single_unknown(graph) diff --git a/generate/quantitative_expr.py b/generate/quantitative_expr.py new file mode 100644 index 00000000..04965000 --- /dev/null +++ b/generate/quantitative_expr.py @@ -0,0 +1,128 @@ +"""Typed expression IR for the arithmetic reader (PR-4). + +The READER's source of meaning for an equation's right-hand side. The binding-graph +deliberately keeps ``BoundEquation.rhs_canonical`` a *string* (a decoupling layer that +does not import the symbolic substrate); this IR lives ABOVE that boundary in the reader, +serializes DOWN to the canonical string (``to_canonical_string``), and is read directly by +the projection (``to_relation``) so meaning is never recovered by re-parsing the string. + +``to_canonical_string`` is byte-identical to the strings the reader emitted before PR-4 +("ref + delta", "ref - delta", "a + b") — so the binding-graph and every downstream hash +are unchanged. Deterministic; no clock, no randomness. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Union + + +@dataclass(frozen=True, slots=True) +class Literal: + """A grounded integer operand (a value sourced from the text).""" + + value: int + + +@dataclass(frozen=True, slots=True) +class Symbol: + """A reference to another bound symbol.""" + + symbol_id: str + + +@dataclass(frozen=True, slots=True) +class Add: + left: "Expr" + right: "Expr" + + +@dataclass(frozen=True, slots=True) +class Sub: + left: "Expr" + right: "Expr" + + +@dataclass(frozen=True, slots=True) +class SumOf: + """An aggregate over ≥2 symbols (the part-whole total).""" + + parts: tuple[Symbol, ...] + + +Expr = Union[Literal, Symbol, Add, Sub, SumOf] + + +def to_canonical_string(expr: Expr) -> str: + """Serialize to the canonical rhs string — byte-identical to the pre-IR format.""" + match expr: + case Literal(value): + return str(value) + case Symbol(symbol_id): + return symbol_id + case Add(left, right): + return f"{to_canonical_string(left)} + {to_canonical_string(right)}" + case Sub(left, right): + return f"{to_canonical_string(left)} - {to_canonical_string(right)}" + case SumOf(parts): + return " + ".join(to_canonical_string(p) for p in parts) + raise TypeError(f"not an Expr: {expr!r}") # pragma: no cover - exhaustive above + + +def dependencies(expr: Expr) -> frozenset[str]: + """The symbols the expression reads (the equation's dependency set).""" + match expr: + case Literal(_): + return frozenset() + case Symbol(symbol_id): + return frozenset({symbol_id}) + case Add(left, right) | Sub(left, right): + return dependencies(left) | dependencies(right) + case SumOf(parts): + out: frozenset[str] = frozenset() + for p in parts: + out |= dependencies(p) + return out + raise TypeError(f"not an Expr: {expr!r}") # pragma: no cover + + +def operation_kind(expr: Expr) -> str: + """The binding-graph ``operation_kind`` an expression lowers to.""" + match expr: + case Add(_, _) | SumOf(_): + return "add" + case Sub(_, _): + return "subtract" + case _: + raise TypeError(f"expression has no operation_kind: {expr!r}") + + +def to_relation(lhs: str, expr: Expr) -> dict[str, Any] | None: + """Project to a relational_metric relation, read from STRUCTURE (no string parse). + + ``None`` for a shape the projection does not handle — the caller refuses rather than + emit a guessed relation (wrong=0 boundary). + """ + match expr: + case Add(Symbol(ref), Literal(delta)): + return {"kind": "more_than", "entity": lhs, "ref": ref, "delta": delta} + case Sub(Symbol(ref), Literal(delta)): + return {"kind": "fewer_than", "entity": lhs, "ref": ref, "delta": delta} + case SumOf(parts): + return {"kind": "sum_of", "entity": lhs, "parts": [p.symbol_id for p in parts]} + case _: + return None + + +__all__ = [ + "Add", + "Expr", + "Literal", + "Sub", + "SumOf", + "Symbol", + "dependencies", + "operation_kind", + "to_canonical_string", + "to_relation", +] diff --git a/tests/test_quantitative_expr.py b/tests/test_quantitative_expr.py new file mode 100644 index 00000000..cf2541c1 --- /dev/null +++ b/tests/test_quantitative_expr.py @@ -0,0 +1,71 @@ +"""Typed expression IR (PR-4) — the reader's source of meaning for an equation rhs. + +Pins: the canonical serialization is byte-identical to the pre-IR string format (so the +binding-graph + every downstream hash is unchanged), the structured projection reads the +IR (never the string), and dependencies/operation_kind derive from the IR. +""" + +from __future__ import annotations + +from generate.quantitative_comprehension import comprehend_quantitative +from generate.quantitative_expr import ( + Add, + Literal, + Sub, + SumOf, + Symbol, + dependencies, + operation_kind, + to_canonical_string, + to_relation, +) + + +def test_canonical_string_is_byte_identical_to_legacy_format() -> None: + assert to_canonical_string(Add(Symbol("liam"), Literal(4))) == "liam + 4" + assert to_canonical_string(Sub(Symbol("noah"), Literal(6))) == "noah - 6" + assert to_canonical_string(SumOf((Symbol("dan"), Symbol("eva")))) == "dan + eva" + assert to_canonical_string(Symbol("x")) == "x" + assert to_canonical_string(Literal(7)) == "7" + + +def test_dependencies_from_structure() -> None: + assert dependencies(Add(Symbol("liam"), Literal(4))) == frozenset({"liam"}) + assert dependencies(Sub(Symbol("noah"), Literal(6))) == frozenset({"noah"}) + assert dependencies(SumOf((Symbol("dan"), Symbol("eva")))) == frozenset({"dan", "eva"}) + assert dependencies(Literal(3)) == frozenset() + + +def test_operation_kind_from_structure() -> None: + assert operation_kind(Add(Symbol("a"), Literal(1))) == "add" + assert operation_kind(SumOf((Symbol("a"), Symbol("b")))) == "add" + assert operation_kind(Sub(Symbol("a"), Literal(1))) == "subtract" + + +def test_to_relation_reads_structure_not_string() -> None: + assert to_relation("mia", Add(Symbol("liam"), Literal(4))) == { + "kind": "more_than", "entity": "mia", "ref": "liam", "delta": 4, + } + assert to_relation("olivia", Sub(Symbol("noah"), Literal(6))) == { + "kind": "fewer_than", "entity": "olivia", "ref": "noah", "delta": 6, + } + assert to_relation("total", SumOf((Symbol("dan"), Symbol("eva")))) == { + "kind": "sum_of", "entity": "total", "parts": ["dan", "eva"], + } + + +def test_to_relation_refuses_unhandled_shape() -> None: + # A literal-only or nested shape the projection doesn't handle returns None (refuse). + assert to_relation("x", Literal(5)) is None + assert to_relation("x", Add(Literal(1), Literal(2))) is None # no symbol ref + + +def test_reader_carries_ir_consistent_with_rhs_canonical() -> None: + # The IR the reader attaches serializes EXACTLY to the equation's rhs_canonical. + comp = comprehend_quantitative( + "Liam has 6 stickers. Mia has 4 more stickers than Liam. How many stickers does Mia have?" + ) + by_lhs = {lhs: expr for lhs, expr in comp.equation_exprs} + for eq in comp.binding_graph.equations: + assert to_canonical_string(by_lhs[eq.lhs_symbol_id]) == eq.rhs_canonical + assert dependencies(by_lhs[eq.lhs_symbol_id]) == eq.dependencies