* 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>
289 lines
9.9 KiB
Python
289 lines
9.9 KiB
Python
"""ADR-0163 Phase D — recognizer_registry tests.
|
|
|
|
Pins:
|
|
- load_ratified_registry returns empty tuple when log is empty
|
|
- filters by state=accepted + kind=exemplar_corpus
|
|
- order: sorted by (review_date, proposal_id)
|
|
- malformed spec -> RegistryLoadError with the offending proposal_id
|
|
- cache hit on identical log; cache invalidates on log mtime change
|
|
- pure: monkeypatch open() to count log reads
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from generate.recognizer_registry import (
|
|
RegistryLoadError,
|
|
clear_registry_cache,
|
|
load_ratified_registry,
|
|
)
|
|
from teaching.proposals import ProposalLog
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_cache() -> Any:
|
|
clear_registry_cache()
|
|
yield
|
|
clear_registry_cache()
|
|
|
|
|
|
def _make_proposal(
|
|
*,
|
|
proposal_id: str,
|
|
shape_category: str,
|
|
review_state: str,
|
|
kind: str = "exemplar_corpus",
|
|
) -> dict[str, Any]:
|
|
"""Build a proposal dict the live log shape accepts.
|
|
|
|
Mirrors the JSONL shape Phase C's CLI writes: source.kind +
|
|
proposed_chain.recognizer_spec carry the load-bearing fields.
|
|
"""
|
|
return {
|
|
"claim_domain": "factual",
|
|
"evidence": [
|
|
{
|
|
"epistemic_status": "coherent",
|
|
"polarity": "affirms",
|
|
"ref": "exemplar:test",
|
|
"source": "corpus",
|
|
}
|
|
],
|
|
"operator_note": "",
|
|
"polarity": "affirms",
|
|
"proposal_id": proposal_id,
|
|
"proposed_chain": {
|
|
"subject": shape_category,
|
|
"intent": "admissibility",
|
|
"connective": "recognizes",
|
|
"object": "abc123def456",
|
|
"recognizer_spec": {
|
|
"shape_category": shape_category,
|
|
"canonical_pattern": {
|
|
"shape_category": shape_category,
|
|
"graph_intent": "setup"
|
|
if shape_category == "descriptive_setup_no_quantity"
|
|
else "aggregate",
|
|
"outcome": "inadmissible_by_design"
|
|
if shape_category == "descriptive_setup_no_quantity"
|
|
else "admissible",
|
|
"quantity_anchor_count": 0,
|
|
"unresolved_notes": [],
|
|
},
|
|
"exemplar_count": 1,
|
|
"exemplar_digest": "deadbeef",
|
|
"coverage": {},
|
|
},
|
|
},
|
|
"provenance": None,
|
|
"replay_evidence": None,
|
|
"review_state": review_state,
|
|
"source": {
|
|
"emitted_at_revision": "abc",
|
|
"kind": kind,
|
|
"source_id": "digest" if kind != "operator" else "",
|
|
},
|
|
"source_candidate_id": f"cand-{proposal_id}",
|
|
}
|
|
|
|
|
|
def _write_log(path: Path, events: list[dict[str, Any]]) -> None:
|
|
"""Write a synthetic proposal log JSONL at *path*."""
|
|
with path.open("w", encoding="utf-8") as fh:
|
|
for ev in events:
|
|
fh.write(json.dumps(ev, sort_keys=True, separators=(",", ":")) + "\n")
|
|
|
|
|
|
def test_empty_log_returns_empty_registry(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
log_path.write_text("", encoding="utf-8")
|
|
log = ProposalLog(log_path)
|
|
assert load_ratified_registry(log) == ()
|
|
|
|
|
|
def test_pending_proposals_not_in_registry(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{
|
|
"event": "created",
|
|
"proposal": _make_proposal(
|
|
proposal_id="aaaa1111",
|
|
shape_category="descriptive_setup_no_quantity",
|
|
review_state="pending",
|
|
),
|
|
},
|
|
])
|
|
assert load_ratified_registry(ProposalLog(log_path)) == ()
|
|
|
|
|
|
def test_non_exemplar_corpus_kind_not_in_registry(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{
|
|
"event": "created",
|
|
"proposal": _make_proposal(
|
|
proposal_id="bbbb2222",
|
|
shape_category="descriptive_setup_no_quantity",
|
|
review_state="pending",
|
|
kind="contemplation",
|
|
),
|
|
},
|
|
{
|
|
"event": "transition",
|
|
"proposal_id": "bbbb2222",
|
|
"to": "accepted",
|
|
"note": "2026-05-27",
|
|
},
|
|
])
|
|
assert load_ratified_registry(ProposalLog(log_path)) == ()
|
|
|
|
|
|
def test_accepted_exemplar_proposal_enters_registry(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{
|
|
"event": "created",
|
|
"proposal": _make_proposal(
|
|
proposal_id="cccc3333",
|
|
shape_category="rate_with_currency",
|
|
review_state="pending",
|
|
),
|
|
},
|
|
{
|
|
"event": "transition",
|
|
"proposal_id": "cccc3333",
|
|
"to": "accepted",
|
|
"note": "2026-05-27",
|
|
},
|
|
])
|
|
reg = load_ratified_registry(ProposalLog(log_path))
|
|
assert len(reg) == 1
|
|
assert reg[0].proposal_id == "cccc3333"
|
|
assert reg[0].shape_category.value == "rate_with_currency"
|
|
assert reg[0].review_date == "2026-05-27"
|
|
|
|
|
|
def test_registry_sort_order_is_review_date_then_id(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{"event": "created", "proposal": _make_proposal(
|
|
proposal_id="zzzzzzzz",
|
|
shape_category="rate_with_currency",
|
|
review_state="pending",
|
|
)},
|
|
{"event": "transition", "proposal_id": "zzzzzzzz", "to": "accepted", "note": "2026-05-27"},
|
|
{"event": "created", "proposal": _make_proposal(
|
|
proposal_id="aaaaaaaa",
|
|
shape_category="rate_with_currency",
|
|
review_state="pending",
|
|
)},
|
|
{"event": "transition", "proposal_id": "aaaaaaaa", "to": "accepted", "note": "2026-05-26"},
|
|
])
|
|
reg = load_ratified_registry(ProposalLog(log_path))
|
|
# Earlier date first.
|
|
assert [r.proposal_id for r in reg] == ["aaaaaaaa", "zzzzzzzz"]
|
|
|
|
|
|
def test_malformed_spec_raises_with_proposal_id(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
broken = _make_proposal(
|
|
proposal_id="badbadbad",
|
|
shape_category="rate_with_currency",
|
|
review_state="pending",
|
|
)
|
|
# Corrupt: shape_category is not a member of ShapeCategory.
|
|
broken["proposed_chain"]["recognizer_spec"]["shape_category"] = "not_a_category"
|
|
_write_log(log_path, [
|
|
{"event": "created", "proposal": broken},
|
|
{"event": "transition", "proposal_id": "badbadbad", "to": "accepted", "note": "2026-05-27"},
|
|
])
|
|
with pytest.raises(RegistryLoadError, match="badbadbad"):
|
|
load_ratified_registry(ProposalLog(log_path))
|
|
|
|
|
|
def test_cache_hit_avoids_re_read(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{"event": "created", "proposal": _make_proposal(
|
|
proposal_id="cccc3333", shape_category="rate_with_currency", review_state="pending",
|
|
)},
|
|
{"event": "transition", "proposal_id": "cccc3333", "to": "accepted", "note": "2026-05-27"},
|
|
])
|
|
log = ProposalLog(log_path)
|
|
|
|
read_counter = {"n": 0}
|
|
real_read = Path.read_bytes
|
|
|
|
def _tracking_read_bytes(self: Path) -> bytes:
|
|
if str(self) == str(log_path):
|
|
read_counter["n"] += 1
|
|
return real_read(self)
|
|
|
|
monkeypatch.setattr(Path, "read_bytes", _tracking_read_bytes)
|
|
load_ratified_registry(log)
|
|
first = read_counter["n"]
|
|
load_ratified_registry(log)
|
|
# Second call uses cache: at most one extra read (the cache-key
|
|
# mtime+sha lookup itself reads bytes), not a full re-projection.
|
|
assert read_counter["n"] - first <= 1
|
|
|
|
|
|
def test_cache_invalidates_on_log_change(tmp_path: Path) -> None:
|
|
log_path = tmp_path / "proposals.jsonl"
|
|
_write_log(log_path, [
|
|
{"event": "created", "proposal": _make_proposal(
|
|
proposal_id="cccc3333", shape_category="rate_with_currency", review_state="pending",
|
|
)},
|
|
{"event": "transition", "proposal_id": "cccc3333", "to": "accepted", "note": "2026-05-27"},
|
|
])
|
|
log = ProposalLog(log_path)
|
|
reg_a = load_ratified_registry(log)
|
|
assert len(reg_a) == 1
|
|
|
|
# Append another accepted proposal; cache must invalidate.
|
|
import time
|
|
time.sleep(0.01)
|
|
with log_path.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps({"event": "created", "proposal": _make_proposal(
|
|
proposal_id="dddd4444",
|
|
shape_category="temporal_aggregation",
|
|
review_state="pending",
|
|
)}) + "\n")
|
|
fh.write(json.dumps({
|
|
"event": "transition", "proposal_id": "dddd4444",
|
|
"to": "accepted", "note": "2026-05-28",
|
|
}) + "\n")
|
|
reg_b = load_ratified_registry(log)
|
|
assert len(reg_b) == 2
|
|
|
|
|
|
def test_live_proposal_log_has_phase_c_pending_proposals() -> None:
|
|
"""Audit-level check: the live log carries the three Phase C
|
|
pending proposals. If this fails the operator has not run
|
|
``core teaching propose-from-exemplars --all`` since Phase B,
|
|
and Phase D's downstream tests will be unable to build the
|
|
synthetic fixture (ADR-0161 §5)."""
|
|
from tests._phase_d_fixture import PHASE_C_PROPOSAL_IDS
|
|
|
|
log = ProposalLog()
|
|
state = log.current_state()
|
|
missing = [pid for pid in PHASE_C_PROPOSAL_IDS if pid not in state]
|
|
assert not missing, (
|
|
f"live proposal log is missing Phase C pendings {missing}; "
|
|
"run `core teaching propose-from-exemplars --all` first"
|
|
)
|
|
# And they are ALL pending — no agent-side ratification (ADR-0161 §5).
|
|
for pid in PHASE_C_PROPOSAL_IDS:
|
|
assert state[pid]["state"] == "pending", (
|
|
f"proposal {pid} state={state[pid]['state']!r}; "
|
|
"ADR-0161 §5 forbids agent-side ratification"
|
|
)
|
|
# Live registry stays empty until the operator ratifies.
|
|
assert load_ratified_registry(log) == ()
|
|
|
|
|