* chore(ADR-0163.C): land three Phase C pending proposals in live log Phase C (#301) shipped the CLI but its PR dry-run wrote to a tmp log path. This commit moves the three Phase C proposals into the live teaching/proposals/proposals.jsonl so the Phase B→C audit trail is visible in the proposal log and the proposals are ready for the operator to ratify after Phase D ships. Proposals (all state=pending, kind="exemplar_corpus"): - 59223f13722f906a1cf9b65d9b01c990 — descriptive_setup_no_quantity - 46ce297f797ff16da12db5de422ca3c9 — rate_with_currency - a3b892546977c5f0f64c578d6052adbd — temporal_aggregation Produced by `core teaching propose-from-exemplars --all` against the live Phase B corpora. No ratification (ADR-0161 §5 — only the repo owner ratifies). The Phase D admissibility-replay gate confirmed replay_equivalent=true, wrong_count_delta=0 for all three. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(ADR-0163.D): wire ratified RecognizerSpecs into math_candidate_graph admissibility surface Phase D is the first PR to extend the math admission surface. The audit (#294) said the gap was admission, not operators, algebra, substrate, or packs. Phase A measured the refusal taxonomy. Phase B authored seeds. Phase C synthesized recognizers. Phase D wires those recognizers into generate/math_candidate_graph.py. Modules - generate/recognizer_registry.py — pure projection over the proposal log. Only proposals with source.kind="exemplar_corpus" AND review_state="accepted" enter the tuple. Sorted by (review_date, proposal_id). In-process cache keyed on log (mtime, sha256) — no filesystem cache (ADR-0161 §1). Malformed accepted specs raise RegistryLoadError citing the offending proposal_id; silent drops are forbidden. - generate/recognizer_match.py — per-category rules-only matchers (no LLM, no embedding, no learned classifier). Honors the Phase C synthesizer's narrowness rule: out-of-corpus currency symbols, window units, and per-unit values do NOT match. Three matchers: _match_descriptive_setup_no_quantity (zero-quantity surface), _match_temporal_aggregation (event_count_per_window with observed_window_units/quantifiers honored), _match_rate_with_currency (currency_per_unit_rate with observed currency/per-unit/amount-kind honored). - generate/math_candidate_graph.py — narrowest-edit guard at the per-statement choice loop. Before the existing "no admissible candidate for statement" refusal, consult the ratified registry. Recognized statements are dropped from per_sentence_choices (zero math state) so the Cartesian product is identical to "this statement was never there." Empty registry is a no-op — backward compatibility preserved byte-identically. Downstream consumption of parsed_anchors (turning recognized rate/temporal surfaces into solver state that produces concrete answers) is Phase E follow-up. Tests (32 new) - tests/_phase_d_fixture.py — synthetic in-memory ratified registry built from the three Phase C pending proposals' content. Per ADR-0161 §5 the agent does NOT ratify the live log; the synthetic registry round-trips the real RecognizerSpec bytes the operator will ratify after Phase D ships. - tests/test_recognizer_registry.py (9) — empty/pending/wrong-kind filtering, sort order, malformed-spec rejection, cache hit + invalidation, live-log Phase C audit check. - tests/test_recognizer_match.py (14) — per-category positive cases, narrowness (out-of-corpus surface forms rejected), no-LLM import check. - tests/test_candidate_graph_recognizer_wiring.py (7) — empty registry preserves existing refusal; synthetic registry: recognized statements no longer trigger per-statement refusal; wrong_count_delta == 0 on GSM8K train_sample; capability axes G1.. G5+S1 wrong=0 unchanged; per-category admission counts on the refused-set; unrecognized statements still refuse with the existing reason. - tests/test_phase_d_replay_evidence.py (2) — full admissibility replay gate under synthetic registry: replay_equivalent=true, wrong_count_delta=0, every capability axis wrong=0; each ratified recognizer admits >= 1 train_sample statement (wiring is consequential). Per-category fixture-based admission counts (synthetic registry vs GSM8K train_sample refused-set sentences): - descriptive_setup_no_quantity: 40 - rate_with_currency: 2 - temporal_aggregation: 7 Narrowness-invariant negative case results (matcher correctly returns None on out-of-corpus / load-bearing-math surfaces): - rate_with_currency: "She paid $5 for the book." (no per-unit) - temporal_aggregation: "On Saturday she went to the store." (single day token) - descriptive_setup_no_quantity: "There are some kids in camp." (indefinite quantifier) Candidates for Phase B round 2 (3 of 20 temporal seeds match the spec's structural commitment but not my surface regex — author_notes explicitly flagged these as schema-gap edge cases): - ta-v1-0004 "Mark does a gig every other day for 2 weeks." - ta-v1-0012 "Robin walks 4 dogs every other day around the park." - ta-v1-0019 "The pump fills the tank with 80 gallons over 6 hours." Three landed wirings DO NOT shift the GSM8K train_sample baseline counts under fixture (correct=3, wrong=0, refused=47 unchanged) — Phase D's narrow wiring is wrong=0 safe by construction; lift to "correct" requires Phase E's downstream parser-side consumption of parsed_anchors. Capability axes G1..G5+S1 wrong=0 unchanged. Cross-refs: ADR-0163 (Phase D), ADR-0057 (proposal review), ADR-0151 (auto-proposal), ADR-0161 §5 (ratification boundary), Phase A PR #297, Phase B PR #298, Phase C PR #301. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
260 lines
9.3 KiB
Python
260 lines
9.3 KiB
Python
"""ADR-0163 Phase D — ratified RecognizerSpec registry projection.
|
|
|
|
Pure projection over the append-only proposal log
|
|
(`teaching/proposals/proposals.jsonl`) into a tuple of
|
|
:class:`RatifiedRecognizer` records the candidate-graph admission
|
|
surface consults.
|
|
|
|
Trust boundary
|
|
- The projection is a *read* over the proposal log. Mutation of the
|
|
active corpus or the proposal log itself is out of scope; that path
|
|
is gated by ADR-0057's ``accept_proposal``.
|
|
- Only proposals with ``review_state == "accepted"`` AND
|
|
``source.kind == "exemplar_corpus"`` AND a parseable
|
|
``proposed_chain.recognizer_spec`` enter the registry. Pending,
|
|
rejected, withdrawn, and non-exemplar proposals are invisible.
|
|
- Malformed accepted proposals raise :class:`RegistryLoadError` with
|
|
the offending ``proposal_id``. Silent drops are forbidden — the
|
|
operator must see them.
|
|
|
|
Determinism
|
|
- ``load_ratified_registry(log)`` is a pure function of the log
|
|
bytes. Same log file → byte-identical tuple, sorted by
|
|
``(review_date, proposal_id)`` ascending.
|
|
- A module-level cache keyed on the log's (mtime, sha256) keeps a hot
|
|
in-process invocation cheap. Cache lives in process; no
|
|
filesystem-level cache is introduced (ADR-0161 §1, ADR-0163
|
|
§Phase C constraint).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
from evals.refusal_taxonomy.shape_categories import ShapeCategory
|
|
from teaching.proposals import ProposalLog
|
|
|
|
|
|
class RegistryLoadError(ValueError):
|
|
"""Raised when an accepted proposal carries a malformed recognizer spec."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RatifiedRecognizer:
|
|
"""One ratified recognizer projected from the proposal log.
|
|
|
|
``canonical_pattern`` carries the per-category bespoke shape the
|
|
Phase C synthesizer produced; consumers MUST branch on
|
|
``shape_category`` before reading. ``review_date`` and
|
|
``ratified_at_revision`` are recorded for audit; matching code
|
|
reads only ``shape_category`` + ``canonical_pattern``.
|
|
"""
|
|
|
|
proposal_id: str
|
|
shape_category: ShapeCategory
|
|
canonical_pattern: Mapping[str, Any]
|
|
spec_digest: str
|
|
review_date: str
|
|
ratified_at_revision: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Keyed on (log_path, mtime_ns, sha256_hex). Value: the projected tuple.
|
|
# Cache is in-process and reset by clear_registry_cache() (tests).
|
|
_CACHE: dict[tuple[str, int, str], tuple[RatifiedRecognizer, ...]] = {}
|
|
|
|
|
|
def clear_registry_cache() -> None:
|
|
"""Reset the in-process registry cache.
|
|
|
|
Useful in tests that mutate the proposal log between calls. In
|
|
production, the cache invalidates automatically when the log's
|
|
(mtime, sha256) changes.
|
|
"""
|
|
_CACHE.clear()
|
|
|
|
|
|
def _log_cache_key(log_path: Path) -> tuple[str, int, str]:
|
|
if not log_path.exists():
|
|
return (str(log_path), 0, "")
|
|
stat = log_path.stat()
|
|
digest = hashlib.sha256(log_path.read_bytes()).hexdigest()
|
|
return (str(log_path), stat.st_mtime_ns, digest)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Projection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _coerce_shape_category(value: Any, proposal_id: str) -> ShapeCategory:
|
|
if not isinstance(value, str):
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: recognizer_spec.shape_category must be "
|
|
f"a string; got {type(value).__name__}"
|
|
)
|
|
for member in ShapeCategory:
|
|
if member.value == value:
|
|
return member
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: shape_category {value!r} is not a "
|
|
f"ShapeCategory member"
|
|
)
|
|
|
|
|
|
def _extract_recognizer(
|
|
proposal: Mapping[str, Any],
|
|
) -> tuple[ShapeCategory, Mapping[str, Any], str]:
|
|
"""Pull (shape_category, canonical_pattern, spec_digest) out of *proposal*.
|
|
|
|
Raises :class:`RegistryLoadError` for any structural break.
|
|
"""
|
|
proposal_id = str(proposal.get("proposal_id") or "")
|
|
chain = proposal.get("proposed_chain") or {}
|
|
if not isinstance(chain, Mapping):
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: proposed_chain must be a mapping"
|
|
)
|
|
rec_spec = chain.get("recognizer_spec")
|
|
if not isinstance(rec_spec, Mapping):
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: proposed_chain.recognizer_spec is "
|
|
"missing or non-mapping (ADR-0163 §Phase C contract)"
|
|
)
|
|
shape_category = _coerce_shape_category(
|
|
rec_spec.get("shape_category"), proposal_id
|
|
)
|
|
canonical_pattern = rec_spec.get("canonical_pattern")
|
|
if not isinstance(canonical_pattern, Mapping):
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: canonical_pattern must be a mapping"
|
|
)
|
|
spec_digest = str(chain.get("object") or "")
|
|
if not spec_digest:
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: proposed_chain.object (spec_digest) "
|
|
"must be non-empty"
|
|
)
|
|
return shape_category, canonical_pattern, spec_digest
|
|
|
|
|
|
def _accepted_review_dates(
|
|
events: list[dict[str, Any]],
|
|
) -> dict[str, tuple[str, str]]:
|
|
"""Walk the log events and return {proposal_id: (review_date, note)}.
|
|
|
|
The accept review_date is parsed out of the transition note: the
|
|
accept_proposal() helper passes the date via operator_note; ADR-0057
|
|
encodes the same date in the corpus-append event's provenance.
|
|
Both shapes are tolerated here.
|
|
"""
|
|
out: dict[str, tuple[str, str]] = {}
|
|
for ev in events:
|
|
kind = ev.get("event")
|
|
if kind != "transition" or ev.get("to") != "accepted":
|
|
continue
|
|
pid = str(ev.get("proposal_id") or "")
|
|
note = str(ev.get("note") or "")
|
|
# Best-effort: pull a YYYY-MM-DD from the note; fall back to "".
|
|
review_date = ""
|
|
for token in note.replace(":", " ").replace(",", " ").split():
|
|
if len(token) == 10 and token[4] == "-" and token[7] == "-":
|
|
review_date = token
|
|
break
|
|
out[pid] = (review_date, note)
|
|
# Walk corpus_append events too — their provenance.review_date is
|
|
# the authoritative source when present.
|
|
for ev in events:
|
|
if ev.get("event") != "accepted_corpus_append":
|
|
continue
|
|
pid = str(ev.get("proposal_id") or "")
|
|
prov = ev.get("provenance") or {}
|
|
if isinstance(prov, Mapping):
|
|
rd = str(prov.get("review_date") or "")
|
|
if rd and pid in out:
|
|
out[pid] = (rd, out[pid][1])
|
|
return out
|
|
|
|
|
|
def load_ratified_registry(
|
|
log: ProposalLog | None = None,
|
|
) -> tuple[RatifiedRecognizer, ...]:
|
|
"""Project the proposal log into a tuple of ratified recognizers.
|
|
|
|
Only proposals whose ``review_state`` is ``"accepted"`` AND whose
|
|
``source.kind`` is ``"exemplar_corpus"`` AND whose
|
|
``proposed_chain.recognizer_spec`` parses as a Phase C
|
|
:class:`teaching.recognizer_synthesis.RecognizerSpec` (validated by
|
|
:func:`_extract_recognizer`) enter the tuple.
|
|
|
|
Returned tuple is sorted by ``(review_date, proposal_id)``
|
|
ascending — stable across runs.
|
|
|
|
The cache is keyed on the proposal log's (mtime, sha256) so writes
|
|
to the log between calls invalidate transparently.
|
|
"""
|
|
proposal_log = log if log is not None else ProposalLog()
|
|
log_path = proposal_log.path
|
|
cache_key = _log_cache_key(log_path)
|
|
cached = _CACHE.get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
state = proposal_log.current_state()
|
|
events = proposal_log.events()
|
|
accept_review_dates = _accepted_review_dates(events)
|
|
|
|
out: list[RatifiedRecognizer] = []
|
|
for proposal_id, record in state.items():
|
|
if record.get("state") != "accepted":
|
|
continue
|
|
source = record.get("source") or {}
|
|
if not isinstance(source, Mapping):
|
|
continue
|
|
if source.get("kind") != "exemplar_corpus":
|
|
continue
|
|
proposal_payload = record.get("proposal") or {}
|
|
if not isinstance(proposal_payload, Mapping):
|
|
raise RegistryLoadError(
|
|
f"proposal {proposal_id!r}: missing 'proposal' payload in "
|
|
"log view"
|
|
)
|
|
try:
|
|
shape_category, canonical_pattern, spec_digest = _extract_recognizer(
|
|
proposal_payload
|
|
)
|
|
except RegistryLoadError:
|
|
raise
|
|
review_date, _note = accept_review_dates.get(proposal_id, ("", ""))
|
|
ratified_at_revision = str(
|
|
source.get("emitted_at_revision") or ""
|
|
)
|
|
out.append(
|
|
RatifiedRecognizer(
|
|
proposal_id=proposal_id,
|
|
shape_category=shape_category,
|
|
canonical_pattern=dict(canonical_pattern),
|
|
spec_digest=spec_digest,
|
|
review_date=review_date,
|
|
ratified_at_revision=ratified_at_revision,
|
|
)
|
|
)
|
|
|
|
out.sort(key=lambda r: (r.review_date, r.proposal_id))
|
|
result = tuple(out)
|
|
_CACHE[cache_key] = result
|
|
return result
|
|
|
|
|
|
__all__ = [
|
|
"RatifiedRecognizer",
|
|
"RegistryLoadError",
|
|
"clear_registry_cache",
|
|
"load_ratified_registry",
|
|
]
|