Merge pull request #615 from AssetOverflow/feat/half-as-many-divide

feat(comprehension): the divisive comparative frame — "half as many" as exact integer division (PR-6c)
This commit is contained in:
Shay 2026-06-06 20:44:52 -07:00 committed by GitHub
commit 409615fcef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 376 additions and 34 deletions

View file

@ -48,7 +48,7 @@ Add three deliverables under `generate/binding_graph/`:
| `add` / `subtract` / `compare_additive` / `transfer` | all dep units equal; lhs == that unit |
| `compare_multiplicative` | dep units cancel; lhs dimensionless |
| `multiply` | lhs == product of dep units |
| `divide` | requires one dividend + one `*__divisor` literal; lhs == quotient |
| `divide` | **single dep**: divide by an implicit dimensionless literal, lhs == dividend unit (`x / dimensionless = x`); **two deps**: one dividend + one `*__divisor` literal, lhs == quotient |
| `apply_rate` | dep with `semantic_role='rate'` carries `X/Y`; other dep carries `Y`; lhs == `X` |
Refusal is typed: every `AdmissibilityError` carries a `reason` from
@ -170,3 +170,36 @@ None. The binding graph still has no runtime wiring outside
`generate/binding_graph/`. `chat/runtime.py`, the cognition eval lane,
the field invariant, the algebra backend, and every other production
hot path are unaffected. Cognition eval lane byte-equal to main.
## Amendment 2026-06-07 — single-dep `divide` (divide by a dimensionless literal)
**What changed.** `_check_divide` now admits a **single-dep** form in addition
to the original two-dep `dividend + *__divisor` form: a quantity divided by an
implicit *dimensionless literal*, with `lhs == dividend unit`.
**Why.** The off-serving comprehension reader's typed expression IR
(`generate/quantitative_expr.py`, PR-4/5c/6a) carries literal operands *inside*
the IR and deliberately does **not** make them dependencies — a `Mul(Symbol,
Literal)` ("twice as many") has `dependencies = {ref}`, and `_check_multiply`
already admits that single dep (`item × dimensionless = item`). "half as many"
(`Div(Symbol, Literal(2))`) is the exact divisive twin: same shape, same single
dep, the divisor is a dimensionless `Literal` in the IR. The original two-dep
convention (a synthesized `*__divisor` *symbol*) collides with that IR design and
would force a per-op special case in the reader plus an extra graph symbol. The
single-dep form makes `divide` **symmetric with `multiply`** so the IR's
"literal operands are not deps" invariant holds uniformly for both.
**Safety.**
- The two-dep rate-adapter path (`*__divisor`) is unchanged.
- Soundness: dividing a unit-bearing quantity by a dimensionless constant
preserves the unit by construction — identical to the multiply twin.
- Exactness (the *value*, not the unit) is the answer oracle's responsibility:
`evals.relational_metric.oracle` admits `divide_by` only when
`base % divisor == 0`, refusing a non-exact division (an odd base over 2)
rather than flooring to a wrong integer. Admissibility proves *dimension*; the
oracle proves *exact integral value*.
- Off-serving: `admissibility.py` has no `generate.derivation` /
`core.reliability_gate` consumer; the frozen GSM8K serving metric cannot move.
- Pinned by `tests/test_binding_graph_admissibility.py`
(`test_divide_single_dep_dimensionless_keeps_unit`,
`test_divide_refuses_zero_or_three_deps`).

View file

