Harden no-reference n-times comparative guard

This commit is contained in:
Shay 2026-06-03 15:22:42 -07:00
parent 3e29559532
commit e1bcdf6286
5 changed files with 420 additions and 0 deletions

View file

@ -0,0 +1,47 @@
# HANDOFF gpt55 2026-06-03
Branch: `codex/ntimes-completeness-guard`
Final commit SHA: reported in the thread final after commit creation.
## File Status
| file | path | status | purpose | ready-to-merge |
|---|---|---|---|---|
| recognizer anchor injector | `generate/recognizer_anchor_inject.py` | FINAL | Refusal-only guard preventing no-reference `<N> times ...` comparative multipliers from being injected as `CandidateInitial(value=N, unit="times")`. | Y |
| completeness guard tests | `tests/test_candidate_graph_completeness_guard.py` | FINAL | Pins the §9 hard negative matrix, with solve/refusal controls. | Y |
| question-layer gap survey | `docs/analysis/question-layer-gap-survey.md` | FINAL | Canonical audited 44-refusal partition and backlog interpretation. | Y |
| solver operation coverage | `docs/analysis/solver-operation-coverage.md` | FINAL | Canonical read-only audit of existing solver op coverage and representation gaps. | Y |
| composition capability scope | `docs/analysis/composition-capability-scope.md` | SUPERSEDED | Superseded by the execution lane canonical copy; intentionally not committed here. | N |
| handoff | `HANDOFF-gpt55-2026-06-03.md` | FINAL | Merge/readiness manifest for this thread. | Y |
## Validation
Completed on branch `codex/ntimes-completeness-guard`:
- `uv run python -m pytest tests/test_candidate_graph_completeness_guard.py -q`
- `21 passed`
- `uv run python -m pytest tests/test_adr_0131_G2_comparatives.py tests/test_adr_0131_G2a_comparative_verb_widening.py -q`
- `30 passed`
- `uv run python -m pytest tests/test_aggregate_total_question_forms.py tests/test_discrete_count_open_noun_class.py tests/test_adr_0163_d2_discrete_count_injection.py -q`
- `59 passed`
- Train-sample probe through `generate.math_candidate_graph.parse_and_solve`
- `6 correct / 44 refused / 0 wrong`
- `refused_nonzero_count = 0`
- admitted IDs: `0003`, `0014`, `0018`, `0021`, `0024`, `0042`
## New Since Last Message
- Broadened the §9 guard from phrase-specific `times as many|more` matching to the structural captured shape: cardinal immediately followed by `times` inside the discrete-count injector.
- Added no-reference hard negatives for `3 times the number of apples` and `3 times the apples`.
- Added explicit safe-refusal controls for no-reference `twice`, no-reference `double`, and case 605.
- Removed the local superseded `composition-capability-scope.md` from this branch's merge set.
- Added this handoff manifest.
## Not Done / WIP
- No 5b emission/representation slice was implemented.
- No <=20-case validation sub-corpus was authored.
- No solver operation kinds or binding-graph node types were added.
- No serving/eval/claims-ledger files were changed.
- The superseded composition scope is not part of this commit; use the execution lane canonical copy instead.

View file

@ -0,0 +1,133 @@
<!-- FILE: docs/analysis/question-layer-gap-survey.md -->
# Question-Layer Gap Survey
Status: Proposed analysis draft. No serving behavior is changed.
Source of truth for the metric is `docs/claims_ledger.md` and
`evals/gsm8k_math/train_sample/v1/report.json`: the current real
`train_sample` result is 6 correct / 44 refused / 0 wrong. This survey assigns
each of the 44 refused case ids to exactly one current failure group. It does
not claim that any group would pass if widened; it names the layer that refuses
today and estimates whether the missing capability is single-step or
composition-bound.
The requested Task A file, `docs/analysis/comprehension-primitive-inventory.md`,
was not present in this worktree. I used the embedded older handoff only as
orientation and grounded the grouping below in the report plus the live parser /
recognizer code.
## Code Map
Current refusal topology:
- `generate/math_candidate_graph.py` first extracts statement choices, then
consults the ratified recognizer registry when a numeric statement has no
parser candidate. If recognition succeeds but injection yields no typed
solver primitive, it now refuses explicitly instead of dropping the statement.
- `generate/recognizer_anchor_inject.py` is still a category dispatch surface.
Only `discrete_count_statement` and a narrow `multiplicative_aggregation`
entry can emit today; other categories return empty and therefore become
explicit refusals.
- `generate/math_candidate_parser.py` admits a closed question grammar:
total-across `How many ... do they have ...`, existential aggregate
`How many ... are there ...`, entity possession, activity `did`, and three
ADR-0163.D.4 patterns. If these emit no `CandidateUnknown`, the graph refuses
before branch enumeration.
- ADR-0174 explicitly deprecates the per-category injector dispatch table as
the long-term runtime admission path. Injector widening should therefore be
treated as stopgap or hypothesis-emitter work, not the primary strategic
direction.
## Assignment Table
Tractability scoring is case-level and arity-aware:
- High: one closed primitive or one local parser frame is plausibly missing.
- Medium: one local frame/schema is clear, but downstream composition is still
likely needed.
- Low: the current refusal is an early stop in a 2-4 capability derivation.
| Group | Count | Case ids | Current failing layer | Tractability | Representative report reason excerpts | Interpretation |
|---|---:|---|---|---|---|---|
| DCS high-arity composition wall | 18 | 0002, 0015, 0016, 0020, 0029, 0031, 0032, 0033, 0034, 0036, 0037, 0038, 0039, 0040, 0041, 0044, 0047, 0049 | Matcher -> injector -> composition | Low | `candidate_graph: recognizer matched but produced no injection for statement: 'She splits it up into 25-foot sections.' (category=discrete_count_statement)`; `candidate_graph: recognizer matched but produced no injection for statement: 'Malcolm is trying to find the fastest walk to school and is currently comparing two routes.' (category=discrete_count_statement)` | The recognizer often fires on a count-like token, but the actual derivation needs division, rest-state, route comparison, percent/rate, chained comparisons, target residuals, or per-entity attributes. Widening the discrete-count injector alone is metric-inert and risks incomplete readings. |
| Missing inverse/residual/comparative question frames | 5 | 0007, 0008, 0009, 0025, 0035 | Question parser / admissibility | Low | `candidate_graph: no admissible candidate for question: 'How many more boxes do they need if Francine has a total of 85 crayons?'`; `candidate_graph: no admissible candidate for question: 'How many more apples would Martha need to give away to be left with only 4 of them?'` | These are not just new surface phrasings for `Unknown(entity, unit)`. They ask for a missing operand, inverse relation, target residual, or conditional total. The question layer must bind the requested slot to a derivation, not merely extract a unit. |
| Rate, currency-rate, and tariff statements | 4 | 0001, 0011, 0017, 0022 | Recognizer -> injector/schema | Medium | `candidate_graph: recognizer matched but produced no injection for statement: 'Tina makes $18.00 an hour.' (category=rate_with_currency)`; `candidate_graph: recognizer matched but produced no injection for statement: 'Hes charging $50.00 per day or $500.00 for 14 days.' (category=temporal_aggregation)` | The local schema gap is visible: `rate_with_currency` and `temporal_aggregation` need typed rate/tariff hypotheses. Case-level admission still needs overtime, profit, historical+today aggregation, or piecewise tariff composition. |
| Non-quantitative relation category used as an early stop | 4 | 0012, 0023, 0027, 0046 | Matcher -> injector | Low | `candidate_graph: recognizer matched but produced no injection for statement: 'He put all of them in his aquarium but his fish ate half of them.' (category=descriptive_setup_no_quantity)`; `candidate_graph: recognizer matched but produced no injection for statement: 'Half of the students are girls, the other half are boys.' (category=descriptive_setup_no_quantity)` | The category name is accurate for the injector: it cannot emit a concrete primitive from the matched surface. The cases need fraction-of-prior, combined-total binding, partition, and percentage-of-subgroup reasoning. |
| Multiplicative aggregate beyond the narrow emitted shapes | 3 | 0006, 0013, 0045 | Recognizer -> injector / composition registry | Medium | `candidate_graph: recognizer matched but produced no injection for statement: 'Mandy started reading books with only 8 pages when she was 6 years old.' (category=multiplicative_aggregation)`; `candidate_graph: recognizer matched but produced no injection for statement: 'Each survey has 10 questions.' (category=multiplicative_aggregation)` | There is already a narrow product injector, but these rows need time/age chains, month segmentation, doubled rates, or survey-count composition. Single product emission may help local state, but full cases still depend on multi-step composition. |
| Financial currency amount / percent mutation statements | 3 | 0019, 0028, 0043 | Recognizer -> injector / mutation schema | Low | `candidate_graph: recognizer matched but produced no injection for statement: 'After the first appointment, John paid $100 for pet insurance that covers 80% of the subsequent visits.' (category=currency_amount)`; `candidate_graph: recognizer matched but produced no injection for statement: 'Her mother gave her an additional $4, and her father twice as much as her mother.' (category=currency_amount)` | The surface contains currency, but the required reading is coverage after first event, percent daily operating cost, revenue target, or comparative gift amount. A `CandidateInitial` currency emission would be an incomplete graph. |
| Fractional relational statements with no parser candidate | 3 | 0004, 0005, 0010 | Statement parser / admissibility | Low | `candidate_graph: no admissible candidate for statement: 'Half of the kids are going to soccer camp, and 1/4 of the kids going to soccer camp are going to soccer camp in the morning.'`; `candidate_graph: no admissible candidate for statement: 'Marion has 1/4 more than what Yun currently has, plus 7.'` | The parser has fraction literals and comparative operations, but these surfaces are relational fractions over prior or unknown quantities. They require held equations or derivation nodes, not a flat possession/operation candidate. |
| Duration and recurrence statement frames | 3 | 0030, 0048, 0050 | Statement parser / temporal composition | Medium | `candidate_graph: no admissible candidate for statement: 'It is a 2-hour drive each way.'`; `candidate_graph: no admissible candidate for statement: 'Mark does a gig every other day for 2 weeks.'` | These expose bounded duration-multiplier or temporal-frequency frames. Each frame is local and deterministic, but the cases still need composition across trip stages, weekly deltas to target, or per-event song duration totals. |
| Relational conjoined-subject each initial | 1 | 0026 | Statement parser / entity binding | Medium | `candidate_graph: no admissible candidate for statement: 'Aaron and his brother Carson each saved up $40 to go to dinner.'` | The parser has an `each` extractor for two named subjects, but this sentence uses a possessive relational subject (`his brother Carson`) and a purpose tail. The local parse is probably narrower than the concept; the full case remains multi-step because bill fraction and shared scoop count follow. |
Total assigned: 18 + 5 + 4 + 4 + 3 + 3 + 3 + 3 + 1 = 44.
## Backlog Interpretation
The audited partition is the stable artifact. A single `count x tractability`
sort is misleading here because count ranges from 1 to 18 while tractability is
coarse; it would place the known composition wall at the top as if it were an
incremental injector work order. In this survey, count is impact evidence, not
the sort key for near-term changes.
### Composition-Bound Work
These groups should feed the ADR-0174 held-hypothesis / derivation-composer
scope, not category-specific injector widening. They need multi-clause state,
referent binding, ratio/fraction relations, target-slot questions, or
event-scope composition before any answer can be safe.
| Group | Count | How to use the count |
|---|---:|---|
| DCS high-arity composition wall | 18 | Main evidence that discrete-count recognition is surfacing a composition wall, not an injector backlog. |
| Missing inverse/residual/comparative question frames | 5 | Question-layer evidence that unknowns must bind to derivation slots, not just noun units. |
| Rate, currency-rate, and tariff statements | 4 | Rate/tariff hypotheses are useful only if downstream overtime, profit, history, or piecewise composition can refuse partial readings. |
| Non-quantitative relation category used as an early stop | 4 | Evidence for relation/partition composition and for avoiding hard-stop loss of later structure. |
| Multiplicative aggregate beyond the narrow emitted shapes | 3 | Product-like anchors need day/month, age-chain, or survey-count composition before admission. |
| Financial currency amount / percent mutation statements | 3 | Currency is not enough; these need percent coverage, operating-cost, or spend/residual mutation. |
| Fractional relational statements with no parser candidate | 3 | Needs relational fraction/equation hypotheses, not flat possession candidates. |
| Duration and recurrence statement frames | 3 | Duration-multiplier and recurrence frames are local signals whose answers require temporal composition. |
### Bounded Near-Term Fixes
These are smaller, lower-blast-radius probes that may be useful as executable
follow-ups or regression tests. They should still preserve refusal-first
admission and should not be represented as the main path to the metric.
| Candidate fix | Case ids | Why bounded |
|---|---|---|
| Relational conjoined-subject `each` binding | 0026 | One row exposes a local entity-binding gap around `Aaron and his brother Carson each ...`; useful as a narrow parser/binding probe even though the full problem remains multi-step. |
| Descriptive/no-quantity early-stop handling | 0012, 0023, 0027, 0046 | The bounded mechanism is to avoid losing later structure when a descriptive relation cannot emit state; the cases behind it still require composition, so this is not a direct answer path. |
| Single-slot question-frame probes | subset of 0007, 0008, 0035 | A few question surfaces can become narrow probes for residual or divisor target-slot binding. They must be held as unknown slots and refused unless the derivation is complete. |
## Layer Notes
The high-count `discrete_count_statement` bucket should not be read as "add more
discrete-count injectors." The report is saying the matcher saw something
count-shaped before the engine had a safe composed reading. In the live code,
recognized-but-uninjected statements refuse explicitly because dropping them
would permit incomplete graphs. ADR-0174 points in the same direction:
injectors should become hypothesis emitters inside a held-hypothesis reader,
where branch disagreement, constraint propagation, and completeness can reject
partial readings.
The five question refusals are genuinely question-layer refusals in the narrow
code sense: `extract_question_candidates` emits no `CandidateUnknown`. But all
five ask for an inverse or residual target. A wider regex that only extracts
the noun would not identify the unknown slot and would still be unsafe under
`wrong = 0`.
## Open Questions for the Claude Lane
- Confirm whether `docs/analysis/comprehension-primitive-inventory.md` was meant
to be landed in this worktree or only supplied out-of-band; this draft could
be amended to cite its exact primitive table once present.
- Run an instrumented read of the 44 refused cases to distinguish "injector
returned empty because no parsed anchors existed" from "anchors existed but
constraint propagation eliminated them."
- For the five question refusals, collect the intended `Unknown` slot shape
without using gold answers: missing operand, residual-to-target, inverse
divisor, or aggregate total.
- Decide whether the next executable lane should prototype rate/tariff
hypotheses or question-target slots first; both are safer as held hypotheses
than as direct category admissions.

View file

@ -0,0 +1,148 @@
<!-- FILE: docs/analysis/solver-operation-coverage.md -->
# Solver Operation Coverage Audit
Status: Proposed analysis draft. No serving behavior is changed. This is a
read-only structural audit to de-risk ADR-0174 Phase 5b / Phase-4-style
composition work. Verdicts below are code-reading conclusions only; they must be
verified in the Claude lane with executable solver/binding-graph cases before
any promotion claim.
## Scope
Read surfaces:
- `generate/math_problem_graph.py`
- `generate/math_solver.py`
- `generate/binding_graph/{model,adapter,admissibility,allocation,question_target,units}.py`
- ADR-0116, ADR-0117, ADR-0132, ADR-0133, ADR-0134, ADR-0135
- Skimmed ADR-0174 Phase 5b and ADR-0203/0204/0205 to confirm current
composition/proof-DAG framing.
Important correction to the relay: the current solver/graph vocabulary is not
only `{add, subtract, transfer, multiply, divide}`. `MathProblemGraph` and
`math_solver` both include the eight operation kinds:
```text
add, subtract, transfer, multiply, divide,
apply_rate, compare_additive, compare_multiplicative
```
ADR-0174 Phase 5b states the same: the solver is already waiting for these
operations; the gap is reader -> injector -> `Operation` front-end wiring plus
composition.
## Existing Operation Substrate
| Surface | Evidence | Consequence |
|---|---|---|
| Closed operation vocabulary | `generate/math_problem_graph.py` defines `VALID_OPERATION_KINDS` with eight kinds. | New arithmetic verbs are not needed for ordinary multiply/divide/rate/comparison chains. |
| Pack-bound solver dispatch | `generate/math_solver.py` maps all eight kinds to `en_arithmetic_v1` lemmas before solving. | Missing pack lemmas fail loudly; no hidden operation fallback. |
| Stateful solver semantics | `_apply` mutates `(actor, unit)` terminal state for `add`, `subtract`, `transfer`, `multiply`, `divide`; `_apply_rate` produces numerator-unit state from denominator-unit state; comparisons derive an actor state from a reference actor. | The solver is good at forward state trajectories, but weak at keeping multiple same-unit derived intermediates alive under one actor. |
| Unknown shape | `Unknown(entity, unit)` resolves either terminal state for one entity or total-across all entities with that unit. | Target questions that ask for a missing operand, number of iterations, or intermediate state are not represented by `Unknown` alone. |
| Binding-graph equation/data model | `BoundEquation(operation_kind=...)`, `BoundUnknown(question_form=...)`, semantic roles include `rate`, `duration`, `difference`, `ratio`. | The graph can name richer forms than the solver's final `Unknown`, but current adapter still comes from existing `MathProblemGraph` operation chains. |
| Unit admissibility | `check_admissibility` covers additive, multiplicative, divide, `apply_rate`, and comparison kinds. | Dimensional proofs exist for the current eight operation kinds; new node types would need explicit admissibility rules. |
| Question-target binding | `infer_question_form` recognizes `ratio`, `difference`, `rate`, `total`, and `count` from operation kinds touching the unknown. | It can label answer form, but it does not solve inverse targets or select intermediate operation indices yet. |
| Acyclicity | ADR-0203 adds `circular_dependency` refusal to the shared binding-graph constructor. | Any new equation/intermediate-node extension must remain a DAG, not a cyclic algebra system hidden inside the graph. |
## Phase-4 Target Chains
| Target chain | Verdict | Existing operations that can carry it | What is missing / risk |
|---|---|---|---|
| Multi-step rate-sum | Expressible via composition for straight-line rate applications and sums. Piecewise tariffs/conditionals need scoped selection before admission. | `apply_rate` computes `X/Y * Y -> X`; `add`/`subtract` can aggregate generated same-unit totals. Binding admissibility has an `apply_rate` rule requiring one rate dep plus one duration/count dep. | The reader must emit the base duration/count quantities, rate hypotheses, and sum operations without clobbering unrelated same-unit state. Piecewise tariffs such as "$50/day or $500/14 days" need a tariff/choice scope or explicit branch-disagreement gate; that is a binding/composition problem, not a missing arithmetic verb. |
| Ratio chain | Expressible via composition for forward ratio chains. Inverse ratio equations need a target/equation extension. | `compare_multiplicative` supports "actor = factor * reference"; `multiply`/`divide` support scalar transformations; `infer_question_form` maps touching `compare_multiplicative` to `ratio`. | Forward chains like `A`, `B = 2A`, `C = 1/2(A+B)` can be represented if the reader emits the right reference actors and order. Inverse forms such as "ducks are 10 more than 4x chickens; ducks = 150; find total birds" require solving for an unknown reference, not just applying a forward operation. That needs equation/target solving or a new binding node shape, not a new `ratio` operation kind. |
| Accumulate-against-target | Needs new binding-graph target/equation capability; not expressible as a fixed existing operation chain in the current solver. | `add`, `subtract`, `multiply`, `divide`, and `apply_rate` can express the arithmetic once the iteration count or missing operand is known. | The current solver consumes a fully specified graph in source order, then resolves terminal `Unknown(entity, unit)`. It cannot represent "after how many weeks", "how many cups to sell to reach profit", or "how long to make back cost" as an unknown operand/iteration count. This needs a first-class target-slot/equation node or bounded inverse solver with refusal/disagreement, plus proof that no cyclic dependency is introduced. |
| Percent/fraction mutation | Mostly expressible via composition for forward mutations; needs intermediate-symbol/scope support for same-unit derived amounts and event subsets. | Percent/fraction values can be scalar `multiply`/`divide` factors, with `add`/`subtract` for mutation and `compare_multiplicative` for relative quantities. Binding admissibility already covers multiply/divide dimensions. | Some cases are safe forward mutations ("eat 75% of a pan", "lose half"). Others require original and derived same-unit quantities to coexist, e.g. principal plus interest, insured vs uninsured portions, operating cost as percent of startup cost. The current solver overwrites `(actor, unit)` for multiply/divide and `apply_rate`, so these need derived intermediate symbols/event scopes or separate binding nodes. A new `percent` operation kind is not structurally necessary; a new scoped intermediate/equation representation may be. |
## Structural Verdict
Phase 4/5b is mostly **not** blocked by missing arithmetic operation kinds. The
current operation vocabulary already covers the primitive arithmetic field:
addition/subtraction/transfer, scalar multiply/divide, rate application, and
additive/multiplicative comparison.
The real scope risk is representation:
- Can the reader emit a chain of typed operations from scattered clauses while
preserving all grounded quantities?
- Can the graph retain intermediate derived quantities when they share the same
actor/unit as their source?
- Can a question bind to a missing operand, iteration count, or intermediate
state instead of only terminal `Unknown(entity, unit)`?
- Can inverse/equation targets be solved under a disagreement rule and the
ADR-0203 acyclicity invariant?
That makes the likely build a **binding-target / intermediate-symbol /
derivation-composer extension**, not a broad new solver primitive pack.
## Chain-Specific Notes
### Multi-step rate-sum
`apply_rate` is first-class: the solver's `_apply_rate` reads the actor's
denominator-unit state and writes numerator-unit state. The binding-graph
adapter synthesizes a rate symbol with composite unit `<num>_per_<denom>`, and
admissibility checks that the denominator cancels. Therefore simple rate-sum is
expressible as:
```text
duration/count fact -> apply_rate -> produced total
produced totals -> add/subtract -> final total
```
The unsafe part is not the operation. It is branch selection and scope:
overtime, tariffs, and "including today" require deciding which rate applies to
which event subset.
### Ratio chain
`compare_multiplicative` already gives a forward ratio operation. It refuses
when the reference actor has no quantity or multiple ambiguous units, which is
the right wrong=0 boundary. Binding target can label ratio-form questions.
The gap is inverse ratio. Current `Operation` is directional: it mutates the
actor from a known reference. If the reference is the unknown and the actor is
given, the solver has no inverse-equation mode.
### Accumulate-against-target
This is the clearest "needs new graph shape" target. A terminal state solver can
answer "what is the total after N weeks"; it cannot answer "what N reaches total
T" unless N is already present as a quantity and a divide operation has been
materialized by the reader. A safe implementation needs a target slot that names
the missing operand/iteration count and an admissibility/disagreement rule for
the inverse derivation.
### Percent/fraction mutation
Percent/fraction does not need a new arithmetic verb. It needs:
- scalar extraction (`75% -> 0.75`, `1/4 -> 0.25`);
- complement derivation when the text says "covers 80%" but the cost asks for
the uncovered part;
- event/subset scoping ("after the first appointment", "subsequent visits");
- intermediate symbols when original principal/cost and derived interest/cost
must both remain available.
The binding graph is the natural home for these intermediates because it already
has `BoundEquation`, dependency sets, unit proofs, question forms, and
acyclicity checks.
## Open Questions for the Claude Lane
- Does `apply_rate` overwriting `(actor, numerator_unit)` create a concrete
hazard in rate-sum cases where the actor already holds money? If yes, the
build needs derived result symbols before promotion.
- Should inverse target solving be introduced as a new `BoundUnknown` form, a
new `BoundEquation` operation kind, or a separate proof/derivation rule over
existing equations?
- Can forward ratio chains be admitted through `compare_multiplicative` without
widening the parser's reference-actor ambiguity beyond the existing refusal
discipline?
- What is the smallest executable probe set that distinguishes "new operation
kind required" from "same operation, new intermediate symbol required" for
percent/fraction mutations?
- When proof-DAG consumers and math binding graphs share
`SemanticSymbolicBindingGraph`, should proposition-specific operation kinds
remain isolated from math admissibility, or should the admissibility dispatcher
split into math/proof entrypoints before more equation kinds are added?

