core/tests/test_quantitative_expr.py
Shay c2b97f40bf test(comprehension): prove the scalar-multiply contract (PR-6a)
The multiplicative comparative frame (PR-5c) admits exactly one shape —
Mul(Symbol, Literal), a unit-bearing symbol times a dimensionless integer
(count × scalar = count). That contract was held by OMISSION: to_relation's
`case _: return None` refused every other Mul shape, but no test would fail
if the guard were loosened, and no doc stated where the guarantee lives.

This makes the obligation meaningfully-failing (CLAUDE.md Schema-Defined
Proof Obligations), with no runtime logic change:

- test_mul_projection_admits_only_symbol_times_literal — Mul(Symbol, Symbol)
  (a count×count product), a commuted factor, and compound factors all REFUSE
  (to_relation → None). Verified to go red when a Mul(Symbol, Symbol) projection
  arm is injected.
- test_literal_factor_is_dimensionless_by_construction — Literal has exactly
  one field (value); a unit-bearing literal multiplication is unrepresentable,
  not merely unchecked.
- test_scalar_only_guard_is_load_bearing — check_admissibility's `multiply`
  dispatch products operand units generally (count×count → count², no refusal),
  so it would NOT catch the masquerade. The projection arm is the sole boundary.

Docstrings on Mul and to_relation now state the scalar-only contract and that
it is enforced at the projection boundary, not in the dimensional checker.

Gates unchanged: setup-oracle 15-case 15/0/0 and R1 2/0/8 (setup_wrong=0);
77 expr/admissibility/reader/setup-oracle tests + 56 architectural invariants
green. No serving path touched.
2026-06-06 17:42:22 -07:00

171 lines
7.7 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 (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
# --------------------------------------------------------------------------- #
# PR-5c — the multiplicative comparative (Mul)
# --------------------------------------------------------------------------- #
def test_mul_serialization_and_derivations() -> None:
from generate.quantitative_expr import Mul
m = Mul(Symbol("anna"), Literal(2))
assert to_canonical_string(m) == "anna * 2"
assert dependencies(m) == frozenset({"anna"})
assert operation_kind(m) == "multiply"
assert to_relation("bella", m) == {
"kind": "times_as_many", "entity": "bella", "ref": "anna", "factor": 2,
}
# --------------------------------------------------------------------------- #
# PR-6a — the scalar-only contract is PROVEN, not held by omission
# --------------------------------------------------------------------------- #
def test_mul_projection_admits_only_symbol_times_literal() -> None:
"""``Mul(Symbol, Literal)`` is the ONLY shape that projects to ``times_as_many``;
every other ``Mul`` shape REFUSES (``to_relation`` → None).
Meaningful-fail (CLAUDE.md Schema-Defined Proof Obligations): each assert below
fails loudly the moment the scalar-only guard is loosened — e.g. if a
``case Mul(Symbol(ref), Symbol(other))`` arm were added, a ``count × count`` product
would masquerade as "N times as many". The dimensional checker does NOT catch this
(``test_scalar_only_guard_is_load_bearing`` shows why), so this projection arm is the
sole boundary.
"""
from generate.quantitative_expr import Mul
# The one admitted shape — the contrast case.
assert to_relation("y", Mul(Symbol("x"), Literal(3))) == {
"kind": "times_as_many", "entity": "y", "ref": "x", "factor": 3,
}
# Two unit-bearing symbols: a count×count product, NOT a scalar multiple → refuse.
assert to_relation("y", Mul(Symbol("a"), Symbol("b"))) is None
# Commuted (factor on the left): the reader only ever builds Symbol*Literal → refuse.
assert to_relation("y", Mul(Literal(2), Symbol("a"))) is None
# A compound (non-literal) factor → refuse.
assert to_relation("y", Mul(Symbol("a"), Add(Symbol("b"), Literal(1)))) is None
assert to_relation("y", Mul(Symbol("a"), SumOf((Symbol("b"), Symbol("c"))))) is None
# A bare literal product carries no symbol to reference → refuse.
assert to_relation("y", Mul(Literal(2), Literal(3))) is None
def test_literal_factor_is_dimensionless_by_construction() -> None:
"""A literal factor cannot carry a unit: ``Literal`` has exactly one field, ``value``.
"Unit-bearing literal multiplication" is structurally unrepresentable — not merely
unchecked. ``count × scalar = count`` holds because the scalar is an ``int`` with no
unit, so the product keeps exactly the referenced symbol's unit. If a ``unit`` field
were ever added to ``Literal``, this test fails and forces the contract to be revisited.
"""
import dataclasses
assert [f.name for f in dataclasses.fields(Literal)] == ["value"]
assert not hasattr(Literal(2), "unit")
def test_scalar_only_guard_is_load_bearing() -> None:
"""WHY the projection arm (not the dimensional checker) owns the scalar-only contract.
``check_admissibility``'s ``multiply`` dispatch products operand units with no equality
requirement, so a ``count × count`` equation is dimensionally ADMISSIBLE (it yields
``count²``). It would never refuse a two-symbol multiply. Hence the refusal in
:func:`to_relation` is load-bearing — it is the only thing standing between a
``Mul(Symbol, Symbol)`` and a fabricated ``times_as_many`` relation.
"""
from generate.binding_graph import (
BoundEquation,
SourceSpanLink,
SymbolBinding,
check_admissibility,
)
from generate.quantitative_expr import Mul
span = SourceSpanLink(source_id="t", start=0, end=1, text="x")
symbols = {
"a": SymbolBinding(symbol_id="a", name="a", semantic_role="quantity",
source_span=span, introduced_by="t", entity="a", unit="item"),
"b": SymbolBinding(symbol_id="b", name="b", semantic_role="quantity",
source_span=span, introduced_by="t", entity="b", unit="item"),
}
eq = BoundEquation(
lhs_symbol_id="c", rhs_canonical="a * b", operation_kind="multiply",
dependencies=frozenset({"a", "b"}), unit_proof="placeholder",
admissibility_status="pending", source_span=span,
)
# The dimensional checker ADMITS count×count (→ item²) — it does not refuse it.
proof = check_admissibility(eq, symbols=symbols)
assert proof.operation_kind == "multiply"
# But the projection REFUSES the same shape — the boundary that keeps wrong=0.
assert to_relation("c", Mul(Symbol("a"), Symbol("b"))) is None