diff --git a/generate/recognizer_anchor_inject.py b/generate/recognizer_anchor_inject.py index d25a165a..89ad8041 100644 --- a/generate/recognizer_anchor_inject.py +++ b/generate/recognizer_anchor_inject.py @@ -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", ] diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index 02775350..8c3861b8 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -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, } diff --git a/teaching/admissibility_exemplars/comparative_with_unit_v1.jsonl b/teaching/admissibility_exemplars/comparative_with_unit_v1.jsonl new file mode 100644 index 00000000..749ee1f0 --- /dev/null +++ b/teaching/admissibility_exemplars/comparative_with_unit_v1.jsonl @@ -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}} \ No newline at end of file diff --git a/teaching/cognition_chains/cognition_chains_v1.jsonl b/teaching/cognition_chains/cognition_chains_v1.jsonl index 8174ed96..2520369f 100644 --- a/teaching/cognition_chains/cognition_chains_v1.jsonl +++ b/teaching/cognition_chains/cognition_chains_v1.jsonl @@ -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"} diff --git a/teaching/exemplar_ingest.py b/teaching/exemplar_ingest.py index e544a8f2..68f564c4 100644 --- a/teaching/exemplar_ingest.py +++ b/teaching/exemplar_ingest.py @@ -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, } diff --git a/teaching/proposals/proposals.jsonl b/teaching/proposals/proposals.jsonl index a73c7363..70482376 100644 --- a/teaching/proposals/proposals.jsonl +++ b/teaching/proposals/proposals.jsonl @@ -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"}} diff --git a/teaching/recognizer_synthesis.py b/teaching/recognizer_synthesis.py index b8e168a6..5bb2c6b8 100644 --- a/teaching/recognizer_synthesis.py +++ b/teaching/recognizer_synthesis.py @@ -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, } diff --git a/tests/test_gsm8k_frontier_report.py b/tests/test_gsm8k_frontier_report.py index 24789dc9..6c798e18 100644 --- a/tests/test_gsm8k_frontier_report.py +++ b/tests/test_gsm8k_frontier_report.py @@ -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 diff --git a/tests/test_recognizer_comparative_inject.py b/tests/test_recognizer_comparative_inject.py new file mode 100644 index 00000000..20edbe50 --- /dev/null +++ b/tests/test_recognizer_comparative_inject.py @@ -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 \ No newline at end of file