feat(ADR-0163.C.2): extend exemplar ingest + synthesis + matchers for round-2 categories (#307)

Unblocks the four Phase B round-2 exemplar corpora (PR #306) so they
can flow through `core teaching propose-from-exemplars`.  The corpora
were committed in #306 but Phase C's ingest validator + synthesizer
were hard-coded to round-1 categories; this PR closes that gap.

Extends three modules with the three new categories
(discrete_count_statement, multiplicative_aggregation, currency_amount):

- teaching/exemplar_ingest.py — per-category validator dispatch +
  _SUPPORTED_CATEGORIES.  The file-stem rule loosens from
  exact ``<category>_v1`` to ``<category>_v<N>`` so the
  temporal_aggregation v2 widening from #306 ingests.
- teaching/recognizer_synthesis.py — per-category synthesizers
  following the same observed_*-set + coverage-histogram pattern as
  round 1.  Determinism, narrowness rule (narrower-not-broader),
  rules-only — same discipline.
- generate/recognizer_match.py — per-category matchers shipped as
  DETECTION-ONLY (return empty parsed_anchors).  Consistent with
  Phase D's current skip-only wiring (PR #302).  Real value
  extraction lands when Phase D.2 plumbs parsed_anchors into the
  solver; until then, detection-only is the right shape and
  preserves wrong=0 by construction.

  graph_intent Literal expanded to include "count" and "amount".

Test updates:
- tests/test_exemplar_ingest.py: extend _ROUND_1 with _ROUND_2;
  test_list_corpora_loads_every_round_1_file now asserts every
  committed corpus (round 1 + round 2) loads.
- tests/test_recognizer_registry.py: rename + repair
  test_live_proposal_log_has_phase_c_pending_proposals →
  test_live_proposal_log_has_phase_c_proposals.  The original
  asserted state=="pending"; PR #304 ratified the three, so the
  test now asserts state=="accepted" and registry length matches.
  Pre-existing failure on main, fixed here.

Validation:
- 132 passed across exemplar_ingest, recognizer_synthesis,
  recognizer_match, recognizer_registry, candidate_graph_wiring,
  admissibility_exemplars, refusal_taxonomy_lane,
  admissibility_replay_gate
- 222 capability-axis tests passed / 2 pre-existing main failures /
  3 skipped — G1..G5 + S1 wrong=0 invariant intact
- 67 smoke passed
- End-to-end CLI sanity check: `core teaching propose-from-exemplars
  teaching/admissibility_exemplars/discrete_count_statement_v1.jsonl
  --log /tmp/test.jsonl` produced proposal_id 8c7645b4..., state
  pending, replay_equivalent=True, wrong_count_delta=0

Empirical projection: of 47 still-refused GSM8K train_sample
statements, ~22 match the discrete_count_statement recognizer, ~2
match multiplicative_aggregation, plus 3 rate_with_currency + 3
temporal_aggregation + 18 descriptive_setup_no_quantity recognized
under the existing round-1 wiring.  After operator ratifies round-2
proposals, the candidate-graph skip-only wiring will drop those
sentences from the math state and a meaningful lift is projected.
wrong=0 preserved at every level by Phase D's skip-only
construction.

Scope: enables the round-2 pipeline; does NOT ratify anything;
does NOT modify generate/math_candidate_graph.py.  Operator runs
propose-from-exemplars + review --accept after merge.
This commit is contained in:
Shay 2026-05-26 15:08:41 -07:00 committed by GitHub
parent 47c0a03d3b
commit 1f5ffcf6c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 401 additions and 19 deletions

View file

@ -148,7 +148,7 @@ class RecognizerMatch:
recognizer: RatifiedRecognizer
category: ShapeCategory
outcome: Literal["admissible", "inadmissible_by_design"]
graph_intent: Literal["setup", "aggregate", "rate"]
graph_intent: Literal["setup", "aggregate", "rate", "count", "amount"]
parsed_anchors: tuple[Mapping[str, Any], ...]
@ -342,10 +342,140 @@ def _match_rate_with_currency(
return (tuple(anchors), "rate")
# ---------------------------------------------------------------------------
# ADR-0163.B.2 round-2 matchers. Detection-only (return empty
# parsed_anchors) — consistent with Phase D's skip-only wiring. Real
# value extraction lands when Phase D.2 plumbs parsed_anchors into the
# solver. Narrowness is enforced via shape predicates (no currency on a
# discrete-count match; no "per X" on a currency_amount match; etc.).
# ---------------------------------------------------------------------------
_PER_UNIT_TOKENS: Final[tuple[str, ...]] = (
" per ", "/", " an hour", " a hour", " a day", " a week", " a month",
" a year", " for one ", " for each ", " for every ",
)
_TEMPORAL_QUANTIFIER_TOKENS: Final[tuple[str, ...]] = (
" per ", " each ", " every ", " daily", " weekly", " monthly",
" yearly", " hourly",
)
_MULTIPLICATIVE_CONNECTIVES: Final[tuple[str, ...]] = (
" with ", " each ", " in each ", " per each ",
)
def _has_per_unit_framing(padded_lower: str) -> bool:
return any(tok in padded_lower for tok in _PER_UNIT_TOKENS)
def _has_temporal_quantifier(padded_lower: str) -> bool:
return any(tok in padded_lower for tok in _TEMPORAL_QUANTIFIER_TOKENS)
def _has_currency_symbol(statement: str) -> bool:
return any(c in statement for c in "$£€¥")
def _match_discrete_count_statement(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["count"]] | None:
"""Detection-only match for "X has N Y" shape.
Conditions:
- statement carries 1 quantity marker (digit or number word)
- statement does NOT carry a currency symbol (else currency_amount)
- statement does NOT carry per-unit framing (else rate_with_currency)
- statement does NOT carry temporal-quantifier framing
(else temporal_aggregation)
- spec's anchor_kind is "discrete_count"
Returns ``(empty parsed_anchors, "count")`` on a hit; real value
extraction is Phase D.2 follow-up.
"""
if spec.get("anchor_kind") != "discrete_count":
return None
padded = _padded_lower(statement)
if not _has_any_quantity_marker(statement, padded):
return None
if _has_currency_symbol(statement):
return None
if _has_per_unit_framing(padded):
return None
if _has_temporal_quantifier(padded):
return None
return (tuple(), "count")
def _match_multiplicative_aggregation(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["aggregate"]] | None:
"""Detection-only match for "M outer × N inner" shape.
Conditions:
- spec's anchor_kind is "multiplicative_aggregate"
- statement carries a multiplicative connective
("with", "each holds", "in each", etc.)
- statement carries 2 quantity markers (the outer + inner counts)
- statement does NOT carry currency-per-unit framing
Returns ``(empty parsed_anchors, "aggregate")`` on a hit.
"""
if spec.get("anchor_kind") != "multiplicative_aggregate":
return None
padded = _padded_lower(statement)
if not any(c in padded for c in _MULTIPLICATIVE_CONNECTIVES):
return None
# Count distinct quantity markers (digits + number words). At least
# two needed to admit a multiplicative shape.
digit_hits = len(_DIGIT_RE.findall(statement))
word_hits = sum(
1 for token in padded.split()
if token.strip(".,;:!?\"'()[]{}").lower() in _NUMBER_WORDS
)
if (digit_hits + word_hits) < 2:
return None
if _has_currency_symbol(statement) and _has_per_unit_framing(padded):
return None
return (tuple(), "aggregate")
def _match_currency_amount(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["amount"]] | None:
"""Detection-only match for "X costs $Y" (NO per-unit framing).
Discriminator vs rate_with_currency: this matcher REQUIRES a
currency symbol AND requires that no per-unit framing is present.
Narrowness: the currency symbol observed in the statement MUST
appear in the spec's ``observed_currency_symbols`` set.
Returns ``(empty parsed_anchors, "amount")`` on a hit.
"""
if spec.get("anchor_kind") != "currency_amount":
return None
observed_symbols = set(spec.get("observed_currency_symbols") or ())
if not observed_symbols:
return None
# Find at least one currency symbol present in the statement that is
# also observed by the spec.
found_observed = any(sym in statement for sym in observed_symbols)
if not found_observed:
return None
padded = _padded_lower(statement)
if _has_per_unit_framing(padded):
return None
return (tuple(), "amount")
_MATCHERS: Final[dict[ShapeCategory, Any]] = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _match_descriptive_setup_no_quantity,
ShapeCategory.TEMPORAL_AGGREGATION: _match_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _match_rate_with_currency,
ShapeCategory.DISCRETE_COUNT_STATEMENT: _match_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _match_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _match_currency_amount,
}

View file

@ -47,6 +47,8 @@ _VALID_WINDOW_UNITS: frozenset[str] = frozenset({
_VALID_WINDOW_QUANTIFIERS: frozenset[str] = frozenset({"each", "every", "per"})
_VALID_CURRENCY_SYMBOLS: frozenset[str] = frozenset({"$", "£", "", "¥"})
_VALID_AMOUNT_KINDS: frozenset[str] = frozenset({"integer", "decimal", "word"})
# Round-2 categories.
_VALID_COUNT_KINDS: frozenset[str] = frozenset({"integer", "word"})
# The categories Phase C ingests in round 1. Adding a category here
@ -55,6 +57,10 @@ _SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY,
ShapeCategory.TEMPORAL_AGGREGATION,
ShapeCategory.RATE_WITH_CURRENCY,
# ADR-0163.B.2 round-2 categories.
ShapeCategory.DISCRETE_COUNT_STATEMENT,
ShapeCategory.MULTIPLICATIVE_AGGREGATION,
ShapeCategory.CURRENCY_AMOUNT,
})
@ -208,10 +214,98 @@ def _validate_rate_with_currency(ctx: str, graph: Mapping[str, Any]) -> None:
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
def _validate_discrete_count_statement(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} discrete_count_statement needs ≥1 anchor")
for a in anchors:
if not isinstance(a, Mapping):
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
_require_keys(ctx, a, frozenset({
"kind", "subject_role", "count_token", "count_kind", "counted_noun",
}))
if a["kind"] != "discrete_count":
raise ExemplarIngestError(f"{ctx} anchor kind must be 'discrete_count'")
if a["count_kind"] not in _VALID_COUNT_KINDS:
raise ExemplarIngestError(
f"{ctx} count_kind {a['count_kind']!r} not in "
f"{sorted(_VALID_COUNT_KINDS)}"
)
for fld in ("subject_role", "count_token", "counted_noun"):
if not isinstance(a[fld], str) or not a[fld]:
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
if graph["graph_intent"] != "count":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'count'")
if graph["outcome"] != "admissible":
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
def _validate_multiplicative_aggregation(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} multiplicative_aggregation needs ≥1 anchor")
for a in anchors:
if not isinstance(a, Mapping):
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
_require_keys(ctx, a, frozenset({
"kind", "outer_count", "outer_unit", "inner_count", "inner_unit",
"subject_role",
}))
if a["kind"] != "multiplicative_aggregate":
raise ExemplarIngestError(
f"{ctx} anchor kind must be 'multiplicative_aggregate'"
)
for fld in (
"outer_count", "outer_unit", "inner_count", "inner_unit", "subject_role",
):
if not isinstance(a[fld], str) or not a[fld]:
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
if graph["graph_intent"] != "aggregate":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'aggregate'")
if graph["outcome"] != "admissible":
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
def _validate_currency_amount(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} currency_amount needs ≥1 anchor")
for a in anchors:
if not isinstance(a, Mapping):
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
_require_keys(ctx, a, frozenset({
"kind", "currency_symbol", "amount", "amount_kind", "subject_role",
}))
if a["kind"] != "currency_amount":
raise ExemplarIngestError(
f"{ctx} anchor kind must be 'currency_amount'"
)
if a["currency_symbol"] not in _VALID_CURRENCY_SYMBOLS:
raise ExemplarIngestError(
f"{ctx} currency_symbol {a['currency_symbol']!r} not in "
f"{sorted(_VALID_CURRENCY_SYMBOLS)}"
)
if a["amount_kind"] not in _VALID_AMOUNT_KINDS:
raise ExemplarIngestError(
f"{ctx} amount_kind {a['amount_kind']!r} not in "
f"{sorted(_VALID_AMOUNT_KINDS)}"
)
for fld in ("amount", "subject_role"):
if not isinstance(a[fld], str) or not a[fld]:
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
if graph["graph_intent"] != "amount":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'amount'")
if graph["outcome"] != "admissible":
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
_CATEGORY_VALIDATORS = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _validate_descriptive_setup,
ShapeCategory.TEMPORAL_AGGREGATION: _validate_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _validate_rate_with_currency,
ShapeCategory.DISCRETE_COUNT_STATEMENT: _validate_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount,
}
@ -312,11 +406,15 @@ def load_exemplar_corpus(path: Path) -> ExemplarCorpus:
f"{path} mixes categories: {category.value!r} and "
f"{ex.shape_category.value!r} both present"
)
expected_stem = f"{category.value}_v1"
if path.stem != expected_stem:
# File stem must be ``<category>_v<N>`` where N is a positive
# integer. Round-2 widenings (e.g. ``temporal_aggregation_v2``)
# are honored under this rule.
stem_prefix = f"{category.value}_v"
if not path.stem.startswith(stem_prefix) or not path.stem[len(stem_prefix):].isdigit():
raise ExemplarIngestError(
f"{path} stem {path.stem!r} does not match category "
f"{category.value!r}; expected stem {expected_stem!r}"
f"{category.value!r}; expected stem '{stem_prefix}<N>' with "
f"N a positive integer"
)
# Deterministic order on the in-memory list mirrors the canonical

