fix(math-graph): refuse contradictory initial possessions (wrong=0 hazard)

MathProblemGraph.__post_init__ now raises MathGraphError when two
InitialPossession entries share the same (entity, unit) key but
declare different quantity values.

Pre-fix behavior surfaced by 2026-05-28 ADR-0174 Phase 3 post-merge
diagnostic: math_solver.solve() line 207 used last-write-wins dict
assignment when consolidating initial state. Two contradictory
inputs would silently overwrite without trace:

  'Sam has 5 marbles. Sam has 3 marbles. How many marbles does Sam have?'
   → returned 3.0 (wrong=0 violation: definite answer from
     contradictory input)

Post-fix: same input refuses with 'no branch produced a solvable
graph' — refusal-preferring discipline as wrong=0 doctrine requires.

Identical duplicates (same value) are admitted as redundant (no
contradiction). Different units for same actor admitted. Different
actors for same unit admitted. Single-value cases (the dominant
real-world pattern) unchanged.

This is an extraction-layer hazard discovered while investigating
Phase 3b scope: Phase 3b compound-clause held hypotheses would
emit multiple CandidateInitial entries per sentence, exercising
exactly this consolidation path. Fixing the silent overwrite NOW
ensures Phase 3b admission doesn't silently produce wrong answers.

Acceptance:
- 4 new tests in TestContradictoryInitialPossessionsRefuse
- 165/165 test_math_problem_graph tests pass (was 161/161)
- Smoke 67/67, packs 141/141 unchanged
- train_sample 3/47/0 unchanged (no real case exercised the
  overwrite — but the hazard was latent)

References: CLAUDE.md §Lookback Review Discipline (the doctrine
that surfaced this), CLAUDE.md §Non-Negotiable Field Invariant
(make illegal states difficult to represent).
This commit is contained in:
Shay 2026-05-28 09:51:14 -07:00
parent 796b446b49
commit d17fec6801
2 changed files with 89 additions and 0 deletions

View file

@ -366,11 +366,28 @@ class MathProblemGraph:
)
seen.add(e)
entity_set = set(self.entities)
# ADR-0174 Phase 3 diagnostic — refuse contradictory initial
# possessions for the same (entity, unit). Surfaced 2026-05-28
# by post-merge diagnostic: prior behavior silently overwrote
# earlier with later in math_solver.solve()'s state dict, so
# 'Sam has 5 marbles. Sam has 3 marbles.' returned 3.0 — a
# wrong=0 violation (definite answer from contradictory input).
# Refuse at construction; admit duplicates only when the value
# matches (redundant but not contradictory).
seen_initial: dict[tuple[str, str], int | float] = {}
for p in self.initial_state:
if p.entity not in entity_set:
raise MathGraphError(
f"initial_state references unknown entity {p.entity!r}"
)
key = (p.entity, p.quantity.unit)
if key in seen_initial and seen_initial[key] != p.quantity.value:
raise MathGraphError(
f"initial_state contains contradictory possessions for "
f"({p.entity!r}, {p.quantity.unit!r}): "
f"{seen_initial[key]} vs {p.quantity.value}"
)
seen_initial[key] = p.quantity.value
for op in self.operations:
if op.actor not in entity_set:
raise MathGraphError(

View file

@ -185,6 +185,78 @@ class TestGroundTruthGraphsAgreeWithExpectedAnswers:
)
class TestContradictoryInitialPossessionsRefuse:
"""ADR-0174 Phase 3 post-merge diagnostic — surfaced 2026-05-28.
Before this fix, two contradictory initial possessions for the
same (entity, unit) silently overwrote each other in
math_solver.solve()'s state dict (line 207: last-write-wins
semantics). 'Sam has 5 marbles. Sam has 3 marbles.' would return
3.0 a wrong=0 violation (definite answer from genuinely
contradictory input).
Fix: MathProblemGraph.__post_init__ now raises MathGraphError on
contradictory (entity, unit) initial possessions. Identical
duplicates are admitted (redundant but not contradictory).
"""
def _ip(self, entity: str, value: int, unit: str) -> InitialPossession:
return InitialPossession(
entity=entity, quantity=Quantity(value=value, unit=unit)
)
def test_contradictory_initial_possessions_refused(self) -> None:
with pytest.raises(MathGraphError, match="contradictory possessions"):
MathProblemGraph(
entities=("Sam",),
initial_state=(
self._ip("Sam", 5, "marbles"),
self._ip("Sam", 3, "marbles"),
),
operations=(),
unknown=Unknown(entity="Sam", unit="marbles"),
)
def test_identical_duplicate_initial_admitted(self) -> None:
# Redundant but not contradictory — must admit.
g = MathProblemGraph(
entities=("Sam",),
initial_state=(
self._ip("Sam", 5, "marbles"),
self._ip("Sam", 5, "marbles"),
),
operations=(),
unknown=Unknown(entity="Sam", unit="marbles"),
)
assert len(g.initial_state) == 2
def test_different_units_same_actor_admitted(self) -> None:
# Sam has apples AND Sam has oranges — no contradiction.
g = MathProblemGraph(
entities=("Sam",),
initial_state=(
self._ip("Sam", 5, "apples"),
self._ip("Sam", 3, "oranges"),
),
operations=(),
unknown=Unknown(entity="Sam", unit="apples"),
)
assert len(g.initial_state) == 2
def test_different_actors_same_unit_admitted(self) -> None:
# Sam has marbles AND Tom has marbles — different keys.
g = MathProblemGraph(
entities=("Sam", "Tom"),
initial_state=(
self._ip("Sam", 5, "marbles"),
self._ip("Tom", 3, "marbles"),
),
operations=(),
unknown=Unknown(entity="Sam", unit="marbles"),
)
assert len(g.initial_state) == 2
class TestCaseIdsAreSequential:
def test_ids_are_gpd_zero_padded_sequential(self) -> None:
cases = _load_cases()