core/generate/quantitative_expr.py
Shay e9cbe65d77 feat(comprehension): the multiplicative comparative frame — first R1 capability (PR-5c)
The first capability slice on the R1 arc, gated by the setup-oracle: turn the
"twice / N times as many" reading from REFUSED into a correct setup, without a single
misread. Builds on the typed IR (PR-4) and the R1 gold (PR-5b).

- IR: a Mul(symbol, literal-factor) node — to_canonical_string "ref * factor",
  operation_kind "multiply", dependencies {ref}, to_relation -> times_as_many. The
  product keeps the symbol's unit (count * scalar = count), admitted by the REAL
  check_admissibility multiply path (the literal factor is dimensionless, not a dep).
- Reader: a multiplicative template "Y has <factor> as many <unit> as X" (factor word:
  twice/double/triple/quadruple) and "Y has <N> times as many <unit> as X", checked
  BEFORE the digit gate (the factor may be a word). 'half' (a /2) is deliberately
  deferred — divide-by-literal is a separate admissibility path.
- setup-oracle: relation_signature now canonicalizes times_as_many.

Setup-oracle R1 result: 2 setup_correct (r1-01 twice; r1-05 the multi-step chain
ivy/jon=3*ivy/kim=jon+2), 0 setup_WRONG, 8 setup_refused. Every hard negative stays a
safe refusal: missing-base (Rosa ungrounded), ambiguous referent, distractor, inverse,
partition, 'altogether'/'in total' phrasings, and 'half' (divide). wrong=0 held through
the first capability addition.

Gates green: setup-oracle R1 setup_wrong=0; 15-case setup gate 15/15 setup_wrong=0;
relational_metric answer lane 15/15 wrong=0; binding-graph admissibility + realize +
architectural invariants + chat-runtime + pipeline (122+). No serving path touched (this
reader feeds the relational_metric / setup-oracle lanes, not the candidate-graph serving).
2026-06-06 17:29:23 -07:00

145 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 Mul:
"""A scalar multiple of a symbol — the multiplicative comparative ("twice/N times
as many"). ``left`` is the referenced symbol, ``right`` a dimensionless literal
factor; the product keeps the symbol's unit (``count × scalar = count``)."""
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, Mul, 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 Mul(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) | Mul(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 Mul(_, _):
return "multiply"
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 Mul(Symbol(ref), Literal(factor)):
return {"kind": "times_as_many", "entity": lhs, "ref": ref, "factor": factor}
case SumOf(parts):
return {"kind": "sum_of", "entity": lhs, "parts": [p.symbol_id for p in parts]}
case _:
return None
__all__ = [
"Add",
"Expr",
"Literal",
"Mul",
"Sub",
"SumOf",
"Symbol",
"dependencies",
"operation_kind",
"to_canonical_string",
"to_relation",
]