From d17fec68012aeb7a4b2a0eea5cc8e84d9711ff30 Mon Sep 17 00:00:00 2001 From: Shay Date: Thu, 28 May 2026 09:51:14 -0700 Subject: [PATCH] fix(math-graph): refuse contradictory initial possessions (wrong=0 hazard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- generate/math_problem_graph.py | 17 ++++++++ tests/test_math_problem_graph.py | 72 ++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/generate/math_problem_graph.py b/generate/math_problem_graph.py index ce7edb93..e58be6d5 100644 --- a/generate/math_problem_graph.py +++ b/generate/math_problem_graph.py @@ -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( diff --git a/tests/test_math_problem_graph.py b/tests/test_math_problem_graph.py index d31ed8a4..c2f7277c 100644 --- a/tests/test_math_problem_graph.py +++ b/tests/test_math_problem_graph.py @@ -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()