feat(gsm8k): Gate A1 multiplicative comparative recognizer injection

Add COMPARATIVE_WITH_UNIT matcher/injector emitting compare_multiplicative
for the closed v1 template family (twice/thrice/N-times/half/quarter/third).
DCS yields comparative surfaces instead of detection-only fallback.

Includes ratified exemplar corpus + accepted recognizer proposal, 19 unit
tests, and live frontier proof that comparative_with_unit no-injection = 0.
wrong=0 preserved; no report.json rebaseline.
This commit is contained in:
Shay 2026-06-17 13:42:28 -07:00
parent ed2d04c99e
commit e578ec72f0
9 changed files with 473 additions and 1 deletions

View file

@ -48,7 +48,11 @@ from __future__ import annotations
from typing import Mapping, Union
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.math_candidate_parser import CandidateInitial, CandidateOperation
from generate.math_candidate_parser import (
CandidateInitial,
CandidateOperation,
_build_compare_multiplicative,
)
from generate.math_problem_graph import (
InitialPossession,
MathGraphError,
@ -56,6 +60,7 @@ from generate.math_problem_graph import (
Quantity,
Rate,
)
from generate.math_roundtrip import roundtrip_admissible
from generate.recognizer_match import (
RecognizerMatch,
extract_proper_noun_subject,
@ -714,6 +719,66 @@ def inject_rate_with_currency(
return tuple(out)
# ---------------------------------------------------------------------------
# Gate A1 — comparative_with_unit → compare_multiplicative (Workstream A)
# ---------------------------------------------------------------------------
def inject_comparative_multiplicative(
match: RecognizerMatch,
sentence: str,
) -> tuple[InjectorEmission, ...]:
"""Narrow injector for ShapeCategory.COMPARATIVE_WITH_UNIT.
Emits ``CandidateOperation(kind="compare_multiplicative")`` only when
the matcher published a fully grounded comparative anchor and
:func:`roundtrip_admissible` accepts the construction.
"""
if not match.parsed_anchors or len(match.parsed_anchors) != 1:
return ()
anchor = match.parsed_anchors[0]
if not isinstance(anchor, dict):
return ()
if anchor.get("kind") != "comparative_multiplicative":
return ()
actor_token = anchor.get("actor_token")
reference_token = anchor.get("reference_actor_token")
unit_token = anchor.get("unit_token")
factor_token = anchor.get("factor_token")
matched_verb = anchor.get("matched_verb")
direction = anchor.get("direction")
factor = anchor.get("factor")
if not all(
isinstance(v, str) and v
for v in (actor_token, reference_token, unit_token, factor_token, matched_verb, direction)
):
return ()
if not isinstance(factor, (int, float)) or factor <= 0:
return ()
# Narrow actor binding (mirror rate v1): ProperName subject only.
actor = extract_proper_noun_subject(sentence)
if not actor or actor != actor_token:
return ()
cand = _build_compare_multiplicative(
actor_raw=actor_token,
factor=float(factor),
matched_verb=matched_verb,
matched_value_token=factor_token,
unit_raw=unit_token,
reference_raw=reference_token,
source=sentence,
direction=direction,
)
if cand is None or not roundtrip_admissible(cand):
return ()
return (cand,)
_INJECTORS: Mapping[ShapeCategory, "type"] = {
ShapeCategory.DISCRETE_COUNT_STATEMENT: inject_discrete_count_statement, # type: ignore[dict-item]
# WAVE-A — multiplicative_aggregation now has a per-category
@ -729,6 +794,10 @@ _INJECTORS: Mapping[ShapeCategory, "type"] = {
# frontier for the currency-per-unit surfaces without touching
# sealed lanes or any other category.
ShapeCategory.RATE_WITH_CURRENCY: inject_rate_with_currency, # type: ignore[dict-item]
# Gate A1 (Workstream A) — comparative_with_unit emits
# CandidateOperation(kind="compare_multiplicative") for the closed
# v1 multiplicative entity-comparison template family.
ShapeCategory.COMPARATIVE_WITH_UNIT: inject_comparative_multiplicative, # type: ignore[dict-item]
# All other recognizer categories continue to route to the
# empty-tuple fallback (explicit "recognizer matched but produced
# no injection" refusal in the candidate-graph). That is the
@ -764,4 +833,5 @@ __all__ = [
"inject_from_match",
"inject_discrete_count_statement",
"inject_rate_with_currency",
"inject_comparative_multiplicative",
]

View file

@ -810,6 +810,10 @@ def _match_discrete_count_statement(
return None
if _has_temporal_quantifier(padded):
return None
# Gate A1 — yield comparative multiplicative surfaces to
# COMPARATIVE_WITH_UNIT instead of detection-only DCS fallback.
if _is_comparative_multiplicative_v1_surface(statement):
return None
anchor = _try_extract_discrete_count_anchor(statement, padded, spec)
if anchor is not None:
@ -1831,6 +1835,113 @@ def _match_currency_amount(
return (tuple(), "amount")
# ---------------------------------------------------------------------------
# Gate A1 — comparative_with_unit → compare_multiplicative (Workstream A)
# ---------------------------------------------------------------------------
from generate.math_candidate_parser import ( # noqa: E402
_ANCHOR_TO_FACTOR,
_COMPARE_MULT_ANCHOR_RE,
_COMPARE_MULT_NTIMES_RE,
_is_indefinite_quantifier,
_resolve_value,
)
_DEFERRED_COMPARATIVE_FACTOR_SURFACES: Final[frozenset[str]] = frozenset({
"double", "triple", "quadruple", "one-third",
})
def _is_comparative_multiplicative_v1_surface(statement: str) -> bool:
"""True when *statement* matches the Gate A1 closed comparative template."""
s = statement.strip()
if _COMPARE_MULT_ANCHOR_RE.match(s) is not None:
return True
return _COMPARE_MULT_NTIMES_RE.match(s) is not None
def _try_extract_comparative_multiplicative_anchor(
statement: str,
spec: Mapping[str, Any],
) -> Mapping[str, Any] | None:
"""Extract one comparative_multiplicative anchor when narrowness holds."""
s = statement.strip()
observed_anchors = set(spec.get("observed_factor_anchors") or ())
allows_numeric = bool(spec.get("allows_numeric_factor"))
m = _COMPARE_MULT_ANCHOR_RE.match(s)
if m is not None:
anchor_word = m.group("anchor").lower()
if anchor_word in _DEFERRED_COMPARATIVE_FACTOR_SURFACES:
return None
if anchor_word not in observed_anchors:
return None
factor, direction = _ANCHOR_TO_FACTOR[anchor_word]
actor_token = m.group("actor")
unit_token = m.group("unit")
reference_token = m.group("reference")
phrase = f"{anchor_word} as many {unit_token}"
return {
"kind": "comparative_multiplicative",
"actor_token": actor_token,
"reference_actor_token": reference_token,
"unit_token": unit_token,
"factor_token": anchor_word,
"factor": factor,
"direction": direction,
"matched_verb": anchor_word,
"comparator_phrase": phrase,
}
m = _COMPARE_MULT_NTIMES_RE.match(s)
if m is not None:
if not allows_numeric:
return None
value_raw = m.group("value")
if _is_indefinite_quantifier(value_raw):
return None
rv = _resolve_value(value_raw)
if rv is None or rv.value <= 0:
return None
actor_token = m.group("actor")
unit_token = m.group("unit")
reference_token = m.group("reference")
phrase = f"{value_raw} times as many {unit_token}"
return {
"kind": "comparative_multiplicative",
"actor_token": actor_token,
"reference_actor_token": reference_token,
"unit_token": unit_token,
"factor_token": value_raw,
"factor": float(rv.value),
"direction": "times",
"matched_verb": "times",
"comparator_phrase": phrase,
}
return None
def _match_comparative_with_unit(
statement: str, spec: Mapping[str, Any]
) -> tuple[tuple[Mapping[str, Any], ...], Literal["compare"]] | None:
"""Gate A1 — multiplicative entity comparison with explicit reference.
Strict match only: returns populated anchors on full v1 template
match, ``None`` otherwise (no detection-only fallback).
"""
if spec.get("anchor_kind") != "comparative_multiplicative":
return None
anchor = _try_extract_comparative_multiplicative_anchor(statement, spec)
if anchor is None:
return None
cmin = int(spec.get("anchor_count_min", 1))
cmax = int(spec.get("anchor_count_max", 1))
if not (cmin <= 1 <= cmax):
return None
return ((anchor,), "compare")
_MATCHERS: Final[dict[ShapeCategory, Any]] = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _match_descriptive_setup_no_quantity,
ShapeCategory.TEMPORAL_AGGREGATION: _match_temporal_aggregation,
@ -1838,6 +1949,7 @@ _MATCHERS: Final[dict[ShapeCategory, Any]] = {
ShapeCategory.DISCRETE_COUNT_STATEMENT: _match_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _match_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _match_currency_amount,
ShapeCategory.COMPARATIVE_WITH_UNIT: _match_comparative_with_unit,
}

View file

@ -0,0 +1,12 @@
{"exemplar_id": "cwu-v1-0001", "shape_category": "comparative_with_unit", "statement": "Alice has twice as many apples as Bob.", "expected_graph": {"subject": "Alice", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Alice", "factor_token": "twice", "factor_kind": "anchor", "direction": "times", "unit_token": "apples", "reference_actor_token": "Bob"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0002", "shape_category": "comparative_with_unit", "statement": "Jerry has thrice as many apples as Tom.", "expected_graph": {"subject": "Jerry", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Jerry", "factor_token": "thrice", "factor_kind": "anchor", "direction": "times", "unit_token": "apples", "reference_actor_token": "Tom"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0003", "shape_category": "comparative_with_unit", "statement": "Brooke has three times as many jumping jacks as Sidney.", "expected_graph": {"subject": "Brooke", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Brooke", "factor_token": "three", "factor_kind": "numeric", "direction": "times", "unit_token": "jumping jacks", "reference_actor_token": "Sidney"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3, "train_case_id": "gsm8k-train-sample-v1-0024"}}
{"exemplar_id": "cwu-v1-0004", "shape_category": "comparative_with_unit", "statement": "Dana has 4 times as many pencils as Eli.", "expected_graph": {"subject": "Dana", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Dana", "factor_token": "4", "factor_kind": "numeric", "direction": "times", "unit_token": "pencils", "reference_actor_token": "Eli"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0005", "shape_category": "comparative_with_unit", "statement": "Alice has half as many apples as Bob.", "expected_graph": {"subject": "Alice", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Alice", "factor_token": "half", "factor_kind": "anchor", "direction": "fraction", "unit_token": "apples", "reference_actor_token": "Bob"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0006", "shape_category": "comparative_with_unit", "statement": "Alice has a quarter as many apples as Bob.", "expected_graph": {"subject": "Alice", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Alice", "factor_token": "quarter", "factor_kind": "anchor", "direction": "fraction", "unit_token": "apples", "reference_actor_token": "Bob"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0007", "shape_category": "comparative_with_unit", "statement": "Alice has a third as many apples as Bob.", "expected_graph": {"subject": "Alice", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Alice", "factor_token": "third", "factor_kind": "anchor", "direction": "fraction", "unit_token": "apples", "reference_actor_token": "Bob"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0008", "shape_category": "comparative_with_unit", "statement": "Mason collected twice as many shells as Nora.", "expected_graph": {"subject": "Mason", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Mason", "factor_token": "twice", "factor_kind": "anchor", "direction": "times", "unit_token": "shells", "reference_actor_token": "Nora"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0009", "shape_category": "comparative_with_unit", "statement": "Ivan has 3 times as many cards as Jerry.", "expected_graph": {"subject": "Ivan", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Ivan", "factor_token": "3", "factor_kind": "numeric", "direction": "times", "unit_token": "cards", "reference_actor_token": "Jerry"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0010", "shape_category": "comparative_with_unit", "statement": "Kira gained twice as many points as Leo.", "expected_graph": {"subject": "Kira", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Kira", "factor_token": "twice", "factor_kind": "anchor", "direction": "times", "unit_token": "points", "reference_actor_token": "Leo"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0011", "shape_category": "comparative_with_unit", "statement": "Nina studied three times as many pages as Omar.", "expected_graph": {"subject": "Nina", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Nina", "factor_token": "three", "factor_kind": "numeric", "direction": "times", "unit_token": "pages", "reference_actor_token": "Omar"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}
{"exemplar_id": "cwu-v1-0012", "shape_category": "comparative_with_unit", "statement": "Paula has half as many marbles as Quinn.", "expected_graph": {"subject": "Paula", "quantity_anchors": [{"kind": "comparative_multiplicative", "subject_role": "Paula", "factor_token": "half", "factor_kind": "anchor", "direction": "fraction", "unit_token": "marbles", "reference_actor_token": "Quinn"}], "graph_intent": "compare", "outcome": "admissible"}, "provenance": {"source": "gate_a1_seed", "author": "Grok (Gate A1)", "round": 1, "category_rank": 3}}

View file

@ -24,3 +24,4 @@
{"chain_id":"admissibility_multiplicative_aggregation_recognizes_c70663bd722672930b6e2f24596fbab28e94135aeffd05e7037fc6f35e5702f7","connective":"recognizes","domains_object_k":1,"domains_subject_k":2,"intent":"admissibility","object":"c70663bd722672930b6e2f24596fbab28e94135aeffd05e7037fc6f35e5702f7","provenance":"adr-0057:discovery_promoted:2026-05-27","subject":"multiplicative_aggregation"}
{"chain_id":"admissibility_currency_amount_recognizes_25df92963a15941294c232f37c91987bb35f91e8f3a483f950da43f84c2b7684","connective":"recognizes","domains_object_k":1,"domains_subject_k":2,"intent":"admissibility","object":"25df92963a15941294c232f37c91987bb35f91e8f3a483f950da43f84c2b7684","provenance":"adr-0057:discovery_promoted:2026-05-27","subject":"currency_amount"}
{"chain_id":"admissibility_temporal_aggregation_recognizes_9684dd780b4d0d387facdce18b474e09413d671b0d4cb944c2754ba2a0bb6208","connective":"recognizes","domains_object_k":1,"domains_subject_k":2,"intent":"admissibility","object":"9684dd780b4d0d387facdce18b474e09413d671b0d4cb944c2754ba2a0bb6208","provenance":"adr-0057:discovery_promoted:2026-05-27","subject":"temporal_aggregation"}
{"chain_id":"admissibility_comparative_with_unit_recognizes_f3be480f69b85cff21ff6525d769a92fa21f0ef89dfb5e3af076265b90d5883d","connective":"recognizes","domains_object_k":1,"domains_subject_k":2,"intent":"admissibility","object":"f3be480f69b85cff21ff6525d769a92fa21f0ef89dfb5e3af076265b90d5883d","provenance":"adr-0057:discovery_promoted:2026-06-17","subject":"comparative_with_unit"}

View file

@ -61,6 +61,8 @@ _SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({
ShapeCategory.DISCRETE_COUNT_STATEMENT,
ShapeCategory.MULTIPLICATIVE_AGGREGATION,
ShapeCategory.CURRENCY_AMOUNT,
# Gate A1 (Workstream A) — multiplicative comparative injection.
ShapeCategory.COMPARATIVE_WITH_UNIT,
})
@ -266,6 +268,45 @@ def _validate_multiplicative_aggregation(ctx: str, graph: Mapping[str, Any]) ->
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
def _validate_comparative_with_unit(ctx: str, graph: Mapping[str, Any]) -> None:
anchors = graph["quantity_anchors"]
if not isinstance(anchors, list) or not anchors:
raise ExemplarIngestError(f"{ctx} comparative_with_unit 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",
"factor_token",
"factor_kind",
"direction",
"unit_token",
"reference_actor_token",
}))
if a["kind"] != "comparative_multiplicative":
raise ExemplarIngestError(
f"{ctx} anchor kind must be 'comparative_multiplicative'"
)
if a["factor_kind"] not in {"anchor", "numeric"}:
raise ExemplarIngestError(
f"{ctx} factor_kind {a['factor_kind']!r} must be 'anchor' or 'numeric'"
)
if a["direction"] not in {"times", "fraction"}:
raise ExemplarIngestError(
f"{ctx} direction {a['direction']!r} must be 'times' or 'fraction'"
)
for fld in (
"subject_role", "factor_token", "unit_token", "reference_actor_token",
):
if not isinstance(a[fld], str) or not a[fld]:
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
if graph["graph_intent"] != "compare":
raise ExemplarIngestError(f"{ctx} graph_intent must be 'compare'")
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:
@ -306,6 +347,7 @@ _CATEGORY_VALIDATORS = {
ShapeCategory.DISCRETE_COUNT_STATEMENT: _validate_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount,
ShapeCategory.COMPARATIVE_WITH_UNIT: _validate_comparative_with_unit,
}

View file

@ -78,3 +78,7 @@
{"event":"transition","note":"WAVE-A re-seed with extract_values=True","proposal_id":"rat1-seed-4dc30608fb783bc7","review_date":"2026-05-27","to":"accepted"}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[],"polarity":"affirms","proposal_id":"rat1-seed-8c3d568c7f90771c","proposed_chain":{"connective":"ratifies","intent":"recognizer_spec_seed","object":"multiplicative_aggregate","recognizer_spec":{"canonical_pattern":{"anchor_kind":"multiplicative_aggregate","extract_values":true,"graph_intent":"aggregate","observed_units":["apple","apples","basket","baskets","book","books","ounce","ounces","strawberries","strawberry"],"outcome":"admissible","shape_category":"multiplicative_aggregation"},"coverage":{},"exemplar_count":0,"exemplar_digest":"8c3d568c7f90771c533e507e614b5385719420198941654a1305628c7b2d81c8","shape_category":"multiplicative_aggregation"},"subject":"multiplicative_aggregation"},"source":{"emitted_at_revision":"flywheel-demo","kind":"exemplar_corpus","source_id":"8c3d568c7f90771c533e507e614b5385719420198941654a1305628c7b2d81c8"}}}
{"event":"transition","note":"flywheel-demo seed","proposal_id":"rat1-seed-8c3d568c7f90771c","review_date":"2026-05-27","to":"accepted"}
{"event":"created","proposal":{"claim_domain":"factual","evidence":[{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0001","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0002","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:gsm8k-train-sample-v1-0024","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0004","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0005","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0006","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0007","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0008","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0009","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0010","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0011","source":"corpus"},{"epistemic_status":"coherent","polarity":"affirms","ref":"exemplar:cwu-v1-0012","source":"corpus"}],"operator_note":"","polarity":"affirms","proposal_id":"bec14058b9afbb76216414e903106ae9","proposed_chain":{"connective":"recognizes","intent":"admissibility","object":"f3be480f69b85cff21ff6525d769a92fa21f0ef89dfb5e3af076265b90d5883d","recognizer_spec":{"canonical_pattern":{"allows_numeric_factor":true,"anchor_count_max":1,"anchor_count_min":1,"anchor_kind":"comparative_multiplicative","graph_intent":"compare","observed_factor_anchors":["half","quarter","third","thrice","twice"],"outcome":"admissible","shape_category":"comparative_with_unit","unresolved_notes":[]},"coverage":{"anchors_comparative_multiplicative":12,"factor:half":2,"factor:numeric":4,"factor:quarter":1,"factor:third":1,"factor:thrice":1,"factor:twice":3},"exemplar_count":12,"exemplar_digest":"4891b1ea35c17dcf92f089ebf0dbd6b4075ad0fe340ab31b36e24f7e4f30fadf","shape_category":"comparative_with_unit"},"subject":"comparative_with_unit"},"provenance":null,"replay_evidence":null,"review_state":"pending","source":{"emitted_at_revision":"ed2d04c99e90f96d04689d0d94c825fc9a48d5b1","kind":"exemplar_corpus","source_id":"4891b1ea35c17dcf92f089ebf0dbd6b4075ad0fe340ab31b36e24f7e4f30fadf"},"source_candidate_id":"3973a72c777f9418df3f7605c7e98e9c4a4b873ce3df111737227c4ea7afd0ed"}}
{"event":"replay","proposal_id":"bec14058b9afbb76216414e903106ae9","replay_evidence":{"baseline":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":1.0,"versor_closure_rate":1.0},"candidate":{"intent_accuracy":1.0,"surface_groundedness":1.0,"term_capture_rate":1.0,"versor_closure_rate":1.0},"capability_axes":{"G1_verb_classes":{"correct":20,"refused":0,"wrong":0},"G2_comparatives":{"correct":29,"refused":0,"wrong":0},"G3_numerics":{"correct":20,"refused":6,"wrong":0},"G4_multi_clause":{"correct":32,"refused":0,"wrong":0},"G5_aggregate":{"correct":20,"refused":0,"wrong":0},"S1_rate_events":{"correct":20,"refused":0,"wrong":0}},"gsm8k_train_sample":{"correct":6,"refused":44,"wrong":0},"regressed_metrics":[],"replay_equivalent":true,"wrong_count_delta":0}}
{"event":"transition","note":"Gate A1 ratification 2026-06-17","proposal_id":"bec14058b9afbb76216414e903106ae9","to":"accepted"}
{"chain_id":"admissibility_comparative_with_unit_recognizes_f3be480f69b85cff21ff6525d769a92fa21f0ef89dfb5e3af076265b90d5883d","event":"accepted_corpus_append","proposal_id":"bec14058b9afbb76216414e903106ae9","provenance":{"adr_id":"adr-0057","raw":"adr-0057:discovery_promoted:2026-06-17","review_date":"2026-06-17","source":"discovery_promoted"}}

View file

@ -347,6 +347,48 @@ def _synthesize_multiplicative_aggregation(
return canonical_pattern, coverage
def _synthesize_comparative_with_unit(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
"""Gate A1 — multiplicative entity comparison seeds."""
exemplars = corpus.exemplars
factor_anchors: list[str] = []
anchor_counts: list[int] = []
coverage_factor: dict[str, int] = {}
has_numeric = False
for ex in exemplars:
anchors = ex.expected_graph["quantity_anchors"]
anchor_counts.append(len(anchors))
for a in anchors:
fk = a.get("factor_kind", "anchor")
if fk == "numeric":
has_numeric = True
coverage_factor["numeric"] = coverage_factor.get("numeric", 0) + 1
else:
token = a["factor_token"]
factor_anchors.append(token)
coverage_factor[token] = coverage_factor.get(token, 0) + 1
canonical_pattern: dict[str, Any] = {
"shape_category": ShapeCategory.COMPARATIVE_WITH_UNIT.value,
"graph_intent": "compare",
"outcome": "admissible",
"anchor_kind": "comparative_multiplicative",
"observed_factor_anchors": _sorted_unique(factor_anchors),
"allows_numeric_factor": has_numeric,
"anchor_count_min": min(anchor_counts),
"anchor_count_max": max(anchor_counts),
"unresolved_notes": _collect_author_notes(exemplars),
}
coverage: dict[str, int] = {
"anchors_comparative_multiplicative": sum(anchor_counts),
}
for token, n in sorted(coverage_factor.items()):
coverage[f"factor:{token}"] = n
return canonical_pattern, coverage
def _synthesize_currency_amount(
corpus: ExemplarCorpus,
) -> tuple[Mapping[str, Any], Mapping[str, int]]:
@ -398,6 +440,7 @@ _SYNTHESIZERS = {
ShapeCategory.DISCRETE_COUNT_STATEMENT: _synthesize_discrete_count_statement,
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _synthesize_multiplicative_aggregation,
ShapeCategory.CURRENCY_AMOUNT: _synthesize_currency_amount,
ShapeCategory.COMPARATIVE_WITH_UNIT: _synthesize_comparative_with_unit,
}

View file

@ -107,6 +107,35 @@ def test_post_inc3_live_runner_has_zero_rate_no_injection():
assert cats.get("rate_with_currency", 0) == 0
def test_post_gate_a1_live_runner_has_zero_comparative_no_injection():
"""Live train_sample: comparative_with_currency bucket closed at injector."""
import re
from collections import Counter
from evals.gsm8k_math.train_sample.v1.runner import build_report
from tests.gsm8k_train_sample_baseline import assert_monotonic_serving_counts
cases_path = _REPO_ROOT / "evals/gsm8k_math/train_sample/v1/cases.jsonl"
cases = [
json.loads(line)
for line in cases_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
report = build_report(cases)
assert_monotonic_serving_counts(report["counts"])
cats: Counter[str] = Counter()
for row in report["per_case"]:
reason = row.get("reason", "")
if "produced no injection" not in reason:
continue
m = re.search(r"category=(\w+)", reason)
if m:
cats[m.group(1)] += 1
assert cats.get("comparative_with_unit", 0) == 0
def test_classify_and_extract_category_logic():
"""Unit the internal classification on the exact reason strings the graph emits."""
# We exercise via the public analyze path with a tiny synthetic report

View file

@ -0,0 +1,159 @@
"""Gate A1 — comparative_with_unit recognizer-anchor injection tests.
Mirrors the Inc2 rate injector ladder: unit confusers, live-registry dispatch,
half/quarter/third serving proof, and DCS yield for comparative surfaces.
"""
from __future__ import annotations
import types
import pytest
from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.math_candidate_parser import CandidateOperation
from generate.math_problem_graph import Comparison
from generate.math_roundtrip import roundtrip_admissible
from generate.recognizer_anchor_inject import (
inject_comparative_multiplicative,
inject_from_match,
)
from generate.recognizer_match import RecognizerMatch, match
from generate.recognizer_registry import load_ratified_registry
def _stub_recognizer(category: ShapeCategory) -> types.SimpleNamespace:
return types.SimpleNamespace(shape_category=category, canonical_pattern={})
def _make_match(anchor: dict) -> RecognizerMatch:
return RecognizerMatch(
recognizer=_stub_recognizer(ShapeCategory.COMPARATIVE_WITH_UNIT),
category=ShapeCategory.COMPARATIVE_WITH_UNIT,
outcome="admissible",
graph_intent="compare",
parsed_anchors=(anchor,),
)
def _anchor(
*,
actor: str = "Alice",
reference: str = "Bob",
unit: str = "apples",
factor_token: str = "twice",
factor: float = 2.0,
direction: str = "times",
matched_verb: str = "twice",
) -> dict:
return {
"kind": "comparative_multiplicative",
"actor_token": actor,
"reference_actor_token": reference,
"unit_token": unit,
"factor_token": factor_token,
"factor": factor,
"direction": direction,
"matched_verb": matched_verb,
"comparator_phrase": f"{factor_token} as many {unit}",
}
@pytest.mark.parametrize(
"sentence,actor,reference,unit,factor_token,factor,direction,matched_verb",
[
("Alice has twice as many apples as Bob.", "Alice", "Bob", "apples", "twice", 2.0, "times", "twice"),
("Jerry has thrice as many apples as Tom.", "Jerry", "Tom", "apples", "thrice", 3.0, "times", "thrice"),
("Dana has 4 times as many pencils as Eli.", "Dana", "Eli", "pencils", "4", 4.0, "times", "times"),
("Alice has half as many apples as Bob.", "Alice", "Bob", "apples", "half", 0.5, "fraction", "half"),
("Alice has a quarter as many apples as Bob.", "Alice", "Bob", "apples", "quarter", 0.25, "fraction", "quarter"),
("Alice has a third as many apples as Bob.", "Alice", "Bob", "apples", "third", 1.0 / 3.0, "fraction", "third"),
],
)
def test_positive_surfaces_emit_compare_multiplicative(
sentence, actor, reference, unit, factor_token, factor, direction, matched_verb
):
emitted = inject_comparative_multiplicative(_make_match(_anchor(
actor=actor,
reference=reference,
unit=unit,
factor_token=factor_token,
factor=factor,
direction=direction,
matched_verb=matched_verb,
)), sentence)
assert len(emitted) == 1
cand = emitted[0]
assert isinstance(cand, CandidateOperation)
assert cand.op.kind == "compare_multiplicative"
assert isinstance(cand.op.operand, Comparison)
assert cand.op.operand.factor == factor
assert cand.op.operand.direction == direction
assert cand.matched_value_token == factor_token
assert cand.matched_verb == matched_verb
assert roundtrip_admissible(cand) is True
@pytest.mark.parametrize(
"sentence",
[
"Jerry has 3 times as many apples.",
"Jerry has twice as many apples.",
"Jerry has 3 times more apples than Bob.",
"Alice has 3 more apples than Bob.",
"He has twice as many apples as Bob.",
"Alice lost twice as many apples as Bob.",
"Alice has one-third as many apples as Bob.",
"Alice has double as many apples as Bob.",
"Jerry has 3 times",
],
)
def test_confuser_surfaces_refuse_injection(sentence: str):
registry = load_ratified_registry()
m = match(sentence, registry)
if m is None or m.category is not ShapeCategory.COMPARATIVE_WITH_UNIT:
return
assert inject_from_match(m, sentence, sealed=False) == ()
def test_unknown_actor_refuses():
emitted = inject_comparative_multiplicative(
_make_match(_anchor(actor="fish")),
"fish have twice as many apples as Bob.",
)
assert emitted == ()
def test_dispatch_table_routes_comparative_with_unit():
registry = load_ratified_registry()
stmt = "Alice has twice as many apples as Bob."
m = match(stmt, registry)
assert m is not None
assert m.category is ShapeCategory.COMPARATIVE_WITH_UNIT
emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1
assert roundtrip_admissible(emitted[0]) is True
def test_dcs_yields_comparative_not_initial_times():
registry = load_ratified_registry()
stmt = "Jerry has 3 times as many apples as Tom."
m = match(stmt, registry)
assert m is not None
assert m.category is ShapeCategory.COMPARATIVE_WITH_UNIT
emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1
assert emitted[0].op.kind == "compare_multiplicative"
def test_matched_tokens_ground_in_source_sentence():
sentence = "Nina studied three times as many pages as Omar."
registry = load_ratified_registry()
m = match(sentence, registry)
assert m is not None
emitted = inject_from_match(m, sentence, sealed=False)
assert len(emitted) == 1
c = emitted[0]
assert c.matched_actor_token in sentence
assert c.matched_reference_actor_token in sentence
assert c.matched_unit_token in sentence