feat(derivation): Workstream A inc 3 — narrow rate connector follow-up (#799)

* docs(analysis): ratify Inc3 rate followup + v2 roadmap update (docs-first, pre any rate logic)

* feat(derivation): Workstream A inc 3 — support 'one' connector in rate_with_currency injector (post docs ratification)

* chore(derivation): clean Inc3 diff hygiene

* chore(derivation): remove Inc3 formatting churn
This commit is contained in:
Shay 2026-06-17 10:08:59 -07:00 committed by GitHub
parent e36591ed46
commit 8a981e66c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 344 additions and 67 deletions

View file

@ -0,0 +1,56 @@
# CORE Problem-Solving Capability Roadmap v2 — 2026-06-17
**Status:** Living document (docs-only update)
**Date:** 2026-06-17
**Context:** Post PR #797 (rate injection) + #798; preparing Inc3 rate follow-up before Gate A1 comparative injection.
## Overview
This v2 roadmap refines the GSM8K Workstream A path and the broader capability sequencing after the rate injection delivered by PR #797.
As of 2026-06-17, PR #797 is merged and #798 is merged.
## GSM8K Workstream A
- Inc 1: reader/recognizer baseline lift (discrete etc.)
- Inc 2: frontier measurement + stale doctrine repair + narrow rate injection (PR #797)
- **Inc 3 (current seam):** Complete the post-#797 rate-follow-up evidence loop: run frontier report from current main, identify the remaining rate-family blocker, and ship at most one narrow Inc3 increment before comparative injection.
### Recommended Inc3 target (narrow)
Make the rate frontier evidence actionable by resolving the next narrow blocker exposed by #797.
Scope candidates (in preference order for this increment):
1. Denominator-state support for rate application (if failures surface as "actor has rate but no denom-unit quantity reachable").
2. Safe connector expansion only if frontier proves "for one cup" is a dominant blocker.
3. Measurement-only frontier report refresh if artifacts stale.
Inc3 selected #2 (connector for "for one cup"/"one" token) because live debug on the pinned report + cases showed the exact remaining rate injector deferral from Inc2 (matcher left rate_anchor_token=None for "one"; spec unresolved_notes explicitly called it out for the Alexa surface). This was the minimal change that reclassifies the rate_with_currency no-injection bucket (making evidence actionable) while preserving all guards. Denom production is larger future work (see ratification for rationale and out-of-scope).
"Complete and harden PR #797" is revised as: Complete the post-#797 rate-follow-up evidence loop: run frontier report from current main, identify the remaining rate-family blocker, and ship at most one narrow Inc3 increment before comparative injection.
As of 2026-06-17, PR #797 is merged and #798 is merged.
Explicitly: do not broaden to full rate language family, comparative injection, or non-rate categories in this increment.
## Gate A1 / Comparative Injection
Deferred until after the post-#797 rate follow-up loop is closed with Inc3 measurement.
## Success Criteria for This Phase
- Frontier report run on current main (train-sample proxy).
- One narrow ratified Inc3 change.
- Wrong=0 preserved on train_sample, practice, and relevant confusers.
- Rate-family "recognized_no_injection" bucket reduced or its refusal mode made actionable (e.g. surfaces the true next blocker like denom reachability).
- No rebaseline of sealed lanes or SHA movement without separate ratification.
- Documentation (this roadmap + Inc3 ratification) committed as docs-first.
## Out of Scope (for Inc3)
- Full comparative (Gate A1) implementation.
- Broad recognizer anchor work or other shape categories.
- Changes to serving sealed paths.
- Any mutation of identity, policy, or algebra invariants.
Follow the ratified Inc3 doc for the exact bounded change.

View file

@ -0,0 +1,85 @@
# GSM8K Workstream A Increment 3 — rate followup (post-#797) ratification
**Date:** 2026-06-17
**Workstream:** A
**Increment:** 3 — post-#797 rate frontier evidence loop closure (narrow)
**Status:** Ratified for implementation (BEFORE code changes)
**Scope lock:** Bounded to making the rate "recognized_no_injection" bucket produce actionable evidence by resolving the explicit remaining connector blocker left open in #797. One smallest change only.
## 1. Which exact refusal bucket is being attacked?
From frontier report run on the (stale but authoritative post-#797) committed proxy `evals/gsm8k_math/train_sample/v1/report.json`:
- Overall: 6 correct / 44 refused / 0 wrong (passed=false)
- recognized_no_injection: 32
- recognized_no_injection_by_category (rate relevant): rate_with_currency: 3
The three rate_with_currency cases still emitting the "recognizer matched but produced no injection" (category=rate_with_currency) are exactly the ones referencing the surfaces left partially unhandled after Inc2:
- 'Tina makes $18.00 an hour.' (category=rate_with_currency)
- 'Alexa has a lemonade stand where she sells lemonade for $2 for one cup.' (category=rate_with_currency)
- 'Erica lives near a lake where most locals sell fish as their main source of income, earning $20 per kg of fish.' (category=rate_with_currency)
Post-#797, the matcher fires for all three and "an"/"per" surfaces now reach the injector and emit a CandidateOperation (verified by live debug on current main). The "for one cup" explicitly sets `rate_anchor_token: None` (see matcher comment and spec unresolved_notes: "Non-canonical 'for one X' framing").
The injector returns () for the "one" case (and any elimination downstream for the others surfaces as the same top-level refusal reason because the statement-level inject did not contribute an admitted choice).
This is the narrow remaining rate-family blocker visible in the rate bucket of the frontier analyzer.
## 2. Which cases are expected to lift?
- On the train_sample proxy: expected 0 net lift in correct count (the Alexa "for one cup" case uses inverse semantics — target cups from known revenue, not forward apply_rate on a held cup count; Tina/Erica denom qty statements use verbs/shapes that do not yet emit the required Initial for "hour"/"kg" unit). The change makes injection succeed for the "one" framing; the case will surface a downstream refusal reason ("no branch produced a solvable graph", "no admissible...", or "requires ... state") instead of the "no injection" one.
- The rate_with_currency slice of recognized_no_injection is expected to drop from 3 (at least the connector case will no longer refuse at the injector boundary).
- No change to non-rate buckets. Wrong remains 0.
The primary deliverable is **actionable evidence**: after the change the frontier report will show the rate category either empty or reclassified to the true next blocker (denom state reachability), closing the post-#797 measurement loop without claiming a correct-count jump.
## 3. Which confusers must still refuse?
All existing confusers from the Inc2 ratification and test suite:
- No denom state for the actor (e.g. isolated rate sentence).
- Wrong actor (rate stated for A, quantity held by B).
- Multiple rates in one sentence.
- Time-unit without conversion (days vs hours).
- Any surface that would produce ambiguous or ungrounded Rate / actor / verb.
The change adds "one" only in the exact "for <one> <unit>" rate framing already present in the ratified rate_with_currency exemplars; no broadening of actor binding, no pronoun support, no new verbs outside the rate anchor list.
## 4. What is the wrong=0 guard?
- All paths still go through the existing five-layer net (matcher narrowness, source grounding in anchors, injector returns () on any construction failure, roundtrip_admissible + constraint propagation elimination, candidate-graph multi-branch disagreement + completeness).
- New surfaces exercise the same `CandidateOperation` + `roundtrip_admissible` + `KIND_TO_VERBS["apply_rate"]` checks.
- "one" treated as a surface alias only for the already-ratified "for one X" exemplar in the rate proposal; added to RATE_ANCHORS and injector allow-list with no other semantic change.
- No sealed path touched (train_sample runner + serving use sealed=False).
- Pre/post change: run the frontier script + `parse_and_solve` on the three rate surfaces + full proxy cases; assert wrong==0 on all.
- The `tests/test_math_candidate_graph_rate_injection.py` and `test_gsm8k_frontier_report.py` continue to pass (the existing test already tolerates non-"no injection" refusals for the Alexa stmt).
- If after change any train_sample case flips from refused to wrong, revert.
## 5. Does this touch serving, sealed lanes, report.json, or solver semantics?
- No changes to sealed injector lane (`_SEALED_INJECTORS` remains empty for this).
- No write of updated report.json in this increment (proxy remains at 6/44/0 unless a later runner run is separately committed; the ratification does not require rebaseline).
- No solver changes (`_apply_rate` unchanged; still requires denom state).
- No graph construction or cartesian changes.
- Touches only: the rate anchor token allow-list (matcher + roundtrip set + injector guard) + comments. This is the minimal patch to retire the explicit "narrow for Inc 2" deferral.
- The frontier report script, ratification, and roadmap update are docs/evidence only.
## 6. What is explicitly out of scope?
- Denominator-state production (seeding Initials for "hour", "kg", "cup" from "works N hours", "trawled 80 kg", etc.). That is future work once the connector surface is closed and the frontier reclassifies the bucket.
- Any change that would allow apply_rate without prior denom state.
- "for one cup" solving (would require inverse/division op or goal-residual style for price-per).
- Expansion to other temporal_aggregation, currency_amount, or non-rate categories.
- Comparative injection / Gate A1.
- Any movement of sealed SHAs, practice lane, or CLAIMS.
- Broad verb or subject binding relaxations.
- Re-running the train_sample runner and committing a new report.json as part of this PR (measurement-only refresh is out; the script run on the committed report is the evidence).
## Implementation Notes (for the PR)
- Smallest diff: 3 locations (RATE_ANCHORS, matcher "one" case, injector allow-list) + doc updates.
- Update comments that say "narrow for Inc 2" or "deferred".
- Run `uv run python scripts/gsm8k_frontier_report.py evals/gsm8k_math/train_sample/v1/report.json` before/after for the artifact.
- `core test --suite gsm8k` or equivalent lane (pytest on the rate graph test + frontier test) + full `core test --suite full -q` before merge when practical.
- Preserve `passed=false` on the proxy.
This Inc3 closes the rate follow-up loop narrowly so that Ladder A has a clean evidence boundary before any comparative work.

View file

@ -150,7 +150,7 @@ COMPARE_MULTIPLICATIVE_ANCHORS: Final[frozenset[str]] = frozenset({
# succeeds. "a"/"an" were documented in the comment but missing from the
# set; added here (Inc 2) with corresponding injector tests.
RATE_ANCHORS: Final[frozenset[str]] = frozenset({
"per", "each", "every", "a", "an",
"per", "each", "every", "a", "an", "one",
})

View file

@ -591,7 +591,7 @@ def _locate_rate_verb(sentence: str) -> str | None:
apply_rate. The literal form is required so CandidateOperation
post-init + roundtrip_admissible grounding checks pass.
"""
rate_verbs = ("per", "each", "every", "a", "an")
rate_verbs = ("per", "each", "every", "a", "an", "one")
for raw in sentence.split():
tok = raw.strip(".,;:!?\"'()[]{}").lower()
if tok in rate_verbs:
@ -670,11 +670,12 @@ def inject_rate_with_currency(
# 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"):
# Missing or invalid connector for this rate surface (e.g. "one"
# from "for one cup", or absent token). Refuse — do not emit
# a CandidateOperation with a verb that does not belong to the
# matched rate expression.
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

View file

@ -340,11 +340,9 @@ def _match_rate_with_currency(
elif m.group(8):
q = m.group(8).lower()
per_unit = m.group(9)
if q in ("each", "every", "a"):
if q in ("each", "every", "a", "one"):
connector = q
else:
# "one" in "for one X" is not a direct RATE_ANCHORS token;
# leave None so injector will refuse (narrow for Inc 2).
connector = None
if not per_unit:

View file

@ -29,8 +29,12 @@ from tests._phase_d_fixture import build_synthetic_registry
_REPO_ROOT = Path(__file__).resolve().parent.parent
_GSM8K_CASES = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
_GSM8K_REPORT = _REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json"
_GSM8K_CASES = (
_REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
)
_GSM8K_REPORT = (
_REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "report.json"
)
@pytest.fixture(scope="module")
@ -46,7 +50,9 @@ def with_synthetic_registry(
"""Patch ``math_candidate_graph._load_ratified_registry_or_empty`` to
return the synthetic registry for the duration of the test."""
monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry,
cg,
"_load_ratified_registry_or_empty",
lambda: synthetic_registry,
)
return synthetic_registry
@ -89,31 +95,38 @@ def test_empty_registry_preserves_existing_refusal_reason() -> None:
def test_recognized_rate_statement_refuses_explicitly_post_wrong_zero_fix(
with_synthetic_registry: tuple[RatifiedRecognizer, ...],
) -> None:
"""With the rate_with_currency recognizer loaded, "Tina makes $18.00
an hour" is recognized but the v1 injector returns () (the
SentenceChoice union does not yet model rates see ADR follow-up).
"""With the rate_with_currency recognizer loaded (synthetic), rate surfaces
that now have v1 injector support ("an" from Inc2, "one" from Inc3) are
injected (CandidateOperation). The early "recognizer matched but produced
no injection" refusal no longer triggers for these supported surfaces.
Pre-#359 behavior: silently drop the recognized-but-uninjectable
statement and admit a partial graph from the rest a wrong>0
hazard analogous to case 0050.
The full sentence provides no denom-unit Initial for the actor, so the
candidate graph produces no admissible branch. Refusal is at question or
"no admissible candidate" level (downstream of injection).
Post-#359 (this test's contract): refuse explicitly with reason
"recognizer matched but produced no injection" naming the
statement and category. This pinned behavior is the wrong=0
safety net for the recognizer path.
Pre-#359: silent drop (wrong>0 hazard).
Post-#359 + Inc2/Inc3: explicit diagnostic for unsupported; supported
rates proceed to state/admissibility checks (wrong=0 preserved).
This test pins the wiring for the synthetic registry path; the
explicit no-injection guard remains for categories without injector.
"""
result = cg.parse_and_solve(
"Tina makes $18.00 an hour. How much does Tina earn after 8 hours?"
)
assert result.refusal_reason is not None
# For this supported rate surface the statement is injected; refusal
# is now "no admissible candidate for question" (or similar) because
# no full admissible graph (missing denom state). The no-injection
# reason is the guard only for injector-return-() cases.
assert (
"recognizer matched but produced no injection" in result.refusal_reason
), f"expected explicit recognizer-refusal, got: {result.refusal_reason!r}"
# The statement IS named in the reason — that's the diagnostic shape
# the post-#359 refusal carries. Update the prior assertion which
# forbade naming, since that assertion encoded the silent-drop
# premise that #359 retired.
assert "Tina makes $18.00 an hour" in result.refusal_reason
"no admissible candidate" in result.refusal_reason
or "recognizer matched but produced no injection" in result.refusal_reason
), f"expected downstream or explicit refusal, got: {result.refusal_reason!r}"
# Keep diagnostic: the problematic rate statement context is involved.
assert (
"Tina makes $18.00 an hour" in result.refusal_reason
or "question" in result.refusal_reason
)
def test_recognized_descriptive_statement_refuses_explicitly_post_wrong_zero_fix(
@ -152,12 +165,13 @@ def _run_gsm8k_train_sample_with_patch(
"""Re-run the gsm8k train_sample under the patched registry and
return the {correct, wrong, refused} counts."""
monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: registry,
cg,
"_load_ratified_registry_or_empty",
lambda: registry,
)
import importlib
runner_mod = importlib.import_module(
"evals.gsm8k_math.train_sample.v1.runner"
)
runner_mod = importlib.import_module("evals.gsm8k_math.train_sample.v1.runner")
cases = runner_mod._load_cases(runner_mod._CASES_PATH)
report = runner_mod.build_report(cases)
return {
@ -178,7 +192,8 @@ def test_wrong_count_stays_zero_under_synthetic_registry(
baseline_report = json.loads(_GSM8K_REPORT.read_text(encoding="utf-8"))
baseline_counts = baseline_report["counts"]
candidate_counts = _run_gsm8k_train_sample_with_patch(
monkeypatch, synthetic_registry,
monkeypatch,
synthetic_registry,
)
assert candidate_counts["wrong"] == 0, (
f"Phase D wiring regressed wrong=0: {candidate_counts}"
@ -196,9 +211,12 @@ def test_capability_axis_wrong_unchanged_under_synthetic_registry(
guarded by a narrow recognizer; it cannot mis-admit a
well-parsed capability-axis statement."""
monkeypatch.setattr(
cg, "_load_ratified_registry_or_empty", lambda: synthetic_registry,
cg,
"_load_ratified_registry_or_empty",
lambda: synthetic_registry,
)
import importlib
lanes = [
("G1_verb_classes", "evals.math_capability_axes.G1_verb_classes.v1.runner"),
("G2_comparatives", "evals.math_capability_axes.G2_comparatives.v1.runner"),
@ -238,9 +256,15 @@ def test_per_category_admission_counts_on_gsm8k_train_sample(
pin them to specific numbers, so the test stays robust to
Phase B corpus updates that narrow or widen specific axes.
"""
cases = [json.loads(l) for l in _GSM8K_CASES.read_text(encoding="utf-8").splitlines() if l.strip()]
cases = [
json.loads(line)
for line in _GSM8K_CASES.read_text(encoding="utf-8").splitlines()
if line.strip()
]
report = json.loads(_GSM8K_REPORT.read_text(encoding="utf-8"))
refused_ids = {e["case_id"] for e in report["per_case"] if e["verdict"] == "refused"}
refused_ids = {
e["case_id"] for e in report["per_case"] if e["verdict"] == "refused"
}
counts: dict[str, int] = {
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY.value: 0,
@ -262,7 +286,9 @@ def test_per_category_admission_counts_on_gsm8k_train_sample(
assert counts[ShapeCategory.RATE_WITH_CURRENCY.value] >= 1
assert counts[ShapeCategory.TEMPORAL_AGGREGATION.value] >= 1
# Surface the counts to stdout for the PR body.
print(f"\nPhase D admission counts (synthetic registry vs GSM8K train_sample refused-set):")
print(
"\nPhase D admission counts (synthetic registry vs GSM8K train_sample refused-set):"
)
for k, v in counts.items():
print(f" {k}: {v}")

View file

@ -6,12 +6,12 @@ These tests pin:
- rate_with_currency appears as a prominent recognized_no_injection category on the committed train-sample report (the measurement target of Inc 2).
- Fully deterministic output (sorted keys, no timestamps, repeatable across runs).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.gsm8k_frontier_report import (
analyze_report,
@ -54,9 +54,21 @@ def test_classify_and_extract_category_logic():
# We exercise via the public analyze path with a tiny synthetic report
fake = {
"per_case": [
{"case_id": "c1", "verdict": "refused", "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'Tina makes $18.00 an hour.' (category=rate_with_currency)"},
{"case_id": "c2", "verdict": "refused", "reason": "candidate_graph: no admissible candidate for statement: 'foo'"},
{"case_id": "c3", "verdict": "refused", "reason": "candidate_graph: no admissible candidate for question: 'bar?'"},
{
"case_id": "c1",
"verdict": "refused",
"reason": "candidate_graph: recognizer matched but produced no injection for statement: 'Tina makes $18.00 an hour.' (category=rate_with_currency)",
},
{
"case_id": "c2",
"verdict": "refused",
"reason": "candidate_graph: no admissible candidate for statement: 'foo'",
},
{
"case_id": "c3",
"verdict": "refused",
"reason": "candidate_graph: no admissible candidate for question: 'bar?'",
},
{"case_id": "c4", "verdict": "correct", "reason": "fast-path"},
{"case_id": "c5", "verdict": "refused", "reason": "some other refusal"},
],
@ -64,6 +76,7 @@ def test_classify_and_extract_category_logic():
}
# Write temp and analyze (or monkey the path; for simplicity use temp file)
import tempfile
with tempfile.TemporaryDirectory() as td:
rp = Path(td) / "fake_report.json"
rp.write_text(json.dumps(fake), encoding="utf-8")
@ -88,13 +101,18 @@ def test_markdown_render_is_stable_and_mentions_rate():
"""Markdown output is deterministic and surfaces the rate frontier for humans."""
fake = {
"per_case": [
{"case_id": "r1", "verdict": "refused", "reason": "candidate_graph: recognizer matched but produced no injection for statement: 'X' (category=rate_with_currency)"},
{
"case_id": "r1",
"verdict": "refused",
"reason": "candidate_graph: recognizer matched but produced no injection for statement: 'X' (category=rate_with_currency)",
},
{"case_id": "c1", "verdict": "correct", "reason": ""},
],
"sample_count": 2,
"exit_criterion": {"correct_min": 10, "passed": False, "wrong_max": 0},
}
import tempfile
with tempfile.TemporaryDirectory() as td:
rp = Path(td) / "r.json"
rp.write_text(json.dumps(fake), encoding="utf-8")
@ -107,4 +125,42 @@ def test_markdown_render_is_stable_and_mentions_rate():
# No timestamps or nondet text
assert "202" not in md and "T" not in md.split("\n", 5)[-1] # rough
# Re-render identical
assert render_markdown(summary) == md
assert render_markdown(summary) == md
def test_inc3_connector_makes_rate_no_injection_actionable():
"""Inc3 effect: supporting 'one' (and prior 'an'/'per') means rate_with_currency
surfaces no longer contribute to recognized_no_injection bucket when injector
succeeds. Use synthetic report to show the reclassification without mutating
the pinned 6/44/0 artifact. rate bucket for no_inj goes to 0 for covered cases;
refusal becomes generic (no_admissible etc)."""
# Synthetic report where the rate stmt now injects (Inc3), so no "no injection"
# for rate; instead a later generic refusal for the case.
fake = {
"per_case": [
{
"case_id": "r1",
"verdict": "refused",
"reason": "candidate_graph: no admissible candidate for statement: 'Alexa ... for one cup'",
},
{
"case_id": "r2",
"verdict": "refused",
"reason": "candidate_graph: recognizer matched but produced no injection for statement: 'unsupported' (category=temporal_aggregation)",
},
],
"sample_count": 2,
}
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as td:
rp = Path(td) / "post_inc3_fake.json"
rp.write_text(json.dumps(fake), encoding="utf-8")
s = analyze_report(rp)
no_inj = s["recognized_no_injection_by_category"]
assert (
"rate_with_currency" not in no_inj or no_inj.get("rate_with_currency", 0) == 0
)
assert s["counts"]["recognized_no_injection"] == 1 # only the unsupported temporal
assert s["counts"].get("no_admissible_statement", 0) == 1

View file

@ -8,9 +8,9 @@ If the exact "hours" denom state is not yet produced by discrete injection for t
the test records the gap (per brief) and still proves the wiring when a covered denom unit is used,
plus that the solver-level refusal for missing denom still works.
"""
from __future__ import annotations
import pytest
from generate.math_candidate_graph import parse_and_solve
from generate.recognizer_registry import load_ratified_registry
@ -60,9 +60,7 @@ def test_confuser_no_denom_state_refuses():
def test_confuser_wrong_actor_refuses():
"""Sam has the hours; Tina states the rate. Must not apply Sam's rate to Tina or vice-versa."""
text = (
"Sam works 3 hours. "
"Tina makes $18.00 an hour. "
"How many dollars does Tina make?"
"Sam works 3 hours. Tina makes $18.00 an hour. How many dollars does Tina make?"
)
res = _run(text)
assert res.answer is None
@ -86,9 +84,7 @@ def test_confuser_multiple_rates_refuses():
def test_confuser_time_unit_without_conversion_refuses():
"""3 days + per-hour rate has no conversion path in scope. Must refuse."""
text = (
"Tina works 3 days. "
"Tina makes $18.00 an hour. "
"How many dollars does Tina make?"
"Tina works 3 days. Tina makes $18.00 an hour. How many dollars does Tina make?"
)
res = _run(text)
assert res.answer is None
@ -110,4 +106,32 @@ def test_injected_apply_rate_does_not_create_wrong_on_known_refused_cases():
res = parse_and_solve(stmt, sealed=False)
assert res.answer is None
assert res.refusal_reason is not None
assert "no injection" in (res.refusal_reason or "") or "requires" in (res.refusal_reason or "").lower() or "question" in (res.refusal_reason or "").lower()
# "one" (Inc3) now injects; refusal for isolated rate is downstream
# ("no admissible", "question", "requires state"). Loose or keeps
# coverage of both pre/post connector cases while wrong=0.
assert (
"no injection" in (res.refusal_reason or "")
or "requires" in (res.refusal_reason or "").lower()
or "question" in (res.refusal_reason or "").lower()
or "no admissible" in (res.refusal_reason or "").lower()
)
# Positive unit coverage for "one" surface injection (Inc3): direct
# from matcher+injector before any graph solve. Unconditional asserts for
# the canonical Alexa "for one cup" case (no silent if-skip).
from generate.recognizer_match import match as _match
from generate.recognizer_anchor_inject import inject_from_match
m = _match(
"Alexa has a lemonade stand where she sells lemonade for $2 for one cup.",
load_ratified_registry(),
)
assert m is not None
assert m.category.name == "RATE_WITH_CURRENCY"
inj = inject_from_match(
m,
"Alexa has a lemonade stand where she sells lemonade for $2 for one cup.",
sealed=False,
)
assert len(inj) == 1
assert getattr(inj[0], "matched_verb", None) == "one"

View file

@ -10,6 +10,7 @@ Covers the exact acceptance cases from the Workstream A Inc 2 brief:
- zero amount refuses
- matched_*_token values are literal substrings from the source sentence
"""
from __future__ import annotations
import types
@ -32,7 +33,9 @@ def _stub_recognizer(category: ShapeCategory) -> types.SimpleNamespace:
return types.SimpleNamespace(shape_category=category, canonical_pattern={})
def _make_match(anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_CURRENCY) -> RecognizerMatch:
def _make_match(
anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_CURRENCY
) -> RecognizerMatch:
"""Minimal RecognizerMatch for direct injector testing of the rate path."""
return RecognizerMatch(
recognizer=_stub_recognizer(category),
@ -43,7 +46,13 @@ def _make_match(anchor: dict, category: ShapeCategory = ShapeCategory.RATE_WITH_
)
def _rate_anchor(symbol: str = "$", amount: str = "2", per_unit: str = "cup", amount_kind: str = "integer", rate_anchor_token: str = "per") -> dict:
def _rate_anchor(
symbol: str = "$",
amount: str = "2",
per_unit: str = "cup",
amount_kind: str = "integer",
rate_anchor_token: str = "per",
) -> dict:
return {
"kind": "currency_per_unit_rate",
"currency_symbol": symbol,
@ -68,14 +77,22 @@ def test_rate_per_cup_emits_apply_rate_with_grounded_tokens():
assert cand.matched_actor_token == "Tina"
assert cand.matched_value_token == "2"
assert cand.matched_unit_token == "dollars"
assert cand.matched_verb in {"per", "a", "an", "each", "every"} # literal surface in sentence
assert cand.matched_verb in {
"per",
"a",
"an",
"each",
"every",
} # literal surface in sentence
assert roundtrip_admissible(cand) is True
def test_rate_an_hour_emits_when_an_in_rate_anchors():
"""$18.00 an hour is a major proxy case. With 'an' in RATE_ANCHORS the
literal verb token must ground."""
m = _make_match(_rate_anchor("$", "18.00", "hour", "decimal", rate_anchor_token="an"))
m = _make_match(
_rate_anchor("$", "18.00", "hour", "decimal", rate_anchor_token="an")
)
emitted = inject_rate_with_currency(m, "Tina makes $18.00 an hour.")
assert len(emitted) == 1
cand = emitted[0]
@ -91,12 +108,13 @@ def test_unknown_actor_refuses_narrow_binding():
m = _make_match(_rate_anchor("$", "20", "kg"))
# No clear ProperName subject (use lowercase common noun at head so the
# ratified extract_proper_noun_subject does not bind; "fish" is not a name).
emitted = inject_rate_with_currency(m, "fish are sold for $20 per kg at the market.")
emitted = inject_rate_with_currency(
m, "fish are sold for $20 per kg at the market."
)
assert emitted == ()
def test_multiple_rates_in_one_sentence_refuses():
m = _make_match(_rate_anchor("$", "18", "hour", rate_anchor_token="an")) # the anchor list would have >1 in real, but we simulate
# Force two by calling the multi logic path (injector sees >1 after loop)
# Simpler: construct a match with two anchors
a1 = _rate_anchor("$", "18", "hour")
@ -159,6 +177,7 @@ def test_dispatch_table_routes_rate_with_currency():
emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1
from generate.math_roundtrip import roundtrip_admissible
assert roundtrip_admissible(emitted[0]) is True
@ -217,6 +236,7 @@ def test_rate_anchor_token_from_matcher_not_whole_sentence_scan():
emitted = inject_from_match(m, stmt, sealed=False)
assert len(emitted) == 1
from generate.math_roundtrip import roundtrip_admissible
cand = emitted[0]
assert isinstance(cand, CandidateOperation)
assert cand.op.kind == "apply_rate"
@ -224,15 +244,16 @@ def test_rate_anchor_token_from_matcher_not_whole_sentence_scan():
assert roundtrip_admissible(cand) is True
def test_for_one_cup_hard_confuser_emits_nothing_no_fallback_to_earlier_a():
"""Hard confuser for whole-sentence fallback removal.
def test_rate_for_one_cup_emits_apply_rate_with_matched_verb_one():
"""Positive coverage for Inc3 "for one cup" connector support (rate_with_currency).
"Alexa has a lemonade stand where she sells lemonade for $2 for one cup."
The live registry will match it as RATE_WITH_CURRENCY (from exemplars).
But rate_anchor_token will be None (from "one" in "for one"), which is
not in the allowed set. With no fallback to _locate_rate_verb, the
injector MUST return ().
This proves we do not bind the unrelated "a" from "a lemonade stand".
The live registry matches as RATE_WITH_CURRENCY.
rate_anchor_token == "one" (from the "for one X" group) is now allowed.
Injector must emit exactly one CandidateOperation with matched_verb="one",
using the rate surface (not falling back to earlier "a" from "a lemonade stand").
roundtrip_admissible must hold. This makes the rate no-injection bucket
actionable (downstream refusal for missing denom state, not injector ()).
"""
registry = load_ratified_registry()
stmt = "Alexa has a lemonade stand where she sells lemonade for $2 for one cup."
@ -240,4 +261,14 @@ def test_for_one_cup_hard_confuser_emits_nothing_no_fallback_to_earlier_a():
assert m is not None
assert m.category is ShapeCategory.RATE_WITH_CURRENCY
emitted = inject_from_match(m, stmt, sealed=False)
assert emitted == ()
assert len(emitted) == 1
from generate.math_roundtrip import roundtrip_admissible
cand = emitted[0]
assert isinstance(cand, CandidateOperation)
assert cand.op.kind == "apply_rate"
assert cand.matched_verb == "one"
assert cand.matched_actor_token == "Alexa"
assert roundtrip_admissible(cand) is True
# Explicitly no fallback to the distracting earlier "a"
assert "one" in stmt.lower() # the token came from the rate span