Skip to content

Commit 7542a7f

Browse files
feat: Add sliding window and minimum improvement ratio to termination confiig (#2324)
1 parent cddd6e0 commit 7542a7f

4 files changed

Lines changed: 245 additions & 12 deletions

File tree

model/definition/src/main/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfig.java

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,41 @@ public record SolverTerminationConfig(
2626
"Use this termination if you want to benchmark your models, not recommended for production use. " +
2727
"If set, unimprovedSpentLimit must be empty. " +
2828
"Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.",
29-
examples = { "1000", "10000" }) @JsonInclude(JsonInclude.Include.NON_NULL) Integer stepCountLimit) {
29+
examples = { "1000", "10000" }) @JsonInclude(JsonInclude.Include.NON_NULL) Integer stepCountLimit,
30+
@JsonFormat(shape = JsonFormat.Shape.STRING) @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(
31+
description = "Sliding window (ISO 8601 duration format) over which score improvement is " +
32+
"measured by the diminished returns termination. Defaults to PT30S when omitted. " +
33+
"Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and " +
34+
"stepCountLimit are both empty).",
35+
examples = { "PT30S", "PT5M" }) Duration slidingWindowDuration,
36+
@JsonInclude(JsonInclude.Include.NON_NULL) @Schema(
37+
description = "Minimum ratio between current and initial improvement before the diminished " +
38+
"returns termination kicks in. Must be strictly positive. Defaults to 0.0001 when omitted. " +
39+
"Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and " +
40+
"stepCountLimit are both empty).",
41+
examples = { "0.0001", "0.01" }) Double minimumImprovementRatio) {
42+
43+
public SolverTerminationConfig {
44+
if (minimumImprovementRatio != null && minimumImprovementRatio <= 0) {
45+
throw new IllegalArgumentException(
46+
"minimumImprovementRatio (" + minimumImprovementRatio + ") must be strictly positive.");
47+
}
48+
}
49+
50+
public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, Integer stepCountLimit) {
51+
this(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null);
52+
}
3053

3154
public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit) {
32-
this(spentLimit, unimprovedSpentLimit, null);
55+
this(spentLimit, unimprovedSpentLimit, null, null, null);
3356
}
3457

3558
public SolverTerminationConfig override(SolverTerminationConfig configuration) {
3659
Duration spentLimit = this.spentLimit;
3760
Duration unimprovedSpentLimit = this.unimprovedSpentLimit;
3861
Integer stepCountLimit = this.stepCountLimit;
62+
Duration slidingWindowDuration = this.slidingWindowDuration;
63+
Double minimumImprovementRatio = this.minimumImprovementRatio;
3964

4065
if (configuration == null) {
4166
return this;
@@ -54,11 +79,20 @@ public SolverTerminationConfig override(SolverTerminationConfig configuration) {
5479
stepCountLimit = configuration.stepCountLimit();
5580
}
5681

82+
if (slidingWindowDuration == null) {
83+
slidingWindowDuration = configuration.slidingWindowDuration();
84+
}
85+
86+
if (minimumImprovementRatio == null) {
87+
minimumImprovementRatio = configuration.minimumImprovementRatio();
88+
}
89+
5790
if (stepCountLimit != null && unimprovedSpentLimit != null) {
5891
throw new IllegalArgumentException("stepCountLimit and unimprovedSpentLimit cannot be set at the same time.");
5992
}
6093

61-
return new SolverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit);
94+
return new SolverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, slidingWindowDuration,
95+
minimumImprovementRatio);
6296
}
6397

