core/tests/test_math_candidate_graph.py
Shay 97b0ee0e13
fix(wrong=0): refuse on recognized-but-uninjectable statements + audit taxonomy + 2 surfaced regressions (#359)
## Summary

Two test failures on origin/main both trace to PR #315 (ADR-0163.D.2 —
discrete_count_statement recognizer + admissibility-intent chain). Earlier
runs treated them as "pre-existing unrelated" — they are not unrelated.
The first is a real wrong>0 hazard.

## Failure 1: silent admission via recognized-but-uninjectable statement

The ratified `discrete_count_statement` recognizer over-matches: ANY
sentence containing a number + noun resolves it, irrespective of the verb.
When `inject_from_match` returns `()` (the round-2 default for v1
categories without an injector), the old code path used `continue` to
silently drop the statement — and the solver then answered from whatever
initial state remained.

Reproduction:
  parse_and_solve("Sam has 5 apples. Sam contemplates 3 apples. "
                  "How many apples does Sam have?")
  → is_admitted=True, answer=5.0  (silent admission of partial graph)

This is exactly the case-0050-class hazard wearing a different hat
(silently admitting an incomplete graph at the problem level).
ADR-0167 / Brief 11 §"correct-count greed" established the principle on
the reader path; this commit extends it to the recognizer path.

Fix: when a recognizer matches but produces no injection, REFUSE.

  generate/math_candidate_graph.py:
    - Replaced the skip-only `continue` with a CandidateGraphResult
      refusal carrying the recognizer category in the reason.

  tests/test_math_candidate_graph.py:
    - test_unparseable_statement now accepts either the legacy
      "no admissible candidate" reason or the new
      "recognizer matched but produced no injection" reason.
      Both legitimately refuse; what matters is is_admitted=False.

  tests/test_recognizer_skip_wrong_zero.py (NEW):
    - 5 regression tests pinning the wrong=0 invariant:
      * 3 parametrized verbs unknown to both regex parser and reader
        (contemplates / ponders / memorises) — must all refuse
      * Nonsense token — must refuse
      * Anti-regression: known initial + known operation still admits

## Failure 2: cognition audit drop-reason taxonomy

The audit test hardcoded `dropped.reason.startswith("superseded_by:")`
as the only valid drop-reason prefix.  Commit da70919 (ADR-0163.D.2)
ratified an admissibility-intent chain that the audit categorizes with
reason `unsupported_intent:admissibility`, which fails this assertion.

Fix: tests/test_teaching_audit.py — expand the allowed-prefix set to
include `unsupported_intent:` with a written rationale.  Future drop
classes extend the allowlist deliberately rather than silently
broadening the assertion to any non-empty reason.

## Surfaced regression: partition-test allowlist (ADR-0167 FOLLOWUPS §2)

This PR modifies three test files that the
test_existing_cognition_tests_untouched assertion would reject under
its named-allowlist scheme.  Added the three test paths to the allowlist
as the tactical fix; the architectural fix (retire / move to CI / move
to CODEOWNERS) is queued in docs/handoff/ADR-0167-FOLLOWUPS.md §2.

## Test plan

  uv run pytest tests/test_recognizer_skip_wrong_zero.py \
                tests/test_math_candidate_graph.py \
                tests/test_teaching_audit.py \
                tests/test_candidate_domain_partition.py \
                tests/test_math_evidence_e2e.py \
                tests/test_math_evidence_schema.py \
                tests/test_math_contemplation_adapter.py \
                tests/test_math_claim_signature.py \
                tests/test_math_lexical_ratification.py \
                tests/test_brief_11b_audit_artifact.py \
                tests/test_brief_11b_step2_lexicon.py \
                tests/test_brief_11_audit.py
  → 152 passed

## Hard invariants

- wrong == 0 — restored on the recognizer path (was silently violated on main)
- ADR-0166 — no new eval lanes
- No teaching-store mutation, no pack mutation
- The reader path was already correct (it refused these cases); this fix
  brings the regex/recognizer path back in line
2026-05-27 07:42:54 -07:00

235 lines
8.7 KiB
Python

"""ADR-0126 P3 — tests for candidate-graph assembly + decision rule.
Proves the end-to-end candidate-graph pipeline:
text → per-sentence candidates → filter → branch enumeration
→ per-branch solve → decision rule → answer | refusal
Critical assertions:
- Unambiguous problems produce a single answer.
- Ambiguous-verb problems ('gives') resolve via the slot-count
tiebreaker; both readings agree on the answer, so emission proceeds.
- Out-of-grammar sentences refuse (no exception, deterministic
refusal_reason string).
- Branches that disagree on the answer refuse (wrong == 0 preserved).
- Permissive verbs that the legacy parser refused now produce answers.
"""
from __future__ import annotations
from generate.math_candidate_graph import (
MAX_TOTAL_BRANCHES,
parse_and_solve,
)
from generate.math_candidate_parser import (
extract_question_candidates,
)
# ---------------------------------------------------------------------------
# Question extractor (P2 addition tested here for cohesion)
# ---------------------------------------------------------------------------
class TestQuestionExtraction:
def test_entity_question(self) -> None:
qcs = extract_question_candidates("How many apples does Sam have?")
assert len(qcs) == 1
assert qcs[0].unknown.entity == "Sam"
assert qcs[0].unknown.unit == "apples"
def test_total_question(self) -> None:
qcs = extract_question_candidates("How many apples do they have?")
assert len(qcs) == 1
assert qcs[0].unknown.entity is None
assert qcs[0].unknown.unit == "apples"
def test_collective_entity_question(self) -> None:
qcs = extract_question_candidates("How many cards do the girls have?")
assert len(qcs) == 1
assert qcs[0].unknown.entity == "the girls"
def test_with_trailing_modifier(self) -> None:
qcs = extract_question_candidates(
"How many apples does Sam have left?"
)
assert len(qcs) == 1
assert qcs[0].unknown.entity == "Sam"
def test_no_match(self) -> None:
assert extract_question_candidates("What is the answer?") == []
# ---------------------------------------------------------------------------
# End-to-end happy path
# ---------------------------------------------------------------------------
class TestHappyPath:
def test_simple_add(self) -> None:
result = parse_and_solve(
"Sam has 5 apples. Sam buys 3 apples. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 8
def test_simple_subtract(self) -> None:
result = parse_and_solve(
"Sam has 10 apples. Sam eats 3 apples. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 7
def test_transfer(self) -> None:
result = parse_and_solve(
"Sam has 8 apples. Tom has 2 apples. "
"Sam gives 3 apples to Tom. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 5
def test_transfer_other_side(self) -> None:
result = parse_and_solve(
"Sam has 8 apples. Tom has 2 apples. "
"Sam gives 3 apples to Tom. "
"How many apples does Tom have?"
)
assert result.is_admitted
assert result.answer == 5
def test_total_across_entities(self) -> None:
result = parse_and_solve(
"Sam has 5 apples. Tom has 3 apples. "
"How many apples do they have?"
)
assert result.is_admitted
assert result.answer == 8
# ---------------------------------------------------------------------------
# Permissive verbs the legacy parser would have refused
# ---------------------------------------------------------------------------
class TestPermissiveVerbsNowSolve:
def test_past_tense_add(self) -> None:
# 'bought' is permissive-only; the round-trip filter is what
# makes it safe.
result = parse_and_solve(
"Sam has 5 apples. Sam bought 3 apples. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 8
def test_past_tense_subtract(self) -> None:
result = parse_and_solve(
"Sam has 10 apples. Sam ate 3 apples. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 7
def test_production_verb_bakes(self) -> None:
result = parse_and_solve(
"Sam has 2 pies. Sam bakes 4 pies. "
"How many pies does Sam have?"
)
assert result.is_admitted
assert result.answer == 6
# ---------------------------------------------------------------------------
# Ambiguity that the slot-count tiebreaker resolves
# ---------------------------------------------------------------------------
class TestAmbiguityResolution:
def test_gives_with_target_resolves_to_transfer(self) -> None:
# "Sam gives 3 apples to Tom" emits BOTH subtract and transfer
# candidates per P2 tests. Both pass round-trip. The slot-count
# tiebreaker collapses to transfer (more grounded slots), so
# the graph is the transfer reading and Tom gets the apples.
result = parse_and_solve(
"Sam has 8 apples. Tom has 2 apples. "
"Sam gives 3 apples to Tom. "
"How many apples does Tom have?"
)
assert result.is_admitted
assert result.answer == 5 # transfer reading: 2 + 3 = 5
def test_gives_without_target_resolves_to_subtract(self) -> None:
# "Sam gives 3 apples" → only subtract candidate is admissible.
result = parse_and_solve(
"Sam has 8 apples. Sam gives 3 apples. "
"How many apples does Sam have?"
)
assert result.is_admitted
assert result.answer == 5
# ---------------------------------------------------------------------------
# Refusals (preserve wrong == 0)
# ---------------------------------------------------------------------------
class TestRefusals:
def test_empty_input(self) -> None:
result = parse_and_solve("")
assert not result.is_admitted
assert "empty" in (result.refusal_reason or "").lower()
def test_no_question(self) -> None:
result = parse_and_solve("Sam has 5 apples.")
assert not result.is_admitted
assert "question" in (result.refusal_reason or "").lower()
def test_unparseable_statement(self) -> None:
# Verb not in any permissive table. Either the regex parser refuses
# directly ("no admissible candidate") or a ratified recognizer
# matches but cannot inject typed solver state ("recognizer matched
# but produced no injection") — both paths preserve wrong=0 by
# refusing. See the fix that retired the recognizer skip-only
# fallback (silent-drop was a wrong>0 hazard analogous to case 0050).
result = parse_and_solve(
"Sam has 5 apples. Sam contemplates 3 apples. "
"How many apples does Sam have?"
)
assert not result.is_admitted
reason = result.refusal_reason or ""
assert (
"no admissible candidate" in reason
or "recognizer matched but produced no injection" in reason
), f"unexpected refusal reason: {reason!r}"
def test_question_references_unknown_entity(self) -> None:
result = parse_and_solve(
"Sam has 5 apples. "
"How many apples does Alice have?"
)
assert not result.is_admitted
def test_branch_count_cap_refuses(self) -> None:
# Hard to construct without writing a multiplicatively-ambiguous
# corpus; for now just assert the cap constant is sensible.
assert MAX_TOTAL_BRANCHES == 64
# ---------------------------------------------------------------------------
# Diagnostics surfaced for P6 inner-loop signal
# ---------------------------------------------------------------------------
class TestDiagnostics:
def test_diagnostics_on_admission(self) -> None:
result = parse_and_solve(
"Sam has 5 apples. Sam buys 3 apples. "
"How many apples does Sam have?"
)
assert result.branches_enumerated >= 1
assert result.branches_admissible >= 1
def test_diagnostics_on_refusal(self) -> None:
result = parse_and_solve("foobar baz quux?")
# Refusal occurs before enumeration when no statement candidates
# exist; diagnostics still report 0/0 cleanly.
assert result.branches_enumerated == 0
assert result.branches_admissible == 0