core/generate/derivation/verify.py
Shay 0bdb3a441c feat(adr-0175-phase3a): self-verification gate (built before the search)
ADR-0175 Phase 3 splits wrong=0-first: build the gate (3a) and PROVE invariant #2
before the bounded search (3b) that could exploit gaps.

generate/derivation/:
- model.py: Quantity / Step / GroundedDerivation. A derivation is a left-fold over
  text-sourced quantities; each Step carries its licensing cue (the lexeme the
  search claims licenses the op).
- verify.py: self_verifies() — grounded operands ∧ grounded operation cues ∧ unit
  consistency ∧ no divide-by-zero. Grounding REUSES the canonical primitives from
  math_roundtrip (_tokens/_token_in/_value_grounds) so the gate cannot drift from
  the round-trip contract. select_self_verified() adds the uniqueness rule:
  unique self-verifying answer resolves; zero or disagreeing refuse (wrong=0).

INVARIANT #2 proven (TestInvariant2_NoSpuriousSelfVerification): the gate refuses
to self-verify a derivation that is not grounded+unit-consistent+unique even when
its value coincides with gold — the 20/5==4 class:
- invented operand not in text -> refused
- operation cue not in text -> refused (division not licensed by any present cue)
- value coincidence (20/5=4) with ungrounded op -> still refused
- add across units (pounds + reps) -> refused
- divide-by-zero -> refused
Plus uniqueness: disagreeing grounded derivations -> refuse; agreeing -> resolve.

Phase 3a is inert (nothing wires generate.derivation into serving). 3b is the
bounded search that produces derivations for this gate + measures the flip-curve
in the practice lane under perturbation.

Verified: 16/16; ruff clean; smoke 67/67; no serving import.
2026-05-28 15:19:02 -07:00

103 lines
3.9 KiB
Python

"""ADR-0175 Phase 3a — the self-verification gate.
The wrong=0-critical gate. A derivation **self-verifies** only when all hold:
1. **operand grounding** — every operand's value token appears in the problem
text (no invented numbers);
2. **operation-cue grounding** — every step's licensing cue lexeme appears in the
text (the operation is licensed by present evidence, not assumed);
3. **unit consistency** — add/subtract require a shared unit; multiply/divide may
compose across units onto the primary;
4. **no divide-by-zero**.
Grounding reuses the canonical primitives from :mod:`generate.math_roundtrip`
(single source of truth — the same checks the round-trip filter uses), so this
gate cannot drift from the round-trip contract.
``select_self_verified`` adds the cross-derivation **uniqueness** rule: among the
self-verifying derivations, a single distinct answer resolves; zero or several
refuse (the disagreement rule — preserves wrong=0).
Invariant #2: a derivation that fails any clause does not self-verify *even if its
value coincides with the gold answer* (the ``20/5 == 4`` class).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
# Canonical grounding primitives — reused so this gate stays identical to the
# round-trip filter's notion of "appears in the problem text".
from generate.math_roundtrip import _token_in, _tokens, _value_grounds
from generate.derivation.model import GroundedDerivation
_SAME_UNIT_REQUIRED: Final[frozenset[str]] = frozenset({"add", "subtract"})
@dataclass(frozen=True, slots=True)
class SelfVerification:
verified: bool
reasons: tuple[str, ...] # empty iff verified; named failures otherwise
@dataclass(frozen=True, slots=True)
class Resolution:
answer: float
answer_unit: str
derivation: GroundedDerivation
def self_verifies(derivation: GroundedDerivation, problem_text: str) -> SelfVerification:
"""Decide whether ``derivation`` self-verifies against ``problem_text``."""
tokens = _tokens(problem_text)
reasons: list[str] = []
# 1. operand grounding — every value must be sourced from the text.
operands = [derivation.start, *(s.operand for s in derivation.steps)]
for q in operands:
if not _value_grounds(q.source_token, tokens):
reasons.append(f"operand {q.source_token!r} not grounded in text")
# 2. operation-cue grounding — every op licensed by a present lexeme.
for step in derivation.steps:
if not _token_in(step.cue, tokens):
reasons.append(f"operation cue {step.cue!r} not grounded in text")
# 3. unit consistency.
primary_unit = derivation.start.unit
for step in derivation.steps:
if step.op in _SAME_UNIT_REQUIRED and step.operand.unit != primary_unit:
reasons.append(
f"unit mismatch: {step.op} of {step.operand.unit!r} into {primary_unit!r}"
)
# 4. divide-by-zero.
for step in derivation.steps:
if step.op == "divide" and step.operand.value == 0:
reasons.append("division by zero")
return SelfVerification(verified=not reasons, reasons=tuple(reasons))
def select_self_verified(
derivations: list[GroundedDerivation],
problem_text: str,
) -> Resolution | None:
"""Among the self-verifying derivations, return the unique answer or refuse.
Refuse-preferring: ``None`` when zero self-verify (no grounded derivation) or
when the self-verifying ones disagree (the multi-branch disagreement rule).
"""
verified = [d for d in derivations if self_verifies(d, problem_text).verified]
if not verified:
return None
distinct = {round(d.answer, 9) for d in verified}
if len(distinct) != 1:
return None # disagreement -> refuse (wrong=0)
chosen = verified[0]
return Resolution(
answer=chosen.answer,
answer_unit=chosen.answer_unit,
derivation=chosen,
)