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 extends Termination> 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 @@
+
+
+
+
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 @@
+
+
+
+
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`