"""ADR-0163.D.2 — per-category recognizer anchor injection. When the candidate-graph pipeline's existing parser yields no candidates for a statement AND the ratified recognizer registry recognizes the statement, this module is consulted to build typed solver primitives (``CandidateInitial`` / future ``CandidateOperation`` values) from the recognizer's ``parsed_anchors``. The output extends ``per_sentence_choices`` the same way the existing parser's output does, so the downstream solver runs unchanged. Doctrine -------- - Pure, deterministic injectors. Same ``(match, sentence)`` → same ``SentenceChoice`` tuple, byte-equal. - Refusal-preferring: each injector returns ``()`` when it cannot build a primitive that passes the existing ``_initial_admissible`` structural check (the wrong=0 safety net the candidate-graph already enforces). - No LLM / embeddings / learned classifiers; the injection is rules-only same discipline as Phase A/C/D detection. - Per-category boundary: the serving _INJECTORS table grows one narrow category at a time (discrete_count_statement in the base D.2 landing; rate_with_currency in Workstream A Inc 2). Every category without a registered injector still routes to the explicit-refusal fallback ("recognizer matched but produced no injection"). This is the current wrong=0 doctrine; the old silent skip-only drop is historical only. Five-layer wrong=0 safety net (the Phase D.2 brief's load-bearing section) is preserved across this module: 1. Matcher narrowness — ``recognizer_match._try_extract_discrete_count_anchor`` refuses on any ambiguity. 2. Extraction correctness — anchor fields ground in the literal statement surface. 3. Injection correctness — the per-category injector returns a ``CandidateInitial`` that passes ``_initial_admissible``; failure to ground yields ``()``. 4. Replay gate — propose-time ``run_admissibility_replay_gate`` auto-rejects any extraction change that lifts the GSM8K wrong count. 5. Multi-branch decision rule — when an injected candidate disagrees with another branch's answer, the candidate-graph refuses. """ 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, _build_compare_multiplicative, _build_unit_partition, ) from generate.math_problem_graph import ( InitialPossession, MathGraphError, Operation, Quantity, Rate, ) from generate.math_roundtrip import roundtrip_admissible from generate.recognizer_match import ( RecognizerMatch, extract_proper_noun_subject, ) # ADR-0170 — the widened injector emission type. Per-category injectors # may emit a tuple of ``CandidateInitial`` (existing) or # ``CandidateOperation`` (new, ADR-0170). The downstream # ``per_sentence_choices`` aggregator dispatches admissibility on the # concrete type (``_initial_admissible`` vs ``roundtrip_admissible``). # No new admission paths are introduced by the widening itself; new # emission shapes ship in subsequent per-injector PRs (ADR-0170 §"impl # outline" W2/W3/W4/W5). InjectorEmission = Union[CandidateInitial, CandidateOperation] # --------------------------------------------------------------------------- # Public surface # --------------------------------------------------------------------------- def inject_from_match( match: RecognizerMatch, sentence: str, *, sealed: bool = False, ) -> tuple[InjectorEmission, ...]: """Dispatch a recognizer match to its per-category injector. Returns an empty tuple when the category has no v1 injector or when the v1 injector refused. Per ADR-0170, the return type is now ``tuple[InjectorEmission, ...]`` (``CandidateInitial | CandidateOperation``) so per-category injectors can emit operations as well as initials. The v1 ``discrete_count_statement`` injector continues to emit only ``CandidateInitial`` — the widening is type-level only in this PR. ADR-0186 — the **sealed injector lane**. When ``sealed=True`` the dispatch first consults :data:`_SEALED_INJECTORS` (the in-development W2-W5 injectors); a sealed injector that emits short-circuits and returns its emission. When ``sealed=False`` (the default, and the value the frozen serving path / ``train_sample`` runner always pass) ``_SEALED_INJECTORS`` is **not** consulted at all, so the ratified serving metric is byte-identical until a reviewed Phase-5 promotion moves an entry into :data:`_INJECTORS`. The seal is injector *eligibility*, not a forked reader: every emission still passes the unchanged admissibility gate downstream. CW-2 (ADR-0169 consumption) — when the per-category injector returns empty AND the matcher published a ``composition_shape`` key in ``parsed_anchors``, the composition registry is consulted: an ``affirms`` entry under :data:`SAFE_COMPOSITION_CATEGORIES` admits the composition; a ``falsifies`` entry continues to refuse; absence continues to refuse. The composition path is read-only over the reviewed math pack — it cannot weaken any existing admission gate. See :mod:`generate.comprehension.composition_registry`. """ if sealed: sealed_injector = _SEALED_INJECTORS.get(match.category) if sealed_injector is not None: emitted = sealed_injector(match, sentence) if emitted: return emitted injector = _INJECTORS.get(match.category) if injector is not None: emitted = injector(match, sentence) if emitted: return emitted return _consult_composition_registry(match, sentence) # --------------------------------------------------------------------------- # CW-2 — composition registry consultation (ADR-0169 consumption) # --------------------------------------------------------------------------- def _consult_composition_registry( match: RecognizerMatch, sentence: str, ) -> tuple[InjectorEmission, ...]: """Composition-registry consultation fallback for ``inject_from_match``. Contract (the contract a matcher extension must honor to enable composition admission via this path): - ``match.parsed_anchors`` carries at least one anchor mapping with a key ``"composition_shape"`` whose value is the surface pattern string used by ratified composition registry entries (e.g. ``"bound(count) × bound(unit_cost)"``). - The same anchor carries a pre-composed payload the registry only gates: either ``"composed_initial"`` (a fully-constructed :class:`CandidateInitial`) or ``"composed_operation"`` (a :class:`CandidateOperation`). This module does NOT perform arithmetic — the matcher / matcher-extension owns the math; the registry owns the admissibility decision. Semantics: - registry empty OR no entry for shape → return ``()`` (refusal-preferring) - entry exists, polarity ``"affirms"`` → admit the pre-composed payload - entry exists, polarity ``"falsifies"`` → return ``()`` (suppressed) This is a registry-driven *gate*, not a registry-driven arithmetic primitive. Per ADR-0169 §"Mutation boundary" the registry never rewrites solver / arithmetic semantics; it ratifies whether a given structural shape may admit. No matcher currently publishes ``composition_shape`` — at land time this path is dormant infrastructure. The case-0019 truth-test will fire only after a matcher extension binds quantity-shape composition anchors (out of scope for this PR; see follow-up brief). """ if not match.parsed_anchors: return () # Lazy import — composition_registry import chain pulls # SAFE_COMPOSITION_CATEGORIES from teaching/, and the load path may # not be needed on every recognizer call. Module-level loader cache # keeps the repeat-call cost at one dict hit after the first load. from generate.comprehension.composition_registry import ( is_affirmed, is_falsified, load_composition_registry, ) registry = load_composition_registry() if registry.is_empty(): return () out: list[InjectorEmission] = [] for anchor in match.parsed_anchors: shape = anchor.get("composition_shape") if isinstance(anchor, Mapping) else None if not isinstance(shape, str): continue if is_falsified(registry, shape): # Falsifying entry — suppress any admission that would have # fired from this anchor; refusal-preferring discipline. return () if not is_affirmed(registry, shape): continue composed_initial = anchor.get("composed_initial") composed_operation = anchor.get("composed_operation") if isinstance(composed_initial, CandidateInitial): out.append(composed_initial) elif isinstance(composed_operation, CandidateOperation): out.append(composed_operation) else: # The registry affirms the shape but no pre-composed payload # is attached — under-admit. The matcher owns producing the # payload; we never invent arithmetic here. return () return tuple(out) # --------------------------------------------------------------------------- # Per-category injectors # --------------------------------------------------------------------------- def inject_discrete_count_statement( match: RecognizerMatch, sentence: str, ) -> tuple[InjectorEmission, ...]: """Build CandidateInitial OR CandidateOperation from ``discrete_count`` parsed anchors, dispatched on the matcher's ``anchor_kind``. Per ADR-0170 W2 — the matcher records ``anchor_kind`` as either ``"possession"`` (verbs ``has/have/had``) or ``"acquisition"`` (verbs in ``_ACQUISITION_VERBS``). - ``possession`` → ``CandidateInitial`` (existing behavior; the sentence asserts an initial state) - ``acquisition`` → ``CandidateOperation(kind='add')`` (new in W2; the sentence asserts an add-operation, preserving ADR-0131.G.1's branch-disagreement discipline — the regex parser's ADD_VERBS path emits the same kind of operation for single-word units, so the injector path complements it on multi-word units without conflicting) v1 narrowness: at most one anchor per match; absent or unconstructable anchors return ``()``. """ if not match.parsed_anchors: return () out: list[InjectorEmission] = [] for anchor in match.parsed_anchors: anchor_kind = anchor.get("anchor_kind", "possession") if anchor_kind == "possession": cand: InjectorEmission | None = _build_initial_from_discrete_count( anchor, sentence ) elif anchor_kind == "acquisition": cand = _build_operation_from_discrete_count_acquisition( anchor, sentence ) else: # Unknown anchor_kind — under-admit. Future widenings (e.g. # "depletion" verbs as CandidateOperation(subtract)) extend # this branch. return () if cand is None: # Under-admit on any failure to construct. Partial # admission would mean the downstream Cartesian product # enumerates a graph missing state. return () out.append(cand) return tuple(out) # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- def _build_initial_from_discrete_count( anchor: Mapping[str, object], sentence: str, ) -> CandidateInitial | None: """Construct one CandidateInitial from a discrete_count anchor. Refuses (returns ``None``) when any field cannot be coerced or when the constructed value would violate ``CandidateInitial`` / ``InitialPossession`` invariants. The resulting CandidateInitial is structurally verified upstream by ``_initial_admissible``. Anchor schema: { "kind": "discrete_count", "subject_role": , "count_token": , # '20' or 'two' "count_kind": <"integer"|"word">, "counted_noun": , # 'paperclips' / 'Pokemon cards' } """ subject_role = anchor.get("subject_role") count_token = anchor.get("count_token") count_kind = anchor.get("count_kind") counted_noun = anchor.get("counted_noun") if ( not isinstance(subject_role, str) or not subject_role or not isinstance(count_token, str) or not count_token or not isinstance(count_kind, str) or not isinstance(counted_noun, str) or not counted_noun ): return None # Resolve the count token to a numeric value. v1 supports integer # and single-word cardinals; hyphenated compounds defer to a follow-up # PR because their resolution requires the language pack's # parse_compound_cardinal helper which is not on this hot path. value = _resolve_count_value(count_token, count_kind) if value is None: return None # A surface like "Jerry has 3 times as many apples", "3 times more # apples", or "3 times the apples" is not an initial possession of # "3 times"; it is an incomplete comparative-multiplicative clause. # Letting this through as an initial consumes the scalar token and # defeats the ADR-0191 completeness guard. Refuse here until a real # compare_multiplicative operation can be emitted. if counted_noun.lower() == "times" and _count_token_followed_by_times( sentence, count_token ): return None # CandidateInitial requires an anchor verb token recognized in its # post-init whitelist (has/have/had/owns/owned/holds/held/contains/ # contained — matched by the recognizer's narrowness rule). We pick # the literal verb token from the sentence so the round-trip ground # check inside _initial_admissible succeeds. Falls back to 'has' when # the verb cannot be located in the surface; that fallback only fires # when the recognizer's match diverges from the sentence and is the # under-admit path. verb_in_sentence = _locate_possession_verb(sentence) if verb_in_sentence is None: return None try: quantity = Quantity(value=value, unit=counted_noun) initial = InitialPossession(entity=subject_role, quantity=quantity) except MathGraphError: return None try: return CandidateInitial( initial=initial, source_span=sentence, matched_anchor=verb_in_sentence, matched_value_token=count_token, matched_unit_token=counted_noun, matched_entity_token=subject_role, ) except ValueError: return None def _build_operation_from_discrete_count_acquisition( anchor: Mapping[str, object], sentence: str, ) -> CandidateOperation | None: """Construct one CandidateOperation(kind='add') from a discrete_count anchor whose ``anchor_kind == "acquisition"``. Per ADR-0170 W2 — acquisition verbs (``collected``, ``received``, ``bought``, ``got``) are routed to operations, not initials, in accordance with ADR-0131.G.1's branch-disagreement discipline. The solver's defaults-from-zero rule resolves single-statement acquisitions correctly (``0 + N = N``). Refuses (returns ``None``) when any field cannot be coerced, when the literal verb token cannot be located in the surface, or when the constructed ``CandidateOperation`` would violate its post-init invariants. The result is admissibility-checked upstream by ``roundtrip_admissible``. Anchor schema (same as possession, with ``anchor_kind`` discriminator): { "kind": "discrete_count", "anchor_kind": "acquisition", "subject_role": , "count_token": , "count_kind": <"integer"|"word">, "counted_noun": , "verb_token": , # e.g. "collected" } """ subject_role = anchor.get("subject_role") count_token = anchor.get("count_token") count_kind = anchor.get("count_kind") counted_noun = anchor.get("counted_noun") verb_token = anchor.get("verb_token") if ( not isinstance(subject_role, str) or not subject_role or not isinstance(count_token, str) or not count_token or not isinstance(count_kind, str) or not isinstance(counted_noun, str) or not counted_noun or not isinstance(verb_token, str) or not verb_token ): return None value = _resolve_count_value(count_token, count_kind) if value is None: return None # Locate the literal verb surface in the sentence so the # round-trip ground check in ``roundtrip_admissible`` succeeds. # The matcher already confirmed ``verb_token`` is in # ``_ACQUISITION_VERBS`` (which is itself a subset of # ``ADD_VERBS``), so the downstream CandidateOperation post-init # whitelist accepts the matched_verb token. located_verb = _locate_token(sentence, verb_token) if located_verb is None: return None try: operand = Quantity(value=value, unit=counted_noun) op = Operation( actor=subject_role, kind="add", operand=operand, ) except MathGraphError: return None try: return CandidateOperation( op=op, source_span=sentence, matched_verb=located_verb, matched_value_token=count_token, matched_unit_token=counted_noun, matched_actor_token=subject_role, ) except ValueError: return None def _locate_token(sentence: str, target_lc: str) -> str | None: """Return the literal-surface form of ``target_lc`` (lowercased) in ``sentence`` whitespace-tokenized, or ``None`` if absent. Used by the acquisition-verb path to extract the matched verb surface for ``CandidateOperation.matched_verb``. Falls back to ``None`` only when the matcher's recorded ``verb_token`` somehow diverges from the sentence surface — the under-admit path. """ for raw in sentence.split(): tok = raw.strip(".,;:!?\"'()[]{}").lower() if tok == target_lc: return tok return None def _count_token_followed_by_times(sentence: str, count_token: str) -> bool: """True when the count surface is immediately followed by ``times``. The discrete-count recognizer can otherwise misread comparative multiplier surfaces as an initial possession of `` times``. This check intentionally sits at the injector boundary: it only suppresses the malformed initial candidate and does not create any new admitting path. """ target = count_token.lower() tokens = [ raw.strip(".,;:!?\"'()[]{}").lower() for raw in sentence.split() ] for i, tok in enumerate(tokens[:-1]): if tok == target and tokens[i + 1] == "times": return True return False def _resolve_count_value(count_token: str, count_kind: str) -> int | None: """Map ``count_token`` to a numeric value. Integer tokens parse with ``int``. Word-form tokens look up ``WORD_NUMBERS`` from the language pack; unknown words refuse. Hyphenated compounds (``twenty-five``) defer to D.2.x — v1 returns ``None`` for them. """ if count_kind == "integer": try: return int(count_token) except ValueError: return None if count_kind == "word": # Local import to keep module import-time cheap and to avoid a # circular import via the math_candidate_parser surface. from generate.math_roundtrip import WORD_NUMBERS token_lc = count_token.lower() if token_lc in WORD_NUMBERS: return int(WORD_NUMBERS[token_lc]) # Hyphenated compound: defer to D.2.x. return None return None def _locate_possession_verb(sentence: str) -> str | None: """Return the first possession-anchor verb (lowercased) found in ``sentence`` whitespace-tokenized, or ``None`` when absent. The verb is the surface token that ``CandidateInitial.__post_init__`` validates against its registered anchor whitelist. Returning the LITERAL surface keeps the round-trip ground check in ``_initial_admissible`` honest. """ possession_verbs = ("has", "have", "had") for raw in sentence.split(): tok = raw.strip(".,;:!?\"'()[]{}").lower() if tok in possession_verbs: return tok return None # --------------------------------------------------------------------------- # Dispatch table — keep deterministic and explicit. # Adding a category here is the SINGLE place a new D.2.x category # registers its injector. No global state, no side effects. # --------------------------------------------------------------------------- _WAVE_A_INJECTABLE_ANCHOR_KINDS: frozenset[str] = frozenset({ "multiplicative_aggregate_each_weighing", }) def inject_multiplicative_aggregation( match: RecognizerMatch, sentence: str, ) -> tuple[InjectorEmission, ...]: """WAVE-A — inject the pre-composed CandidateInitial for the specific value-extracted multiplicative_aggregate shapes. Narrow by anchor ``kind`` to avoid intercepting ME-3 / ME-4 additive/subtractive anchors that share the same matcher entry point but require the composition_registry consult path. Only anchors whose ``kind`` is in :data:`_WAVE_A_INJECTABLE_ANCHOR_KINDS` emit here; everything else returns () and falls through to ``_consult_composition_registry``. """ if not match.parsed_anchors: return () out: list[InjectorEmission] = [] for anchor in match.parsed_anchors: if not isinstance(anchor, Mapping): continue kind = anchor.get("kind") if kind not in _WAVE_A_INJECTABLE_ANCHOR_KINDS: continue composed = anchor.get("composed_initial") if isinstance(composed, CandidateInitial): out.append(composed) return tuple(out) # --------------------------------------------------------------------------- # Inc 2 — rate_with_currency → apply_rate (Workstream A) # --------------------------------------------------------------------------- _CURRENCY_SYMBOL_TO_UNIT: dict[str, str] = { "$": "dollars", # Other symbols (pounds, euros, yen) deferred in Inc 2. # Full support requires symmetric _unit_grounds entries + ratified observed sets + tests. } def _parse_amount_token(token: str, amount_kind: str) -> float | None: """Parse the amount surface token. Supports integer and decimal. Slash fractions (e.g. "3/4") are deferred in v1 for rate_with_currency (return None → injector refuses). The Rate constructor will still refuse <= 0. """ if "/" in token: return None # unsupported in this increment per brief try: if amount_kind == "decimal" or "." in token: val = float(token) else: val = float(int(token)) except (ValueError, TypeError): return None return val if val > 0 else None def _locate_rate_verb(sentence: str) -> str | None: """Return the literal rate-anchor token found in the sentence surface. We accept the tokens that are (or will be) in RATE_ANCHORS for apply_rate. The literal form is required so CandidateOperation post-init + roundtrip_admissible grounding checks pass. """ rate_verbs = ("per", "each", "every", "a", "an", "one") for raw in sentence.split(): tok = raw.strip(".,;:!?\"'()[]{}").lower() if tok in rate_verbs: return tok # preserve the surface case? but anchors are lower; use lower for consistency with other injectors return None def inject_rate_with_currency( match: RecognizerMatch, sentence: str, ) -> tuple[InjectorEmission, ...]: """Narrow, refusal-preferring injector for ShapeCategory.RATE_WITH_CURRENCY. When the matcher has produced one or more "currency_per_unit_rate" anchors, attempt to emit a CandidateOperation(kind="apply_rate", operand=Rate(...)) **only** when every slot is source-grounded and the resulting object will pass downstream admissibility. Actor binding (v1): only a ProperName extractable from the same sentence (via the existing ratified extract_proper_noun_subject) or a safe prior-subject path already exercised by the caller. No pronoun guessing ("he", "she", "they"), no "nearest entity". Amount: integer or decimal only. Slash fractions refuse in v1. Zero/negative/NaN refuse (Rate post-init + explicit guard). Multi-anchor sentence: refuse (ambiguity). Unknown symbol or per_unit: the matcher already filtered these (narrowness from the ratified spec); we still double-check. On any failure to construct a fully admissible primitive we return () so the candidate-graph will emit the explicit "recognizer matched but produced no injection" refusal (the current wrong=0 doctrine). matched_verb is the literal surface token ("per", "an", ...) so that KIND_TO_VERBS["apply_rate"] (RATE_ANCHORS) and the CandidateOperation roundtrip filter accept it. """ if not match.parsed_anchors: return () out: list[InjectorEmission] = [] for anchor in match.parsed_anchors: if not isinstance(anchor, dict): return () if anchor.get("kind") != "currency_per_unit_rate": continue symbol = anchor.get("currency_symbol") amount_token = anchor.get("amount") amount_kind = anchor.get("amount_kind") per_unit = anchor.get("per_unit") if not isinstance(symbol, str) or symbol not in _CURRENCY_SYMBOL_TO_UNIT: return () if not isinstance(amount_token, str) or not isinstance(amount_kind, str): return () if not isinstance(per_unit, str) or not per_unit: return () value = _parse_amount_token(amount_token, amount_kind) if value is None or value <= 0: return () numerator_unit = _CURRENCY_SYMBOL_TO_UNIT[symbol] # Actor — narrow v1 actor = extract_proper_noun_subject(sentence) if not actor: return () # For currency_per_unit_rate, the rate_anchor_token from the matcher # (localized to the rate span in _CURRENCY_AMOUNT_RE) is mandatory. # No whole-sentence fallback is allowed, because _locate_rate_verb # can still pick an unrelated earlier "a". rate_anchor_token = anchor.get("rate_anchor_token") if not rate_anchor_token or rate_anchor_token not in ( "per", "each", "every", "a", "an", "one", ): # Missing or invalid connector for this rate surface (e.g. absent # token). "one" (from "for one cup") is now supported (Inc 3). # Refuse on anything else. return () verb_token = rate_anchor_token try: rate = Rate( value=value, numerator_unit=numerator_unit, denominator_unit=per_unit, ) op = Operation( actor=actor, kind="apply_rate", operand=rate, ) except MathGraphError: return () try: cand = CandidateOperation( op=op, source_span=sentence, matched_verb=verb_token, matched_value_token=amount_token, matched_unit_token=numerator_unit, # per CandidateOperation docstring for Rate matched_actor_token=actor, ) except ValueError: return () out.append(cand) if len(out) > 1: # Multiple rate anchors in one sentence — ambiguity. Refuse. return () 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,) # --------------------------------------------------------------------------- # Gate A2a — unit_partition → unit_partition (Workstream A) # --------------------------------------------------------------------------- def inject_unit_partition( match: RecognizerMatch, sentence: str, ) -> tuple[InjectorEmission, ...]: """Narrow injector for ShapeCategory.UNIT_PARTITION. Emits ``CandidateOperation(kind="unit_partition")`` when the matcher published a fully grounded partition anchor and roundtrip admissibility holds. Pronoun subjects are emitted with the surface pronoun; the candidate-graph lookback path resolves them to a discourse antecedent. """ 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") != "unit_partition": return () actor_token = anchor.get("actor_token") chunk_size_token = anchor.get("chunk_size_token") chunk_unit_token = anchor.get("chunk_unit_token") counted_noun_token = anchor.get("counted_noun_token") partition_verb_token = anchor.get("partition_verb_token") if not all( isinstance(v, str) and v for v in ( actor_token, chunk_size_token, chunk_unit_token, counted_noun_token, partition_verb_token, ) ): return () if not chunk_size_token.isdigit(): return () chunk_size = int(chunk_size_token) if chunk_size <= 0: return () requires_pronoun = bool(anchor.get("requires_pronoun_resolution")) if not requires_pronoun: actor = extract_proper_noun_subject(sentence) if not actor or actor != actor_token: return () bound_actor = actor_token else: bound_actor = actor_token cand = _build_unit_partition( actor_raw=bound_actor, chunk_size=float(chunk_size), chunk_unit_raw=chunk_unit_token, result_unit_raw=counted_noun_token, matched_verb=partition_verb_token, matched_value_token=chunk_size_token, source=sentence, ) 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 # injector that consumes value-extracted anchors. Specs without # ``extract_values=True`` continue to return empty parsed_anchors # (detection-only) so the existing wrong=0 path is byte-identical. ShapeCategory.MULTIPLICATIVE_AGGREGATION: inject_multiplicative_aggregation, # type: ignore[dict-item] # Inc 2 (Workstream A) — rate_with_currency now emits # CandidateOperation(kind="apply_rate", operand=Rate(...)) when # all slots are source-grounded. The solver already implements # _apply_rate and refuses when the actor lacks denom-unit state. # This closes the "recognizer matched but produced no injection" # 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] # Gate A2a (Workstream A) — unit_partition emits # CandidateOperation(kind="unit_partition") for fixed-size measure # chunking with explicit chunk-size unit and result_unit contract. ShapeCategory.UNIT_PARTITION: inject_unit_partition, # 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 # current wrong=0 doctrine; the old skip-only drop is historical. # # Deferred (separate ratifications): # ShapeCategory.TEMPORAL_AGGREGATION, CURRENCY_AMOUNT (pure amount), # etc. } # ADR-0186 — the sealed injector lane (resume ADR-0170 W3-W5 under the # ADR-0175 serving seal). Note: W2 (DCS-S1 acquisition verbs) is NOT sealed — # it shipped directly to serving ``_INJECTORS`` in PR #377, *before* this lane # existed (ADR-0186 = PR #487), and holds wrong=0 on train_sample (4/0/46). The # lane hosts the *future* sealed capabilities (W3-W5) only. # Entries here are consulted **only** when # ``inject_from_match(..., sealed=True)`` — i.e. by the sealed eval runner, # never by the frozen serving path or the ``train_sample`` runner (both pass # ``sealed=False``). This keeps the ratified serving metric byte-identical # until a reviewed Phase-5 promotion moves an entry into ``_INJECTORS``. # # It is intentionally empty at land time: this PR ships the seal *mechanism* # (the dispatch + the byte-identical guarantee), validated by # tests/test_adr_0186_sealed_injector_lane.py. The first sealed *capability* # (per ADR-0186 §5.3, the CandidateRate schema unblocking the matcher-complete # rate_with_currency / temporal_aggregation categories) is its own follow-up. _SEALED_INJECTORS: Mapping[ShapeCategory, "type"] = {} __all__ = [ "InjectorEmission", "inject_from_match", "inject_discrete_count_statement", "inject_rate_with_currency", "inject_comparative_multiplicative", "inject_unit_partition", ]