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 extends Termination> 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));
}
}