core/generate/derivation/verify.py
Shay 0fbcce429b
feat(adr-0182): cross-composer disagreement pooling — distractor 0014 + disguised-polarity refuse (confuser wrong 5->2) (#476)
Implements ADR-0182's first win on top of EX-6 (#473). The distractor-quantity
confuser 0014 misfired (20x3x5=300) because a blunt product-of-all was the *unique*
self-verifying reading: the completeness clause forces the distractor into it, and
no rival reading existed to trigger the wrong=0 disagreement rule. No tight cue rule
separates it from the legitimate cross-unit products (`for` licenses both the 0014
distractor AND the correct train-0021 product) -- that is the deferred cue-precision
problem. So instead of a reactive patch, let the disagreement rule do the refusing.

Mechanism (sealed lane; chat/ does not import these -> serving 3/47/0 frozen):
- verify.classify_derivation: a derivation is `complete` (commit-eligible),
  `exempt` (verified but for an isolated-foreign unused quantity -> commit-
  INELIGIBLE), or None. Refactored self_verifies into _base_reasons +
  _unused_quantities so the two share logic and cannot drift (behavior identical;
  385 derivation tests + smoke 67 green).
- accumulate.accumulation_candidates: exposes the ungated readings, incl. a
  distractor-skip reading that drops an isolated-foreign quantity from a multi-
  quantity change clause (20+5=25). compose_accumulation is byte-identical
  (drop_isolated_foreign=False + the same gate).
- search.multiplicative_candidates / multistep.candidate_chains: ungated candidates.
- pool.resolve_pooled: pools every composer's readings; disagreement -> refuse; a
  single answer commits only if a `complete` candidate produced it (exempt-only ->
  refuse, so the commit-path completeness guarantee from ADR-0175 is untouched).
- confuser runner: _engine_answer now delegates to resolve_pooled (the prior
  first-composer-wins order could not notice it held two incompatible readings).

Result (the microscope):
- confuser wrong 5 -> 2. distractor 0014 refuses (product 300 vs additive 25);
  BONUS: both disguised-polarity cases (0001/0003) refuse -- the spurious
  "buys X for N coins" product disagrees with the accumulation reading. Remaining
  wrong: 0016 (distractor in the anchor clause -> needs anchor-skip, separate step)
  and 0020 (temporal-scope). pair-tells 4 -> 1.
- genuine positives still 7 solved, 0 wrong.
- train_sample 3/47/0 and practice 3/47/0 byte-identical (they call
  compose_accumulation/search directly -- unchanged -- not the pool).
- smoke 67, architectural invariants 40, lane-SHA freeze 8/8.

Tests:
- test_adr_0182_pool.py: classify (complete/exempt/None incl. narrow-exemption
  edges) + resolve_pooled, with the wrong=0 obligation test_exempt_only_never_commits
  (a distractor with no multiplicative cue must refuse, not commit 25 -- fails loudly
  if the exemption is made commit-eligible).
- test_adr_0163_f2_confusers.py: baseline tightened wrong 5->2, pair_tells 4->1;
  new test_distractor_0014_refuses_via_pooling + test_disguised_polarity_does_not_misfire.

Stacked on #473 (EX-6); merge #473 first. 0016 + the remaining wrongs are follow-ups.
2026-05-29 13:22:19 -07:00

190 lines
8.3 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 collections import Counter
from generate.derivation.extract import extract_quantities
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 _base_reasons(derivation: GroundedDerivation, tokens: frozenset[str]) -> list[str]:
"""The grounding ∧ cue ∧ unit ∧ divide-by-zero clauses (everything *but*
completeness). Shared by :func:`self_verifies` and :func:`classify_derivation`
so the two cannot drift."""
reasons: list[str] = []
# 1. operand grounding — every TEXT operand value must be sourced from the
# text. Comparative operands (ADR-0176 MS-2: twice -> x2, 'N times' -> xN)
# are grounded by their cue (clause 2), not by a text value token, so they
# are exempt here — their pack-supplied scalar is not a number in the text.
operands = [derivation.start, *(s.operand for s in derivation.steps if not s.comparative)]
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 reasons
def _unused_quantities(derivation: GroundedDerivation, problem_text: str) -> Counter[str]:
"""Problem quantities (by source token) the derivation does not consume."""
problem_quantities = Counter(q.source_token for q in extract_quantities(problem_text))
used = Counter(
[derivation.start.source_token]
+ [step.operand.source_token for step in derivation.steps]
)
return problem_quantities - used
def self_verifies(derivation: GroundedDerivation, problem_text: str) -> SelfVerification:
"""Decide whether ``derivation`` self-verifies against ``problem_text``."""
tokens = _tokens(problem_text)
reasons = _base_reasons(derivation, tokens)
# 5. completeness — a trustworthy derivation must account for every quantity
# the problem states. A derivation that ignores given numbers is an
# incomplete reading (typically a correct *first step* of a multi-step
# problem, mistaken for the whole answer). Refuse-preferring: unused
# quantities -> not self-verified. This is the clause the practice-lane
# microscope identified (ADR-0175 self-verification strengthening): it
# catches the multi-step-incomplete attempts the cue/grounding clauses
# cannot, because their operands ARE grounded.
unused = _unused_quantities(derivation, problem_text)
if unused:
reasons.append(f"incomplete: unused problem quantities {sorted(unused.keys())}")
return SelfVerification(verified=not reasons, reasons=tuple(reasons))
def classify_derivation(derivation: GroundedDerivation, problem_text: str) -> str | None:
"""ADR-0182 — the commit-eligibility class of a derivation, for pooling.
Returns:
* ``"complete"`` — passes every clause *including* full completeness;
**commit-eligible** (may resolve as an answer).
* ``"exempt"`` — passes every base clause and the only unused quantities are
**isolated-foreign** (unit non-empty ∧ equal to no *used* operand's unit, i.e.
a candidate distractor standing alone in a dimension the reading never
touches); **commit-INELIGIBLE**. It exists only to enter the pool and force a
disagreement → refusal, never to be committed alone (see :mod:`generate.
derivation.pool`). This keeps the commit-path completeness guarantee
(ADR-0175's multi-step-incomplete defence) intact — the exemption widens only
what can *refuse*.
* ``None`` — fails a base clause, or an unused quantity is not
isolated-foreign (empty unit, or a unit shared with a used operand → real
signal the reading dropped).
"""
tokens = _tokens(problem_text)
if _base_reasons(derivation, tokens):
return None
unused = _unused_quantities(derivation, problem_text)
if not unused:
return "complete"
used_units = {derivation.start.unit, *(step.operand.unit for step in derivation.steps)}
units_by_token: dict[str, set[str]] = {}
for q in extract_quantities(problem_text):
units_by_token.setdefault(q.source_token, set()).add(q.unit)
for token in unused:
token_units = units_by_token.get(token, {""})
# isolated-foreign iff *every* occurrence has a non-empty unit not shared
# with any used operand. An empty unit, or a unit a used operand carries,
# disqualifies the exemption — that quantity is real signal, not a distractor.
if any((not unit) or (unit in used_units) for unit in token_units):
return None
return "exempt"
def select_self_verified(
derivations: list[GroundedDerivation],
problem_text: str,
*,
target_units: tuple[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).
ADR-0176 MS-2 question-targeting: when ``target_units`` is non-empty (the unit
the question asks for), derivations whose ``answer_unit`` is not among them are
dropped — a chain that computes the wrong kind of quantity answered a different
question. Empty ``target_units`` imposes no constraint (the unit signal may be
unavailable, e.g. a superordinate the units pack doesn't yet cover).
"""
verified = [d for d in derivations if self_verifies(d, problem_text).verified]
if target_units:
verified = [d for d in verified if d.answer_unit in target_units]
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,
)