diff --git a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfig.java b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfig.java index d9a9ff93565..47680b290b0 100644 --- a/model/definition/src/main/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfig.java +++ b/model/definition/src/main/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfig.java @@ -26,16 +26,41 @@ public record SolverTerminationConfig( "Use this termination if you want to benchmark your models, not recommended for production use. " + "If set, unimprovedSpentLimit must be empty. " + "Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", - examples = { "1000", "10000" }) @JsonInclude(JsonInclude.Include.NON_NULL) Integer stepCountLimit) { + examples = { "1000", "10000" }) @JsonInclude(JsonInclude.Include.NON_NULL) Integer stepCountLimit, + @JsonFormat(shape = JsonFormat.Shape.STRING) @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( + description = "Sliding window (ISO 8601 duration format) over which score improvement is " + + "measured by the diminished returns termination. Defaults to PT30S when omitted. " + + "Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and " + + "stepCountLimit are both empty).", + examples = { "PT30S", "PT5M" }) Duration slidingWindowDuration, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( + description = "Minimum ratio between current and initial improvement before the diminished " + + "returns termination kicks in. Must be strictly positive. Defaults to 0.0001 when omitted. " + + "Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit and " + + "stepCountLimit are both empty).", + examples = { "0.0001", "0.01" }) Double minimumImprovementRatio) { + + public SolverTerminationConfig { + if (minimumImprovementRatio != null && minimumImprovementRatio <= 0) { + throw new IllegalArgumentException( + "minimumImprovementRatio (" + minimumImprovementRatio + ") must be strictly positive."); + } + } + + public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, Integer stepCountLimit) { + this(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null); + } public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit) { - this(spentLimit, unimprovedSpentLimit, null); + this(spentLimit, unimprovedSpentLimit, null, null, null); } public SolverTerminationConfig override(SolverTerminationConfig configuration) { Duration spentLimit = this.spentLimit; Duration unimprovedSpentLimit = this.unimprovedSpentLimit; Integer stepCountLimit = this.stepCountLimit; + Duration slidingWindowDuration = this.slidingWindowDuration; + Double minimumImprovementRatio = this.minimumImprovementRatio; if (configuration == null) { return this; @@ -54,11 +79,20 @@ public SolverTerminationConfig override(SolverTerminationConfig configuration) { stepCountLimit = configuration.stepCountLimit(); } + if (slidingWindowDuration == null) { + slidingWindowDuration = configuration.slidingWindowDuration(); + } + + if (minimumImprovementRatio == null) { + minimumImprovementRatio = configuration.minimumImprovementRatio(); + } + if (stepCountLimit != null && unimprovedSpentLimit != null) { throw new IllegalArgumentException("stepCountLimit and unimprovedSpentLimit cannot be set at the same time."); } - return new SolverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit); + return new SolverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, slidingWindowDuration, + minimumImprovementRatio); } } diff --git a/model/definition/src/test/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfigTest.java b/model/definition/src/test/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfigTest.java new file mode 100644 index 00000000000..bd8042b4f7e --- /dev/null +++ b/model/definition/src/test/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfigTest.java @@ -0,0 +1,76 @@ +package ai.timefold.solver.model.definition.api.termination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class SolverTerminationConfigTest { + + @Test + void rejectsZeroMinimumImprovementRatio() { + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, 0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minimumImprovementRatio") + .hasMessageContaining("must be strictly positive"); + } + + @Test + void rejectsNegativeMinimumImprovementRatio() { + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, -0.01)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be strictly positive"); + } + + @Test + void acceptsNullMinimumImprovementRatio() { + SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null); + + assertThat(config.minimumImprovementRatio()).isNull(); + assertThat(config.slidingWindowDuration()).isNull(); + } + + @Test + void acceptsPositiveMinimumImprovementRatio() { + SolverTerminationConfig config = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01); + + assertThat(config.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(5)); + assertThat(config.minimumImprovementRatio()).isEqualTo(0.01); + } + + @Test + void threeArgConstructorDefaultsDiminishedReturnsTuningToNull() { + SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, 100); + + assertThat(config.slidingWindowDuration()).isNull(); + assertThat(config.minimumImprovementRatio()).isNull(); + } + + @Test + void overrideFillsMissingDiminishedReturnsTuningFromFallback() { + SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null); + SolverTerminationConfig fallback = + new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001); + + SolverTerminationConfig merged = primary.override(fallback); + + assertThat(merged.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(2)); + assertThat(merged.minimumImprovementRatio()).isEqualTo(0.001); + } + + @Test + void overrideKeepsPrimaryDiminishedReturnsTuningWhenPresent() { + SolverTerminationConfig primary = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(10), 0.5); + SolverTerminationConfig fallback = + new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001); + + SolverTerminationConfig merged = primary.override(fallback); + + assertThat(merged.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(10)); + assertThat(merged.minimumImprovementRatio()).isEqualTo(0.5); + } +} diff --git a/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java b/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java index 1b70c6ec936..d1c3039c565 100644 --- a/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java +++ b/model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java @@ -4,6 +4,8 @@ import java.time.Duration; import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import jakarta.enterprise.context.ApplicationScoped; @@ -46,22 +48,24 @@ public class TerminationService { public TerminationConfig resolveTerminationConfig(SolverTerminationConfig terminationConfig) { if (terminationConfig == null) { - return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit); + return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null); } - Duration spentLimit = requireNonNullElse(terminationConfig.spentLimit(), this.spentLimit); + var spentLimit = requireNonNullElse(terminationConfig.spentLimit(), this.spentLimit); // unimprovedSpentLimit may be null - Duration unimprovedSpentLimit = + var unimprovedSpentLimit = terminationConfig.unimprovedSpentLimit() != null ? terminationConfig.unimprovedSpentLimit() : this.unimprovedSpentLimit; - Integer stepCountLimit = + var stepCountLimit = terminationConfig.stepCountLimit() != null ? terminationConfig.stepCountLimit() : this.stepCountLimit; - return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit); + return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, + terminationConfig.slidingWindowDuration(), terminationConfig.minimumImprovementRatio()); } private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, - Integer stepCountLimit) { - TerminationConfig terminationConfig = new TerminationConfig() + Integer stepCountLimit, Duration diminishedReturnsSlidingWindowDuration, + Double diminishedReturnsMinimumImprovementRatio) { + var terminationConfig = new TerminationConfig() .withTerminationCompositionStyle(TerminationCompositionStyle.OR) .withSpentLimit(spentLimit) .withBestScoreLimit(bestScoreLimit); @@ -73,8 +77,23 @@ private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration terminationConfig.withStepCountLimit(stepCountLimit); LOGGER.info("Using time spent ({}) with step count limit ({}) termination.", spentLimit, stepCountLimit); } else { - terminationConfig.withDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig()); - LOGGER.info("Using time spent ({}) with diminished returns termination.", spentLimit); + var diminishedReturnsConfig = new DiminishedReturnsTerminationConfig(); + List tuning = new ArrayList<>(2); + if (diminishedReturnsSlidingWindowDuration != null) { + diminishedReturnsConfig.setSlidingWindowDuration(diminishedReturnsSlidingWindowDuration); + tuning.add("slidingWindowDuration=" + diminishedReturnsSlidingWindowDuration); + } + if (diminishedReturnsMinimumImprovementRatio != null) { + diminishedReturnsConfig.setMinimumImprovementRatio(diminishedReturnsMinimumImprovementRatio); + tuning.add("minimumImprovementRatio=" + diminishedReturnsMinimumImprovementRatio); + } + terminationConfig.withDiminishedReturnsConfig(diminishedReturnsConfig); + if (tuning.isEmpty()) { + LOGGER.info("Using time spent ({}) with diminished returns termination.", spentLimit); + } else { + LOGGER.info("Using time spent ({}) with diminished returns termination ({}).", spentLimit, + String.join(", ", tuning)); + } } return terminationConfig; diff --git a/model/worker/src/test/java/ai/timefold/solver/model/worker/impl/termination/TerminationServiceTest.java b/model/worker/src/test/java/ai/timefold/solver/model/worker/impl/termination/TerminationServiceTest.java new file mode 100644 index 00000000000..2115b591494 --- /dev/null +++ b/model/worker/src/test/java/ai/timefold/solver/model/worker/impl/termination/TerminationServiceTest.java @@ -0,0 +1,104 @@ +package ai.timefold.solver.model.worker.impl.termination; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Optional; + +import ai.timefold.solver.core.config.solver.termination.TerminationCompositionStyle; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.model.definition.api.termination.SolverTerminationConfig; + +import org.junit.jupiter.api.Test; + +class TerminationServiceTest { + + private static TerminationService service() { + return new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty()); + } + + @Test + void nullInputUsesPlatformSpentLimitAndDiminishedReturnsDefaults() { + TerminationConfig resolved = service().resolveTerminationConfig(null); + + assertThat(resolved.getTerminationCompositionStyle()).isEqualTo(TerminationCompositionStyle.OR); + assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofSeconds(10)); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getDiminishedReturnsConfig()).isNotNull(); + // No platform-level tuning anymore: solver-core defaults apply (both null on the config). + assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration()).isNull(); + assertThat(resolved.getDiminishedReturnsConfig().getMinimumImprovementRatio()).isNull(); + } + + @Test + void perRequestDiminishedReturnsTuningIsForwarded() { + SolverTerminationConfig input = new SolverTerminationConfig( + Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofMinutes(1)); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getDiminishedReturnsConfig()).isNotNull(); + assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration()) + .isEqualTo(Duration.ofMinutes(5)); + assertThat(resolved.getDiminishedReturnsConfig().getMinimumImprovementRatio()).isEqualTo(0.01); + } + + @Test + void unimprovedSpentLimitDisablesDiminishedReturns() { + SolverTerminationConfig input = new SolverTerminationConfig( + Duration.ofMinutes(1), Duration.ofSeconds(30), null, Duration.ofMinutes(5), 0.01); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(30)); + assertThat(resolved.getStepCountLimit()).isNull(); + // diminished-returns tuning on the request is ignored when unimprovedSpentLimit is set. + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void stepCountLimitDisablesDiminishedReturns() { + SolverTerminationConfig input = new SolverTerminationConfig( + Duration.ofMinutes(1), null, 1000, Duration.ofMinutes(5), 0.01); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + assertThat(resolved.getStepCountLimit()).isEqualTo(1000); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void nullSpentLimitOnRequestFallsBackToPlatformSpentLimit() { + SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, null, null); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofSeconds(10)); + assertThat(resolved.getDiminishedReturnsConfig()).isNotNull(); + } + + @Test + void platformUnimprovedSpentLimitDisablesDiminishedReturnsWhenNoRequest() { + TerminationService service = new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty()); + + TerminationConfig resolved = service.resolveTerminationConfig(null); + + assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(5)); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void platformStepCountLimitDisablesDiminishedReturnsWhenNoRequest() { + TerminationService service = new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(50)); + + TerminationConfig resolved = service.resolveTerminationConfig(null); + + assertThat(resolved.getStepCountLimit()).isEqualTo(50); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } +}