diff --git a/src/main/java/dev/samhb/interleave/minimize/DeltaDebugger.java b/src/main/java/dev/samhb/interleave/minimize/DeltaDebugger.java index f407fa7..47cd924 100644 --- a/src/main/java/dev/samhb/interleave/minimize/DeltaDebugger.java +++ b/src/main/java/dev/samhb/interleave/minimize/DeltaDebugger.java @@ -77,26 +77,24 @@ public Trace minimize(Program program, Trace failingTrace, TraceOutcome expected * Checks whether replaying the given trace still produces the expected outcome. * *

Replays the trace from the initial state using {@link ExecutionDriver} and - * verifies the final configuration matches the expected outcome. For VIOLATION - * outcomes, the invariant is re-checked to ensure the trace genuinely reproduces - * the bug. + * verifies the final configuration matches the expected outcome. Only thread choices are + * replayed; recorded step outcomes and the trace's terminal verdict are ignored. Replay + * stops when the schedule is consumed or all threads terminate. * - *

The VIOLATION guard is not {@code isDeadlockCandidate()}, and must not be "simplified" into - * it. The guard below expands to {@code !allTerminated() && !enabled.isEmpty()} — live threads - * and something still enabled — which is a different predicate from "not a deadlock". Using - * the canonical predicate here would accept deadlocked configurations that violate the invariant, - * and would let the minimiser shrink a trace into one that ends in a state the search would have - * classified differently. The cost of the current form is that a violation occurring in a deadlocked - * state is not recognised as still failing, so such a trace cannot be minimised. That is a real - * limitation, but fixing it changes which traces the minimiser accepts and needs its own - * specification and tests rather than a quiet edit here. Recorded in - * {@code docs/specs/active/13-deferred-debt/03-canonical-configuration-key.md}. + *

For {@code VIOLATION} outcomes with an invariant, the invariant predicate is evaluated + * directly regardless of thread termination or deadlock status, ensuring invariant violations + * occurring in deadlocked terminal configurations are recognized and minimizable. When no invariant + * is supplied, fallback checks require live threads and non-deadlocked configurations. + * + *

An {@link IllegalScheduleException} from replay or invariant evaluation produces + * {@code false}; other runtime exceptions propagate to the caller. * * @param program the program to replay * @param trace the trace to replay * @param expectedOutcome the expected outcome to verify * @param invariant the invariant to check for VIOLATION outcomes; may be null - * @return true if the replayed trace reproduces the expected outcome + * @return true if the final configuration satisfies the selected outcome check; + * false for an illegal schedule or {@code INCOMPLETE} */ private boolean isStillFailing(Program program, Trace trace, TraceOutcome expectedOutcome, Invariant invariant) { try { @@ -108,7 +106,7 @@ private boolean isStillFailing(Program program, Trace trace, TraceOutcome expect case COMPLETED -> config.allTerminated(); case DEADLOCK -> config.isDeadlockCandidate(); case VIOLATION -> invariant != null - ? !config.allTerminated() && !config.isDeadlockCandidate() && !invariant.holds(config.state(), config) + ? !invariant.holds(config.state(), config) : !config.allTerminated() && !config.isDeadlockCandidate(); // INCOMPLETE describes the search, not a schedule: it means the preemption bound // was reached. No replay of a prefix can reproduce that, so reporting true here diff --git a/src/main/java/dev/samhb/interleave/search/CorruptedTraceException.java b/src/main/java/dev/samhb/interleave/search/CorruptedTraceException.java new file mode 100644 index 0000000..b9aac0e --- /dev/null +++ b/src/main/java/dev/samhb/interleave/search/CorruptedTraceException.java @@ -0,0 +1,16 @@ +/** Signals a recorded trace outcome that diverges from actual step execution. */ +package dev.samhb.interleave.search; + +/** + * Signals that replaying a recorded step produced an execution outcome that disagrees + * with the outcome recorded in the trace. + */ +public class CorruptedTraceException extends RuntimeException { + /** + * Creates corrupted trace exception with diagnostic message. + * @param message diagnostic explaining the outcome mismatch + */ + public CorruptedTraceException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/samhb/interleave/search/TraceReplayer.java b/src/main/java/dev/samhb/interleave/search/TraceReplayer.java index d7e6679..8947910 100644 --- a/src/main/java/dev/samhb/interleave/search/TraceReplayer.java +++ b/src/main/java/dev/samhb/interleave/search/TraceReplayer.java @@ -9,12 +9,13 @@ public final class TraceReplayer { public TraceReplayer() {} /** - * Replays thread choices while trusting the step outcomes recorded in the trace. + * Replays thread choices while validating each step outcome against the trace. * *

The program supplies a fresh initial configuration. Each chosen step mutates that - * configuration’s state; the recorded outcome, rather than the returned execution outcome, - * controls counter advancement. This method does not validate trace consistency or verdicts. - * Callers must supply aligned thread/outcome lists and valid thread IDs and counters. + * configuration’s state and must produce the outcome recorded in the trace. + * An empty trace returns the initial configuration. Replay does not reject a chosen step + * solely because it is disabled, or validate the trace's terminal verdict. Exceptions from + * model callbacks propagate to the caller. * *

{@code
      * Configuration end = new TraceReplayer().replay(program, trace);
@@ -22,6 +23,7 @@ public TraceReplayer() {}
      * @param program program whose initial state and steps are replayed
      * @param trace recorded thread choices and outcomes
      * @return configuration after the recorded prefix has been executed
+     * @throws CorruptedTraceException if an executed step outcome differs from the trace outcome
      * @throws IndexOutOfBoundsException if a recorded thread, counter, or outcome index is invalid
      */
     public Configuration replay(Program program, Trace trace) {
@@ -35,10 +37,17 @@ public Configuration replay(Program program, Trace trace) {
             int pc = config.programCounters().get(threadId);
             Step step = thread.steps().get(pc);
 
-            step.execute(config.state());
+            StepOutcome actualOutcome = step.execute(config.state());
+            if (actualOutcome != outcome) {
+                throw new CorruptedTraceException(
+                    "Trace outcome mismatch at step " + i + " (thread " + threadId + "): "
+                    + "recorded " + outcome + " but execution produced " + actualOutcome
+                );
+            }
             config = config.successor(threadId, outcome, program.threads(), config.state());
         }
 
         return config;
     }
 }
+
diff --git a/src/test/java/dev/samhb/interleave/minimize/DeltaDebuggerTest.java b/src/test/java/dev/samhb/interleave/minimize/DeltaDebuggerTest.java
index 6571ecb..1dae302 100644
--- a/src/test/java/dev/samhb/interleave/minimize/DeltaDebuggerTest.java
+++ b/src/test/java/dev/samhb/interleave/minimize/DeltaDebuggerTest.java
@@ -92,4 +92,58 @@ void minimize_returnsSubsequence() {
         assertTrue(minimizedIndex == minimizedThreadIds.size(),
             "Minimized trace should be a subsequence of original");
     }
+
+    @Test
+    void minimize_invariantViolationInDeadlockedConfiguration_succeeds() {
+        // T0 writes counter=1 then blocks on a condition that never becomes true.
+        // T1 performs an irrelevant read then terminates.
+        // Invariant asserts counter != 1.
+        // The terminal configuration is deadlocked (T0 live and blocked; T1 terminated),
+        // yet violates the invariant. DeltaDebugger should accept and minimize this trace.
+        Program program = new Program(CounterState.of(0), List.of(
+            new ModelThread(0, List.of(
+                new WriteCounterStep(0),
+                new Step() {
+                    @Override
+                    public boolean enabled(SharedState state) {
+                        return false; // permanently blocked -> deadlock candidate
+                    }
+
+                    @Override
+                    public StepOutcome execute(SharedState state) {
+                        return StepOutcome.BLOCKED;
+                    }
+
+                    @Override
+                    public Set reads() {
+                        return Set.of();
+                    }
+
+                    @Override
+                    public Set writes() {
+                        return Set.of();
+                    }
+                }
+            )),
+            new ModelThread(1, List.of(new ReadCounterStep(1)))
+        ));
+
+        Invariant invariant = (state, config) -> ((CounterState) state).counter() != 1;
+        Trace failing = Trace.of(
+            List.of(1, 0),
+            List.of(StepOutcome.ADVANCED, StepOutcome.ADVANCED),
+            TraceOutcome.VIOLATION
+        );
+
+        Configuration end = new TraceReplayer().replay(program, failing);
+        assertTrue(end.isDeadlockCandidate(), "Replayed configuration must be a deadlock candidate");
+        assertFalse(invariant.holds(end.state(), end), "Invariant must fail");
+
+        DeltaDebugger debugger = new DeltaDebugger();
+        Trace minimized = debugger.minimize(program, failing, TraceOutcome.VIOLATION, invariant);
+
+        assertEquals(List.of(0), minimized.threadIds(), "Should shrink away T1's irrelevant read");
+        assertEquals(List.of(StepOutcome.ADVANCED), minimized.outcomes());
+    }
 }
+
diff --git a/src/test/java/dev/samhb/interleave/search/TraceReplayerTest.java b/src/test/java/dev/samhb/interleave/search/TraceReplayerTest.java
new file mode 100644
index 0000000..f0ef946
--- /dev/null
+++ b/src/test/java/dev/samhb/interleave/search/TraceReplayerTest.java
@@ -0,0 +1,55 @@
+/** Verifies trace replay against actual step execution and detects corrupted outcomes. */
+package dev.samhb.interleave.search;
+
+import dev.samhb.interleave.bugs.ReadCounterStep;
+import dev.samhb.interleave.bugs.WriteCounterStep;
+import dev.samhb.interleave.core.*;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TraceReplayerTest {
+
+    @Test
+    void replay_matchingTrace_succeeds() {
+        Program program = new Program(CounterState.of(0), List.of(
+            new ModelThread(0, List.of(new WriteCounterStep(0), new ReadCounterStep(0)))
+        ));
+
+        Trace trace = Trace.of(
+            List.of(0, 0),
+            List.of(StepOutcome.ADVANCED, StepOutcome.ADVANCED),
+            TraceOutcome.COMPLETED
+        );
+
+        TraceReplayer replayer = new TraceReplayer();
+        Configuration end = replayer.replay(program, trace);
+
+        assertEquals(1, ((CounterState) end.state()).counter());
+        assertTrue(end.allTerminated());
+    }
+
+    @Test
+    void replay_divergentOutcome_throwsCorruptedTraceException() {
+        Program program = new Program(CounterState.of(0), List.of(
+            new ModelThread(0, List.of(new WriteCounterStep(0)))
+        ));
+
+        // The step produces StepOutcome.ADVANCED, but the trace erroneously claims BLOCKED
+        Trace corruptedTrace = Trace.of(
+            List.of(0),
+            List.of(StepOutcome.BLOCKED),
+            TraceOutcome.VIOLATION
+        );
+
+        TraceReplayer replayer = new TraceReplayer();
+        CorruptedTraceException ex = assertThrows(
+            CorruptedTraceException.class,
+            () -> replayer.replay(program, corruptedTrace)
+        );
+
+        assertTrue(ex.getMessage().contains("recorded BLOCKED but execution produced ADVANCED"));
+    }
+}