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:
parent
47c0a03d3b
commit
1f5ffcf6c7
5 changed files with 401 additions and 19 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue