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:
parent
796b446b49
commit
d17fec6801
2 changed files with 89 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue