CP-1 of ADR-0177: the per-(cue, op, unit_shape) reliability ledger + credit
assignment mechanism. Mirrors the ADR-0175 per-class ledger discipline
(core/reliability_gate/ledger.py): counts-only integers, reliability via the
pinned conservative_floor, refusals never counted as commitments.
- generate/cue_precision/ledger.py
- CuePattern: (cue, op, unit_shape) key; op in VALID_OPS, unit_shape closed-set.
- pattern_for_step / patterns_in_chain: per-step extraction. unit_shape compares
the operand unit to the model's running (primary/start) unit; a dimensionless
comparative scalar scales within the dimension -> same_unit.
- PatternTally: counts-only (correct/wrong, no refused axis); reliability =
conservative_floor(correct, committed); 0.0 while cold/below N_MIN.
- CuePrecisionLedger: immutable pattern->tally map (canonical sorted tuple);
record_chain / record_case credit candidate chains by gold label, independent
of whether the search resolved or refused.
Inert substrate: not wired into the gate, any scorer, or the search (CP-2/CP-3).
Imported by nothing outside its own tests (asserted by a source-tree scan).
Tests (tests/test_adr_0177_cp1_ledger.py, 27 passing): pattern validation;
unit_shape classification; cold ledger -> 0 reliability; credit assignment;
refusals-not-counted; reliability earned by volume; determinism/replay;
immutability; inertness scan. Smoke suite green (67 passed).
224 lines
9.5 KiB
Python
224 lines
9.5 KiB
Python
"""ADR-0177 CP-1 — the per-cue-pattern reliability ledger + credit assignment.
|
|
|
|
A replayable tally of *counted* gold-labels per **cue-pattern**, mirroring the
|
|
ADR-0175 per-class ledger (:mod:`core.reliability_gate.ledger`) but keyed on a
|
|
``(cue, op, unit_shape)`` pattern string instead of a capability axis. Nothing
|
|
learned, nothing stochastic — every figure is an integer count, and reliability
|
|
is the same pinned :func:`conservative_floor` (commitment precision, earned by
|
|
volume, never by a lucky streak).
|
|
|
|
This is **inert substrate** (ADR-0177 §"Recommended sequencing" CP-1): the
|
|
mechanism + credit assignment only. It is **not** wired into the gate, any
|
|
scorer, or the search (that is CP-2/CP-3). It is imported by nothing outside its
|
|
own tests, exactly as ``core/reliability_gate/`` shipped before its consumer.
|
|
|
|
Credit assignment (ADR-0177 §"Credit assignment"): for a practice case the search
|
|
emits candidate chains; each chain is labelled by gold (``answer == gold``); for
|
|
**every step's pattern** in a chain we record ``+correct`` if the chain matched
|
|
gold else ``+wrong``. Learning does **not** depend on the search *resolving* — it
|
|
labels candidates, separate from the resolve/refuse decision (ADR-0177 §"The
|
|
mechanism"). A case-level *refusal* is therefore never counted as a commitment:
|
|
the ledger only ever sees gold-labelled candidate chains, and a pattern tally has
|
|
no "refused" axis at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Final, Iterable
|
|
|
|
from core.reliability_gate.floor import conservative_floor
|
|
from generate.derivation.model import VALID_OPS, GroundedDerivation, Step
|
|
|
|
# A step either stays within the running unit dimension or crosses into another.
|
|
CROSS_UNIT: Final[str] = "cross_unit"
|
|
SAME_UNIT: Final[str] = "same_unit"
|
|
UNIT_SHAPES: Final[frozenset[str]] = frozenset({CROSS_UNIT, SAME_UNIT})
|
|
|
|
# Replay rounding for gold comparison — identical to the verify gate's notion of
|
|
# "same answer" (generate/derivation/verify.py uses round(answer, 9)).
|
|
_GOLD_DECIMALS: Final[int] = 9
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CuePattern:
|
|
"""A ``(cue, op, unit_shape)`` reading the search asserts the text licenses.
|
|
|
|
``cue`` is the surface lexeme licensing ``op`` (e.g. ``"per"``); ``op`` is a
|
|
:data:`generate.derivation.model.VALID_OPS` member; ``unit_shape`` records
|
|
whether the operation crosses units (ADR-0177 §"Pattern key" — cross-unit
|
|
multiplication is the aggregate signal).
|
|
"""
|
|
|
|
cue: str
|
|
op: str
|
|
unit_shape: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.cue, str) or not self.cue:
|
|
raise ValueError("cue must be a non-empty str")
|
|
if self.op not in VALID_OPS:
|
|
raise ValueError(f"op must be one of {sorted(VALID_OPS)}, got {self.op!r}")
|
|
if self.unit_shape not in UNIT_SHAPES:
|
|
raise ValueError(
|
|
f"unit_shape must be one of {sorted(UNIT_SHAPES)}, got {self.unit_shape!r}"
|
|
)
|
|
|
|
|
|
def _unit_shape(running_unit: str, operand_unit: str) -> str:
|
|
"""Classify a step's unit shape against the running (primary) unit.
|
|
|
|
The value model keeps the primary (``start``) unit through the whole fold
|
|
(``GroundedDerivation.answer_unit == start.unit``), so the running unit is the
|
|
start unit at every step. A dimensionless operand (a comparative scalar carries
|
|
``unit == ""``) *scales within* the current dimension — ``twice as many apples``
|
|
stays apples — so it reads :data:`SAME_UNIT`, not a cross-unit aggregate. The
|
|
gate already forces add/subtract operands to share the primary unit, so only
|
|
multiply/divide can ever be :data:`CROSS_UNIT`.
|
|
"""
|
|
if operand_unit == "" or operand_unit == running_unit:
|
|
return SAME_UNIT
|
|
return CROSS_UNIT
|
|
|
|
|
|
def pattern_for_step(derivation: GroundedDerivation, step: Step) -> CuePattern:
|
|
"""The :class:`CuePattern` a single step contributes within ``derivation``."""
|
|
return CuePattern(
|
|
cue=step.cue,
|
|
op=step.op,
|
|
unit_shape=_unit_shape(derivation.start.unit, step.operand.unit),
|
|
)
|
|
|
|
|
|
def patterns_in_chain(derivation: GroundedDerivation) -> tuple[CuePattern, ...]:
|
|
"""Every step's pattern, in step order. Each *occurrence* counts (ADR-0177
|
|
credit assignment is per-step, so a 3-step product-of-all credits its pattern
|
|
three times — reliability is earned by clean appearances)."""
|
|
return tuple(pattern_for_step(derivation, step) for step in derivation.steps)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PatternTally:
|
|
"""Immutable per-pattern outcome counts.
|
|
|
|
Mirrors :class:`core.reliability_gate.ledger.ClassTally`: counts-only,
|
|
reliability is commitment precision via the pinned conservative floor. There
|
|
is **no** refused axis — a candidate chain is always a gold-labelled
|
|
commitment; case-level refusals are never recorded here (ADR-0177).
|
|
"""
|
|
|
|
pattern: CuePattern
|
|
correct: int = 0
|
|
wrong: int = 0
|
|
|
|
def __post_init__(self) -> None:
|
|
for value in (self.correct, self.wrong):
|
|
if not isinstance(value, int) or value < 0:
|
|
raise ValueError("tally counts must be non-negative ints")
|
|
|
|
@property
|
|
def committed(self) -> int:
|
|
"""Gold-labelled candidate-chain appearances of this pattern."""
|
|
return self.correct + self.wrong
|
|
|
|
@property
|
|
def reliability(self) -> float:
|
|
"""Conservative lower bound on commitment precision (ADR-0175 §4a floor).
|
|
|
|
``0.0`` for a cold/low pattern (below ``N_MIN`` committed): a cold ledger
|
|
trusts nothing, which is the wrong=0 safety property CP-2 will rely on.
|
|
"""
|
|
return conservative_floor(self.correct, self.committed)
|
|
|
|
def record(self, *, correct: int = 0, wrong: int = 0) -> "PatternTally":
|
|
"""Return a new tally with the given outcomes added (immutable update)."""
|
|
return PatternTally(
|
|
pattern=self.pattern,
|
|
correct=self.correct + correct,
|
|
wrong=self.wrong + wrong,
|
|
)
|
|
|
|
|
|
def _sort_key(pattern: CuePattern) -> tuple[str, str, str]:
|
|
return (pattern.cue, pattern.op, pattern.unit_shape)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CuePrecisionLedger:
|
|
"""Immutable map of :class:`CuePattern` -> :class:`PatternTally`.
|
|
|
|
Canonical storage is a tuple sorted by pattern (deterministic, byte-stable
|
|
across runs). Every ``record_*`` returns a new ledger (immutability rule);
|
|
an absent pattern reads as an empty tally, so a cold ledger reports ``0.0``
|
|
reliability for every pattern.
|
|
"""
|
|
|
|
tallies: tuple[PatternTally, ...] = field(default_factory=tuple)
|
|
|
|
def __post_init__(self) -> None:
|
|
seen: set[CuePattern] = set()
|
|
for tally in self.tallies:
|
|
if tally.pattern in seen:
|
|
raise ValueError(f"duplicate pattern in ledger: {tally.pattern!r}")
|
|
seen.add(tally.pattern)
|
|
|
|
def tally_for(self, pattern: CuePattern) -> PatternTally:
|
|
"""The tally for ``pattern``, or an empty one if unseen (cold ⇒ 0)."""
|
|
for tally in self.tallies:
|
|
if tally.pattern == pattern:
|
|
return tally
|
|
return PatternTally(pattern=pattern)
|
|
|
|
def reliability(self, pattern: CuePattern) -> float:
|
|
"""Conservative reliability of ``pattern`` (``0.0`` when cold/low)."""
|
|
return self.tally_for(pattern).reliability
|
|
|
|
def _record_pattern(
|
|
self, pattern: CuePattern, *, correct: int = 0, wrong: int = 0
|
|
) -> "CuePrecisionLedger":
|
|
index = {tally.pattern: tally for tally in self.tallies}
|
|
base = index.get(pattern, PatternTally(pattern=pattern))
|
|
index[pattern] = base.record(correct=correct, wrong=wrong)
|
|
ordered = tuple(sorted(index.values(), key=lambda t: _sort_key(t.pattern)))
|
|
return CuePrecisionLedger(tallies=ordered)
|
|
|
|
def record_chain(
|
|
self, derivation: GroundedDerivation, *, matched_gold: bool
|
|
) -> "CuePrecisionLedger":
|
|
"""Credit every step's pattern in ``derivation`` by its gold label.
|
|
|
|
``+correct`` per step occurrence when the chain matched gold, else
|
|
``+wrong``. A chain whose value cannot be computed (a divide-by-zero the
|
|
gate would reject) is not a labelable reading and contributes nothing —
|
|
a deliberate, documented skip, not a swallowed error.
|
|
"""
|
|
ledger = self
|
|
for pattern in patterns_in_chain(derivation):
|
|
if matched_gold:
|
|
ledger = ledger._record_pattern(pattern, correct=1)
|
|
else:
|
|
ledger = ledger._record_pattern(pattern, wrong=1)
|
|
return ledger
|
|
|
|
def record_case(
|
|
self,
|
|
candidate_chains: Iterable[GroundedDerivation],
|
|
gold_answer: float,
|
|
) -> "CuePrecisionLedger":
|
|
"""Label each candidate chain by gold and credit its patterns.
|
|
|
|
Independent of whether the search *resolved* the case (ADR-0177): the
|
|
ledger learns from labelling candidates, so a refused case still records
|
|
only its candidates' gold labels — never a separate refusal penalty.
|
|
"""
|
|
ledger = self
|
|
for derivation in candidate_chains:
|
|
try:
|
|
value = derivation.answer
|
|
except ZeroDivisionError:
|
|
# Non-computable chain: not a labelable reading (the verify gate
|
|
# rejects divide-by-zero before .answer is relied upon). Skip it.
|
|
continue
|
|
matched = round(value, _GOLD_DECIMALS) == round(gold_answer, _GOLD_DECIMALS)
|
|
ledger = ledger.record_chain(derivation, matched_gold=matched)
|
|
return ledger
|