From 4062feed62ac123f1817123299cd672d58a2ab00 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Mon, 26 Oct 2026 20:36:50 +0200 Subject: [PATCH 1/8] fix: give simulated annealing a time gradient from every termination that can end the phase Simulated annealing cools by the time gradient of the phase termination. - OR and AND composites now return -1.0 when no termination supports a time gradient, as the contract says. OR returned 0.0 and AND 1.0, so simulated annealing did not cool, or started at its minimum temperature, and a nested composite hid the gradient of the others. - The solver bridge now includes the phase-only terminations of the solver termination, which can end the phase too. Diminished returns configured at solver level never reached the gradient. - Diminished returns now estimates the time gradient: one sliding window is left after the time since its grace period started. This estimate was chosen from six candidates in an offline replay of diminished returns on quickstart runs. - Without any termination that can estimate the time left, for example when only terminateEarly() stops the solver, simulated annealing keeps its starting temperature, as before, and logs a warning. # Conflicts: # core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java --- .../SimulatedAnnealingAcceptor.java | 12 +++++ .../AbstractCompositeTermination.java | 28 ++++++++++++ .../termination/AndCompositeTermination.java | 17 ++++--- .../DiminishedReturnsTermination.java | 17 ++++++- .../termination/OrCompositeTermination.java | 13 ++++-- .../SolverBridgePhaseTermination.java | 4 ++ .../SimulatedAnnealingAcceptorTest.java | 12 +++++ .../AndCompositeTerminationTest.java | 29 ++++++++---- .../DiminishedReturnsTerminationTest.java | 23 ++++++++++ .../OrCompositeTerminationTest.java | 16 +++---- .../SolverBridgePhaseTerminationTest.java | 45 +++++++++++++++++++ .../optimization-algorithms/local-search.adoc | 2 + .../upgrade-to-latest.adoc | 15 +++++++ 13 files changed, 206 insertions(+), 27 deletions(-) create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java index 48f3636cc20..7477246691e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java @@ -19,6 +19,7 @@ public class SimulatedAnnealingAcceptor extends AbstractAcceptor phaseScope) { startingTemperatureLevels = startingTemperature.toLevelDoubles(); temperatureLevels = startingTemperatureLevels; levelsLength = startingTemperatureLevels.length; + noTimeGradientWarned = false; } @Override @@ -83,6 +85,16 @@ public void stepStarted(LocalSearchStepScope stepScope) { super.stepStarted(stepScope); // TimeGradient only refreshes at the beginning of a step, so this code is in stepStarted instead of stepEnded var timeGradient = stepScope.getTimeGradient(); + if (timeGradient < 0.0) { + // Without a known end, such as when only terminateEarly() stops the solver, it keeps the starting temperature. + if (!noTimeGradientWarned) { + logger.warn(""" + Simulated annealing does not cool, because no termination can estimate how long the phase will continue. + Maybe configure a time limit or a diminished returns termination."""); + noTimeGradientWarned = true; + } + timeGradient = 0.0; + } var reverseTimeGradient = 1.0 - timeGradient; temperatureLevels = new double[levelsLength]; for (var i = 0; i < levelsLength; i++) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java index 94e23b5fb8a..cc5ecade022 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java @@ -109,4 +109,32 @@ public final List> getPhaseTerminationList() { return List.copyOf(result); } + /** + * The timeGradient of this solver termination, as a phase sees it: + * solver terminations measure the solver, phase-only terminations the phase that they can end. + * + * @return -1.0 if none of the terminations supports a timeGradient + */ + final double calculateSolverTimeGradientForPhase(AbstractPhaseScope phaseScope) { + var timeGradient = -1.0; + for (var termination : terminationList) { + var nextTimeGradient = switch (termination) { + case AbstractCompositeTermination compositeTermination -> compositeTermination + .calculateSolverTimeGradientForPhase(phaseScope); + case SolverTermination solverTermination -> solverTermination + .calculateSolverTimeGradient(phaseScope.getSolverScope()); + case PhaseTermination phaseTermination when phaseTermination + .isApplicableTo(phaseScope.getClass()) -> + phaseTermination.calculatePhaseTimeGradient(phaseScope); + default -> -1.0; + }; + if (nextTimeGradient >= 0.0) { + timeGradient = timeGradient < 0.0 ? nextTimeGradient : combineTimeGradients(timeGradient, nextTimeGradient); + } + } + return timeGradient; + } + + abstract double combineTimeGradients(double timeGradient, double otherTimeGradient); + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java index 4e9571f8267..5268829f1ab 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java @@ -52,15 +52,15 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { * Calculates the minimum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. * - * @return the minimum timeGradient of the Terminations. + * @return the minimum timeGradient of the Terminations, or -1.0 if none of them supports it. */ @Override public double calculateSolverTimeGradient(SolverScope solverScope) { - var timeGradient = 1.0; + var timeGradient = -1.0; for (var termination : solverTerminationList) { var nextTimeGradient = termination.calculateSolverTimeGradient(solverScope); if (nextTimeGradient >= 0.0) { - timeGradient = Math.min(timeGradient, nextTimeGradient); + timeGradient = timeGradient < 0.0 ? nextTimeGradient : Math.min(timeGradient, nextTimeGradient); } } return timeGradient; @@ -70,23 +70,28 @@ public double calculateSolverTimeGradient(SolverScope solverScope) { * Calculates the minimum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. * - * @return the minimum timeGradient of the Terminations. + * @return the minimum timeGradient of the Terminations, or -1.0 if none of them supports it. */ @Override public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScope) { - var timeGradient = 1.0; + var timeGradient = -1.0; for (var termination : phaseTerminationList) { if (!termination.isApplicableTo(phaseScope.getClass())) { continue; } var nextTimeGradient = termination.calculatePhaseTimeGradient(phaseScope); if (nextTimeGradient >= 0.0) { - timeGradient = Math.min(timeGradient, nextTimeGradient); + timeGradient = timeGradient < 0.0 ? nextTimeGradient : Math.min(timeGradient, nextTimeGradient); } } return timeGradient; } + @Override + double combineTimeGradients(double timeGradient, double otherTimeGradient) { + return Math.min(timeGradient, otherTimeGradient); + } + @Override public AndCompositeTermination createChildThreadTermination(SolverScope solverScope, ChildThreadType childThreadType) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java index b32c0e17eb2..c93deb0d926 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java @@ -160,7 +160,22 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { @Override public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScope) { - return -1.0; + return calculateTimeGradient(System.nanoTime()); + } + + /** + * Assumes that one sliding window is left after the time since the grace period started, + * which a change of a harder level restarts. + */ + double calculateTimeGradient(long currentTime) { + if (!isGracePeriodStarted) { + return 0.0; + } + var timeSinceGracePeriodStartNanos = currentTime - gracePeriodStartTimeNanos; + if (timeSinceGracePeriodStartNanos <= 0L) { + return 0.0; + } + return timeSinceGracePeriodStartNanos / (double) (timeSinceGracePeriodStartNanos + slidingWindowNanos); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java index 1ba9317278a..0edbd190b53 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java @@ -94,11 +94,11 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { * Calculates the maximum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. * - * @return the maximum timeGradient of the terminations. + * @return the maximum timeGradient of the terminations, or -1.0 if none of them supports it. */ @Override public double calculateSolverTimeGradient(SolverScope solverScope) { - var timeGradient = 0.0; + var timeGradient = -1.0; for (var termination : solverTerminationList) { var nextTimeGradient = termination.calculateSolverTimeGradient(solverScope); if (nextTimeGradient >= 0.0) { @@ -112,11 +112,11 @@ public double calculateSolverTimeGradient(SolverScope solverScope) { * Calculates the maximum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. * - * @return the maximum timeGradient of the supported terminations. + * @return the maximum timeGradient of the supported terminations, or -1.0 if none of them supports it. */ @Override public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScope) { - var timeGradient = 0.0; + var timeGradient = -1.0; for (var termination : phaseTerminationList) { if (!termination.isApplicableTo(phaseScope.getClass())) { continue; @@ -129,6 +129,11 @@ public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScop return timeGradient; } + @Override + double combineTimeGradients(double timeGradient, double otherTimeGradient) { + return Math.max(timeGradient, otherTimeGradient); + } + @Override public OrCompositeTermination createChildThreadTermination(SolverScope solverScope, ChildThreadType childThreadType) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java index 18bb6a7d938..ce857143d71 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java @@ -49,6 +49,10 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { @Override public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScope) { + if (solverTermination instanceof AbstractCompositeTermination compositeTermination) { + // Its phase-only terminations can end the phase too. + return compositeTermination.calculateSolverTimeGradientForPhase(phaseScope); + } return solverTermination.calculateSolverTimeGradient(phaseScope.getSolverScope()); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptorTest.java index f0dd4e7875e..16ccae8976b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptorTest.java @@ -88,4 +88,16 @@ void negativeSimulatedAnnealingSize() { assertThatIllegalArgumentException().isThrownBy(() -> acceptor.phaseStarted(null)); } + @Test + void keepsStartingTemperatureWithoutTimeGradient() { + var acceptor = new SimulatedAnnealingAcceptor<>(); + acceptor.setStartingTemperature(SimpleScore.of(200)); + var phaseScope = new LocalSearchPhaseScope<>(new SolverScope<>(), 0); + acceptor.phaseStarted(phaseScope); + var stepScope = new LocalSearchStepScope<>(phaseScope); + stepScope.setTimeGradient(-1.0); + acceptor.stepStarted(stepScope); + assertThat(acceptor.temperatureLevels).containsExactly(200.0); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTerminationTest.java index 66b25b3516f..2a388262592 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTerminationTest.java @@ -118,8 +118,8 @@ void calculateSolverTimeGradientTest() { when(termination1.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); when(termination2.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored, min(unsupported,unsupported) = 1.0 (default) - assertThat(compositeTermination.calculateSolverTimeGradient(solverScope)).isEqualTo(1.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculateSolverTimeGradient(solverScope)).isEqualTo(-1.0, offset(0.0)); when(termination1.calculateSolverTimeGradient(solverScope)).thenReturn(0.5); when(termination2.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); @@ -158,8 +158,8 @@ void calculatePhaseTimeGradientTest() { when(termination1.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(termination2.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored, min(unsupported,unsupported) = 1.0 (default) - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(1.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(termination1.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); when(termination2.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); @@ -198,13 +198,13 @@ void calculatePhaseTimeGradientInapplicableTest() { when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored, min(unsupported,unsupported) = 1.0 (default) - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(1.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored = 1.0 (default) - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(1.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); @@ -212,4 +212,17 @@ void calculatePhaseTimeGradientInapplicableTest() { assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.5, offset(0.0)); } + @Test + void nestedCompositeWithoutTimeGradientIsIgnored() { + PhaseTermination unsupportedTermination = mockPhaseTermination(); + PhaseTermination supportedTermination = mockPhaseTermination(); + UniversalTermination compositeTermination = new AndCompositeTermination<>( + new OrCompositeTermination<>(unsupportedTermination), supportedTermination); + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + + when(unsupportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); + when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.4); + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.4, offset(0.0)); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java index 0a3f168da14..399c3ebc748 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java @@ -2,6 +2,7 @@ import static ai.timefold.solver.core.impl.solver.termination.DiminishedReturnsTermination.NANOS_PER_MILLISECOND; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -278,4 +279,26 @@ void childThreadTerminationKeepsTheSlidingWindow() { assertThat(childTermination.getSlidingWindowNanos()).isEqualTo(10 * NANOS_PER_MILLISECOND); assertThat(childTermination.getMinimumImprovementRatio()).isEqualTo(0.5); } + + @Test + void timeGradientAssumesOneWindowLeftSinceTheLastReset() { + var termination = new DiminishedReturnsTermination(10, 0.5); + assertThat(termination.calculateTimeGradient(5 * NANOS_PER_MILLISECOND)).isZero(); + termination.start(0L, InnerScore.fullyAssigned(HardSoftScore.of(-1, 0))); + assertThat(termination.calculateTimeGradient(0L)).isZero(); + assertThat(termination.calculateTimeGradient(10 * NANOS_PER_MILLISECOND)).isEqualTo(0.5, offset(1e-9)); + assertThat(termination.calculateTimeGradient(30 * NANOS_PER_MILLISECOND)).isEqualTo(0.75, offset(1e-9)); + // A change of a harder level restarts the grace period, and the time gradient with it. + termination.isTerminated(30 * NANOS_PER_MILLISECOND, InnerScore.fullyAssigned(HardSoftScore.of(0, 0))); + assertThat(termination.calculateTimeGradient(30 * NANOS_PER_MILLISECOND)).isZero(); + assertThat(termination.calculateTimeGradient(40 * NANOS_PER_MILLISECOND)).isEqualTo(0.5, offset(1e-9)); + } + + @Test + void phaseTimeGradientIsSupported() { + var termination = new DiminishedReturnsTermination(10_000, 0.5); + termination.start(System.nanoTime(), InnerScore.fullyAssigned(SimpleScore.ZERO)); + assertThat(termination.calculatePhaseTimeGradient(mock(AbstractPhaseScope.class))).isBetween(0.0, 1.0); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTerminationTest.java index c2373b9a815..6b7641188a5 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTerminationTest.java @@ -120,8 +120,8 @@ void calculateSolverTimeGradientTest() { when(termination1.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); when(termination2.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored, max(unsupported,unsupported) = 0.0 (default) - assertThat(compositeTermination.calculateSolverTimeGradient(solverScope)).isEqualTo(0.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculateSolverTimeGradient(solverScope)).isEqualTo(-1.0, offset(0.0)); when(termination1.calculateSolverTimeGradient(solverScope)).thenReturn(0.5); when(termination2.calculateSolverTimeGradient(solverScope)).thenReturn(-1.0); @@ -160,8 +160,8 @@ void calculatePhaseTimeGradientTest() { when(termination1.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(termination2.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored, max(unsupported,unsupported) = 0.0 (default) - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(termination1.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); when(termination2.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); @@ -200,13 +200,13 @@ void calculatePhaseTimeGradientInapplicableTest() { when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); - // Negative time gradient values are unsupported and ignored - assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.0, offset(0.0)); + // No applicable termination supports a time gradient. + assertThat(compositeTermination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); when(inapplicableTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); when(supportedTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.5); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java new file mode 100644 index 00000000000..cdceb41981a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java @@ -0,0 +1,45 @@ +package ai.timefold.solver.core.impl.solver.termination; + +import static ai.timefold.solver.core.impl.solver.termination.AbstractCompositeTerminationTest.mockPhaseTermination; +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockSolverScope; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.Offset.offset; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class SolverBridgePhaseTerminationTest { + + @Test + void solverLevelPhaseTerminationGivesThePhaseATimeGradient() { + PhaseTermination phaseOnlyTermination = mockPhaseTermination(); + var bridge = new SolverBridgePhaseTermination<>( + new OrCompositeTermination<>(new TimeMillisSpentTermination<>(1000L), phaseOnlyTermination)); + SolverScope solverScope = mockSolverScope(); + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(solverScope.calculateTimeMillisSpentUpToNow()).thenReturn(200L); + when(phaseScope.calculatePhaseTimeMillisSpentUpToNow()).thenReturn(900L); + + when(phaseOnlyTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.6); + assertThat(bridge.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.6, offset(0.0)); + // A solver-level time limit measures the time of the solver, not of the phase. + when(phaseOnlyTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(-1.0); + assertThat(bridge.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.2, offset(0.0)); + } + + @Test + void noTimeGradient() { + var bridge = new SolverBridgePhaseTermination(new BasicPlumbingTermination<>(false)); + SolverScope solverScope = mockSolverScope(); + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + assertThat(bridge.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0, offset(0.0)); + } + +} diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc index 1735bba84d5..eca1d6e8d6d 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc @@ -399,6 +399,8 @@ Simulated Annealing does not always pick the move with the highest score, neithe At least at first. Instead, it gives non improving moves also a chance to be picked, depending on its score and the time gradient of the ``Termination``. In the end, it gradually turns into Hill Climbing, only accepting improving moves. +It cools only with a termination that can estimate how long the phase will continue, +such as a time limit or diminished returns; without one, it keeps its starting temperature. [#simulatedAnnealingConfiguration] diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc index c368f64cbf9..6c5a3e3c36a 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc @@ -134,6 +134,21 @@ you must implement the new API. For `Solver`, implement `solveAndSummarize(problem)` instead of `solve(problem)`. ==== +icon:info-circle[role=yellow,set=fas] Simulated annealing cools with every termination +[%collapsible%open] +==== +Simulated annealing cools according to the time gradient of the terminations. +Before, it did not cool when diminished returns was the only termination, +when diminished returns was configured at solver level, +or when a nested `and`/`or` composite had no termination with a time gradient; +diminished returns now estimates the time left. +So the results of simulated annealing in such configurations change. + +Without any termination that can estimate the time left, +for example when only `terminateEarly()` stops the solver, +simulated annealing keeps its starting temperature, as before, and logs a warning. +==== + ''' [#previewFeatureUpgrade] From 24198818f9594ffbd4305c06b2b2b9d8710d5a8d Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Fri, 6 Nov 2026 10:13:20 +0100 Subject: [PATCH 2/8] fix: make a solver-level AND wait for terminations that can only end a phase A solver-level AND checks each termination at its own level: solver terminations against the solver, phase-only terminations (diminished returns, step count, unimproved step count) against the phase. A phase-only termination that does not apply to a phase blocks the AND there. Before, the solver ignored phase-only terminations in an AND, and an AND of only such terminations ended the solver before the first phase. The simulated annealing time gradient follows the same rule. Docs explain both levels, link the terminations, and note that diminished returns is a moving target for simulated annealing. --- .../core/impl/phase/AbstractPhaseFactory.java | 25 ++------ .../AbstractCompositeTermination.java | 21 ++++++ .../termination/AndCompositeTermination.java | 19 +++++- .../termination/OrCompositeTermination.java | 16 ++++- .../SolverBridgePhaseTermination.java | 8 +++ .../core/impl/solver/DefaultSolverTest.java | 27 ++++++++ .../SolverBridgePhaseTerminationTest.java | 38 +++++++++++ .../optimization-algorithms/local-search.adoc | 19 +++++- .../optimization-algorithms/overview.adoc | 64 +++++++++++++++++-- .../upgrade-to-latest.adoc | 58 +++++++++++++++-- 10 files changed, 263 insertions(+), 32 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java index 73d0e1a9ab6..7e06ba956ef 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java @@ -52,24 +52,13 @@ protected PhaseTermination buildPhaseTermination(HeuristicConfigPolic var phaseName = this.getClass().getSimpleName() .replace("PhaseFactory", "") .replace("Default", ""); - if (solverTermination != resultingTermination) { - // Only fail if the user put the inapplicable termination on the phase, not on the solver. - // On the solver level, inapplicable phase terminations are skipped. - // Otherwise you would only be able to configure a global phase-level termination on the solver - // if it was applicable to all phases. - if (!inapplicableTerminationList.isEmpty()) { - throw new IllegalStateException( - """ - The phase (%s) configured with terminations (%s) includes some terminations which are not applicable to it (%s). - Maybe remove these terminations from the phase's configuration.""" - .formatted(phaseName, phaseTermination, inapplicableTerminationList)); - } - } else if (!inapplicableTerminationList.isEmpty()) { - logger.trace(""" - The solver-level termination ({}) includes phase-level terminations ({}) \ - which are not applicable to the phase ({}). - These phase-level terminations will not take effect in this phase.""", - solverTermination, inapplicableTerminationList, phaseName); + // Inapplicable solver-level terminations stay behind the bridge, which never counts them as met in this phase. + if (!inapplicableTerminationList.isEmpty()) { + throw new IllegalStateException( + """ + The phase (%s) configured with terminations (%s) includes some terminations which are not applicable to it (%s). + Maybe remove these terminations from the phase's configuration.""" + .formatted(phaseName, phaseTermination, inapplicableTerminationList)); } return resultingTermination; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java index cc5ecade022..14a9f128a5b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractCompositeTermination.java @@ -109,6 +109,27 @@ public final List> getPhaseTerminationList() { return List.copyOf(result); } + /** + * Whether this solver termination ends the phase: + * solver terminations measure the solver, phase-only terminations the phase that they can end. + * A phase-only termination that is not applicable to the phase counts as not terminated. + */ + abstract boolean isSolverTerminatedForPhase(AbstractPhaseScope phaseScope); + + static boolean isTerminatedForPhase(Termination termination, + AbstractPhaseScope phaseScope) { + return switch (termination) { + case AbstractCompositeTermination compositeTermination -> compositeTermination + .isSolverTerminatedForPhase(phaseScope); + case SolverTermination solverTermination -> solverTermination + .isSolverTerminated(phaseScope.getSolverScope()); + case PhaseTermination phaseTermination -> phaseTermination.isApplicableTo(phaseScope.getClass()) + && phaseTermination.isPhaseTerminated(phaseScope); + default -> throw new IllegalStateException("Impossible state: the termination (%s) is of an unknown type." + .formatted(termination)); + }; + } + /** * The timeGradient of this solver termination, as a phase sees it: * solver terminations measure the solver, phase-only terminations the phase that they can end. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java index 5268829f1ab..8d79d852288 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AndCompositeTermination.java @@ -23,10 +23,17 @@ public AndCompositeTermination(Termination... terminations) { } /** - * @return true if all terminations are terminated. + * Only a phase can check the terminations that can only end a phase, + * see {@link #isSolverTerminatedForPhase(AbstractPhaseScope)}. + * + * @return true if all terminations are terminated; + * false if this includes a termination that can only end a phase. */ @Override public boolean isSolverTerminated(SolverScope solverScope) { + if (solverTerminationList.size() != terminationList.size()) { + return false; + } for (var termination : solverTerminationList) { if (!termination.isSolverTerminated(solverScope)) { return false; @@ -48,6 +55,16 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { return true; } + @Override + boolean isSolverTerminatedForPhase(AbstractPhaseScope phaseScope) { + for (var termination : terminationList) { + if (!isTerminatedForPhase(termination, phaseScope)) { + return false; + } + } + return true; + } + /** * Calculates the minimum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java index 0edbd190b53..10b9ba4224c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/OrCompositeTermination.java @@ -56,8 +56,11 @@ private void recordTermination(SolverScope solverScope, Termination solverScope) { @@ -90,6 +93,17 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { return false; } + @Override + boolean isSolverTerminatedForPhase(AbstractPhaseScope phaseScope) { + for (var termination : terminationList) { + if (isTerminatedForPhase(termination, phaseScope)) { + recordTermination(phaseScope.getSolverScope(), termination); + return true; + } + } + return false; + } + /** * Calculates the maximum timeGradient of all Terminations. * Not supported timeGradients (-1.0) are ignored. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java index ce857143d71..3e0190ba2c5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTermination.java @@ -22,6 +22,11 @@ * The effect of this in practice would have been that, * if a solver-level {@link TimeMillisSpentTermination} were configured to terminate after 10 seconds, * each phase would effectively start a new 10-second counter. + *

+ * A composite solver termination can also include terminations that can only end a phase. + * Each of its terminations is checked at its own level: + * solver terminations against the solver, the other terminations against the phase. + * The time gradient follows the same rule. * * @param */ @@ -39,6 +44,9 @@ public SolverBridgePhaseTermination(SolverTermination solverTerminati @Override @SuppressWarnings({ "rawtypes", "unchecked" }) public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { + if (solverTermination instanceof AbstractCompositeTermination compositeTermination) { + return compositeTermination.isSolverTerminatedForPhase(phaseScope); + } var terminated = solverTermination.isSolverTerminated(phaseScope.getSolverScope()); // If the solver is not finished yet, we need to check the phase termination if (!terminated && solverTermination instanceof PhaseTermination phaseTermination) { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java index fd5886a4b10..0294712e1f2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java @@ -349,6 +349,33 @@ void summaryWithBestScoreLimit() { assertThat(summary.lastPhase()).isEqualTo(new DefaultPhaseId(PhaseType.CONSTRUCTION_HEURISTIC, 0)); } + @Test + void solverLevelAndWaitsForPhaseOnlyTermination() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()) + .withTerminationConfig(new TerminationConfig() + .withTerminationCompositionStyle(TerminationCompositionStyle.AND) + .withMoveCountLimit(10L) + .withStepCountLimit(100)); + var summary = solveAndSummarizeUninitialized(solverConfig); + // Every local search step evaluates at least one move. + assertThat(summary.moveEvaluationCount()).isGreaterThanOrEqualTo(100L); + assertThat(summary.termination()).isEqualTo(new DefaultTerminationId(TerminationType.AND, null, 0, null)); + } + + @Test + void solverLevelAndOfPhaseOnlyTerminationsRunsThePhases() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()) + .withTerminationConfig(new TerminationConfig() + .withTerminationCompositionStyle(TerminationCompositionStyle.AND) + .withStepCountLimit(10) + .withUnimprovedStepCountLimit(5)); + var summary = solveAndSummarizeUninitialized(solverConfig); + assertThat(summary.moveEvaluationCount()).isGreaterThanOrEqualTo(10L); + assertThat(summary.termination()).isEqualTo(new DefaultTerminationId(TerminationType.AND, null, 0, null)); + } + @Test void summaryWithAndTermination() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java index cdceb41981a..153c54a3c3a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java @@ -33,6 +33,44 @@ void solverLevelPhaseTerminationGivesThePhaseATimeGradient() { assertThat(bridge.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.2, offset(0.0)); } + @Test + void solverLevelAndWaitsForPhaseOnlyTermination() { + PhaseTermination phaseOnlyTermination = mockPhaseTermination(); + var bridge = new SolverBridgePhaseTermination<>( + new AndCompositeTermination<>(new TimeMillisSpentTermination<>(1000L), phaseOnlyTermination)); + SolverScope solverScope = mockSolverScope(); + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(solverScope.calculateTimeMillisSpentUpToNow()).thenReturn(2000L); + when(phaseScope.calculatePhaseTimeMillisSpentUpToNow()).thenReturn(100L); + + when(phaseOnlyTermination.isPhaseTerminated(phaseScope)).thenReturn(false); + when(phaseOnlyTermination.calculatePhaseTimeGradient(phaseScope)).thenReturn(0.3); + assertThat(bridge.isPhaseTerminated(phaseScope)).isFalse(); + assertThat(bridge.calculatePhaseTimeGradient(phaseScope)).isEqualTo(0.3, offset(0.0)); + + when(phaseOnlyTermination.isPhaseTerminated(phaseScope)).thenReturn(true); + assertThat(bridge.isPhaseTerminated(phaseScope)).isTrue(); + // The solver-level time limit measures the time of the solver, not of the phase. + when(solverScope.calculateTimeMillisSpentUpToNow()).thenReturn(200L); + assertThat(bridge.isPhaseTerminated(phaseScope)).isFalse(); + } + + @Test + void solverLevelAndBlocksPhaseWithInapplicableTermination() { + PhaseTermination inapplicableTermination = mockPhaseTermination(false); + var bridge = new SolverBridgePhaseTermination<>( + new AndCompositeTermination<>(new TimeMillisSpentTermination<>(1000L), inapplicableTermination)); + SolverScope solverScope = mockSolverScope(); + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(solverScope.calculateTimeMillisSpentUpToNow()).thenReturn(2000L); + + assertThat(bridge.isPhaseTerminated(phaseScope)).isFalse(); + // Between phases, the solver cannot check a phase-only termination. + assertThat(bridge.solverTermination.isSolverTerminated(solverScope)).isFalse(); + } + @Test void noTimeGradient() { var bridge = new SolverBridgePhaseTermination(new BasicPlumbingTermination<>(false)); diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc index eca1d6e8d6d..f9fe5f0ff6d 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/local-search.adoc @@ -399,8 +399,23 @@ Simulated Annealing does not always pick the move with the highest score, neithe At least at first. Instead, it gives non improving moves also a chance to be picked, depending on its score and the time gradient of the ``Termination``. In the end, it gradually turns into Hill Climbing, only accepting improving moves. -It cools only with a termination that can estimate how long the phase will continue, -such as a time limit or diminished returns; without one, it keeps its starting temperature. +It cools only with a xref:optimization-algorithms/overview.adoc#termination[termination] +that can estimate how long the phase will continue, +such as a xref:optimization-algorithms/overview.adoc#timeMillisSpentTermination[time limit] +or xref:optimization-algorithms/overview.adoc#diminishedReturnsTermination[diminished returns]; +without one, it keeps its starting temperature. + +[NOTE] +==== +Simulated Annealing cools best with a time limit, because the end of the phase is fixed. +With diminished returns, the end is a moving target: it moves while the score continues to improve. +When a harder score level changes, diminished returns restarts its time window, +and Simulated Annealing heats up again. +The phase can end before Simulated Annealing is cold. +xref:optimization-algorithms/overview.adoc#combiningMultipleTerminations[Combine] diminished returns with a time limit in `OR` +to give Simulated Annealing a fixed end. +In `AND`, the end is still a moving target. +==== [#simulatedAnnealingConfiguration] diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc index 6f88b757da4..09b333e6ad5 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc @@ -294,7 +294,7 @@ This is especially common during xref:responding-to-change/real-time-planning.ad ==== For synchronous termination, configure a `Termination` on a `Solver` or a `Phase` when it needs to stop. -Every `Termination` can calculate a _time gradient_ (needed for some optimization algorithms), +Most terminations can calculate a _time gradient_ (needed for some optimization algorithms), which is a ratio between the time already spent solving and the estimated entire solving time of the `Solver` or `Phase`. @@ -325,6 +325,10 @@ and should therefore be mostly independent of the factors listed above. ==== Combining multiple terminations Terminations can be combined. +`OR` is the default composition style. +A `` can also contain more `` elements, each with its own composition style. +The time gradient of `OR` is the largest time gradient of its terminations, and the time gradient of `AND` is the smallest. + The following example will terminate the solver when the <> or after the solver has already run for 5 minutes, whichever comes first: @@ -339,7 +343,8 @@ whichever comes first: ---- Alternatively you can use `AND`. -The following example will terminate after reaching a feasible score of at least `-100` and no improvements in `5` steps: +The following example will terminate after reaching a <> +and <>: [source,xml,options="nowrap"] ---- @@ -353,6 +358,49 @@ The following example will terminate after reaching a feasible score of at least This example ensures it does not just terminate after finding a feasible solution, but also completes any obvious improvements on that solution before terminating. +[#phaseOnlyTerminationsOnTheSolver] +===== Terminations that can only end a phase + +Some terminations can only end a phase, such as +<>, <> +and <>. +When placed on a phase, all terminations measure that phase. +For example, a <> counts from the start of the phase. + +When placed on the `Solver`, each termination is measured at its own level: + +* A termination that can only end a phase measures each phase separately, +and only in the phases that it supports. +For example, diminished returns does not apply to the Construction Heuristic phase. +* Every other termination measures the whole solver. +For example, a <> counts from the start of the solver. + +With `OR`, the phase ends as soon as one termination is met. +The solver ends as soon as one termination which measures the whole solver is met, or after the last phase. + +With `AND`, the phase ends only when all terminations are met, +each measured at its own level. +A phase that a termination does not support is not ended by the `AND`. +Such a phase ends by itself, for example when the Construction Heuristic has initialized all planning entities. +The solver does not skip a phase because of such an `AND`, and it ends after the last phase. + +The following example runs for at least 5 minutes from the start of the solver, +and also until diminished returns is met in the Local Search phase: + +[source,xml,options="nowrap"] +---- + + + AND + 5 + + + ... + +---- + +If the same `AND` is configured on the `` phase instead, +the 5 minutes count from the start of that phase. [#asynchronousTermination] ==== Asynchronous termination from another thread @@ -674,7 +722,9 @@ This is useful for hardware performance independent runs. ---- -This `Termination` can only be used for a `Phase` (such as ````), not for the `Solver` itself. +This `Termination` can only end a `Phase`. +On the `Solver`, it measures each `Phase` separately. +See <>. [#unimprovedStepCountTermination] @@ -696,7 +746,9 @@ If the score has not improved recently, it is unlikely to improve in a reasonabl It has been observed that once a new best solution is found (even after a long time without improvement on the best solution), the next few steps tend to improve the best solution. -This `Termination` can only be used for a `Phase` (such as ````), not for the `Solver` itself. +This `Termination` can only end a `Phase`, and it does not apply to Construction Heuristic and custom phases. +On the `Solver`, it measures each `Phase` separately. +See <>. [#scoreCalculationCountTermination] @@ -760,6 +812,10 @@ image::optimization-algorithms/overview/diminishedReturnsTermination.png[align=" ---- +This `Termination` can only end a `Phase`, and it does not apply to Construction Heuristic and custom phases. +On the `Solver`, it measures each `Phase` separately. +See <>. + There are two properties that can optionally be configured: * `slidingWindowDuration`, which is the initial grace period and is the length of the window that is used for the rate of improvement calculation. diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc index 6c5a3e3c36a..31dd9223fc3 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc @@ -134,21 +134,67 @@ you must implement the new API. For `Solver`, implement `solveAndSummarize(problem)` instead of `solve(problem)`. ==== -icon:info-circle[role=yellow,set=fas] Simulated annealing cools with every termination +''' + +icon:eye[set=far] Simulated annealing cools with every termination [%collapsible%open] ==== -Simulated annealing cools according to the time gradient of the terminations. -Before, it did not cool when diminished returns was the only termination, +xref:optimization-algorithms/local-search.adoc#simulatedAnnealing[Simulated annealing] cools according to the time gradient of the xref:optimization-algorithms/overview.adoc#termination[terminations]. +Before, it did not cool when xref:optimization-algorithms/overview.adoc#diminishedReturnsTermination[diminished returns] was the only termination, when diminished returns was configured at solver level, -or when a nested `and`/`or` composite had no termination with a time gradient; -diminished returns now estimates the time left. +or when a nested xref:optimization-algorithms/overview.adoc#combiningMultipleTerminations[`and`/`or` composite] had no termination with a time gradient; +diminished returns now estimates the time left according to its current time window, +and simulated annealing heats up again when that window restarts. So the results of simulated annealing in such configurations change. Without any termination that can estimate the time left, -for example when only `terminateEarly()` stops the solver, +for example when only xref:optimization-algorithms/overview.adoc#asynchronousTermination[`terminateEarly()`] stops the solver, simulated annealing keeps its starting temperature, as before, and logs a warning. ==== +''' +icon:eye[set=far] Solver termination with `AND` waits for terminations that can only end a phase +[%collapsible%open] +==== +A solver termination that xref:optimization-algorithms/overview.adoc#combiningMultipleTerminations[combines terminations] with `AND` +now also waits for the terminations which can only end a phase: +xref:optimization-algorithms/overview.adoc#diminishedReturnsTermination[diminished returns], +xref:optimization-algorithms/overview.adoc#stepCountTermination[`stepCountLimit`] and xref:optimization-algorithms/overview.adoc#unimprovedStepCountTermination[`unimprovedStepCountLimit`]. +Each of them measures the current phase, and every other termination measures the whole solver. +Before, the solver ignored them and stopped as soon as the other terminations were met. +An `AND` of only such terminations stopped the solver before the first phase. + +For example, with this solver termination, Local Search now runs for at least 5 minutes from the start of the solver, +and also until diminished returns is met: + +[source,xml,options="nowrap"] +---- + + + AND + 5 + + + ... + +---- + +Before, the solver stopped after 5 minutes. +To keep that behavior, remove the terminations which can only end a phase: + +[source,xml,options="nowrap"] +---- + + + 5 + + ... + +---- + +See xref:optimization-algorithms/overview.adoc#phaseOnlyTerminationsOnTheSolver[terminations that can only end a phase]. +==== + ''' [#previewFeatureUpgrade] From 789084103b974c25766abed6cef791ffc89f03fc Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Fri, 6 Nov 2026 10:24:02 +0100 Subject: [PATCH 3/8] test: show how a solver-level AND of a global and a local termination runs the phases --- .../solver/termination/TerminationTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java index cca00b66eb5..9da8064b412 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java @@ -7,6 +7,7 @@ import java.time.Clock; import java.time.Duration; +import java.util.ArrayList; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -26,6 +27,10 @@ import ai.timefold.solver.core.config.solver.termination.DiminishedReturnsTerminationConfig; import ai.timefold.solver.core.config.solver.termination.TerminationCompositionStyle; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope; +import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.solver.DefaultSolver; import ai.timefold.solver.core.testdomain.TestdataEasyScoreCalculator; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -348,6 +353,59 @@ void mixedSolverPhaseTerminations() { assertThat(bestSolution).isNotNull(); } + /** + * A solver-level AND of a global termination (move count, measured over the whole solver) + * and a local termination (unimproved step count, which can only end a phase and restarts in each phase). + * The global termination is met during the Construction Heuristic, yet: + *

    + *
  • The Construction Heuristic still completes, because the local termination does not apply to it, + * so the AND cannot be met there.
  • + *
  • The solver does not skip the next phase, because only a phase can check the local termination.
  • + *
  • Each Local Search phase waits for its own unimproved steps.
  • + *
+ */ + @Test + void solverLevelAndOfGlobalAndLocalTermination() { + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEasyScoreCalculatorClass(TestdataEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig() + .withTerminationCompositionStyle(TerminationCompositionStyle.AND) + .withMoveCountLimit(1L) + .withUnimprovedStepCountLimit(3)) + // Phase validation requires a termination on a phase that another phase follows; + // this one is far away, so the solver-level AND ends the phase. + .withPhases(new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig().withTerminationConfig(new TerminationConfig().withStepCountLimit(10_000)), + new LocalSearchPhaseConfig()); + var solver = (DefaultSolver) SolverFactory. create(solverConfig) + .buildSolver(); + var endedPhaseList = new ArrayList>(); + var entityAssignedAfterConstruction = new boolean[] { false }; + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter<>() { + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + endedPhaseList.add(phaseScope); + if (phaseScope instanceof ConstructionHeuristicPhaseScope constructionScope) { + entityAssignedAfterConstruction[0] = constructionScope.getWorkingSolution() + .getEntityList().stream().allMatch(entity -> entity.getValue() != null); + } + } + }); + var solution = TestdataSolution.generateSolution(2, 2); + solution.getEntityList().forEach(entity -> entity.setValue(null)); + + solver.solve(solution); + + assertThat(endedPhaseList) + .map(phaseScope -> phaseScope.getClass().getSimpleName()) + .containsExactly("ConstructionHeuristicPhaseScope", "LocalSearchPhaseScope", "LocalSearchPhaseScope"); + assertThat(entityAssignedAfterConstruction[0]).isTrue(); + assertThat(endedPhaseList.subList(1, 3)) + .allSatisfy(phaseScope -> assertThat(phaseScope.getNextStepIndex()).isGreaterThanOrEqualTo(3)); + } + static class TerminationArgumentSource implements ArgumentsProvider { @Override From 3a219c96e85fa7a1b63fa6d9065e36a0e034da85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 6 Nov 2026 10:28:28 +0100 Subject: [PATCH 4/8] Apply suggestion from @triceo --- .../ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc index 31dd9223fc3..2e594895618 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc @@ -153,6 +153,7 @@ simulated annealing keeps its starting temperature, as before, and logs a warnin ==== ''' + icon:eye[set=far] Solver termination with `AND` waits for terminations that can only end a phase [%collapsible%open] ==== From b855d8c276b5ddfa8025583fcdd1dca4eca3f672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 6 Nov 2026 10:29:21 +0100 Subject: [PATCH 5/8] Apply suggestion from @triceo --- .../ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc index 2e594895618..17db1789e84 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc @@ -196,7 +196,6 @@ To keep that behavior, remove the terminations which can only end a phase: See xref:optimization-algorithms/overview.adoc#phaseOnlyTerminationsOnTheSolver[terminations that can only end a phase]. ==== -''' [#previewFeatureUpgrade] == Changes to preview features From 1617d9678d3f03f02d77cb8e6dd0abef79a3cc43 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Fri, 6 Nov 2026 10:32:40 +0100 Subject: [PATCH 6/8] fix: accept a phase after local search that a solver-level phase-only termination ends --- .../solver/core/impl/phase/PhaseFactory.java | 17 ++++++++- .../core/api/solver/SolverFactoryTest.java | 35 +++++++++++++++++++ .../solver/termination/TerminationTest.java | 5 +-- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java index 94ea9a44642..97e4dd3dc1d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java @@ -15,10 +15,12 @@ import ai.timefold.solver.core.impl.exhaustivesearch.DefaultExhaustiveSearchPhaseFactory; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhaseFactory; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.partitionedsearch.DefaultPartitionedSearchPhaseFactory; import ai.timefold.solver.core.impl.phase.custom.DefaultCustomPhaseFactory; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; +import ai.timefold.solver.core.impl.solver.termination.UniversalTermination; public interface PhaseFactory { @@ -48,7 +50,7 @@ static List> buildPhases(List phaseCon var phaseConfig = phaseConfigList.get(phaseIndex); if (phaseIndex > 0) { PhaseConfig previousPhaseConfig = phaseConfigList.get(phaseIndex - 1); - if (!canTerminate(previousPhaseConfig)) { + if (!canTerminate(previousPhaseConfig) && !canSolverTerminationEndPhase(termination, previousPhaseConfig)) { throw new IllegalStateException("Solver configuration contains an unreachable phase. " + "Phase #" + phaseIndex + " (" + phaseConfig + ") follows a phase " + "without a configured termination (" + previousPhaseConfig + ")."); @@ -86,6 +88,19 @@ static List> buildPhases(List phaseCon return phaseList; } + /** + * A phase-only termination in the solver termination ends the phase but not the solver, + * so the next phase runs. + */ + private static boolean canSolverTerminationEndPhase(SolverTermination termination, + PhaseConfig phaseConfig) { + return phaseConfig instanceof LocalSearchPhaseConfig + && termination instanceof UniversalTermination universalTermination + && universalTermination.getPhaseTerminationList().stream() + .anyMatch(phaseTermination -> !(phaseTermination instanceof SolverTermination) + && phaseTermination.isApplicableTo(LocalSearchPhaseScope.class)); + } + static boolean canTerminate(PhaseConfig phaseConfig) { if (phaseConfig instanceof ConstructionHeuristicPhaseConfig || phaseConfig instanceof ExhaustiveSearchPhaseConfig diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverFactoryTest.java index 3b1a8d1cce9..c7f99ae1cb0 100644 --- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverFactoryTest.java @@ -187,6 +187,41 @@ void localSearchAfterUnterminatedLocalSearch() { .hasMessageContaining("unreachable phase"); } + @Test + void localSearchAfterLocalSearchEndedBySolverTermination() { + // Diminished returns on the solver ends each local search, not the solver, so the second one is reachable. + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEasyScoreCalculatorClass(DummySimpleScoreEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig() + .withSecondsSpentLimit(10L) + .withDiminishedReturns()) + .withPhases(new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig(), + new LocalSearchPhaseConfig()); + + var solverFactory = SolverFactory. create(solverConfig); + Assertions.assertThat(solverFactory.buildSolver()).isNotNull(); + } + + @Test + void localSearchAfterLocalSearchEndedOnlyWithSolver() { + // A time limit on the solver ends the solver too, so the second local search is unreachable. + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEasyScoreCalculatorClass(DummySimpleScoreEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig().withSecondsSpentLimit(10L)) + .withPhases(new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig(), + new LocalSearchPhaseConfig()); + + var solverFactory = SolverFactory. create(solverConfig); + Assertions.assertThatThrownBy(solverFactory::buildSolver) + .hasMessageContaining("unreachable phase"); + } + @Test void validateInitializationPhases() { // Default configuration diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java index 9da8064b412..ada1fb927f0 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationTest.java @@ -374,10 +374,7 @@ void solverLevelAndOfGlobalAndLocalTermination() { .withTerminationCompositionStyle(TerminationCompositionStyle.AND) .withMoveCountLimit(1L) .withUnimprovedStepCountLimit(3)) - // Phase validation requires a termination on a phase that another phase follows; - // this one is far away, so the solver-level AND ends the phase. - .withPhases(new ConstructionHeuristicPhaseConfig(), - new LocalSearchPhaseConfig().withTerminationConfig(new TerminationConfig().withStepCountLimit(10_000)), + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig(), new LocalSearchPhaseConfig()); var solver = (DefaultSolver) SolverFactory. create(solverConfig) .buildSolver(); From 4928a607a0e11ade2ee905949432a6efd8ba8078 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Fri, 6 Nov 2026 10:37:50 +0100 Subject: [PATCH 7/8] fix: restart the diminished returns grace period on every step The grace period restarted only inside isPhaseTerminated(). An AND skips that call after a termination that is not met, so the restart came late, and simulated annealing did not heat up again. --- .../DiminishedReturnsTermination.java | 9 +++++++- .../DiminishedReturnsTerminationTest.java | 23 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java index c93deb0d926..9746bf20320 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java @@ -205,7 +205,14 @@ public void stepStarted(AbstractStepScope stepScope) { @Override public void stepEnded(AbstractStepScope stepScope) { - step(System.nanoTime(), stepScope.getPhaseScope().getBestScore()); + var phaseScope = stepScope.getPhaseScope(); + var currentTime = System.nanoTime(); + var bestScore = phaseScope. getBestScore(); + step(currentTime, bestScore); + if (isApplicableTo(phaseScope.getClass())) { + // Restarts the grace period on time, also when a composite such as AND skips isPhaseTerminated(). + isTerminated(currentTime, bestScore); + } } @SuppressWarnings("rawtypes") diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java index 399c3ebc748..0e34983ec0f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.api.score.HardSoftScore; import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; import ai.timefold.solver.core.impl.score.director.InnerScore; @@ -253,9 +254,6 @@ void testGracePeriodAndLongInitializationPeriod() { termination.stepStarted(firstStepScope); assertThat(termination.isGracePeriodStarted()).isTrue(); assertThat(termination.isTerminated(termination.getGracePeriodStartTimeNanos(), score)).isFalse(); - // It can be terminated after the first step started - assertThat(termination.isTerminated(termination.getGracePeriodStartTimeNanos() + 2_000 * NANOS_PER_MILLISECOND, score)) - .isTrue(); termination.stepEnded(firstStepScope); // Second step @@ -267,6 +265,8 @@ void testGracePeriodAndLongInitializationPeriod() { assertThat(termination.getGracePeriodStartTimeNanos()).isEqualTo(currentTime); termination.stepEnded(secondStepScope); + // It can be terminated after the first step started + assertThat(termination.isTerminated(currentTime + 2_000 * NANOS_PER_MILLISECOND, score)).isTrue(); // Timeout assertThat(termination.isTerminated(currentTime + 11_000 * NANOS_PER_MILLISECOND, score)).isTrue(); } @@ -294,6 +294,23 @@ void timeGradientAssumesOneWindowLeftSinceTheLastReset() { assertThat(termination.calculateTimeGradient(40 * NANOS_PER_MILLISECOND)).isEqualTo(0.5, offset(1e-9)); } + @Test + void stepEndedRestartsGracePeriodWithoutIsPhaseTerminated() { + // An AND skips isPhaseTerminated() of its later terminations while an earlier one is not met. + var termination = new DiminishedReturnsTermination(10_000, 0.5); + termination.start(0L, InnerScore.fullyAssigned(HardSoftScore.of(-1, 0))); + var phaseScope = mock(LocalSearchPhaseScope.class); + doReturn(InnerScore.fullyAssigned(HardSoftScore.of(0, 0))).when(phaseScope).getBestScore(); + var stepScope = mock(AbstractStepScope.class); + doReturn(phaseScope).when(stepScope).getPhaseScope(); + + termination.stepEnded(stepScope); + + var gracePeriodStartTimeNanos = termination.getGracePeriodStartTimeNanos(); + assertThat(gracePeriodStartTimeNanos).isPositive(); + assertThat(termination.calculateTimeGradient(gracePeriodStartTimeNanos)).isZero(); + } + @Test void phaseTimeGradientIsSupported() { var termination = new DiminishedReturnsTermination(10_000, 0.5); From 9e9419e4c3b8c357b7b159e422cfabd0dd26f271 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Fri, 6 Nov 2026 10:38:46 +0100 Subject: [PATCH 8/8] test: make the diminished returns grace period test read in order --- .../termination/DiminishedReturnsTerminationTest.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java index 0e34983ec0f..e510d4252a4 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTerminationTest.java @@ -260,15 +260,13 @@ void testGracePeriodAndLongInitializationPeriod() { var secondStepScope = mock(AbstractStepScope.class); doReturn(phaseScope).when(secondStepScope).getPhaseScope(); // The second step does not start the timer again - var currentTime = termination.getGracePeriodStartTimeNanos(); + var firstStepStartTime = termination.getGracePeriodStartTimeNanos(); termination.stepStarted(secondStepScope); - assertThat(termination.getGracePeriodStartTimeNanos()).isEqualTo(currentTime); + assertThat(termination.getGracePeriodStartTimeNanos()).isEqualTo(firstStepStartTime); termination.stepEnded(secondStepScope); - // It can be terminated after the first step started - assertThat(termination.isTerminated(currentTime + 2_000 * NANOS_PER_MILLISECOND, score)).isTrue(); - // Timeout - assertThat(termination.isTerminated(currentTime + 11_000 * NANOS_PER_MILLISECOND, score)).isTrue(); + // One sliding window after the first step started, the grace period ends without improvement. + assertThat(termination.isTerminated(firstStepStartTime + 1_000 * NANOS_PER_MILLISECOND, score)).isTrue(); } @Test