refactor(comprehension): typed expression IR as the source of meaning (PR-4)
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.
This commit is contained in:
parent
0882a4f8ef
commit
06450928c9
3 changed files with 238 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
128
generate/quantitative_expr.py
Normal file
128
generate/quantitative_expr.py
Normal 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",
|
||||
]
|
||||
71
tests/test_quantitative_expr.py
Normal file
71
tests/test_quantitative_expr.py
Normal 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
|
||||
Loading…
Reference in a new issue