diff --git a/benchmark/src/main/resources/benchmark.xsd b/benchmark/src/main/resources/benchmark.xsd index 1549f7cff7..34bef398b7 100644 --- a/benchmark/src/main/resources/benchmark.xsd +++ b/benchmark/src/main/resources/benchmark.xsd @@ -587,6 +587,15 @@ + + + + + + + + + diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index d348497b80..eea65dcb89 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -80,6 +80,17 @@ "old": "method Score_ ai.timefold.solver.core.api.score.constraint.ConstraintMatch>::getScore()", "new": "method Score_ ai.timefold.solver.core.api.score.constraint.ConstraintMatch>::getScore()", "justification": "False positive after addition of @NonNull annotation" + }, + { + "ignore": true, + "code": "java.annotation.attributeValueChanged", + "old": "class ai.timefold.solver.core.config.solver.termination.TerminationConfig", + "new": "class ai.timefold.solver.core.config.solver.termination.TerminationConfig", + "annotationType": "jakarta.xml.bind.annotation.XmlType", + "attribute": "propOrder", + "oldValue": "{\"terminationClass\", \"terminationCompositionStyle\", \"spentLimit\", \"millisecondsSpentLimit\", \"secondsSpentLimit\", \"minutesSpentLimit\", \"hoursSpentLimit\", \"daysSpentLimit\", \"unimprovedSpentLimit\", \"unimprovedMillisecondsSpentLimit\", \"unimprovedSecondsSpentLimit\", \"unimprovedMinutesSpentLimit\", \"unimprovedHoursSpentLimit\", \"unimprovedDaysSpentLimit\", \"unimprovedScoreDifferenceThreshold\", \"bestScoreLimit\", \"bestScoreFeasible\", \"stepCountLimit\", \"unimprovedStepCountLimit\", \"scoreCalculationCountLimit\", \"terminationConfigList\"}", + "newValue": "{\"terminationClass\", \"terminationCompositionStyle\", \"spentLimit\", \"millisecondsSpentLimit\", \"secondsSpentLimit\", \"minutesSpentLimit\", \"hoursSpentLimit\", \"daysSpentLimit\", \"unimprovedSpentLimit\", \"unimprovedMillisecondsSpentLimit\", \"unimprovedSecondsSpentLimit\", \"unimprovedMinutesSpentLimit\", \"unimprovedHoursSpentLimit\", \"unimprovedDaysSpentLimit\", \"unimprovedScoreDifferenceThreshold\", \"bestScoreLimit\", \"bestScoreFeasible\", \"stepCountLimit\", \"unimprovedStepCountLimit\", \"scoreCalculationCountLimit\", \"moveCountLimit\", \"stopFlatLineDetectionRatio\", \"noStopFlatLineDetectionRatio\", \"delayFlatLineSecondsSpentLimit\", \"terminationConfigList\"}", + "justification": "Add new termination config" } ] } diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/termination/TerminationConfig.java b/core/src/main/java/ai/timefold/solver/core/config/solver/termination/TerminationConfig.java index 2c3456ab50..08396b056b 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/termination/TerminationConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/termination/TerminationConfig.java @@ -39,6 +39,9 @@ "unimprovedStepCountLimit", "scoreCalculationCountLimit", "moveCountLimit", + "stopFlatLineDetectionRatio", + "noStopFlatLineDetectionRatio", + "delayFlatLineSecondsSpentLimit", "terminationConfigList" }) public class TerminationConfig extends AbstractConfig { @@ -78,6 +81,10 @@ public class TerminationConfig extends AbstractConfig { private Long moveCountLimit = null; + private Double stopFlatLineDetectionRatio = null; + private Double noStopFlatLineDetectionRatio = null; + private Long delayFlatLineSecondsSpentLimit = null; + @XmlElement(name = "termination") private List terminationConfigList = null; @@ -257,6 +264,30 @@ public void setMoveCountLimit(@Nullable Long moveCountLimit) { this.moveCountLimit = moveCountLimit; } + public @Nullable Double getStopFlatLineDetectionRatio() { + return stopFlatLineDetectionRatio; + } + + public void setStopFlatLineDetectionRatio(@Nullable Double stopFlatLineDetectionRatio) { + this.stopFlatLineDetectionRatio = stopFlatLineDetectionRatio; + } + + public @Nullable Double getNoStopFlatLineDetectionRatio() { + return noStopFlatLineDetectionRatio; + } + + public void setNoStopFlatLineDetectionRatio(@Nullable Double noStopFlatLineDetectionRatio) { + this.noStopFlatLineDetectionRatio = noStopFlatLineDetectionRatio; + } + + public @Nullable Long getDelayFlatLineSecondsSpentLimit() { + return delayFlatLineSecondsSpentLimit; + } + + public void setDelayFlatLineSecondsSpentLimit(@Nullable Long delayFlatLineSecondsSpentLimit) { + this.delayFlatLineSecondsSpentLimit = delayFlatLineSecondsSpentLimit; + } + public @Nullable List<@NonNull TerminationConfig> getTerminationConfigList() { return terminationConfigList; } @@ -380,6 +411,21 @@ public TerminationConfig withTerminationClass(Class termi return this; } + public @NonNull TerminationConfig withStopFlatLineDetectionRatio(@NonNull Double stopFlatLineDetectionRatio) { + this.stopFlatLineDetectionRatio = stopFlatLineDetectionRatio; + return this; + } + + public @NonNull TerminationConfig withNoStopFlatLineDetectionRatio(@NonNull Double noStopFlatLineDetectionRatio) { + this.noStopFlatLineDetectionRatio = noStopFlatLineDetectionRatio; + return this; + } + + public @NonNull TerminationConfig withDelayFlatLineSecondsSpentLimit(@NonNull Long delayFlatLineSecondsSpentLimit) { + this.delayFlatLineSecondsSpentLimit = delayFlatLineSecondsSpentLimit; + return this; + } + public @NonNull TerminationConfig withTerminationConfigList(@NonNull List<@NonNull TerminationConfig> terminationConfigList) { this.terminationConfigList = terminationConfigList; @@ -489,6 +535,9 @@ public boolean isConfigured() { unimprovedStepCountLimit != null || scoreCalculationCountLimit != null || moveCountLimit != null || + stopFlatLineDetectionRatio != null || + noStopFlatLineDetectionRatio != null || + delayFlatLineSecondsSpentLimit != null || isTerminationListConfigured(); } @@ -529,6 +578,12 @@ private boolean isTerminationListConfigured() { inheritedConfig.getScoreCalculationCountLimit()); moveCountLimit = ConfigUtils.inheritOverwritableProperty(moveCountLimit, inheritedConfig.getMoveCountLimit()); + stopFlatLineDetectionRatio = ConfigUtils.inheritOverwritableProperty(stopFlatLineDetectionRatio, + inheritedConfig.getStopFlatLineDetectionRatio()); + noStopFlatLineDetectionRatio = ConfigUtils.inheritOverwritableProperty(noStopFlatLineDetectionRatio, + inheritedConfig.getNoStopFlatLineDetectionRatio()); + delayFlatLineSecondsSpentLimit = ConfigUtils.inheritOverwritableProperty(delayFlatLineSecondsSpentLimit, + inheritedConfig.getDelayFlatLineSecondsSpentLimit()); terminationConfigList = ConfigUtils.inheritMergeableListConfig( terminationConfigList, inheritedConfig.getTerminationConfigList()); return this; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractTermination.java index f8cd68cee1..8332841d61 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/AbstractTermination.java @@ -12,7 +12,7 @@ public abstract sealed class AbstractTermination implements Termination permits AbstractCompositeTermination, BasicPlumbingTermination, BestScoreFeasibleTermination, BestScoreTermination, ChildThreadPlumbingTermination, MoveCountTermination, PhaseToSolverTerminationBridge, ScoreCalculationCountTermination, - StepCountTermination, TimeMillisSpentTermination, UnimprovedStepCountTermination, + StepCountTermination, TimeMillisSpentTermination, UnimprovedBestSolutionTermination, UnimprovedStepCountTermination, UnimprovedTimeMillisSpentScoreDifferenceThresholdTermination, UnimprovedTimeMillisSpentTermination { protected final transient Logger logger = LoggerFactory.getLogger(getClass()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactory.java index 02922575fb..8f51535218 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactory.java @@ -88,6 +88,13 @@ The termination with bestScoreFeasible (%s) can only be used with a score type \ if (terminationConfig.getMoveCountLimit() != null) { terminationList.add(new MoveCountTermination<>(terminationConfig.getMoveCountLimit())); } + if (terminationConfig.getStopFlatLineDetectionRatio() != null + || terminationConfig.getNoStopFlatLineDetectionRatio() != null + || terminationConfig.getDelayFlatLineSecondsSpentLimit() != null) { + terminationList.add(new UnimprovedBestSolutionTermination<>(terminationConfig.getStopFlatLineDetectionRatio(), + terminationConfig.getNoStopFlatLineDetectionRatio(), + terminationConfig.getDelayFlatLineSecondsSpentLimit())); + } terminationList.addAll(buildInnerTermination(configPolicy)); return buildTerminationFromList(terminationList); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTermination.java new file mode 100644 index 0000000000..7233900be7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTermination.java @@ -0,0 +1,210 @@ +package ai.timefold.solver.core.impl.solver.termination; + +import java.time.Clock; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; + +public final class UnimprovedBestSolutionTermination extends AbstractTermination { + + // Evaluation delay to avoid early conclusions + private final long delayExecutionTimeMillis; + // This setting determines the amount of time + // that is allowed without any improvements since the last best solution was identified. + // For example, if the last solution was found at 10 seconds and the setting is configured to 0.5, + // the solver will stop if no improvement is made within 5 seconds. + private final double stopFlatLineDetectionRatio; + // This criterion functions similarly to the stopFlatLineDetectionRatio, + // as it is also used to identify periods without improvement. + // However, the key difference is that it focuses on detecting "flat lines" between solution improvements. + // When a flat line is detected after the solution has improved, + // it indicates that the previous duration was not enough to terminate the process, + // but it indicates that the solver will begin + // re-evaluating the termination criterion from the last improvement before the recent improvement. + private final double noStopFlatLineDetectionRatio; + private final Clock clock; + // The field stores the time of the first best solution of the current curve. + // If a solving process involves multiple curves, + // the value is tied to the growth of the last curve analyzed. + protected long initialCurvePointMillis; + protected long lastImprovementMillis; + private Score previousBest; + protected Score currentBest; + protected boolean waitForFirstBestScore; + protected Boolean terminate; + + public UnimprovedBestSolutionTermination(Double stopFlatLineDetectionRatio, + Double noStopFlatLineDetectionRatio, Long delayFlatLineSecondsSpentLimit) { + this(stopFlatLineDetectionRatio, noStopFlatLineDetectionRatio, delayFlatLineSecondsSpentLimit, Clock.systemUTC()); + } + + public UnimprovedBestSolutionTermination(Double stopFlatLineDetectionRatio, Double noStopFlatLineDetectionRatio, + Long delayFlatLineSecondsSpentLimit, Clock clock) { + this.stopFlatLineDetectionRatio = Objects.requireNonNull(stopFlatLineDetectionRatio, + "The field stopFlatLineDetectionRatio is required for the termination UnimprovedBestSolutionTermination"); + this.noStopFlatLineDetectionRatio = Objects.requireNonNull(noStopFlatLineDetectionRatio, + "The field noStopFlatLineDetectionRatio is required for the termination UnimprovedBestSolutionTermination"); + this.delayExecutionTimeMillis = + (Objects.requireNonNull(delayFlatLineSecondsSpentLimit, + "The field delayFlatLineSecondsSpentLimit is required for the termination UnimprovedBestSolutionTermination") + * 1000L); + this.clock = Objects.requireNonNull(clock); + if (stopFlatLineDetectionRatio < 0) { + throw new IllegalArgumentException( + "The stopFlatLineDetectionRatio (%.2f) cannot be negative.".formatted(stopFlatLineDetectionRatio)); + } + if (noStopFlatLineDetectionRatio < 0) { + throw new IllegalArgumentException( + "The noStopFlatLineDetectionRatio (%.2f) cannot be negative.".formatted(noStopFlatLineDetectionRatio)); + } + if (noStopFlatLineDetectionRatio > stopFlatLineDetectionRatio) { + throw new IllegalArgumentException( + "The noStopFlatLineDetectionRatio (%.2f) cannot be greater than stopFlatLineDetectionRatio (%.2f)." + .formatted(noStopFlatLineDetectionRatio, stopFlatLineDetectionRatio)); + } + if (delayFlatLineSecondsSpentLimit < 0) { + throw new IllegalArgumentException( + "The delayFlatLineSecondsSpentLimit (%d) cannot be negative.".formatted(delayFlatLineSecondsSpentLimit)); + } + } + + public long getDelayExecutionTimeMillis() { + return delayExecutionTimeMillis; + } + + public double getStopFlatLineDetectionRatio() { + return stopFlatLineDetectionRatio; + } + + public double getNoStopFlatLineDetectionRatio() { + return noStopFlatLineDetectionRatio; + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + @SuppressWarnings("unchecked") + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + initialCurvePointMillis = clock.millis(); + lastImprovementMillis = 0L; + currentBest = phaseScope.getBestScore(); + previousBest = currentBest; + waitForFirstBestScore = true; + terminate = null; + } + + @Override + public void stepStarted(AbstractStepScope stepScope) { + super.stepStarted(stepScope); + terminate = null; + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void stepEnded(AbstractStepScope stepScope) { + super.stepEnded(stepScope); + if (waitForFirstBestScore) { + waitForFirstBestScore = ((Score) currentBest).compareTo(stepScope.getScore()) >= 0; + } + } + + // ************************************************************************ + // Terminated methods + // ************************************************************************ + + @Override + public boolean isSolverTerminated(SolverScope solverScope) { + throw new UnsupportedOperationException( + "%s can only be used for phase termination.".formatted(getClass().getSimpleName())); + } + + @Override + public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { + if (terminate != null) { + return terminate; + } + // Validate if there is a first best solution + if (waitForFirstBestScore) { + return false; + } + var currentTimeMillis = clock.millis(); + var improved = currentBest.compareTo(phaseScope.getBestScore()) < 0; + var lastImprovementInterval = lastImprovementMillis - initialCurvePointMillis; + var completeInterval = currentTimeMillis - initialCurvePointMillis; + var newInterval = currentTimeMillis - lastImprovementMillis; + if (improved) { + // If there is a flat line between the last and new best solutions, + // the initial value becomes the most recent best score, + // as it would be the starting point for the new curve. + var minInterval = Math.floor(lastImprovementInterval * noStopFlatLineDetectionRatio); + var maxInterval = Math.floor(lastImprovementInterval * stopFlatLineDetectionRatio); + if (lastImprovementMillis > 0 && completeInterval >= delayExecutionTimeMillis && newInterval >= minInterval + && newInterval < maxInterval) { + initialCurvePointMillis = lastImprovementMillis; + previousBest = currentBest; + if (logger.isInfoEnabled()) { + logger.debug("Starting a new curve with ({}), time interval ({}s)", + previousBest, + String.format("%.2f", completeInterval / 1000.0)); + } + } + lastImprovementMillis = currentTimeMillis; + currentBest = phaseScope.getBestScore(); + terminate = null; + return false; + } else { + if (completeInterval < delayExecutionTimeMillis) { + return false; + } + var maxInterval = Math.floor(lastImprovementInterval * stopFlatLineDetectionRatio); + if (newInterval > maxInterval) { + terminate = true; + return true; + } else { + terminate = null; + return false; + } + } + } + + // ************************************************************************ + // Time gradient methods + // ************************************************************************ + + @Override + public double calculateSolverTimeGradient(SolverScope solverScope) { + throw new UnsupportedOperationException( + "%s can only be used for phase termination.".formatted(getClass().getSimpleName())); + } + + @Override + public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScope) { + // The value will change during the solving process. + // Therefore, it is not possible to provide a number asymptotically incrementally + return -1.0; + } + + // ************************************************************************ + // Other methods + // ************************************************************************ + + @Override + public UnimprovedBestSolutionTermination createChildThreadTermination(SolverScope solverScope, + ChildThreadType childThreadType) { + return new UnimprovedBestSolutionTermination<>(stopFlatLineDetectionRatio, noStopFlatLineDetectionRatio, + delayExecutionTimeMillis / 1000, clock); + } + + @Override + public String toString() { + return "UnimprovedBestSolutionTermination(%.2f, %.2f, %d)".formatted(stopFlatLineDetectionRatio, + noStopFlatLineDetectionRatio, delayExecutionTimeMillis / 1000); + } +} diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index 766a833033..0c0be6c341 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -197,6 +197,12 @@ + + + + + + diff --git a/core/src/test/java/ai/timefold/solver/core/config/solver/termination/TerminationConfigTest.java b/core/src/test/java/ai/timefold/solver/core/config/solver/termination/TerminationConfigTest.java index db0bc3fbcf..3791f8a68d 100644 --- a/core/src/test/java/ai/timefold/solver/core/config/solver/termination/TerminationConfigTest.java +++ b/core/src/test/java/ai/timefold/solver/core/config/solver/termination/TerminationConfigTest.java @@ -84,6 +84,19 @@ void childWithTimeSpentLimitShouldNotInheritTimeSpentLimitFromParent() { assertThat(child.getMinutesSpentLimit()).isNull(); } + @Test + void childWithUnimprovedPropertiesFromParent() { + TerminationConfig child = new TerminationConfig(); + TerminationConfig parent = new TerminationConfig() + .withStopFlatLineDetectionRatio(0.5) + .withNoStopFlatLineDetectionRatio(0.1) + .withDelayFlatLineSecondsSpentLimit(10L); + child.inherit(parent); + assertThat(child.getStopFlatLineDetectionRatio()).isEqualTo(0.5); + assertThat(child.getNoStopFlatLineDetectionRatio()).isEqualTo(0.1); + assertThat(child.getDelayFlatLineSecondsSpentLimit()).isEqualTo(10L); + } + @Test void checkMoveCountMetrics() { TerminationConfig parent = new TerminationConfig() diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactoryTest.java index ab32c6adc4..751a611728 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/TerminationFactoryTest.java @@ -201,4 +201,55 @@ void bestScoreFeasible_requiresAtLeastOneFeasibleLevel() { .isThrownBy(() -> terminationFactory.buildTermination(heuristicConfigPolicy)) .withMessageContaining("can only be used with a score type that has at least 1 feasible level"); } + + @Test + void buildBestScoreFeasible() { + var heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); + when(heuristicConfigPolicy.getScoreDefinition()).thenReturn(new HardSoftScoreDefinition()); + var terminationConfig = new TerminationConfig(); + terminationConfig.setBestScoreFeasible(true); + var termination = TerminationFactory.create(terminationConfig).buildTermination(heuristicConfigPolicy); + assertThat(termination) + .isInstanceOf(BestScoreFeasibleTermination.class); + } + + @Test + void buildStepCountLimit() { + var heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); + when(heuristicConfigPolicy.getScoreDefinition()).thenReturn(new HardSoftScoreDefinition()); + var terminationConfig = new TerminationConfig(); + terminationConfig.setStepCountLimit(1); + var termination = TerminationFactory.create(terminationConfig).buildTermination(heuristicConfigPolicy); + assertThat(termination) + .isInstanceOf(StepCountTermination.class); + } + + @Test + void buildUnimprovedStepCountLimit() { + var heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); + when(heuristicConfigPolicy.getScoreDefinition()).thenReturn(new HardSoftScoreDefinition()); + var terminationConfig = new TerminationConfig(); + terminationConfig.withUnimprovedStepCountLimit(1); + var termination = TerminationFactory.create(terminationConfig).buildTermination(heuristicConfigPolicy); + assertThat(termination) + .isInstanceOf(UnimprovedStepCountTermination.class); + } + + @Test + void buildUnimprovedBestScoreRatio() { + var terminationConfig = new TerminationConfig(); + terminationConfig.setStopFlatLineDetectionRatio(0.5); + terminationConfig.setNoStopFlatLineDetectionRatio(0.1); + terminationConfig.setDelayFlatLineSecondsSpentLimit(10L); + var termination = TerminationFactory.create(terminationConfig) + .buildTermination(mock(HeuristicConfigPolicy.class)); + assertThat(termination) + .isInstanceOf(UnimprovedBestSolutionTermination.class); + assertThat(((UnimprovedBestSolutionTermination) termination).getStopFlatLineDetectionRatio()) + .isEqualTo(0.5); + assertThat(((UnimprovedBestSolutionTermination) termination).getNoStopFlatLineDetectionRatio()) + .isEqualTo(0.1); + assertThat(((UnimprovedBestSolutionTermination) termination).getDelayExecutionTimeMillis()) + .isEqualTo(10000L); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTerminationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTerminationTest.java new file mode 100644 index 0000000000..2bdaf510b2 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/termination/UnimprovedBestSolutionTerminationTest.java @@ -0,0 +1,130 @@ +package ai.timefold.solver.core.impl.solver.termination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.Mockito.when; + +import java.time.Clock; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.testdata.domain.TestdataSolution; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class UnimprovedBestSolutionTerminationTest { + + @Test + void testTermination() { + Clock clock = Mockito.mock(Clock.class); + var currentTime = Clock.systemUTC().millis(); + var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, 10L, clock); + var solverScope = Mockito.mock(SolverScope.class); + var phaseScope = Mockito.mock(LocalSearchPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(phaseScope.getBestScore()).thenReturn(SimpleScore.of(2)); + when(solverScope.getMoveEvaluationSpeed()).thenReturn(1L); + termination.phaseStarted(phaseScope); + termination.waitForFirstBestScore = false; + + // Terminate + termination.currentBest = SimpleScore.of(2); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 21_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isTrue(); + + // Don't terminate + termination.terminate = null; + termination.currentBest = SimpleScore.of(2); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 14_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); + + assertThat(termination.calculatePhaseTimeGradient(phaseScope)).isEqualTo(-1.0); + } + + @Test + void testStartNewPoint() { + Clock clock = Mockito.mock(Clock.class); + var currentTime = Clock.systemUTC().millis(); + var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, 10L, clock); + var solverScope = Mockito.mock(SolverScope.class); + var phaseScope = Mockito.mock(LocalSearchPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(phaseScope.getBestScore()).thenReturn(SimpleScore.of(2)); + when(solverScope.getMoveEvaluationSpeed()).thenReturn(1L); + termination.phaseStarted(phaseScope); + termination.waitForFirstBestScore = false; + + // New start point + termination.currentBest = SimpleScore.of(1); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 14_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); + assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime + 10_000); + + // Don't change start point - flat line smaller than the minimum + termination.terminate = null; + termination.currentBest = SimpleScore.of(1); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 11_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); + assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime); + + // Don't change start point - flat line larger than the minimum + termination.terminate = null; + termination.currentBest = SimpleScore.of(1); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 16_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); + assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime); + } + + @Test + void testDelayInterval() { + Clock clock = Mockito.mock(Clock.class); + var currentTime = Clock.systemUTC().millis(); + var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, 10L, clock); + var solverScope = Mockito.mock(SolverScope.class); + var phaseScope = Mockito.mock(LocalSearchPhaseScope.class); + when(phaseScope.getSolverScope()).thenReturn(solverScope); + when(phaseScope.getBestScore()).thenReturn(SimpleScore.of(2)); + when(solverScope.getMoveEvaluationSpeed()).thenReturn(1L); + termination.phaseStarted(phaseScope); + termination.waitForFirstBestScore = false; + + // Don't terminate + termination.currentBest = SimpleScore.of(2); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 1_000; + when(clock.millis()).thenReturn(currentTime + 9_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); + + // Terminate + termination.terminate = null; + termination.currentBest = SimpleScore.of(2); + termination.initialCurvePointMillis = currentTime; + termination.lastImprovementMillis = currentTime + 1_000; + when(clock.millis()).thenReturn(currentTime + 10_000); + assertThat(termination.isPhaseTerminated(phaseScope)).isTrue(); + } + + @Test + void invalidTermination() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new UnimprovedBestSolutionTermination(-1.0, 0.0, 0L)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, -1.0, 0L)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, 1.0, 0L)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new UnimprovedBestSolutionTermination(1.0, 1.0, -1L)); + } +} diff --git a/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.png b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.png new file mode 100644 index 0000000000..4e039c9df3 Binary files /dev/null and b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.png differ diff --git a/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.svg b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.svg new file mode 100644 index 0000000000..7b423055d0 --- /dev/null +++ b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNoImprovement.svg @@ -0,0 +1,7677 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + en + + + + + + No improvement after some time + +   +   + + + Time + + + Score + + + best score(score of best solution) + local optimum + + + + + + + + No improvement after 30 seconds + 70s + + 10s + 100s + + + + + diff --git a/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.png b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.png new file mode 100644 index 0000000000..387b830b4f Binary files /dev/null and b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.png differ diff --git a/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.svg b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.svg new file mode 100644 index 0000000000..3b82aae0b3 --- /dev/null +++ b/docs/src/modules/ROOT/images/optimization-algorithms/overview/terminationNonStopCriteria.svg @@ -0,0 +1,7688 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + en + + + + + + Non-stop criterion + +   +   + + + Time + + + Score + + + best score(score of best solution) + + + + + + 70s + + 10s + 82s + + + + + + + No improvement after 12 seconds + new start point + + + diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc index a8f0c7f83d..32b1842beb 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc @@ -565,6 +565,68 @@ Terminates as soon as a feasible solution has been discovered. This `Termination` is usually combined with other terminations. +[#unimprovedBestSolutionTermination] +==== `UnimprovedBestSolutionTermination` + +`UnimprovedBestSolutionTermination` terminates the process when the solver cannot enhance the best solution over a time period. +The duration for this period is determined by the last time +the solver improved the solution and how much time has passed without any improvements. + +[source,xml,options="nowrap"] +---- + + 0.5 + 0.1 + 120 + +---- + +Let's consider a solution process based on the following image. + +image::optimization-algorithms/overview/terminationNoImprovement.png[align="center"] + +In the previous image, the unimproved time is represented by the flat red line. +With the stop condition `stopFlatLineDetectionRatio` set to `0.5`, +and considering the start point at `10 seconds`, the last best score achieved at `70 seconds`, +the solver will terminate the process if no improvement is found within a maximum of `30 seconds`. +This `30-seconds` limit is calculated +by multiplying the last improvement time (`70s - 10s = 60 seconds`) by the `stopFlatLineDetectionRatio` (`0.5`). +Similarly, +setting the `stopFlatLineDetectionRatio` to `1.0` will lead to termination after `60 seconds` if there is no improvement. + +[NOTE] +==== +The `Termination` process requires a minimum execution time, specified by `delayFlatLineSecondsSpentLimit`. +==== + +The solving process may identify no improvement periods that are not significant enough to trigger the termination. +These periods are referred to as *non-stop* periods, which are defined by the `noStopFlatLineDetectionRatio`. +Let's analyze the next image: + +image::optimization-algorithms/overview/terminationNonStopCriteria.png[align="center"] + +The orange flat line represents a *non-stop* period. +By setting the `stopFlatLineDetectionRatio` to `0.5` and the `noStopFlatLineDetectionRatio` to `0.1`, +we can calculate the unimproved stop period as `(70s - 10s) * 0.5 = 30s`, +while the unimproved non-stop period is calculated as `(70s - 10s) * 0.1 = 6s`. +The difference between the solutions found at `70 seconds` and `82 seconds` is `12 seconds`. +A *non-stop* period is accepted if the time difference between the last best solution and the new one is greater +than or equal to the *non-stop* period and less than the stop period. + +Accepting a *non-stop* stop period means adjusting the start time to the last improvement time. +In the previous example, the start time will change from `10` to `70` seconds. + +Evaluating the stopping criterion only at the beginning of the solver process may prevent it from terminating, +as the necessary period without improvement tends to increase over time. +Adjusting the start time enables the solver +to advance its termination analysis when the solution remains unchanged for a specific duration, +and terminate the solver after evaluating more recent events. + +[NOTE] +==== +The minimum execution time must be met whenever the start time changes, +meaning the total executed time will be calculated from the new starting point. +==== [#stepCountTermination] ==== `StepCountTermination`