@ -16,11 +16,16 @@ Supported relation kinds (the v1 + PR-6b forward-substitutable grammar):
- ``more_than`` : ``entity = ref + delta``
- ``fewer_than`` : ``entity = ref - delta``
- ``times_as_many`` : ``entity = ref * factor`` (dimensionless integer scalar)
- ``divide_by`` : ``entity = ref // divisor`` (exact integer division only)
- ``sum_of`` : ``entity = sum(parts)`` (part-whole / total)
Every relation's references must already be resolved (forward-substitutable /
triangular). The oracle refuses anything else, never guesses. PR-6b keeps this
off-serving: it lets setup-correct R1 cases compute answers in the eval lane only.
triangular). The oracle refuses anything else, never guesses. PR-6b/6c keep this
off-serving: they let setup-correct R1 cases compute answers in the eval lane only.
``divide_by`` is exact-only: a non-exact division (``base % divisor != 0``, e.g. an
odd base halved) REFUSES rather than flooring to a wrong integer the wrong=0 boundary
for the "half as many" frame (PR-6c).
"""
from __future__ import annotations
@ -32,7 +37,9 @@ class OracleError(ValueError):
"""Malformed or out-of-grammar case — the oracle refuses, never guesses."""
_SUPPORTED = frozenset({"fact", "more_than", "fewer_than", "sum_of", "times_as_many"})
_SUPPORTED = frozenset(
{"fact", "more_than", "fewer_than", "sum_of", "times_as_many", "divide_by"}
)
def oracle_answer(relations: list[dict[str, Any]], query: dict[str, Any]) -> int:
@ -75,6 +82,17 @@ def oracle_answer(relations: list[dict[str, Any]], query: dict[str, Any]) -> int
if not isinstance(factor, int) or isinstance(factor, bool):
raise OracleError(f"factor must be int: {rel!r}")
values[entity] = values[ref] * factor
elif kind == "divide_by":
ref = rel.get("ref")
divisor = rel.get("divisor")
if ref not in values:
raise OracleError(f"forward reference to unresolved {ref!r}")
if not isinstance(divisor, int) or isinstance(divisor, bool) or divisor == 0:
raise OracleError(f"divisor must be a nonzero int: {rel!r}")
if values[ref] % divisor != 0:
# Exact-only: refuse rather than floor to a wrong integer (wrong=0).
raise OracleError(f"non-exact division {values[ref]}/{divisor}: {rel!r}")
values[entity] = values[ref] // divisor
else: # sum_of
parts = rel.get("parts")
if not isinstance(parts, list) or not parts:

View file

@ -1,5 +1,5 @@
{"id": "r1-01-twice", "text": "Anna has 6 apples. Bella has twice as many apples as Anna. How many apples does Bella have?", "relations": [{"kind": "fact", "entity": "anna", "value": 6}, {"kind": "times_as_many", "entity": "bella", "ref": "anna", "factor": 2}], "expected_units": {"anna": "item", "bella": "item"}, "query": {"entity": "bella", "unit": "item"}, "gold": 12, "notes": "Multiplicative: bella = 2 * anna. The reader has no multiplicative template AND 'twice' is non-digit -> must REFUSE, never misread as a fact/additive."}
{"id": "r1-02-half", "text": "Carl has 8 coins. Dora has half as many coins as Carl. How many coins does Dora have?", "relations": [{"kind": "fact", "entity": "carl", "value": 8}, {"kind": "times_as_many", "entity": "dora", "ref": "carl", "factor": 0.5}], "expected_units": {"carl": "item", "dora": "item"}, "query": {"entity": "dora", "unit": "item"}, "notes": "Multiplicative (factor 0.5). 'half' is non-digit -> must REFUSE."}
{"id": "r1-02-half", "text": "Carl has 8 coins. Dora has half as many coins as Carl. How many coins does Dora have?", "relations": [{"kind": "fact", "entity": "carl", "value": 8}, {"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 2}], "expected_units": {"carl": "item", "dora": "item"}, "query": {"entity": "dora", "unit": "item"}, "gold": 4, "notes": "Divisive: dora = carl / 2 = 4 (exact integer division). 'half as many' fits the [Y has WORD as many UNIT as X] template; the WORD maps to a divisor, not a factor (PR-6c). Exact-divisibility is the wrong=0 boundary: an odd base would refuse, never round."}
{"id": "r1-03-more-total", "text": "Finn has 10 books. Evan has 5 more books than Finn. How many books do Evan and Finn have altogether?", "relations": [{"kind": "fact", "entity": "finn", "value": 10}, {"kind": "more_than", "entity": "evan", "ref": "finn", "delta": 5}, {"kind": "sum_of", "entity": "total", "parts": ["evan", "finn"]}], "expected_units": {"finn": "item", "evan": "item", "total": "item"}, "query": {"entity": "total", "unit": "item"}, "notes": "Additive + aggregate. 'altogether' (not the bare 'have') may fall outside the strict query template -> likely REFUSE; if read, structure must match exactly."}
{"id": "r1-04-fewer-total", "text": "Gail has 20 cards. Hank has 6 fewer cards than Gail. How many cards do Gail and Hank have in total?", "relations": [{"kind": "fact", "entity": "gail", "value": 20}, {"kind": "fewer_than", "entity": "hank", "ref": "gail", "delta": 6}, {"kind": "sum_of", "entity": "total", "parts": ["gail", "hank"]}], "expected_units": {"gail": "item", "hank": "item", "total": "item"}, "query": {"entity": "total", "unit": "item"}, "notes": "Additive(fewer) + aggregate. 'in total' qualifier likely outside the query template -> REFUSE expected."}
{"id": "r1-05-chain", "text": "Ivy has 4 pens. Jon has 3 times as many pens as Ivy. Kim has 2 more pens than Jon. How many pens does Kim have?", "relations": [{"kind": "fact", "entity": "ivy", "value": 4}, {"kind": "times_as_many", "entity": "jon", "ref": "ivy", "factor": 3}, {"kind": "more_than", "entity": "kim", "ref": "jon", "delta": 2}], "expected_units": {"ivy": "item", "jon": "item", "kim": "item"}, "query": {"entity": "kim", "unit": "item"}, "gold": 14, "notes": "Multi-step derived chain: jon=3*ivy (intermediate), kim=jon+2. The multiplicative middle step has no template -> must REFUSE the whole reading, never read a partial/wrong chain."}

View file

@ -45,6 +45,8 @@ def relation_signature(relations: list[dict[str, Any]]) -> tuple[tuple, ...]:
out.append((kind, r["entity"], r["ref"], int(r["delta"])))
elif kind == "times_as_many":
out.append(("times_as_many", r["entity"], r["ref"], r["factor"]))
elif kind == "divide_by":
out.append(("divide_by", r["entity"], r["ref"], int(r["divisor"])))
elif kind == "sum_of":
out.append(("sum_of", r["entity"], tuple(sorted(r["parts"]))))
else: # an unknown relation kind is itself a structural difference, not a crash

View file

@ -218,14 +218,30 @@ def _check_multiply(
def _check_divide(
dep_units: list[tuple[SymbolBinding, UnitVector]],
) -> UnitProof:
"""Dividend / divisor. Divisor identified by ``__divisor`` suffix.
"""Dividend / divisor. Two admissible forms:
Refuses with ``operand_arity`` if dep set is not exactly one dividend
+ one ``*__divisor`` literal. The adapter is responsible for naming.
- **single dep** divide by an implicit *dimensionless literal* (the reader's
"half as many"). The divisor is carried in the reader's typed IR as a
dimensionless :class:`~generate.quantitative_expr.Literal`, NOT as a graph
symbol, exactly as the multiplicative factor is. This is symmetric with
:func:`_check_multiply`'s single-dep dimensionless scaling: the quotient keeps
the dividend's unit (``x / dimensionless = x``).
- **two deps** dividend + a ``*__divisor`` literal (the rate-adapter convention).
lhs == quotient of the two units. The adapter is responsible for naming.
Refuses with ``operand_arity`` for any other arity.
"""
if len(dep_units) == 1:
# Divide by an implicit dimensionless literal — symmetric with single-dep
# multiply. No graph divisor symbol exists, so there is nothing to quotient
# against; the quotient keeps the dividend's unit by construction.
only = dep_units[0][1]
return UnitProof(
operation_kind="divide", lhs_unit=only, operand_units=(only,)
)
if len(dep_units) != 2:
raise AdmissibilityError(
"operand_arity", f"divide requires exactly 2 deps; got {len(dep_units)}"
"operand_arity", f"divide requires 1 or 2 deps; got {len(dep_units)}"
)
dividend: UnitVector | None = None
divisor: UnitVector | None = None

View file

@ -43,6 +43,7 @@ from generate.binding_graph.units import UnitAlgebraError, parse_unit
from generate.meaning_graph.reader import Refusal, _split_sentences
from generate.quantitative_expr import (
Add,
Div,
Expr,
Literal,
Mul,
@ -154,25 +155,48 @@ class _Mul:
unit: str
#: Word factors for "twice/double/triple ... as many". 'half' (a /2, the divide path)
#: is deliberately ABSENT — divide-by-literal is a separate admissibility path, deferred.
@dataclass(frozen=True, slots=True)
class _Div:
"""Divisive comparative: entity = ref / divisor (R1, "half as many"). The
divisor is a dimensionless integer literal; the quotient keeps ref's unit."""
entity: str
ref: str
divisor: int
unit: str
#: Word factors for "twice/double/triple ... as many" (a multiply by a dimensionless int).
_FACTOR_WORDS: dict[str, int] = {"twice": 2, "double": 2, "triple": 3, "quadruple": 4}
#: Word divisors for "half ... as many" (a divide by a dimensionless int). The divisive
#: twin of ``_FACTOR_WORDS``; both slot into the same 8-token "<WORD> as many" template.
#: 'third'/'quarter' (non-power-of-two surface forms with an article) are deferred.
_DIVISOR_WORDS: dict[str, int] = {"half": 2}
def _try_multiplicative(entity: str, toks: list[str], detail: str) -> "_Mul | None":
"""Match "Y has <factor-word> as many <unit> as X" or "Y has <N> times as many
<unit> as X" → ``_Mul``. Returns None if the clause is not multiplicative (the
caller then tries the digit-led fact/additive templates)."""
# [Y, has, FACTORWORD, as, many, UNIT, as, X]
def _try_multiplicative(entity: str, toks: list[str], detail: str) -> "_Mul | _Div | None":
"""Match the comparative templates → ``_Mul`` (multiply) or ``_Div`` (divide).
- "Y has <factor-word> as many <unit> as X" ``_Mul`` (twice/double/triple/quadruple)
- "Y has <divisor-word> as many <unit> as X" ``_Div`` (half)
- "Y has <N> times as many <unit> as X" ``_Mul``
Returns None if the clause is not comparative (the caller then tries the digit-led
fact/additive templates)."""
# [Y, has, WORD, as, many, UNIT, as, X] — factor and divisor words share this shape.
if (
len(toks) == 8
and toks[2] in _FACTOR_WORDS
and toks[3] == "as"
and toks[4] == "many"
and toks[6] == "as"
):
return _Mul(entity, _ident(toks[7], detail), _FACTOR_WORDS[toks[2]],
_resolve_unit(_ident(toks[5], detail)))
ref = _ident(toks[7], detail)
unit = _resolve_unit(_ident(toks[5], detail))
if toks[2] in _FACTOR_WORDS:
return _Mul(entity, ref, _FACTOR_WORDS[toks[2]], unit)
if toks[2] in _DIVISOR_WORDS:
return _Div(entity, ref, _DIVISOR_WORDS[toks[2]], unit)
# [Y, has, N, times, as, many, UNIT, as, X]
if (
len(toks) == 9
@ -240,6 +264,7 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
facts: list[_Fact] = []
eqs: list[_Eq] = []
muls: list[_Mul] = []
divs: list[_Div] = []
queries: list[tuple] = []
try:
@ -253,6 +278,8 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
eqs.append(spec)
elif isinstance(spec, _Mul):
muls.append(spec)
elif isinstance(spec, _Div):
divs.append(spec)
else:
queries.append(spec)
except _QReject as rej:
@ -269,6 +296,8 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
unit_of[e.entity], role_of[e.entity] = e.unit, "count"
for m in muls:
unit_of[m.entity], role_of[m.entity] = m.unit, "count"
for d in divs:
unit_of[d.entity], role_of[d.entity] = d.unit, "count"
query = queries[0]
sum_eq: tuple[str, tuple[str, ...]] | None = None
@ -288,6 +317,8 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
referenced.update((e.entity, e.ref))
for m in muls:
referenced.update((m.entity, m.ref))
for d in divs:
referenced.update((d.entity, d.ref))
if sum_eq is not None:
referenced.add(sum_eq[0])
referenced.update(sum_eq[1])
@ -321,6 +352,9 @@ def comprehend_quantitative(text: str, source_id: str = "input") -> QuantCompreh
expr_specs.extend(
(m.entity, Mul(Symbol(m.ref), Literal(m.factor))) for m in muls
)
expr_specs.extend(
(d.entity, Div(Symbol(d.ref), Literal(d.divisor))) for d in divs
)
if sum_eq is not None:
lhs, parts = sum_eq
expr_specs.append((lhs, SumOf(tuple(Symbol(p) for p in parts))))

View file

@ -65,6 +65,27 @@ class Mul:
right: "Expr"
@dataclass(frozen=True, slots=True)
class Div:
"""Exact integer division of a symbol by a dimensionless literal divisor — the
fractional comparative ("half/a third as many"). ``left`` is the referenced symbol,
``right`` a dimensionless literal divisor; the quotient keeps the symbol's unit
(``count / scalar = count``).
"half as many" is modelled as ``Div(Symbol, Literal(2))``, NOT ``Mul`` by a rational:
the system is integer-exact end to end (``oracle_answer -> int``) and :class:`Literal`
is a dimensionless *integer* (the contract PR-6a proved load-bearing), so a fractional
factor is not representable. Division by an integer divisor keeps everything integral.
Divisor-only contract (the wrong=0 boundary). The only admitted shape is
``Div(Symbol, Literal)`` see :func:`to_relation`, which refuses every other shape.
Exactness is enforced downstream: the answer oracle admits the quotient ONLY when
``base % divisor == 0`` (an odd base over 2 refuses, never floors to a wrong integer)."""
left: "Expr"
right: "Expr"
@dataclass(frozen=True, slots=True)
class SumOf:
"""An aggregate over ≥2 symbols (the part-whole total)."""
@ -72,7 +93,7 @@ class SumOf:
parts: tuple[Symbol, ...]
Expr = Union[Literal, Symbol, Add, Sub, Mul, SumOf]
Expr = Union[Literal, Symbol, Add, Sub, Mul, Div, SumOf]
def to_canonical_string(expr: Expr) -> str:
@ -88,6 +109,8 @@ def to_canonical_string(expr: Expr) -> str:
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 Div(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
@ -100,7 +123,7 @@ def dependencies(expr: Expr) -> frozenset[str]:
return frozenset()
case Symbol(symbol_id):
return frozenset({symbol_id})
case Add(left, right) | Sub(left, right) | Mul(left, right):
case Add(left, right) | Sub(left, right) | Mul(left, right) | Div(left, right):
return dependencies(left) | dependencies(right)
case SumOf(parts):
out: frozenset[str] = frozenset()
@ -119,6 +142,8 @@ def operation_kind(expr: Expr) -> str:
return "subtract"
case Mul(_, _):
return "multiply"
case Div(_, _):
return "divide"
case _:
raise TypeError(f"expression has no operation_kind: {expr!r}")
@ -129,9 +154,11 @@ def to_relation(lhs: str, expr: Expr) -> dict[str, Any] | None:
``None`` for a shape the projection does not handle the caller refuses rather than
emit a guessed relation (wrong=0 boundary). Each ``case`` is intentionally a *narrow*
structural pattern, not a kind tag: ``Mul(Symbol, Literal)`` is the only multiplicative
shape projected (the scalar-only contract a ``count × count`` ``Mul(Symbol, Symbol)``
or a compound factor falls through to ``None``). The dimensional checker would not catch
such a masquerade (it products units happily), so this boundary is load-bearing.
shape projected and ``Div(Symbol, Literal)`` the only divisive one (the scalar/divisor
contracts a ``count × count`` ``Mul(Symbol, Symbol)``, a compound factor, or a
symbol-over-symbol ``Div`` falls through to ``None``). The dimensional checker would not
catch such a masquerade (it products/quotients units happily), so this boundary is
load-bearing.
"""
match expr:
case Add(Symbol(ref), Literal(delta)):
@ -140,6 +167,8 @@ def to_relation(lhs: str, expr: Expr) -> dict[str, Any] | None:
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 Div(Symbol(ref), Literal(divisor)):
return {"kind": "divide_by", "entity": lhs, "ref": ref, "divisor": divisor}
case SumOf(parts):
return {"kind": "sum_of", "entity": lhs, "parts": [p.symbol_id for p in parts]}
case _:
@ -148,6 +177,7 @@ def to_relation(lhs: str, expr: Expr) -> dict[str, Any] | None:
__all__ = [
"Add",
"Div",
"Expr",
"Literal",
"Mul",

View file

@ -247,6 +247,63 @@ def test_divide_refuses_three_deps() -> None:
assert ei.value.reason == "operand_arity"
# --------------------------------------------------------------------------- #
# ADR-0134 amendment 2026-06-07 — single-dep divide (divide by a dimensionless literal)
# --------------------------------------------------------------------------- #
def test_divide_single_dep_dimensionless_keeps_unit() -> None:
"""A single-dep divide (the reader's "half as many") divides by an implicit
dimensionless literal and keeps the dividend's unit — symmetric with single-dep
multiply.
Meaningful-fail: if the ``len == 1`` branch were removed (reverting to ``!= 2``
refuse), this admission turns into an ``operand_arity`` refusal and the assert fails.
"""
symbols = {"carl": _sym("carl", unit="item")}
proof = check_admissibility(
_eq(kind="divide", deps=frozenset({"carl"})), symbols=symbols
)
assert proof.operation_kind == "divide"
assert proof.lhs_unit == parse_unit("item") # item / dimensionless = item
assert proof.operand_units == (parse_unit("item"),)
def test_divide_refuses_zero_or_three_deps() -> None:
"""The single-dep extension is narrow: zero deps and three deps still refuse with
``operand_arity`` only one (dimensionless divide) or two (rate divide) are admitted.
"""
with pytest.raises(AdmissibilityError) as ei0:
check_admissibility(_eq(kind="divide", deps=frozenset()), symbols={})
assert ei0.value.reason == "operand_arity"
symbols = {
"a": _sym("a", unit="foot"),
"b": _sym("b", unit="hour"),
"op_000__divisor": _sym("op_000__divisor", unit="hour"),
}
with pytest.raises(AdmissibilityError) as ei3:
check_admissibility(
_eq(kind="divide", deps=frozenset({"a", "b", "op_000__divisor"})),
symbols=symbols,
)
assert ei3.value.reason == "operand_arity"
def test_divide_two_dep_rate_path_unchanged_by_amendment() -> None:
"""The original two-dep rate divide (dividend + ``*__divisor``) is untouched — the
amendment only ADDED the single-dep form."""
symbols = {
"q_actor_foot_t0": _sym("q_actor_foot_t0", unit="foot"),
"op_000__divisor": _sym("op_000__divisor", unit="hour"),
}
proof = check_admissibility(
_eq(kind="divide", deps=frozenset({"q_actor_foot_t0", "op_000__divisor"})),
symbols=symbols,
)
assert proof.lhs_unit.exponents == (1, -1, 0, 0, 0, 0) # foot / hour = speed
# ---------------------------------------------------------------------------
# apply_rate
# ---------------------------------------------------------------------------

View file

@ -196,3 +196,25 @@ def test_multiplicative_missing_base_refuses() -> None:
# never fabricate a base quantity.
comp = comprehend_quantitative("Quinn has twice as many toys as Rosa. How many toys does Quinn have?")
assert isinstance(comp, Refusal)
def test_half_as_many_builds_divide_equation() -> None:
# PR-6c: "half as many" is the divisive twin of "twice as many" — operation_kind
# "divide", a single symbol dep (the divisor literal is in the IR, not a graph symbol),
# and the REAL single-dep admissibility check (item / dimensionless = item) admits it.
comp = _comp("Carl has 8 coins. Dora has half as many coins as Carl. How many coins does Dora have?")
eq = next(e for e in comp.binding_graph.equations if e.lhs_symbol_id == "dora")
assert eq.operation_kind == "divide"
assert eq.rhs_canonical == "carl / 2"
assert eq.dependencies == frozenset({"carl"}) # uniform with Mul: literal not a dep
assert eq.admissibility_status == "admitted"
assert single_unknown(comp.binding_graph).symbol_id == "dora"
# The graph carries ONLY the two entities — no synthesized __divisor symbol pollutes
# it (that is why the symmetric single-dep divide was chosen over divisor synthesis).
assert {s.symbol_id for s in comp.binding_graph.symbols} == {"carl", "dora"}
def test_half_as_many_missing_base_refuses() -> None:
# "half as many ... as Rod" with no value for Rod -> ungrounded base -> REFUSE.
comp = comprehend_quantitative("Sue has half as many pears as Rod. How many pears does Sue have?")
assert isinstance(comp, Refusal)

View file

@ -169,3 +169,61 @@ def test_scalar_only_guard_is_load_bearing() -> None:
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
# --------------------------------------------------------------------------- #
# PR-6c — the divisive comparative (Div), the divisor twin of Mul
# --------------------------------------------------------------------------- #
def test_div_serialization_and_derivations() -> None:
from generate.quantitative_expr import Div
d = Div(Symbol("carl"), Literal(2))
assert to_canonical_string(d) == "carl / 2"
assert dependencies(d) == frozenset({"carl"}) # the literal divisor is NOT a dep
assert operation_kind(d) == "divide"
assert to_relation("dora", d) == {
"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 2,
}
def test_div_projection_admits_only_symbol_over_literal() -> None:
"""``Div(Symbol, Literal)`` is the ONLY shape that projects to ``divide_by``; every
other ``Div`` shape REFUSES (``to_relation`` None) the divisor-only twin of the
scalar-only Mul contract.
Meaningful-fail: a ``Div(Symbol, Symbol)`` is a quantity-over-quantity ratio (the
rate-divide family), NOT a divide-by-dimensionless-literal; projecting it as
``divide_by`` would fabricate a divisor. These asserts fail the moment that guard is
loosened.
"""
from generate.quantitative_expr import Div
# The one admitted shape.
assert to_relation("dora", Div(Symbol("carl"), Literal(2))) == {
"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 2,
}
# Quantity over quantity (a ratio), not a dimensionless divide → refuse.
assert to_relation("dora", Div(Symbol("a"), Symbol("b"))) is None
# Commuted (literal dividend) → refuse.
assert to_relation("dora", Div(Literal(8), Symbol("a"))) is None
# Compound divisor → refuse.
assert to_relation("dora", Div(Symbol("a"), Add(Symbol("b"), Literal(1)))) is None
# A bare literal quotient carries no symbol to reference → refuse.
assert to_relation("dora", Div(Literal(8), Literal(2))) is None
def test_div_is_symmetric_with_mul_in_the_ir() -> None:
"""``Div`` and ``Mul`` are structural twins: single-symbol dep, dimensionless literal
operand, the operand is never a dependency. This symmetry is what lets the reader
build BOTH uniformly (``deps = dependencies(expr)``) without a per-op special case.
"""
from generate.quantitative_expr import Div, Mul
mul = Mul(Symbol("anna"), Literal(2))
div = Div(Symbol("carl"), Literal(2))
assert dependencies(mul) == frozenset({"anna"})
assert dependencies(div) == frozenset({"carl"}) # identical shape: literal not a dep
assert operation_kind(mul) == "multiply"
assert operation_kind(div) == "divide"

View file

@ -152,19 +152,21 @@ def test_reader_units_read_from_the_binding_graph() -> None:
# --------------------------------------------------------------------------- #
def test_r1_multiplicative_supported_rest_refused_wrong_zero() -> None:
def test_r1_comparative_supported_rest_refused_wrong_zero() -> None:
r = run_r1()
assert r["total"] == 10
# THE invariant through the first capability slice: NO R1 case is misread. Adding the
# multiplicative frame turned refusals into correct readings without any setup_wrong.
# THE invariant through every capability slice: NO R1 case is misread. Each frame
# turns refusals into correct readings without ever producing a setup_wrong.
assert r["setup_wrong"] == 0
# The multiplicative frame (PR-5c) reads "twice as many" (r1-01) and the multi-step
# chain whose middle step is "N times as many" (r1-05); the rest stay safe refusals.
by_id = {d["id"]: d["outcome"] for d in r["details"]}
# Multiplicative frame (PR-5c): "twice as many" (r1-01) + the multi-step chain whose
# middle step is "N times as many" (r1-05).
assert by_id["r1-01-twice"] == "correct"
assert by_id["r1-05-chain"] == "correct"
assert r["setup_correct"] == 2
assert r["setup_refused"] == 8
# Divisive frame (PR-6c): "half as many" (r1-02).
assert by_id["r1-02-half"] == "correct"
assert r["setup_correct"] == 3
assert r["setup_refused"] == 7
# No detail is ever WRONG, and every non-correct one is a typed refusal.
for d in r["details"]:
assert d["outcome"] in ("correct", "refused")
@ -209,12 +211,82 @@ def test_r1_answer_lane_scores_only_setup_correct_fixtures() -> None:
assert r["setup_wrong"] == 0
assert r["wrong"] == 0
assert r["gold_error"] == 0
assert r["correct"] == 2
assert r["refused"] == 8
assert r["correct"] == 3
assert r["refused"] == 7
by_id = {d["id"]: d for d in r["details"]}
assert by_id["r1-01-twice"] == {"id": "r1-01-twice", "outcome": "correct", "answer": 12}
assert by_id["r1-02-half"] == {"id": "r1-02-half", "outcome": "correct", "answer": 4}
assert by_id["r1-05-chain"] == {"id": "r1-05-chain", "outcome": "correct", "answer": 14}
for fixture_id, detail in by_id.items():
if fixture_id not in {"r1-01-twice", "r1-05-chain"}:
if fixture_id not in {"r1-01-twice", "r1-02-half", "r1-05-chain"}:
assert detail["outcome"] == "refused"
assert detail.get("reason")
# --------------------------------------------------------------------------- #
# PR-6c — off-serving answer oracle support for divide_by ("half as many")
# --------------------------------------------------------------------------- #
def test_oracle_computes_divide_by_exact() -> None:
assert oracle_answer(
[
{"kind": "fact", "entity": "carl", "value": 8},
{"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 2},
],
{"entity": "dora"},
) == 4
def test_oracle_refuses_non_exact_division() -> None:
"""The wrong=0 boundary of the divisive frame: a non-exact division REFUSES rather
than flooring to a wrong integer. ``7 // 2 == 3`` would be WRONG; the oracle raises.
Meaningful-fail: if the ``base % divisor != 0`` guard were dropped, this would return
3 (a fabricated answer) instead of raising the assert flips from pass to fail.
"""
with pytest.raises(OracleError):
oracle_answer(
[
{"kind": "fact", "entity": "xio", "value": 7},
{"kind": "divide_by", "entity": "yon", "ref": "xio", "divisor": 2},
],
{"entity": "yon"},
)
def test_oracle_rejects_bad_divisor_and_forward_ref() -> None:
"""The full ``divide_by`` refusal contract — every bad-divisor / unresolved-base class
raises ``OracleError`` (never a ZeroDivisionError, never a silent float/floor)."""
base = {"kind": "fact", "entity": "carl", "value": 8}
def _bad_divisor(divisor: object) -> None:
with pytest.raises(OracleError):
oracle_answer(
[base, {"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": divisor}],
{"entity": "dora"},
)
_bad_divisor(0.5) # non-integer (fractional < 1)
_bad_divisor(1.5) # non-integer (fractional > 1)
_bad_divisor(0) # zero divisor — never ZeroDivisionError
_bad_divisor(True) # bool is not an admissible int divisor (isinstance(True, int) is True)
# Forward reference to an unresolved base → refuse.
with pytest.raises(OracleError):
oracle_answer(
[{"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 2}],
{"entity": "dora"},
)
def test_oracle_divide_by_one_is_identity() -> None:
"""``divisor=1`` is intentionally ALLOWED: base / 1 = base, exact. The reader never
constructs it (``_DIVISOR_WORDS`` only maps 'half'2), but the oracle's grammar admits
it mathematically. Pinned so the choice stays deliberate, not accidental."""
assert oracle_answer(
[
{"kind": "fact", "entity": "carl", "value": 8},
{"kind": "divide_by", "entity": "dora", "ref": "carl", "divisor": 1},
],
{"entity": "dora"},
) == 8