diff --git a/benchmark/src/main/resources/benchmark.xsd b/benchmark/src/main/resources/benchmark.xsd index 50feb9d165..c80c46d370 100644 --- a/benchmark/src/main/resources/benchmark.xsd +++ b/benchmark/src/main/resources/benchmark.xsd @@ -593,6 +593,9 @@ + + + diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index 73ec6bd04b..4ed7705772 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -89,7 +89,7 @@ "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\", \"flatLineDetectionRatio\", \"newCurveDetectionRatio\", \"terminationConfigList\"}", + "newValue": "{\"terminationClass\", \"terminationCompositionStyle\", \"spentLimit\", \"millisecondsSpentLimit\", \"secondsSpentLimit\", \"minutesSpentLimit\", \"hoursSpentLimit\", \"daysSpentLimit\", \"unimprovedSpentLimit\", \"unimprovedMillisecondsSpentLimit\", \"unimprovedSecondsSpentLimit\", \"unimprovedMinutesSpentLimit\", \"unimprovedHoursSpentLimit\", \"unimprovedDaysSpentLimit\", \"unimprovedScoreDifferenceThreshold\", \"bestScoreLimit\", \"bestScoreFeasible\", \"stepCountLimit\", \"unimprovedStepCountLimit\", \"scoreCalculationCountLimit\", \"moveCountLimit\", \"flatLineDetectionRatio\", \"newCurveDetectionRatio\", \"minimalExecutionTimeSeconds\", \"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 4fa83c492f..cbd6b4a212 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 @@ -41,6 +41,7 @@ "moveCountLimit", "flatLineDetectionRatio", "newCurveDetectionRatio", + "minimalExecutionTimeSeconds", "terminationConfigList" }) public class TerminationConfig extends AbstractConfig { @@ -82,6 +83,7 @@ public class TerminationConfig extends AbstractConfig { private Double flatLineDetectionRatio = null; private Double newCurveDetectionRatio = null; + private Long minimalExecutionTimeSeconds = null; @XmlElement(name = "termination") private List terminationConfigList = null; @@ -278,6 +280,14 @@ public void setNewCurveDetectionRatio(@Nullable Double newCurveDetectionRatio) { this.newCurveDetectionRatio = newCurveDetectionRatio; } + public @Nullable Long getMinimalExecutionTimeSeconds() { + return minimalExecutionTimeSeconds; + } + + public void setMinimalExecutionTimeSeconds(@Nullable Long minimalExecutionTimeSeconds) { + this.minimalExecutionTimeSeconds = minimalExecutionTimeSeconds; + } + public @Nullable List<@NonNull TerminationConfig> getTerminationConfigList() { return terminationConfigList; } @@ -411,6 +421,11 @@ public TerminationConfig withTerminationClass(Class termi return this; } + public @NonNull TerminationConfig withMinimalExecutionTimeSeconds(@NonNull Long minimalExecutionTimeSeconds) { + this.minimalExecutionTimeSeconds = minimalExecutionTimeSeconds; + return this; + } + public @NonNull TerminationConfig withTerminationConfigList(@NonNull List<@NonNull TerminationConfig> terminationConfigList) { this.terminationConfigList = terminationConfigList; @@ -522,6 +537,7 @@ public boolean isConfigured() { moveCountLimit != null || flatLineDetectionRatio != null || newCurveDetectionRatio != null || + minimalExecutionTimeSeconds != null || isTerminationListConfigured(); } @@ -566,6 +582,8 @@ private boolean isTerminationListConfigured() { inheritedConfig.getFlatLineDetectionRatio()); newCurveDetectionRatio = ConfigUtils.inheritOverwritableProperty(newCurveDetectionRatio, inheritedConfig.getNewCurveDetectionRatio()); + minimalExecutionTimeSeconds = ConfigUtils.inheritOverwritableProperty(minimalExecutionTimeSeconds, + inheritedConfig.getMinimalExecutionTimeSeconds()); terminationConfigList = ConfigUtils.inheritMergeableListConfig( terminationConfigList, inheritedConfig.getTerminationConfigList()); return this; 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 385b2483c7..0f0e0a2aaf 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 @@ -90,7 +90,7 @@ The termination with bestScoreFeasible (%s) can only be used with a score type \ } if (terminationConfig.getFlatLineDetectionRatio() != null) { terminationList.add(new UnimprovedBestSolutionTermination<>(terminationConfig.getFlatLineDetectionRatio(), - terminationConfig.getNewCurveDetectionRatio())); + terminationConfig.getNewCurveDetectionRatio(), terminationConfig.getMinimalExecutionTimeSeconds())); } 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 index 4be7ceb578..5c7c5543a1 100644 --- 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 @@ -12,7 +12,7 @@ public final class UnimprovedBestSolutionTermination extends AbstractTermination { // Minimal interval of time to avoid early conclusions - protected static final long MINIMAL_INTERVAL_TIME_MILLIS = 10_000L; + private final long minimalExecutionTimeMillis; // 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, @@ -37,15 +37,20 @@ public final class UnimprovedBestSolutionTermination extends Abstract protected boolean waitForFirstBestScore; protected Boolean terminate; - public UnimprovedBestSolutionTermination(Double flatLineDetectionRatio, Double newCurveDetectionRatio) { - this(flatLineDetectionRatio, newCurveDetectionRatio, Clock.systemUTC()); + public UnimprovedBestSolutionTermination(Double flatLineDetectionRatio, + Double newCurveDetectionRatio, Long minimalExecutionTimeSeconds) { + this(flatLineDetectionRatio, newCurveDetectionRatio, minimalExecutionTimeSeconds, Clock.systemUTC()); } - public UnimprovedBestSolutionTermination(Double flatLineDetectionRatio, Double newCurveDetectionRatio, Clock clock) { + public UnimprovedBestSolutionTermination(Double flatLineDetectionRatio, Double newCurveDetectionRatio, + Long minimalExecutionTimeSeconds, Clock clock) { this.flatLineDetectionRatio = Objects.requireNonNull(flatLineDetectionRatio, "The field flatLineDetectionRatio is required for the termination UnimprovedBestSolutionTermination"); this.newCurveDetectionRatio = Objects.requireNonNull(newCurveDetectionRatio, "The field newCurveDetectionRatio is required for the termination UnimprovedBestSolutionTermination"); + this.minimalExecutionTimeMillis = Objects.requireNonNull(minimalExecutionTimeSeconds, + "The field minimalExecutionTimeSeconds is required for the termination UnimprovedBestSolutionTermination") + * 1000L; this.clock = Objects.requireNonNull(clock); if (flatLineDetectionRatio < 0) { throw new IllegalArgumentException( @@ -60,10 +65,10 @@ public UnimprovedBestSolutionTermination(Double flatLineDetectionRatio, Double n "The newCurveDetectionRatio (%.2f) cannot be greater than flatLineDetectionRatio (%.2f)." .formatted(newCurveDetectionRatio, flatLineDetectionRatio)); } - } - - public double getFlatLineDetectionRatio() { - return flatLineDetectionRatio; + if (minimalExecutionTimeSeconds <= 0) { + throw new IllegalArgumentException( + "The minimalExecutionTimeSeconds %d must be great than zero.".formatted(minimalExecutionTimeSeconds)); + } } // ************************************************************************ @@ -118,15 +123,16 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { } 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(completeInterval * newCurveDetectionRatio); - var maxInterval = Math.floor(completeInterval * flatLineDetectionRatio); - if (lastImprovementMillis > 0 && completeInterval >= MINIMAL_INTERVAL_TIME_MILLIS && newInterval > minInterval + var minInterval = Math.floor(lastImprovementInterval * newCurveDetectionRatio); + var maxInterval = Math.floor(lastImprovementInterval * flatLineDetectionRatio); + if (lastImprovementMillis > 0 && completeInterval >= minimalExecutionTimeMillis && newInterval > minInterval && newInterval < maxInterval) { initialCurvePointMillis = lastImprovementMillis; previousBest = currentBest; @@ -141,10 +147,10 @@ public boolean isPhaseTerminated(AbstractPhaseScope phaseScope) { terminate = null; return false; } else { - if (completeInterval < MINIMAL_INTERVAL_TIME_MILLIS) { + if (completeInterval < minimalExecutionTimeMillis) { return false; } - var maxInterval = Math.floor(completeInterval * flatLineDetectionRatio); + var maxInterval = Math.floor(lastImprovementInterval * flatLineDetectionRatio); if (newInterval > maxInterval) { terminate = true; return true; @@ -179,7 +185,8 @@ public double calculatePhaseTimeGradient(AbstractPhaseScope phaseScop @Override public UnimprovedBestSolutionTermination createChildThreadTermination(SolverScope solverScope, ChildThreadType childThreadType) { - return new UnimprovedBestSolutionTermination<>(flatLineDetectionRatio, newCurveDetectionRatio, clock); + return new UnimprovedBestSolutionTermination<>(flatLineDetectionRatio, newCurveDetectionRatio, + minimalExecutionTimeMillis, clock); } @Override diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index 7ad1ecedd1..95f99e1cfc 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -201,6 +201,8 @@ + + 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 index 3d8d529551..c0c1fb7d2f 100644 --- 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 @@ -20,8 +20,7 @@ class UnimprovedBestSolutionTerminationTest { void testTermination() { Clock clock = Mockito.mock(Clock.class); var currentTime = Clock.systemUTC().millis(); - var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, - clock); + 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); @@ -33,16 +32,16 @@ void testTermination() { // Terminate termination.currentBest = SimpleScore.of(2); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + 10000; - when(clock.millis()).thenReturn(currentTime + 21000); + 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 + 10000; - when(clock.millis()).thenReturn(currentTime + 20000); + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 14_000); assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); } @@ -51,8 +50,7 @@ void testTermination() { void testStartNewCurve() { Clock clock = Mockito.mock(Clock.class); var currentTime = Clock.systemUTC().millis(); - var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, - clock); + var termination = new UnimprovedBestSolutionTermination(0.5, 0.3, 10L, clock); var solverScope = Mockito.mock(SolverScope.class); var phaseScope = Mockito.mock(LocalSearchPhaseScope.class); when(phaseScope.getSolverScope()).thenReturn(solverScope); @@ -64,17 +62,17 @@ void testStartNewCurve() { // Adding a new curve termination.currentBest = SimpleScore.of(1); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + 10000; - when(clock.millis()).thenReturn(currentTime + 19000); + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 14_000); assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); - assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime + 10000); + assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime + 10_000); // Not adding a new curve - flat line smaller than the minimum termination.terminate = null; termination.currentBest = SimpleScore.of(1); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + 10000; - when(clock.millis()).thenReturn(currentTime + 11000); + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 11_000); assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime); @@ -82,8 +80,8 @@ void testStartNewCurve() { termination.terminate = null; termination.currentBest = SimpleScore.of(1); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + 10000; - when(clock.millis()).thenReturn(currentTime + 20000); + termination.lastImprovementMillis = currentTime + 10_000; + when(clock.millis()).thenReturn(currentTime + 16_000); assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); assertThat(termination.initialCurvePointMillis).isEqualTo(currentTime); } @@ -92,8 +90,7 @@ void testStartNewCurve() { void testMinimalInterval() { Clock clock = Mockito.mock(Clock.class); var currentTime = Clock.systemUTC().millis(); - var termination = new UnimprovedBestSolutionTermination(0.5, 0.4, - clock); + 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); @@ -105,26 +102,28 @@ void testMinimalInterval() { // Don't terminate termination.currentBest = SimpleScore.of(2); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + UnimprovedBestSolutionTermination.MINIMAL_INTERVAL_TIME_MILLIS; - when(clock.millis()).thenReturn(currentTime + UnimprovedBestSolutionTermination.MINIMAL_INTERVAL_TIME_MILLIS * 2 - 1); + termination.lastImprovementMillis = currentTime + 1_000; + when(clock.millis()).thenReturn(currentTime + 9_000); assertThat(termination.isPhaseTerminated(phaseScope)).isFalse(); - // Don't terminate + // Terminate termination.terminate = null; termination.currentBest = SimpleScore.of(2); termination.initialCurvePointMillis = currentTime; - termination.lastImprovementMillis = currentTime + UnimprovedBestSolutionTermination.MINIMAL_INTERVAL_TIME_MILLIS; - when(clock.millis()).thenReturn(currentTime + UnimprovedBestSolutionTermination.MINIMAL_INTERVAL_TIME_MILLIS * 2 + 1); + 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)); + .isThrownBy(() -> new UnimprovedBestSolutionTermination(-1.0, 0.0, 1L)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, -1.0, 1L)); assertThatIllegalArgumentException() - .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, -1.0)); + .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, 1.0, 1L)); assertThatIllegalArgumentException() - .isThrownBy(() -> new UnimprovedBestSolutionTermination(0.0, 1.0)); + .isThrownBy(() -> new UnimprovedBestSolutionTermination(1.0, 1.0, 0L)); } }