core/generate/derivation/model.py
Shay 5a9454af20 feat(adr-0176-ms2): multi-step chain model — text + comparative operands
MS-2 of multi-step composition. Extends the derivation model so a chain mixes
text-quantity operands and COMPARATIVE-scalar operands (twice->x2, 'N times'->xN,
half->x0.5), self-verifying the whole chain with completeness over body+question
and question-target matching.

- model.py: Step gains comparative flag.
- comparatives.py: ComparativeScalar gains number_token (the '<N> times' number,
  so completeness counts the consumed body quantity); comparative_step(cs) bridges
  a scalar into a Step (operand grounded by cue, not a text value token).
- verify.py: self_verifies exempts comparative operands from value-grounding
  (clause 1) — they are cue-grounded (clause 2); completeness (Counter) counts a
  digit comparative's number_token as consuming the body quantity. Adds target_units
  to select_self_verified: a chain whose answer_unit isn't the asked unit is dropped
  (question-target match; empty target_units imposes no constraint).

Proves the multi-step shapes from the gold structures: 0024 (text sum then 'three
times' scale -> 438), 0033 father-chain (digit-comparative '7 times' + fixed 'half'
+ text add -> 47). Full 0033 DAG (quantity reuse + the question's 25) deferred.

25 MS-2 tests; full derivation surface 69/69 (3a/3b/comparatives/ms1/ms2); ruff
clean; smoke 67. Not wired into serving (model ready for MS-3 target-guided search).
2026-05-28 16:35:41 -07:00

76 lines
2.7 KiB
Python

"""ADR-0175 Phase 3a — grounded-derivation value model.
A derivation is a left-fold over text-sourced quantities: a ``start`` quantity
followed by ordered ``Step``s. Each step names the operation, its operand, and
the **licensing cue** — the surface lexeme the search claims licenses that
operation. The cue is verified against the problem text by the gate
(:mod:`generate.derivation.verify`); the model itself only computes the value.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
VALID_OPS: Final[frozenset[str]] = frozenset({"multiply", "divide", "add", "subtract"})
@dataclass(frozen=True, slots=True)
class Quantity:
"""A quantity drawn from the problem. ``source_token`` is the surface token
as it appears in the text (used by the gate to prove the value is grounded)."""
value: float
unit: str
source_token: str
@dataclass(frozen=True, slots=True)
class Step:
"""One operation applied to the running value.
``cue`` is the surface lexeme the search asserts licenses ``op`` here; the
gate refuses to self-verify unless ``cue`` actually appears in the text.
"""
op: str
operand: Quantity
cue: str
# ADR-0176 MS-2: when True the operand is a comparative scalar (twice -> x2,
# 'N times' -> xN). It is grounded by ``cue`` (the comparative lexeme), not by a
# text value token, and it does not count as a body quantity for completeness.
comparative: bool = False
def __post_init__(self) -> None:
if self.op not in VALID_OPS:
raise ValueError(f"op must be one of {sorted(VALID_OPS)}, got {self.op!r}")
@dataclass(frozen=True, slots=True)
class GroundedDerivation:
start: Quantity
steps: tuple[Step, ...]
@property
def answer(self) -> float:
"""Left-fold the steps over ``start``. Raises on divide-by-zero (the gate
rejects such derivations before this is relied upon)."""
value = self.start.value
for step in self.steps:
operand = step.operand.value
if step.op == "multiply":
value = value * operand
elif step.op == "divide":
value = value / operand # ZeroDivisionError surfaces; gate guards
elif step.op == "add":
value = value + operand
else: # subtract
value = value - operand
return value
@property
def answer_unit(self) -> str:
"""The aggregate keeps the primary (``start``) unit. Multiply/divide
compose across units onto the primary; add/subtract require (and the gate
enforces) a shared unit, so the primary is correct in every admitted case."""
return self.start.unit