core/generate/derivation/pool.py
Shay a862d73084
feat(adr-0182): prior-state question guard — temporal-scope confuser refuses (confuser wrong 2->1, pair-tells ->0) (#480)
The "before/left" reading-rule lever. The microscope showed only the question-time
reading is cleanly achievable: "for N money = spend" is cue-precision-blocked (the
`for` cue is overloaded across train_sample -- `for $2`, `for 14 days`, `for 10 reps`
-- so a spend rule risks regressing train-0021/0003 and is the overfitting trap),
and the disguised-polarity cases already refuse via pooling. "left" is already handled
by loss verbs.

What ships: a question-scope guard. A question asking for a state *before* a stated
change ("How much did Lisa have before lunch?", gold 50) asks for a temporal point
the forward composers do not compute -- they derive the final/net state (50-20=30).
Until a question-time reader exists that is a refusal, never a guess at the wrong
point. target.asks_prior_state detects before/initially/originally/at first/to begin
with/to start with/at the start in the QUESTION CLAUSE only (the last `?`-sentence),
so body narrative does not trip it -- verified safe against train-0003 ("sells before
school starts"), 0010 ("had 20 initially, then lost 12"), 0028. `used to` is excluded
(the purpose infinitive "beads used to make a bracelet" is a false positive).
resolve_pooled refuses when asks_prior_state holds.

Result (sealed lane; chat/ does not import these -> serving 3/47/0 frozen):
- confuser wrong 2 -> 1 (only 0016 distractor-anchor-skip remains). temporal-scope
  category cleared (0 wrong / 2 refused). pair-tells 1 -> 0: 0020 ("before lunch")
  refuses while its minimal-pair twin 0021 ("left", gold 30) still solves the net --
  the discrimination the corpus is built to measure.
- genuine positives still 7 solved, 0 wrong.
- train_sample 3/47/0 and practice 3/47/0 byte-identical.
- 205 derivation/target/pool tests + 40 architectural invariants green.

Tests:
- test_adr_0182_pool.py TestPriorStateQuestionGuard: detector true/false edges
  (before-in-question vs before-in-body vs `used to make` false positive vs the
  `left` twin) + resolve_pooled refuses the before-question while the left-twin
  resolves forward to 30.
- test_adr_0163_f2_confusers.py: baseline wrong 2->1, pair_tells 1->0; new
  test_temporal_scope_does_not_misfire + test_before_left_minimal_pair_discriminated.

Stacked on #476 (pooling); merge #476 first. 0016 anchor-skip is the next lever.
Pairs with ADR-0163-F2 graduation amendment (#478).
2026-05-29 14:22:28 -07:00

97 lines
4.2 KiB
Python

"""ADR-0182 — cross-composer disagreement pooling.
The distractor-quantity confusers (``confuser-v1-0014`` → 300, ``0016`` → 3840)
misfire because a blunt product-of-all is the *unique* self-verifying reading: the
completeness clause forces the distractor into it, and no rival reading exists to
trigger the wrong=0 disagreement rule. The microscope showed no tight cue/gate rule
separates these 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 (ADR-0177).
This module sidesteps cue precision: it pools the **ungated candidate readings of
every composer** (accumulation, in-clause multiplicative, target-guided chain) for
one problem and resolves them together. A reading is classified
(:func:`generate.derivation.verify.classify_derivation`) as ``complete``
(commit-eligible) or ``exempt`` (commit-INELIGIBLE — verified but for an
isolated-foreign *distractor* quantity it leaves unused). The rule:
* candidates disagree (more than one distinct answer across the pool) → **refuse**
(the wrong=0 disagreement rule does the work);
* a single distinct answer → commit **only if** a ``complete`` candidate produced it
(an ``exempt``-only answer never commits — full completeness is still required to
resolve, so ADR-0175's multi-step-incomplete defence is untouched).
For a distractor problem this gives the product (``complete``) a competing additive
reading (``exempt``) to disagree with → refusal; for a genuine product (0021/0003)
there is no rival reading, so it still commits. Deterministic; sealed (serving is
never invoked here).
"""
from __future__ import annotations
from generate.derivation.accumulate import accumulation_candidates
from generate.derivation.model import GroundedDerivation
from generate.derivation.multistep import candidate_chains
from generate.derivation.search import multiplicative_candidates
from generate.derivation.target import asks_prior_state
from generate.derivation.verify import Resolution, classify_derivation
def pooled_candidates(problem_text: str) -> list[GroundedDerivation]:
"""Every composer's ungated candidate readings, de-duplicated. Deterministic
order (accumulation, then multiplicative, then chain)."""
seen: set[tuple[object, ...]] = set()
pooled: list[GroundedDerivation] = []
for derivation in (
*accumulation_candidates(problem_text),
*multiplicative_candidates(problem_text),
*candidate_chains(problem_text),
):
key = (
round(derivation.answer, 9),
derivation.start.source_token,
tuple((step.op, step.operand.source_token) for step in derivation.steps),
)
if key in seen:
continue
seen.add(key)
pooled.append(derivation)
return pooled
def resolve_pooled(problem_text: str) -> Resolution | None:
"""Resolve a problem by pooling every composer's readings. Refuse-preferring.
Refuses on no verifying candidate, on disagreement among the pool, or when the
sole answer is produced only by commit-ineligible (``exempt``) readings.
ADR-0182 question-scope guard: a question asking for a *prior* state ("how much
did Lisa have **before** lunch?") asks for a temporal point the forward composers
do not compute — they derive the final/net state. Until a question-time reader
exists, that is a refusal, never a guess at the wrong point.
"""
if asks_prior_state(problem_text):
return None
classified = [
(kind, derivation)
for derivation in pooled_candidates(problem_text)
if (kind := classify_derivation(derivation, problem_text)) is not None
]
if not classified:
return None
distinct = {round(derivation.answer, 9) for _, derivation in classified}
if len(distinct) != 1:
return None # disagreement -> refuse (wrong=0)
committable = [d for kind, d in classified if kind == "complete"]
if not committable:
return None # exempt-only -> refuse (a commit requires full completeness)
chosen = committable[0]
return Resolution(
answer=chosen.answer,
answer_unit=chosen.answer_unit,
derivation=chosen,
)