core/generate/recognizer_anchor_inject.py
Shay 65405f1128
feat(derivation): Gate A2a unit partition injection (#809)
* feat(derivation): Gate A2a unit partition injection

Add typed unit_partition primitive with PartitionChunk/result_unit
contract, recognizer-injector bridge, DCS yield guard, and pronoun
lookback support. Closes unit_partition recognized_no_injection on live
train_sample (0002 partition stmt reclassifies); wrong=0 preserved.

* test(gsm8k): harden unit partition confusers

* test(gsm8k): add unit partition pronoun safety regressions

* chore(gsm8k): fix unit partition exemplar file ending

* chore(derivation): type unit partition solution step operand
2026-06-17 18:14:24 -07:00

915 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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": <str>,
"count_token": <str>, # '20' or 'two'
"count_kind": <"integer"|"word">,
"counted_noun": <str>, # '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": <str>,
"count_token": <str>,
"count_kind": <"integer"|"word">,
"counted_noun": <str>,
"verb_token": <str>, # 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 ``<N> 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",
]