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/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/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/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..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,4 +109,53 @@ 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. + * + * @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..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,19 +55,29 @@ 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. * - * @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 +87,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..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 @@ -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 @@ -190,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/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..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,15 +93,26 @@ 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. * - * @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 +126,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 +143,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..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) { @@ -49,6 +57,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/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/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/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/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..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 @@ -2,11 +2,13 @@ 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; 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; @@ -252,22 +254,19 @@ 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 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); - // 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 @@ -278,4 +277,43 @@ 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 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); + 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..153c54a3c3a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/SolverBridgePhaseTerminationTest.java @@ -0,0 +1,83 @@ +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 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)); + 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/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..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 @@ -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,56 @@ 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)) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig(), + 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 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..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,6 +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 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 c368f64cbf9..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 @@ -136,6 +136,67 @@ For `Solver`, implement `solveAndSummarize(problem)` instead of `solve(problem)` ''' +icon:eye[set=far] Simulated annealing cools with every termination +[%collapsible%open] +==== +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 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 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] == Changes to preview features