View file

@ -304,6 +304,17 @@ def _build_initial_from_discrete_count(
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
@ -433,6 +444,26 @@ def _locate_token(sentence: str, target_lc: str) -> str | None:
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.

View file

@ -89,3 +89,64 @@ def test_guard_is_refusal_only_not_answer_changing() -> None:
# Same value, same unit-bearing graph — guard does not mutate solving.
assert res.answer == 438.0
assert res.selected_graph is not None
@pytest.mark.parametrize(
("factor", "comparative"),
[
("2", "as many"),
("3", "as many"),
("5", "as many"),
("two", "as many"),
("three", "as many"),
("five", "as many"),
("3", "more"),
("three", "more"),
("3", "the number of"),
("3", "the"),
],
)
def test_n_times_without_reference_refuses(
factor: str, comparative: str
) -> None:
"""A dropped comparative multiplier must not admit as an initial
possession of ``<N> times``.
This is the Phase-5b hard negative: if future emission work makes
comparative-multiplicative surfaces reach the graph, the gate must
refuse incomplete ``<N> times ...`` clauses instead of consuming
the scalar as an ordinary count.
"""
res = parse_and_solve(
"Tom has 7 apples. "
f"Jerry has {factor} times {comparative} apples. "
"How many apples do they have together?"
)
assert res.answer is None
def test_n_times_as_many_with_reference_still_solves() -> None:
"""The guard only blocks incomplete comparative clauses; a fully
referenced compare_multiplicative graph still solves."""
res = parse_and_solve(
"Tom has 7 apples. Jerry has 3 times as many apples as Tom. "
"How many apples do they have together?"
)
assert res.answer == 28.0
@pytest.mark.parametrize(
"question",
[
"Tom has 7 apples. Jerry has twice as many apples. "
"How many apples do they have together?",
"Tom has 7 apples. Jerry has double the apples. "
"How many apples do they have together?",
"Ivan has 20 dice. Jerry has twice as many dice as Ivan. "
"How many dice do they have altogether?",
],
)
def test_existing_multiplier_refusals_stay_refused(question: str) -> None:
"""Existing safe multiplier refusals must not become admissions."""
res = parse_and_solve(question)
assert res.answer is None