Merge pull request #610 from AssetOverflow/feat/quant-expression-ir

refactor(comprehension): typed expression IR as the source of meaning (PR-4)
This commit is contained in:
Shay 2026-06-06 17:03:21 -07:00 committed by GitHub
commit 3d006d2e19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 238 additions and 35 deletions

View file

@ -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)

View file

@ -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",
]

View file

@ -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