feat(adr-0177-cp2a): cue-precision ledger training + measurement (+ unit hygiene) (#461)
CP-2a populates the CP-1 ledger from gold-labelled candidate readings and reports per-pattern reliability — the measurement the cue-precision thesis rests on. Plus the function-word unit filter, whose value this measurement makes concrete (clean unit_shape labelling). What landed (all sealed; serving 3/47/0 byte-identical): - generate/cue_precision/trainer.py — train_from_cases(cases, enumerators): folds gold-labelled candidate chains into the ledger via record_case. Decoupled (the candidate enumerators are injected, so the package still imports nothing from search). candidates_for dedupes a reading shared by two enumerators. - generate/derivation/multistep.py — extracted the enumeration half of search_chain into public candidate_chains(problem_text); search_chain now delegates (verified byte-identical: ms3 tests + practice counts unchanged). CP-2 needs the readings the search weighs, not just the one it resolves. - generate/derivation/extract.py — function-word unit filter (_NON_UNIT_WORDS): blanks spurious function-word units ($0.75 each -> "", 3/4 of -> "") that corrupt same-unit detection and unit_shape. Closed lexeme set, ADR-0165-safe. - evals/gsm8k_math/practice/v1/cue_precision_report.py — trains over 200 sealed cases (50 train_sample + 150 ADR-0163-F additive) with the real enumerators and prints the per-pattern reliability table. - tests/test_adr_0177_cp2a_training.py — trainer obligations (credit/dedupe/ determinism/empty) via synthetic enumerators; real-measurement well-formedness; search_chain parity. Load-bearing finding (recorded in ADR-0177): over 200 cases EVERY (cue,op,unit_shape) pattern floors at ~0.0 reliability (best: for-multiply-cross_unit 0.0116 at 2/34). The blunt product/sum-of-all readings are almost always wrong vs gold, so the conservative floor correctly trusts nothing. => CP-2b (trust reliable cues) is blocked on candidate GENERATION, not the ledger: candidate readings must get less crude (clause/referent structure, ADR-0178 GB-3b) before any cue earns reliability. Cue-precision and compositional structure are coupled; structure comes first. Verification: 107 targeted tests green (CP-2a/CP-1/extract/ms3/GB-1/2/3/MS-1/2) + architectural invariants; serving CLAIMS.md sha unchanged; practice 4/1/45 and 0/1/149 unchanged. Inert: trains/reports only, consulted by no search/gate.
This commit is contained in:
parent
fd96074230
commit
7451e7cd74
7 changed files with 333 additions and 17 deletions
|
|
@ -146,6 +146,27 @@ Each needs a failing-under-violation test (CLAUDE.md §Schema-Defined Proof Obli
|
||||||
only if its patterns clear `θ`; the search orders/prunes by reliability. Tests:
|
only if its patterns clear `θ`; the search orders/prunes by reliability. Tests:
|
||||||
invariant #1 (cold ⇒ no proposals, no regression); U2 never causes a wrong=0
|
invariant #1 (cold ⇒ no proposals, no regression); U2 never causes a wrong=0
|
||||||
violation.
|
violation.
|
||||||
|
- **CP-2a — ledger training + measurement (landed).** The training step
|
||||||
|
(`generate/cue_precision/trainer.py`) folds gold-labelled candidate readings
|
||||||
|
from the real search enumerators (`search._sentence_candidates` +
|
||||||
|
`multistep.candidate_chains`) into the CP-1 ledger; the measurement
|
||||||
|
(`evals/gsm8k_math/practice/v1/cue_precision_report.py`) reports per-pattern
|
||||||
|
reliability over the 200 sealed cases (50 train_sample + 150 ADR-0163-F
|
||||||
|
additive). Inert: trains/reports only, consulted by nobody — serving `3/47/0`
|
||||||
|
byte-identical, practice counts unchanged. `search_chain` now delegates
|
||||||
|
enumeration to the public `candidate_chains` (verified byte-identical).
|
||||||
|
- **CP-2a finding (load-bearing): no cue is reliable yet — CP-2b is blocked on
|
||||||
|
candidate *generation*, not on the ledger.** Trained over 200 cases, **every**
|
||||||
|
`(cue, op, unit_shape)` pattern floors at ≈ 0.0 (best: `for·multiply·cross_unit`
|
||||||
|
= 0.0116 at 2/34; `each·multiply` ≈ 0.006; `times·multiply` 0/57, `total·add`
|
||||||
|
0/47). The blunt product/sum-of-all readings the search proposes are almost
|
||||||
|
always wrong vs gold, so the conservative floor correctly trusts nothing. The
|
||||||
|
lever is therefore **not** "trust high-reliability cues" (there are none) — it
|
||||||
|
is that the candidate readings must get *less crude* (clause structure +
|
||||||
|
referent-awareness, i.e. **ADR-0178 GB-3b**) before any pattern earns reliability.
|
||||||
|
Cue-precision (CP-2b) and compositional structure (GB-3b) are **coupled, and
|
||||||
|
structure comes first.** This is the ADR-0177 §"bottleneck" honesty, now
|
||||||
|
measured rather than asserted. (Table reproducible via the report; deterministic.)
|
||||||
- **CP-3 — disagreement resolution (U3), wrong=0-first.** Margin+θ-gated resolution;
|
- **CP-3 — disagreement resolution (U3), wrong=0-first.** Margin+θ-gated resolution;
|
||||||
**prove ties refuse before enabling resolution.** Measure any coverage delta.
|
**prove ties refuse before enabling resolution.** Measure any coverage delta.
|
||||||
- **CP-4 — measurement + scale dependency.** Per-pattern reliability table; the
|
- **CP-4 — measurement + scale dependency.** Per-pattern reliability table; the
|
||||||
|
|
|
||||||
73
evals/gsm8k_math/practice/v1/cue_precision_report.py
Normal file
73
evals/gsm8k_math/practice/v1/cue_precision_report.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""ADR-0177 CP-2a — the cue-precision measurement.
|
||||||
|
|
||||||
|
Trains the CP-1 ledger over the gold-labelled sealed cases (train_sample + the
|
||||||
|
ADR-0163-F additive practice set) using the *real* search candidate enumerators,
|
||||||
|
then reports each ``(cue, op, unit_shape)`` pattern's commitment precision via the
|
||||||
|
pinned conservative floor.
|
||||||
|
|
||||||
|
This is the diagnostic the cue-precision thesis rests on: it shows which cues are
|
||||||
|
*reliable* (high floor over enough committed trials) versus which are noise. CP-2b
|
||||||
|
consults this table to decide which patterns a self-verifying chain may be trusted
|
||||||
|
on; until then it is a read-only measurement that changes no search/gate behaviour
|
||||||
|
(serving stays ``3/47/0``).
|
||||||
|
|
||||||
|
Deterministic and replay-stable: fixed case order, fixed enumerator order, the
|
||||||
|
ledger's canonical sorted storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generate.cue_precision.ledger import CuePrecisionLedger
|
||||||
|
from generate.cue_precision.trainer import TrainingCase, train_from_cases
|
||||||
|
from generate.derivation.multistep import candidate_chains
|
||||||
|
from generate.derivation.search import _sentence_candidates
|
||||||
|
from evals.gsm8k_math.practice.v1.runner import _load_practice_cases
|
||||||
|
from evals.gsm8k_math.train_sample.v1.runner import _CASES_PATH, _load_cases
|
||||||
|
|
||||||
|
# The real readings the search weighs: the multiplicative per-sentence products
|
||||||
|
# (the lane the practice scorer attempts) and the multistep principled chains
|
||||||
|
# (which carry the additive candidates the ADR-0163-F set exercises). Fixed order.
|
||||||
|
_ENUMERATORS = (_sentence_candidates, candidate_chains)
|
||||||
|
|
||||||
|
|
||||||
|
def _training_cases() -> list[TrainingCase]:
|
||||||
|
"""All gold-labelled sealed cases, train_sample first then practice. Stable."""
|
||||||
|
cases: list[TrainingCase] = []
|
||||||
|
for record in _load_cases(_CASES_PATH):
|
||||||
|
cases.append((record["question"], float(record["answer_numeric"])))
|
||||||
|
for record in _load_practice_cases():
|
||||||
|
cases.append((record["question"], float(record["answer_numeric"])))
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
def build_cue_precision_ledger() -> CuePrecisionLedger:
|
||||||
|
"""Train the ledger over every sealed case with the real enumerators."""
|
||||||
|
return train_from_cases(_training_cases(), _ENUMERATORS)
|
||||||
|
|
||||||
|
|
||||||
|
def format_reliability_table(ledger: CuePrecisionLedger) -> str:
|
||||||
|
"""A deterministic Markdown table of every learned pattern, sorted canonically."""
|
||||||
|
lines = [
|
||||||
|
"| cue | op | unit_shape | correct | wrong | committed | reliability |",
|
||||||
|
"|-----|----|-----------|--------:|------:|----------:|------------:|",
|
||||||
|
]
|
||||||
|
for tally in ledger.tallies: # already canonically sorted
|
||||||
|
p = tally.pattern
|
||||||
|
lines.append(
|
||||||
|
f"| {p.cue} | {p.op} | {p.unit_shape} | {tally.correct} | "
|
||||||
|
f"{tally.wrong} | {tally.committed} | {tally.reliability:.4f} |"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ledger = build_cue_precision_ledger()
|
||||||
|
cases = _training_cases()
|
||||||
|
print(f"cue-precision ledger trained over {len(cases)} sealed cases")
|
||||||
|
print(f"distinct (cue, op, unit_shape) patterns: {len(ledger.tallies)}")
|
||||||
|
print(format_reliability_table(ledger))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""ADR-0177 CP-1 — cue-precision reliability ledger substrate.
|
"""ADR-0177 CP-1/CP-2a — cue-precision reliability ledger + training.
|
||||||
|
|
||||||
Standalone, deterministic, replay-stable. **Inert**: NOT wired into the gate, any
|
Standalone, deterministic, replay-stable. **Inert w.r.t. the runtime path**: NOT
|
||||||
scorer, or the search (that is CP-2/CP-3). Imported by nothing outside its own
|
wired into the gate, any scorer, or the search (trust/guidance is CP-2b/CP-3). The
|
||||||
tests — like ``core/reliability_gate/`` before its consumer existed.
|
CP-2a trainer is consumed only by the sealed eval measurement, never by serving.
|
||||||
|
|
||||||
Public surface:
|
Public surface:
|
||||||
- :class:`CuePattern` — the ``(cue, op, unit_shape)`` reading key.
|
- :class:`CuePattern` — the ``(cue, op, unit_shape)`` reading key.
|
||||||
|
|
@ -13,6 +13,8 @@ Public surface:
|
||||||
precision via the pinned ADR-0175 conservative floor.
|
precision via the pinned ADR-0175 conservative floor.
|
||||||
- :class:`CuePrecisionLedger` — immutable pattern->tally map + credit assignment
|
- :class:`CuePrecisionLedger` — immutable pattern->tally map + credit assignment
|
||||||
(``record_chain`` / ``record_case``).
|
(``record_chain`` / ``record_case``).
|
||||||
|
- :func:`train_from_cases`, :func:`candidates_for` (CP-2a) — fold gold-labelled
|
||||||
|
candidate readings (from injected enumerators) into a trained ledger.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -27,14 +29,24 @@ from generate.cue_precision.ledger import (
|
||||||
pattern_for_step,
|
pattern_for_step,
|
||||||
patterns_in_chain,
|
patterns_in_chain,
|
||||||
)
|
)
|
||||||
|
from generate.cue_precision.trainer import (
|
||||||
|
CandidateEnumerator,
|
||||||
|
TrainingCase,
|
||||||
|
candidates_for,
|
||||||
|
train_from_cases,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CROSS_UNIT",
|
"CROSS_UNIT",
|
||||||
|
"CandidateEnumerator",
|
||||||
"CuePattern",
|
"CuePattern",
|
||||||
"CuePrecisionLedger",
|
"CuePrecisionLedger",
|
||||||
"PatternTally",
|
"PatternTally",
|
||||||
"SAME_UNIT",
|
"SAME_UNIT",
|
||||||
|
"TrainingCase",
|
||||||
"UNIT_SHAPES",
|
"UNIT_SHAPES",
|
||||||
|
"candidates_for",
|
||||||
"pattern_for_step",
|
"pattern_for_step",
|
||||||
"patterns_in_chain",
|
"patterns_in_chain",
|
||||||
|
"train_from_cases",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
68
generate/cue_precision/trainer.py
Normal file
68
generate/cue_precision/trainer.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""ADR-0177 CP-2a — populate the cue-precision ledger from gold-labelled cases.
|
||||||
|
|
||||||
|
The CP-1 ledger (:mod:`generate.cue_precision.ledger`) is the mechanism; this is
|
||||||
|
the **training step** that gives it signal. For each ``(problem_text, gold)`` case
|
||||||
|
it gathers the candidate readings the search would consider, labels each by gold,
|
||||||
|
and folds the per-step ``(cue, op, unit_shape)`` credit into the ledger
|
||||||
|
(``record_case``). The result is the per-pattern reliability table — the
|
||||||
|
*measurement* CP-2b/CP-3 will consult before trusting a cue.
|
||||||
|
|
||||||
|
Decoupled by construction: the candidate *enumerators* are injected (callables
|
||||||
|
``problem_text -> Iterable[GroundedDerivation]``), so this module imports nothing
|
||||||
|
from :mod:`generate.derivation.search` / ``multistep`` and stays as inert and
|
||||||
|
replay-stable as CP-1. The eval side (:mod:`evals.gsm8k_math...`) wires the real
|
||||||
|
enumerators to the real cases.
|
||||||
|
|
||||||
|
wrong=0 posture: training reads gold (Tier-1, available in the sealed practice
|
||||||
|
regime only) and writes counts. It changes no search/gate behaviour — the ledger
|
||||||
|
is still consulted by nobody until CP-2b. Serving stays ``3/47/0``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
|
from generate.cue_precision.ledger import CuePrecisionLedger
|
||||||
|
from generate.derivation.model import GroundedDerivation
|
||||||
|
|
||||||
|
# A candidate enumerator turns a problem into the readings the search considers.
|
||||||
|
CandidateEnumerator = Callable[[str], Iterable[GroundedDerivation]]
|
||||||
|
|
||||||
|
# A training case: the problem text and its gold numeric answer.
|
||||||
|
TrainingCase = tuple[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
def candidates_for(
|
||||||
|
problem_text: str, enumerators: Iterable[CandidateEnumerator]
|
||||||
|
) -> tuple[GroundedDerivation, ...]:
|
||||||
|
"""The deduplicated union of every enumerator's candidates, in stable order.
|
||||||
|
|
||||||
|
A reading produced by two enumerators is counted **once** per case (per-step
|
||||||
|
credit already counts each pattern occurrence within a chain; double-counting
|
||||||
|
the whole chain across enumerators would inflate the same evidence twice).
|
||||||
|
Dedup preserves first-seen order, so the fold is deterministic.
|
||||||
|
"""
|
||||||
|
seen: dict[GroundedDerivation, None] = {}
|
||||||
|
for enumerate_candidates in enumerators:
|
||||||
|
for candidate in enumerate_candidates(problem_text):
|
||||||
|
seen.setdefault(candidate, None)
|
||||||
|
return tuple(seen)
|
||||||
|
|
||||||
|
|
||||||
|
def train_from_cases(
|
||||||
|
cases: Iterable[TrainingCase],
|
||||||
|
enumerators: Iterable[CandidateEnumerator],
|
||||||
|
) -> CuePrecisionLedger:
|
||||||
|
"""Fold every case's gold-labelled candidates into a fresh ledger.
|
||||||
|
|
||||||
|
Deterministic in ``cases`` order, ``enumerators`` order, and each enumerator's
|
||||||
|
own candidate order. A case with no candidate contributes nothing (no refusal
|
||||||
|
penalty — the ledger only labels readings, ADR-0177).
|
||||||
|
"""
|
||||||
|
enumerator_tuple = tuple(enumerators)
|
||||||
|
ledger = CuePrecisionLedger()
|
||||||
|
for problem_text, gold in cases:
|
||||||
|
candidates = candidates_for(problem_text, enumerator_tuple)
|
||||||
|
if candidates:
|
||||||
|
ledger = ledger.record_case(candidates, float(gold))
|
||||||
|
return ledger
|
||||||
|
|
@ -23,6 +23,13 @@ this module, so none of this can move the serving ``3/47/0``):
|
||||||
* **EX-5 — sentence-final numbers.** A number with no following unit word (end of
|
* **EX-5 — sentence-final numbers.** A number with no following unit word (end of
|
||||||
sentence/text or before terminal punctuation) extracts with an empty unit so it
|
sentence/text or before terminal punctuation) extracts with an empty unit so it
|
||||||
stays available to the completeness check without inventing a unit lexeme.
|
stays available to the completeness check without inventing a unit lexeme.
|
||||||
|
* **Unit hygiene (function-word filter).** When the token after a number is a
|
||||||
|
function word (``$0.75 each`` → ``each``, ``$40 to go`` → ``to``, ``3/4 of`` →
|
||||||
|
``of``), the single-word unit pattern would tag it as the unit — a spurious unit
|
||||||
|
that corrupts same-unit detection (GB-2/GB-3) and CP-1's ``unit_shape``. Such
|
||||||
|
units are blanked (empty, like a sentence-final number): the value stays
|
||||||
|
grounded, the unit is honestly unknown. Closed lexeme set (``_NON_UNIT_WORDS``),
|
||||||
|
not a grammar template (ADR-0165).
|
||||||
|
|
||||||
EX-3 (multi-word units) is deliberately **not** integrated. Two distinct traps
|
EX-3 (multi-word units) is deliberately **not** integrated. Two distinct traps
|
||||||
defeat the tightest lookahead-anchored rule the brief admits:
|
defeat the tightest lookahead-anchored rule the brief admits:
|
||||||
|
|
@ -88,13 +95,35 @@ _FINAL_NUMBER_RE: Final[re.Pattern[str]] = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Function words that are never units. When the token immediately after a number
|
||||||
|
# is one of these (``$0.75 each``, ``$40 to go``, ``3/4 of``), the single-word unit
|
||||||
|
# pattern would otherwise tag the function word as the unit — a spurious unit that
|
||||||
|
# corrupts same-unit detection (GB-2/GB-3) and CP-1's unit_shape. Emitting an empty
|
||||||
|
# unit instead (like a sentence-final number) is honest: the value is grounded, the
|
||||||
|
# unit is simply unknown. Closed lexeme set (cf. ``WORD_NUMBERS``); ADR-0165-safe —
|
||||||
|
# it names tokens that are not unit nouns, it does not parse sentence structure.
|
||||||
|
_NON_UNIT_WORDS: Final[frozenset[str]] = frozenset(
|
||||||
|
{
|
||||||
|
"a", "an", "the", "of", "to", "for", "in", "on", "at", "as", "than",
|
||||||
|
"per", "each", "every", "and", "or", "with", "by", "from", "more",
|
||||||
|
"less", "about", "that",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_unit(unit: str) -> str:
|
||||||
|
"""Lowercase a unit token; blank it if it is a non-unit function word."""
|
||||||
|
lowered = unit.lower()
|
||||||
|
return "" if lowered in _NON_UNIT_WORDS else lowered
|
||||||
|
|
||||||
|
|
||||||
def _quantity(value_token: str, unit: str) -> Quantity | None:
|
def _quantity(value_token: str, unit: str) -> Quantity | None:
|
||||||
"""Build a quantity from an already-matched numeric token."""
|
"""Build a quantity from an already-matched numeric token."""
|
||||||
try:
|
try:
|
||||||
value = float(value_token)
|
value = float(value_token)
|
||||||
except ValueError: # pragma: no cover - regex guarantees numeric
|
except ValueError: # pragma: no cover - regex guarantees numeric
|
||||||
return None
|
return None
|
||||||
return Quantity(value=value, unit=unit.lower(), source_token=value_token)
|
return Quantity(value=value, unit=_clean_unit(unit), source_token=value_token)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_word_number(first: str, second: str | None) -> float | None:
|
def _resolve_word_number(first: str, second: str | None) -> float | None:
|
||||||
|
|
@ -172,7 +201,7 @@ def extract_quantities(problem_text: str) -> tuple[Quantity, ...]:
|
||||||
found.append(
|
found.append(
|
||||||
(
|
(
|
||||||
match.start(1),
|
match.start(1),
|
||||||
Quantity(value=value, unit=match.group(3).lower(), source_token=source_token),
|
Quantity(value=value, unit=_clean_unit(match.group(3)), source_token=source_token),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,27 @@ def _candidate_chains(
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_chains(
|
||||||
|
problem_text: str, target: Target | None = None
|
||||||
|
) -> list[GroundedDerivation]:
|
||||||
|
"""The bounded, deterministic candidate readings :func:`search_chain` weighs.
|
||||||
|
|
||||||
|
The *enumeration* half of the search, with no gate applied: extract quantities,
|
||||||
|
refuse-on-overflow (``> MAX_QUANTITIES``) / too-few (``< 2``) by yielding no
|
||||||
|
candidates, derive the target, and build the principled set. Exposed for CP-2
|
||||||
|
ledger training (ADR-0177), which must see every reading the search considers —
|
||||||
|
not just the one it resolves to. ``search_chain`` delegates here, so the two
|
||||||
|
can never drift.
|
||||||
|
"""
|
||||||
|
quantities = list(extract_quantities(problem_text))
|
||||||
|
if not 2 <= len(quantities) <= MAX_QUANTITIES:
|
||||||
|
return [] # refuse-on-overflow / too few to compose
|
||||||
|
resolved: Target = target if target is not None else extract_target(
|
||||||
|
problem_text, known_units=tuple(q.unit for q in quantities)
|
||||||
|
)
|
||||||
|
return _candidate_chains(quantities, problem_text, resolved)
|
||||||
|
|
||||||
|
|
||||||
def search_chain(problem_text: str, target: Target | None = None) -> Resolution | None:
|
def search_chain(problem_text: str, target: Target | None = None) -> Resolution | None:
|
||||||
"""Target-guided bounded multi-step search over the problem's quantities.
|
"""Target-guided bounded multi-step search over the problem's quantities.
|
||||||
|
|
||||||
|
|
@ -72,18 +93,11 @@ def search_chain(problem_text: str, target: Target | None = None) -> Resolution
|
||||||
(and, when a target unit is known, matches it); refuses on no candidate,
|
(and, when a target unit is known, matches it); refuses on no candidate,
|
||||||
disagreement, or > :data:`MAX_QUANTITIES` quantities. Deterministic.
|
disagreement, or > :data:`MAX_QUANTITIES` quantities. Deterministic.
|
||||||
"""
|
"""
|
||||||
quantities = list(extract_quantities(problem_text))
|
|
||||||
if not 2 <= len(quantities) <= MAX_QUANTITIES:
|
|
||||||
return None # refuse-on-overflow / too few to compose
|
|
||||||
|
|
||||||
resolved: Target = target if target is not None else extract_target(
|
|
||||||
problem_text, known_units=tuple(q.unit for q in quantities)
|
|
||||||
)
|
|
||||||
candidates = _candidate_chains(quantities, problem_text, resolved)
|
|
||||||
# Target-UNIT matching is deferred: the model's answer_unit is the start
|
# Target-UNIT matching is deferred: the model's answer_unit is the start
|
||||||
# quantity's unit, which is wrong for cross-unit products (6 boxes x 50 apples
|
# quantity's unit, which is wrong for cross-unit products (6 boxes x 50 apples
|
||||||
# -> value 300 but unit "boxes"), so a unit gate would over-refuse correct
|
# -> value 300 but unit "boxes"), so a unit gate would over-refuse correct
|
||||||
# answers. The Target still prunes via its aggregation cue + supplies the
|
# answers. The Target still prunes via its aggregation cue + supplies the
|
||||||
# question quantities (above). Unit matching returns once a result-unit model
|
# question quantities. Unit matching returns once a result-unit model exists.
|
||||||
# exists (a superordinate-units pack + product-unit inference).
|
return select_self_verified(
|
||||||
return select_self_verified(candidates, problem_text, target_units=())
|
candidate_chains(problem_text, target), problem_text, target_units=()
|
||||||
|
)
|
||||||
|
|
|
||||||
99
tests/test_adr_0177_cp2a_training.py
Normal file
99
tests/test_adr_0177_cp2a_training.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""ADR-0177 CP-2a — ledger training + the cue-precision measurement.
|
||||||
|
|
||||||
|
Covers the decoupled trainer (synthetic enumerators, so the obligations are proven
|
||||||
|
without relying on search internals) and an integration check that the real
|
||||||
|
measurement builds a non-empty, well-formed ledger over the sealed cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from generate.cue_precision.ledger import CuePattern, CuePrecisionLedger
|
||||||
|
from generate.cue_precision.trainer import candidates_for, train_from_cases
|
||||||
|
from generate.derivation.model import GroundedDerivation, Quantity, Step
|
||||||
|
|
||||||
|
|
||||||
|
def _chain(start_val: float, start_unit: str, op: str, operand_val: float, operand_unit: str, cue: str) -> GroundedDerivation:
|
||||||
|
return GroundedDerivation(
|
||||||
|
start=Quantity(start_val, start_unit, str(start_val)),
|
||||||
|
steps=(Step(op=op, operand=Quantity(operand_val, operand_unit, str(operand_val)), cue=cue),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrainFromCases:
|
||||||
|
def test_matching_chain_credits_correct(self) -> None:
|
||||||
|
# 6 boxes * 4 (apples) = 24 == gold -> pattern (each, multiply, cross_unit) +correct.
|
||||||
|
chain = _chain(6.0, "boxes", "multiply", 4.0, "apples", "each")
|
||||||
|
ledger = train_from_cases([("6 boxes 4 apples each", 24.0)], [lambda _t: [chain]])
|
||||||
|
tally = ledger.tally_for(CuePattern(cue="each", op="multiply", unit_shape="cross_unit"))
|
||||||
|
assert (tally.correct, tally.wrong) == (1, 0)
|
||||||
|
|
||||||
|
def test_mismatching_chain_credits_wrong(self) -> None:
|
||||||
|
# same chain, gold != 24 -> the pattern is credited wrong (the elimination signal).
|
||||||
|
chain = _chain(6.0, "boxes", "multiply", 4.0, "apples", "each")
|
||||||
|
ledger = train_from_cases([("6 boxes 4 apples each", 999.0)], [lambda _t: [chain]])
|
||||||
|
tally = ledger.tally_for(CuePattern(cue="each", op="multiply", unit_shape="cross_unit"))
|
||||||
|
assert (tally.correct, tally.wrong) == (0, 1)
|
||||||
|
|
||||||
|
def test_same_chain_from_two_enumerators_counted_once(self) -> None:
|
||||||
|
chain = _chain(2.0, "x", "add", 3.0, "x", "and")
|
||||||
|
ledger = train_from_cases(
|
||||||
|
[("2 x and 3 x", 5.0)],
|
||||||
|
[lambda _t: [chain], lambda _t: [chain]], # two enumerators, same reading
|
||||||
|
)
|
||||||
|
tally = ledger.tally_for(CuePattern(cue="and", op="add", unit_shape="same_unit"))
|
||||||
|
assert tally.committed == 1 # deduped, not 2
|
||||||
|
|
||||||
|
def test_deterministic(self) -> None:
|
||||||
|
chain = _chain(6.0, "boxes", "multiply", 4.0, "apples", "each")
|
||||||
|
cases = [("a", 24.0), ("b", 1.0)]
|
||||||
|
enums = [lambda _t: [chain]]
|
||||||
|
assert train_from_cases(cases, enums) == train_from_cases(cases, enums)
|
||||||
|
|
||||||
|
def test_no_candidates_yields_empty_ledger(self) -> None:
|
||||||
|
ledger = train_from_cases([("nothing here", 0.0)], [lambda _t: []])
|
||||||
|
assert ledger == CuePrecisionLedger()
|
||||||
|
|
||||||
|
def test_candidates_for_dedupes_preserving_order(self) -> None:
|
||||||
|
a = _chain(1.0, "u", "add", 2.0, "u", "and")
|
||||||
|
b = _chain(1.0, "u", "multiply", 2.0, "u", "each")
|
||||||
|
got = candidates_for("t", [lambda _t: [a, b], lambda _t: [a]])
|
||||||
|
assert got == (a, b)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealMeasurement:
|
||||||
|
def test_ledger_builds_nonempty_and_well_formed(self) -> None:
|
||||||
|
from evals.gsm8k_math.practice.v1.cue_precision_report import (
|
||||||
|
build_cue_precision_ledger,
|
||||||
|
format_reliability_table,
|
||||||
|
)
|
||||||
|
|
||||||
|
ledger = build_cue_precision_ledger()
|
||||||
|
assert len(ledger.tallies) >= 1 # the search produces *some* labelled patterns
|
||||||
|
for tally in ledger.tallies:
|
||||||
|
assert 0.0 <= tally.reliability < 1.0 # conservative floor is in [0, 1)
|
||||||
|
assert tally.committed == tally.correct + tally.wrong
|
||||||
|
# deterministic render
|
||||||
|
assert format_reliability_table(ledger) == format_reliability_table(
|
||||||
|
build_cue_precision_ledger()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchChainParity:
|
||||||
|
"""The candidate_chains refactor must not change search_chain behaviour."""
|
||||||
|
|
||||||
|
def test_decimal_product_still_resolves(self) -> None:
|
||||||
|
from generate.derivation.multistep import search_chain
|
||||||
|
|
||||||
|
text = (
|
||||||
|
"There are 48 boxes with 24 erasers in each box. "
|
||||||
|
"They sell the erasers for $0.75 each. How much money will they make?"
|
||||||
|
)
|
||||||
|
res = search_chain(text)
|
||||||
|
assert res is not None and res.answer == 864.0
|
||||||
|
|
||||||
|
def test_candidate_chains_is_enumeration_only(self) -> None:
|
||||||
|
from generate.derivation.multistep import candidate_chains, search_chain
|
||||||
|
|
||||||
|
# too-few quantities -> no candidates -> search refuses
|
||||||
|
assert candidate_chains("She has 6 apples.") == []
|
||||||
|
assert search_chain("She has 6 apples.") is None
|
||||||
Loading…
Reference in a new issue