View file

@ -256,10 +256,148 @@ def _synthesize_rate_with_currency(
return canonical_pattern, coverage
def _synthesize_discrete_count_statement(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — discrete-count seeds.
Each anchor carries (count_token, count_kind, counted_noun). The
synthesizer records ``observed_count_kinds`` as a narrowness gate
(integer/word); ``observed_counted_nouns`` is coverage-only gating
on every noun in the seed corpus would over-narrow the matcher
across the GSM8K nominal vocabulary.
"""
exemplars = corpus.exemplars
count_kinds: list[str] = []
counted_nouns: list[str] = []
anchor_counts: list[int] = []
coverage_count_kind: dict[str, int] = {}
coverage_counted_noun: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
ck = a["count_kind"]
noun = a["counted_noun"]
count_kinds.append(ck)
counted_nouns.append(noun)
coverage_count_kind[ck] = coverage_count_kind.get(ck, 0) + 1
coverage_counted_noun[noun] = coverage_counted_noun.get(noun, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.DISCRETE_COUNT_STATEMENT.value,
"graph_intent": "count",
"outcome": "admissible",
"anchor_kind": "discrete_count",
"observed_count_kinds": _sorted_unique(count_kinds),
"observed_counted_nouns": _sorted_unique(counted_nouns),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {"anchors_discrete_count": sum(anchor_counts)}
for k, n in sorted(coverage_count_kind.items()):
coverage[f"count_kind:{k}"] = n
for noun, n in sorted(coverage_counted_noun.items()):
coverage[f"counted_noun:{noun}"] = n
return canonical_pattern, coverage
def _synthesize_multiplicative_aggregation(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — multiplicative-aggregate seeds (``M outer × N inner``).
Multi-anchor cases (joined aggregations like Ella's apples) widen
``anchor_count_max`` naturally.
"""
exemplars = corpus.exemplars
outer_units: list[str] = []
inner_units: list[str] = []
anchor_counts: list[int] = []
coverage_outer: dict[str, int] = {}
coverage_inner: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
ou = a["outer_unit"]
iu = a["inner_unit"]
outer_units.append(ou)
inner_units.append(iu)
coverage_outer[ou] = coverage_outer.get(ou, 0) + 1
coverage_inner[iu] = coverage_inner.get(iu, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.MULTIPLICATIVE_AGGREGATION.value,
"graph_intent": "aggregate",
"outcome": "admissible",
"anchor_kind": "multiplicative_aggregate",
"observed_outer_units": _sorted_unique(outer_units),
"observed_inner_units": _sorted_unique(inner_units),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_multiplicative_aggregate": sum(anchor_counts),
}
for u, n in sorted(coverage_outer.items()):
coverage[f"outer_unit:{u}"] = n
for u, n in sorted(coverage_inner.items()):
coverage[f"inner_unit:{u}"] = n
return canonical_pattern, coverage
def _synthesize_currency_amount(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""ADR-0163.B.2 — currency-amount seeds.
Distinct from ``rate_with_currency``: NO per-unit framing. The
synthesizer records observed currency symbols + amount kinds as
narrowness gates.
"""
exemplars = corpus.exemplars
currency_symbols: list[str] = []
amount_kinds: list[str] = []
anchor_counts: list[int] = []
coverage_currency: dict[str, int] = {}
coverage_amount_kind: dict[str, int] = {}
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
cs = a["currency_symbol"]
ak = a["amount_kind"]
currency_symbols.append(cs)
amount_kinds.append(ak)
coverage_currency[cs] = coverage_currency.get(cs, 0) + 1
coverage_amount_kind[ak] = coverage_amount_kind.get(ak, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.CURRENCY_AMOUNT.value,
"graph_intent": "amount",
"outcome": "admissible",
"anchor_kind": "currency_amount",
"observed_currency_symbols": _sorted_unique(currency_symbols),
"observed_amount_kinds": _sorted_unique(amount_kinds),
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {"anchors_currency_amount": sum(anchor_counts)}
for sym, n in sorted(coverage_currency.items()):
coverage[f"currency_symbol:{sym}"] = n
for k, n in sorted(coverage_amount_kind.items()):
coverage[f"amount_kind:{k}"] = n
return canonical_pattern, coverage
_SYNTHESIZERS = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _synthesize_descriptive_setup_no_quantity,
ShapeCategory.TEMPORAL_AGGREGATION: _synthesize_temporal_aggregation,
ShapeCategory.RATE_WITH_CURRENCY: _synthesize_rate_with_currency,
ShapeCategory.DISCRETE_COUNT_STATEMENT: _synthesize_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _synthesize_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _synthesize_currency_amount,
}

View file

@ -34,6 +34,15 @@ _ROUND_1 = (
("rate_with_currency_v1.jsonl", ShapeCategory.RATE_WITH_CURRENCY),
)
# ADR-0163.B.2 — round-2 corpora present on main.
_ROUND_2 = (
("discrete_count_statement_v1.jsonl", ShapeCategory.DISCRETE_COUNT_STATEMENT),
("multiplicative_aggregation_v1.jsonl", ShapeCategory.MULTIPLICATIVE_AGGREGATION),
("currency_amount_v1.jsonl", ShapeCategory.CURRENCY_AMOUNT),
("temporal_aggregation_v2.jsonl", ShapeCategory.TEMPORAL_AGGREGATION),
)
_ALL_CORPORA = _ROUND_1 + _ROUND_2
@pytest.mark.parametrize(("filename", "category"), _ROUND_1)
def test_loads_phase_b_corpus_without_loss(filename: str, category: ShapeCategory) -> None:
@ -64,7 +73,10 @@ def test_corpus_digest_is_byte_stable(filename: str, _category: ShapeCategory) -
def test_list_corpora_loads_every_round_1_file() -> None:
corpora = list_corpora(_EXEMPLARS_ROOT)
cats = {c.shape_category for c in corpora}
assert cats == {cat for _, cat in _ROUND_1}
# After ADR-0163.B.2, round-2 categories also load. The discriminator
# the test pins is "every committed corpus loads"; round 1 is a subset.
expected = {cat for _, cat in _ALL_CORPORA}
assert cats == expected
# Stable iteration order.
again = list_corpora(_EXEMPLARS_ROOT)
assert [c.corpus_digest for c in corpora] == [c.corpus_digest for c in again]

View file

@ -262,28 +262,32 @@ def test_cache_invalidates_on_log_change(tmp_path: Path) -> None:
assert len(reg_b) == 2
def test_live_proposal_log_has_phase_c_pending_proposals() -> None:
def test_live_proposal_log_has_phase_c_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)."""
proposals. Post-#304 (operator ratification round 1) they are
all ``accepted`` and the registry returns three entries. If a
future ratification round withdraws any of them, this test will
surface the change."""
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}; "
f"live proposal log is missing Phase C proposals {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) == ()
# Post-#304 they are accepted. ADR-0161 §5 — only the operator
# ratifies; this test pins the operator's round-1 ratification.
accepted_count = sum(
1 for pid in PHASE_C_PROPOSAL_IDS
if state[pid]["state"] == "accepted"
)
assert accepted_count == len(PHASE_C_PROPOSAL_IDS), (
f"expected {len(PHASE_C_PROPOSAL_IDS)} accepted Phase C proposals, "
f"got {accepted_count}: {[(pid[:12], state[pid]['state']) for pid in PHASE_C_PROPOSAL_IDS]}"
)
# Registry exposes the ratified set.
assert len(load_ratified_registry(log)) == len(PHASE_C_PROPOSAL_IDS)