6498
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package ai.timefold.solver.model.definition.api.termination;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import java.time.Duration;
7+
8+
import org.junit.jupiter.api.Test;
9+
10+
class SolverTerminationConfigTest {
11+
12+
@Test
13+
void rejectsZeroMinimumImprovementRatio() {
14+
assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, 0.0))
15+
.isInstanceOf(IllegalArgumentException.class)
16+
.hasMessageContaining("minimumImprovementRatio")
17+
.hasMessageContaining("must be strictly positive");
18+
}
19+
20+
@Test
21+
void rejectsNegativeMinimumImprovementRatio() {
22+
assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, -0.01))
23+
.isInstanceOf(IllegalArgumentException.class)
24+
.hasMessageContaining("must be strictly positive");
25+
}
26+
27+
@Test
28+
void acceptsNullMinimumImprovementRatio() {
29+
SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null);
30+
31+
assertThat(config.minimumImprovementRatio()).isNull();
32+
assertThat(config.slidingWindowDuration()).isNull();
33+
}
34+
35+
@Test
36+
void acceptsPositiveMinimumImprovementRatio() {
37+
SolverTerminationConfig config =
38+
new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01);
39+
40+
assertThat(config.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(5));
41+
assertThat(config.minimumImprovementRatio()).isEqualTo(0.01);
42+
}
43+
44+
@Test
45+
void threeArgConstructorDefaultsDiminishedReturnsTuningToNull() {
46+
SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, 100);
47+
48+
assertThat(config.slidingWindowDuration()).isNull();
49+
assertThat(config.minimumImprovementRatio()).isNull();
50+
}
51+
52+
@Test
53+
void overrideFillsMissingDiminishedReturnsTuningFromFallback() {
54+
SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null);
55+
SolverTerminationConfig fallback =
56+
new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001);
57+
58+
SolverTerminationConfig merged = primary.override(fallback);
59+
60+
assertThat(merged.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(2));
61+
assertThat(merged.minimumImprovementRatio()).isEqualTo(0.001);
62+
}
63+
64+
@Test
65+
void overrideKeepsPrimaryDiminishedReturnsTuningWhenPresent() {
66+
SolverTerminationConfig primary =
67+
new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(10), 0.5);
68+
SolverTerminationConfig fallback =
69+
new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001);
70+
71+
SolverTerminationConfig merged = primary.override(fallback);
72+
73+
assertThat(merged.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(10));
74+
assertThat(merged.minimumImprovementRatio()).isEqualTo(0.5);
75+
}
76+
}

model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import java.time.Duration;
66
import java.time.format.DateTimeParseException;
7+
import java.util.ArrayList;
8+
import java.util.List;
79
import java.util.Optional;
810

911
import jakarta.enterprise.context.ApplicationScoped;
@@ -46,22 +48,24 @@ public class TerminationService {
4648

4749
public TerminationConfig resolveTerminationConfig(SolverTerminationConfig terminationConfig) {
4850
if (terminationConfig == null) {
49-
return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit);
51+
return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null);
5052
}
51-
Duration spentLimit = requireNonNullElse(terminationConfig.spentLimit(), this.spentLimit);
53+
var spentLimit = requireNonNullElse(terminationConfig.spentLimit(), this.spentLimit);
5254
// unimprovedSpentLimit may be null
53-
Duration unimprovedSpentLimit =
55+
var unimprovedSpentLimit =
5456
terminationConfig.unimprovedSpentLimit() != null ? terminationConfig.unimprovedSpentLimit()
5557
: this.unimprovedSpentLimit;
56-
Integer stepCountLimit =
58+
var stepCountLimit =
5759
terminationConfig.stepCountLimit() != null ? terminationConfig.stepCountLimit() : this.stepCountLimit;
5860

59-
return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit);
61+
return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit,
62+
terminationConfig.slidingWindowDuration(), terminationConfig.minimumImprovementRatio());
6063
}
6164

6265
private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit,
63-
Integer stepCountLimit) {
64-
TerminationConfig terminationConfig = new TerminationConfig()
66+
Integer stepCountLimit, Duration diminishedReturnsSlidingWindowDuration,
67+
Double diminishedReturnsMinimumImprovementRatio) {
68+
var terminationConfig = new TerminationConfig()
6569
.withTerminationCompositionStyle(TerminationCompositionStyle.OR)
6670
.withSpentLimit(spentLimit)
6771
.withBestScoreLimit(bestScoreLimit);
@@ -73,8 +77,23 @@ private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration
7377
terminationConfig.withStepCountLimit(stepCountLimit);
7478
LOGGER.info("Using time spent ({}) with step count limit ({}) termination.", spentLimit, stepCountLimit);
7579
} else {
76-
terminationConfig.withDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig());
77-
LOGGER.info("Using time spent ({}) with diminished returns termination.", spentLimit);
80+
var diminishedReturnsConfig = new DiminishedReturnsTerminationConfig();
81+
List<String> tuning = new ArrayList<>(2);
82+
if (diminishedReturnsSlidingWindowDuration != null) {
83+
diminishedReturnsConfig.setSlidingWindowDuration(diminishedReturnsSlidingWindowDuration);
84+
tuning.add("slidingWindowDuration=" + diminishedReturnsSlidingWindowDuration);
85+
}
86+
if (diminishedReturnsMinimumImprovementRatio != null) {
87+
diminishedReturnsConfig.setMinimumImprovementRatio(diminishedReturnsMinimumImprovementRatio);
88+
tuning.add("minimumImprovementRatio=" + diminishedReturnsMinimumImprovementRatio);
89+
}
90+
terminationConfig.withDiminishedReturnsConfig(diminishedReturnsConfig);
91+
if (tuning.isEmpty()) {
92+
LOGGER.info("Using time spent ({}) with diminished returns termination.", spentLimit);
93+
} else {
94+
LOGGER.info("Using time spent ({}) with diminished returns termination ({}).", spentLimit,
95+
String.join(", ", tuning));
96+
}
7897
}
7998

8099
return terminationConfig;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package ai.timefold.solver.model.worker.impl.termination;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.time.Duration;
6+
import java.util.Optional;
7+
8+
import ai.timefold.solver.core.config.solver.termination.TerminationCompositionStyle;
9+
import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
10+
import ai.timefold.solver.model.definition.api.termination.SolverTerminationConfig;
11+
12+
import org.junit.jupiter.api.Test;
13+
14+
class TerminationServiceTest {
15+
16+
private static TerminationService service() {
17+
return new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty());
18+
}
19+
20+
@Test
21+
void nullInputUsesPlatformSpentLimitAndDiminishedReturnsDefaults() {
22+
TerminationConfig resolved = service().resolveTerminationConfig(null);
23+
24+
assertThat(resolved.getTerminationCompositionStyle()).isEqualTo(TerminationCompositionStyle.OR);
25+
assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofSeconds(10));
26+
assertThat(resolved.getUnimprovedSpentLimit()).isNull();
27+
assertThat(resolved.getStepCountLimit()).isNull();
28+
assertThat(resolved.getDiminishedReturnsConfig()).isNotNull();
29+
// No platform-level tuning anymore: solver-core defaults apply (both null on the config).
30+
assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration()).isNull();
31+
assertThat(resolved.getDiminishedReturnsConfig().getMinimumImprovementRatio()).isNull();
32+
}
33+
34+
@Test
35+
void perRequestDiminishedReturnsTuningIsForwarded() {
36+
SolverTerminationConfig input = new SolverTerminationConfig(
37+
Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01);
38+
39+
TerminationConfig resolved = service().resolveTerminationConfig(input);
40+
41+
assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofMinutes(1));
42+
assertThat(resolved.getUnimprovedSpentLimit()).isNull();
43+
assertThat(resolved.getStepCountLimit()).isNull();
44+
assertThat(resolved.getDiminishedReturnsConfig()).isNotNull();
45+
assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration())
46+
.isEqualTo(Duration.ofMinutes(5));
47+
assertThat(resolved.getDiminishedReturnsConfig().getMinimumImprovementRatio()).isEqualTo(0.01);
48+
}
49+
50+
@Test
51+
void unimprovedSpentLimitDisablesDiminishedReturns() {
52+
SolverTerminationConfig input = new SolverTerminationConfig(
53+
Duration.ofMinutes(1), Duration.ofSeconds(30), null, Duration.ofMinutes(5), 0.01);
54+
55+
TerminationConfig resolved = service().resolveTerminationConfig(input);
56+
57+
assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(30));
58+
assertThat(resolved.getStepCountLimit()).isNull();
59+
// diminished-returns tuning on the request is ignored when unimprovedSpentLimit is set.
60+
assertThat(resolved.getDiminishedReturnsConfig()).isNull();
61+
}
62+
63+
@Test
64+
void stepCountLimitDisablesDiminishedReturns() {
65+
SolverTerminationConfig input = new SolverTerminationConfig(
66+
Duration.ofMinutes(1), null, 1000, Duration.ofMinutes(5), 0.01);
67+
68+
TerminationConfig resolved = service().resolveTerminationConfig(input);
69+
70+
assertThat(resolved.getStepCountLimit()).isEqualTo(1000);
71+
assertThat(resolved.getUnimprovedSpentLimit()).isNull();
72+
assertThat(resolved.getDiminishedReturnsConfig()).isNull();
73+
}
74+
75+
@Test
76+
void nullSpentLimitOnRequestFallsBackToPlatformSpentLimit() {
77+
SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, null, null);
78+
79+
TerminationConfig resolved = service().resolveTerminationConfig(input);
80+
81+
assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofSeconds(10));
82+
assertThat(resolved.getDiminishedReturnsConfig()).isNotNull();
83+
}
84+
85+
@Test
86+
void platformUnimprovedSpentLimitDisablesDiminishedReturnsWhenNoRequest() {
87+
TerminationService service = new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty());
88+
89+
TerminationConfig resolved = service.resolveTerminationConfig(null);
90+
91+
assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(5));
92+
assertThat(resolved.getDiminishedReturnsConfig()).isNull();
93+
}
94+
95+
@Test
96+
void platformStepCountLimitDisablesDiminishedReturnsWhenNoRequest() {
97+
TerminationService service = new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(50));
98+
99+
TerminationConfig resolved = service.resolveTerminationConfig(null);
100+
101+
assertThat(resolved.getStepCountLimit()).isEqualTo(50);
102+
assertThat(resolved.getDiminishedReturnsConfig()).isNull();
103+
}
104+
}

0 commit comments

Comments
 (0)