diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index ace1153b..912ba428 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -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, } diff --git a/teaching/exemplar_ingest.py b/teaching/exemplar_ingest.py index f4e07d77..e544a8f2 100644 --- a/teaching/exemplar_ingest.py +++ b/teaching/exemplar_ingest.py @@ -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 ``_v`` 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}' with " + f"N a positive integer" ) # Deterministic order on the in-memory list mirrors the canonical diff --git a/teaching/recognizer_synthesis.py b/teaching/recognizer_synthesis.py index 5d4645e7..b8e168a6 100644 --- a/teaching/recognizer_synthesis.py +++ b/teaching/recognizer_synthesis.py @@ -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, } diff --git a/tests/test_exemplar_ingest.py b/tests/test_exemplar_ingest.py index 261969b2..6cd309dc 100644 --- a/tests/test_exemplar_ingest.py +++ b/tests/test_exemplar_ingest.py @@ -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] diff --git a/tests/test_recognizer_registry.py b/tests/test_recognizer_registry.py index 618b87d3..90248781 100644 --- a/tests/test_recognizer_registry.py +++ b/tests/test_recognizer_registry.py @@ -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)