diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc index c51dfa84b9..4399e9e553 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc @@ -140,15 +140,22 @@ The `run` configuration controls how long the solver runs: - `spentLimit` sets the maximum duration (in ISO 8601 duration format) for solving a dataset. - `unimprovedSpentLimit` sets the maximum duration (in ISO 8601 duration format) for solving a dataset since the dataset score improved. -If no value is provided, the default diminished returns termination will apply. If set, stepCountLimit must be empty. +If no value is provided, the default diminished returns termination will apply. If set, stepCountLimit and moveCountLimit must be empty. _Warning_: using this option will disable the default diminished returns termination which is recommended for most use cases - `stepCountLimit` sets the maximum solver step count for solving a dataset. The solver will stop solving after a pre-determined amount of steps. Use when you require results independently of the hardware resources performance. 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. -- `slidingWindowDuration` sets the sliding window (in 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 termination is active (i.e. unimprovedSpentLimit and stepCountLimit are both empty). +- `moveCountLimit` sets the maximum number of moves evaluated by the solver for solving a dataset. +The solver will stop solving after a pre-determined amount of moves have been evaluated. +Use when you require results independently of the hardware resources performance. +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. +Can be combined with `stepCountLimit`: the solver stops as soon as either limit is reached. + +- `slidingWindowDuration` sets the sliding window (in 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 termination is active (i.e. unimprovedSpentLimit, stepCountLimit and moveCountLimit are all empty). -- `minimumImprovementRatio` sets the 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 termination is active (i.e. unimprovedSpentLimit and stepCountLimit are both empty). +- `minimumImprovementRatio` sets the 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 termination is active (i.e. unimprovedSpentLimit, stepCountLimit and moveCountLimit are all empty). === Response diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfig.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfig.java index 3ccabb0e90..6c4d958511 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfig.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfig.java @@ -17,7 +17,7 @@ public record SolverTerminationConfig( description = "Maximum unimproved score duration (ISO 8601 duration format). " + "If the score has not improved during this period (e.g. PT5M), terminate the solver. " + "If no value is provided, the default diminished returns termination will apply. " + - "If set, stepCountLimit must be empty. " + + "If set, stepCountLimit and moveCountLimit must be empty. " + "Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", examples = { "PT5M", "PT30S" }) @JsonInclude(JsonInclude.Include.NON_NULL) Duration unimprovedSpentLimit, @Schema(description = "Maximum solver step count. " + @@ -26,18 +26,28 @@ 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" }, + minimum = "0") @JsonInclude(JsonInclude.Include.NON_NULL) Integer stepCountLimit, + @Schema(description = "Maximum number of moves evaluated by the solver. " + + "The solver will stop solving after a pre-determined amount of moves have been evaluated. " + + "Use when you require results independently of the hardware resources performance. " + + "Use this termination if you want to benchmark your models, not recommended for production use. " + + "If set, unimprovedSpentLimit must be empty. " + + "Can be combined with stepCountLimit: the solver stops as soon as either limit is reached. " + + "Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", + examples = { "100000", "1000000" }, + minimum = "0") @JsonInclude(JsonInclude.Include.NON_NULL) Long moveCountLimit, @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).", + "Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit, " + + "stepCountLimit and moveCountLimit are all 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).", + "Only takes effect when diminished returns is active (i.e. unimprovedSpentLimit, " + + "stepCountLimit and moveCountLimit are all empty).", examples = { "0.0001", "0.01" }) Double minimumImprovementRatio) { public SolverTerminationConfig { @@ -45,20 +55,41 @@ public record SolverTerminationConfig( throw new IllegalArgumentException( "minimumImprovementRatio (" + minimumImprovementRatio + ") must be strictly positive."); } + if (unimprovedSpentLimit != null && (stepCountLimit != null || moveCountLimit != null)) { + throw new IllegalArgumentException( + "unimprovedSpentLimit cannot be set at the same time as stepCountLimit or moveCountLimit."); + } + if (stepCountLimit != null && stepCountLimit < 0) { + throw new IllegalArgumentException("stepCountLimit (" + stepCountLimit + ") cannot be negative."); + } + if (moveCountLimit != null && moveCountLimit < 0) { + throw new IllegalArgumentException("moveCountLimit (" + moveCountLimit + ") cannot be negative."); + } + } + + public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, Integer stepCountLimit, + Duration slidingWindowDuration, Double minimumImprovementRatio) { + this(spentLimit, unimprovedSpentLimit, stepCountLimit, null, slidingWindowDuration, minimumImprovementRatio); + } + + public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, Integer stepCountLimit, + Long moveCountLimit) { + this(spentLimit, unimprovedSpentLimit, stepCountLimit, moveCountLimit, null, null); } public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, Integer stepCountLimit) { - this(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null); + this(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null, null); } public SolverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit) { - this(spentLimit, unimprovedSpentLimit, null, null, null); + this(spentLimit, unimprovedSpentLimit, null, null, null, null); } public SolverTerminationConfig override(SolverTerminationConfig configuration) { Duration spentLimit = this.spentLimit; Duration unimprovedSpentLimit = this.unimprovedSpentLimit; Integer stepCountLimit = this.stepCountLimit; + Long moveCountLimit = this.moveCountLimit; Duration slidingWindowDuration = this.slidingWindowDuration; Double minimumImprovementRatio = this.minimumImprovementRatio; @@ -79,6 +110,10 @@ public SolverTerminationConfig override(SolverTerminationConfig configuration) { stepCountLimit = configuration.stepCountLimit(); } + if (moveCountLimit == null) { + moveCountLimit = configuration.moveCountLimit(); + } + if (slidingWindowDuration == null) { slidingWindowDuration = configuration.slidingWindowDuration(); } @@ -87,12 +122,8 @@ public SolverTerminationConfig override(SolverTerminationConfig configuration) { 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, slidingWindowDuration, - minimumImprovementRatio); + return new SolverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, moveCountLimit, + slidingWindowDuration, minimumImprovementRatio); } } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfigTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfigTest.java index c809e3209f..53a43691aa 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfigTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/termination/SolverTerminationConfigTest.java @@ -11,22 +11,64 @@ class SolverTerminationConfigTest { @Test void rejectsZeroMinimumImprovementRatio() { - assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, 0.0)) + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null, 0.0)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("minimumImprovementRatio") .hasMessageContaining("must be strictly positive"); } + @Test + void rejectsNegativeStepCountLimit() { + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, -1, null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stepCountLimit") + .hasMessageContaining("cannot be negative"); + } + + @Test + void rejectsNegativeMoveCountLimit() { + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, -1L, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("moveCountLimit") + .hasMessageContaining("cannot be negative"); + } + + @Test + void acceptsZeroCountLimits() { + SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, 0, 0L, null, null); + + assertThat(config.stepCountLimit()).isZero(); + assertThat(config.moveCountLimit()).isZero(); + } + + @Test + void rejectsUnimprovedSpentLimitCombinedWithStepCountLimit() { + assertThatThrownBy( + () -> new SolverTerminationConfig(Duration.ofMinutes(1), Duration.ofSeconds(30), 1000, null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unimprovedSpentLimit") + .hasMessageContaining("stepCountLimit"); + } + + @Test + void rejectsUnimprovedSpentLimitCombinedWithMoveCountLimit() { + assertThatThrownBy( + () -> new SolverTerminationConfig(Duration.ofMinutes(1), Duration.ofSeconds(30), null, 100_000L, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unimprovedSpentLimit") + .hasMessageContaining("moveCountLimit"); + } + @Test void rejectsNegativeMinimumImprovementRatio() { - assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, -0.01)) + assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, 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); + SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null, null); assertThat(config.minimumImprovementRatio()).isNull(); assertThat(config.slidingWindowDuration()).isNull(); @@ -35,25 +77,64 @@ void acceptsNullMinimumImprovementRatio() { @Test void acceptsPositiveMinimumImprovementRatio() { SolverTerminationConfig config = - new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01); + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, Duration.ofMinutes(5), 0.01); assertThat(config.slidingWindowDuration()).isEqualTo(Duration.ofMinutes(5)); assertThat(config.minimumImprovementRatio()).isEqualTo(0.01); } + @Test + void fiveArgConstructorPreservesLegacyShapeAndDefaultsMoveCountLimitToNull() { + SolverTerminationConfig config = new SolverTerminationConfig( + Duration.ofMinutes(1), null, 1000, Duration.ofMinutes(5), 0.01); + + assertThat(config.spentLimit()).isEqualTo(Duration.ofMinutes(1)); + assertThat(config.stepCountLimit()).isEqualTo(1000); + assertThat(config.moveCountLimit()).isNull(); + 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.moveCountLimit()).isNull(); + assertThat(config.slidingWindowDuration()).isNull(); + assertThat(config.minimumImprovementRatio()).isNull(); + } + + @Test + void fourArgConstructorDefaultsDiminishedReturnsTuningToNull() { + SolverTerminationConfig config = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, 100_000L); + + assertThat(config.moveCountLimit()).isEqualTo(100_000L); assertThat(config.slidingWindowDuration()).isNull(); assertThat(config.minimumImprovementRatio()).isNull(); } + @Test + void acceptsMoveCountLimit() { + SolverTerminationConfig config = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, 100_000L, null, null); + + assertThat(config.moveCountLimit()).isEqualTo(100_000L); + } + + @Test + void moveCountLimitAndStepCountLimitCanCombine() { + SolverTerminationConfig config = + new SolverTerminationConfig(Duration.ofMinutes(1), null, 1000, 100_000L, null, null); + + assertThat(config.stepCountLimit()).isEqualTo(1000); + assertThat(config.moveCountLimit()).isEqualTo(100_000L); + } + @Test void overrideFillsMissingDiminishedReturnsTuningFromFallback() { - SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null); + SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null, null); SolverTerminationConfig fallback = - new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001); + new SolverTerminationConfig(null, null, null, null, Duration.ofMinutes(2), 0.001); SolverTerminationConfig merged = primary.override(fallback); @@ -64,13 +145,67 @@ void overrideFillsMissingDiminishedReturnsTuningFromFallback() { @Test void overrideKeepsPrimaryDiminishedReturnsTuningWhenPresent() { SolverTerminationConfig primary = - new SolverTerminationConfig(Duration.ofMinutes(1), null, null, Duration.ofMinutes(10), 0.5); + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, Duration.ofMinutes(10), 0.5); SolverTerminationConfig fallback = - new SolverTerminationConfig(null, null, null, Duration.ofMinutes(2), 0.001); + new SolverTerminationConfig(null, 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); } + + @Test + void overrideFillsMissingMoveCountLimitFromFallback() { + SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, null, null); + SolverTerminationConfig fallback = new SolverTerminationConfig(null, null, null, 100_000L, null, null); + + SolverTerminationConfig merged = primary.override(fallback); + + assertThat(merged.moveCountLimit()).isEqualTo(100_000L); + } + + @Test + void overrideKeepsPrimaryMoveCountLimitWhenPresent() { + SolverTerminationConfig primary = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, 500_000L, null, null); + SolverTerminationConfig fallback = new SolverTerminationConfig(null, null, null, 100_000L, null, null); + + SolverTerminationConfig merged = primary.override(fallback); + + assertThat(merged.moveCountLimit()).isEqualTo(500_000L); + } + + @Test + void overrideRejectsMoveCountLimitCombinedWithUnimprovedSpentLimit() { + SolverTerminationConfig primary = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, 100_000L, null, null); + SolverTerminationConfig fallback = + new SolverTerminationConfig(null, Duration.ofSeconds(30), null, null, null, null); + + assertThatThrownBy(() -> primary.override(fallback)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unimprovedSpentLimit") + .hasMessageContaining("moveCountLimit"); + } + + @Test + void overrideRejectsStepCountLimitCombinedWithUnimprovedSpentLimit() { + SolverTerminationConfig primary = new SolverTerminationConfig(Duration.ofMinutes(1), null, 1000); + SolverTerminationConfig fallback = + new SolverTerminationConfig(null, Duration.ofSeconds(30), null, null, null, null); + + assertThatThrownBy(() -> primary.override(fallback)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unimprovedSpentLimit") + .hasMessageContaining("stepCountLimit"); + } + + @Test + void nullConfigurationOverrideReturnsThis() { + SolverTerminationConfig primary = + new SolverTerminationConfig(Duration.ofMinutes(1), null, null, 100_000L, null, null); + + assertThat(primary.override(null)).isSameAs(primary); + } } diff --git a/service/test-model/src/build/openapi.json b/service/test-model/src/build/openapi.json index 03f96e37d2..82f66cc844 100644 --- a/service/test-model/src/build/openapi.json +++ b/service/test-model/src/build/openapi.json @@ -2234,7 +2234,7 @@ "example" : "PT1H" }, "unimprovedSpentLimit" : { - "description" : "Maximum unimproved score duration (ISO 8601 duration format). If the score has not improved during this period (e.g. PT5M), terminate the solver. If no value is provided, the default diminished returns termination will apply. If set, stepCountLimit must be empty. Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", + "description" : "Maximum unimproved score duration (ISO 8601 duration format). If the score has not improved during this period (e.g. PT5M), terminate the solver. If no value is provided, the default diminished returns termination will apply. If set, stepCountLimit and moveCountLimit must be empty. Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", "type" : "string", "allOf" : [ { "$ref" : "#/components/schemas/Duration" @@ -2244,11 +2244,19 @@ "stepCountLimit" : { "format" : "int32", "description" : "Maximum solver step count. The solver will stop solving after a pre-determined amount of steps. Use when you require results independently of the hardware resources performance. 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.", + "minimum" : 0, "type" : "integer", "example" : 1000 }, + "moveCountLimit" : { + "format" : "int64", + "description" : "Maximum number of moves evaluated by the solver. The solver will stop solving after a pre-determined amount of moves have been evaluated. Use when you require results independently of the hardware resources performance. Use this termination if you want to benchmark your models, not recommended for production use. If set, unimprovedSpentLimit must be empty. Can be combined with stepCountLimit: the solver stops as soon as either limit is reached. Warning: using this option will disable the default diminished returns termination which is recommended for most use cases.", + "minimum" : 0, + "type" : "integer", + "example" : 100000 + }, "slidingWindowDuration" : { - "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).", + "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, stepCountLimit and moveCountLimit are all empty).", "type" : "string", "allOf" : [ { "$ref" : "#/components/schemas/Duration" @@ -2257,7 +2265,7 @@ }, "minimumImprovementRatio" : { "format" : "double", - "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).", + "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, stepCountLimit and moveCountLimit are all empty).", "type" : "number", "example" : 0.0001 } diff --git a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationConfigParams.java b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationConfigParams.java index d5ef443a26..44d7bdf264 100644 --- a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationConfigParams.java +++ b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationConfigParams.java @@ -8,6 +8,7 @@ public class TerminationConfigParams { public static final String TERMINATION_MAXIMUM_UNIMPROVED_SPENT_LIMIT = "timefold.model.termination.maximum-unimproved-spent-limit"; public static final String TERMINATION_STEP_COUNT_LIMIT = "timefold.model.termination.step-count-limit"; + public static final String TERMINATION_MOVE_COUNT_LIMIT = "timefold.model.termination.move-count-limit"; public static final String TERMINATION_BEST_SCORE_LIMIT = "timefold.model.termination.best-score-limit"; } \ No newline at end of file diff --git a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationService.java b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationService.java index 5516851514..4e0d193c6b 100644 --- a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationService.java +++ b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/termination/TerminationService.java @@ -31,6 +31,7 @@ public class TerminationService { private final Duration unimprovedSpentLimit; private final String bestScoreLimit; // exposed for testing private final Integer stepCountLimit; + private final Long moveCountLimit; @Inject TerminationService( @@ -38,32 +39,71 @@ public class TerminationService { @ConfigProperty( name = TerminationConfigParams.TERMINATION_UNIMPROVED_SPENT_LIMIT) Optional unimprovedSpentLimit, @ConfigProperty(name = TerminationConfigParams.TERMINATION_BEST_SCORE_LIMIT) Optional bestScoreLimit, - @ConfigProperty(name = TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT) Optional stepCountLimit) { + @ConfigProperty(name = TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT) Optional stepCountLimit, + @ConfigProperty(name = TerminationConfigParams.TERMINATION_MOVE_COUNT_LIMIT) Optional moveCountLimit) { this.spentLimit = parseDurationFromConfig(TerminationConfigParams.TERMINATION_SPENT_LIMIT, spentLimit); this.unimprovedSpentLimit = unimprovedSpentLimit .map(s -> parseDurationFromConfig(TerminationConfigParams.TERMINATION_UNIMPROVED_SPENT_LIMIT, s)).orElse(null); this.bestScoreLimit = bestScoreLimit.orElse(null); this.stepCountLimit = stepCountLimit.orElse(null); + this.moveCountLimit = moveCountLimit.orElse(null); + requireNonNegativeFromConfig(TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT, this.stepCountLimit); + requireNonNegativeFromConfig(TerminationConfigParams.TERMINATION_MOVE_COUNT_LIMIT, this.moveCountLimit); + if (this.unimprovedSpentLimit != null && (this.stepCountLimit != null || this.moveCountLimit != null)) { + throw new TimefoldRuntimeException(ErrorCodes.INVALID_TERMINATION_CONFIG, + ("Property '%s' cannot be set at the same time as '%s' or '%s' in the platform configuration. " + + "Please remove either of them.").formatted( + TerminationConfigParams.TERMINATION_UNIMPROVED_SPENT_LIMIT, + TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT, + TerminationConfigParams.TERMINATION_MOVE_COUNT_LIMIT), + false); + } + } + + private void requireNonNegativeFromConfig(String propertyName, Number value) { + if (value != null && value.longValue() < 0) { + throw new TimefoldRuntimeException(ErrorCodes.INVALID_TERMINATION_CONFIG, + "Invalid value ('%s') of property '%s' in the platform configuration. It cannot be negative." + .formatted(value, propertyName), + false); + } } public TerminationConfig resolveTerminationConfig(SolverTerminationConfig terminationConfig) { if (terminationConfig == null) { - return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, null, null); + return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, moveCountLimit, null, null); } var spentLimit = requireNonNullElse(terminationConfig.spentLimit(), this.spentLimit); - // unimprovedSpentLimit may be null - var unimprovedSpentLimit = - terminationConfig.unimprovedSpentLimit() != null ? terminationConfig.unimprovedSpentLimit() - : this.unimprovedSpentLimit; - var stepCountLimit = - terminationConfig.stepCountLimit() != null ? terminationConfig.stepCountLimit() : this.stepCountLimit; - - return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, + // unimprovedSpentLimit is mutually exclusive with stepCountLimit and moveCountLimit, + // so an explicit choice on the request must not inherit the other kind from the platform configuration; + // otherwise the inherited value would silently win over what the request asked for. + Duration unimprovedSpentLimit; + Integer stepCountLimit; + Long moveCountLimit; + if (terminationConfig.unimprovedSpentLimit() != null) { + unimprovedSpentLimit = terminationConfig.unimprovedSpentLimit(); + stepCountLimit = null; + moveCountLimit = null; + } else if (terminationConfig.stepCountLimit() != null || terminationConfig.moveCountLimit() != null) { + // stepCountLimit and moveCountLimit belong together, so the platform value of the other one still applies. + unimprovedSpentLimit = null; + stepCountLimit = + terminationConfig.stepCountLimit() != null ? terminationConfig.stepCountLimit() : this.stepCountLimit; + moveCountLimit = + terminationConfig.moveCountLimit() != null ? terminationConfig.moveCountLimit() : this.moveCountLimit; + } else { + // the request picks no termination kind, so the platform configuration applies unchanged + unimprovedSpentLimit = this.unimprovedSpentLimit; + stepCountLimit = this.stepCountLimit; + moveCountLimit = this.moveCountLimit; + } + + return solverTerminationConfig(spentLimit, unimprovedSpentLimit, stepCountLimit, moveCountLimit, terminationConfig.slidingWindowDuration(), terminationConfig.minimumImprovementRatio()); } private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration unimprovedSpentLimit, - Integer stepCountLimit, Duration diminishedReturnsSlidingWindowDuration, + Integer stepCountLimit, Long moveCountLimit, Duration diminishedReturnsSlidingWindowDuration, Double diminishedReturnsMinimumImprovementRatio) { var terminationConfig = new TerminationConfig() .withTerminationCompositionStyle(TerminationCompositionStyle.OR) @@ -73,9 +113,19 @@ private TerminationConfig solverTerminationConfig(Duration spentLimit, Duration if (unimprovedSpentLimit != null) { terminationConfig.withUnimprovedSpentLimit(unimprovedSpentLimit); LOGGER.info("Using time spent ({}) with unimproved time spent ({}) termination.", spentLimit, unimprovedSpentLimit); - } else if (stepCountLimit != null) { - terminationConfig.withStepCountLimit(stepCountLimit); - LOGGER.info("Using time spent ({}) with step count limit ({}) termination.", spentLimit, stepCountLimit); + } else if (stepCountLimit != null || moveCountLimit != null) { + // stepCountLimit and moveCountLimit are both hard, deterministic caps used for benchmarking; + // they are OR-composed and may be combined so whichever is reached first terminates the solver. + List limits = new ArrayList<>(2); + if (stepCountLimit != null) { + terminationConfig.withStepCountLimit(stepCountLimit); + limits.add("step count limit (%d)".formatted(stepCountLimit)); + } + if (moveCountLimit != null) { + terminationConfig.withMoveCountLimit(moveCountLimit); + limits.add("move count limit (%d)".formatted(moveCountLimit)); + } + LOGGER.info("Using time spent ({}) with {} termination.", spentLimit, String.join(" or ", limits)); } else { var diminishedReturnsConfig = new DiminishedReturnsTerminationConfig(); List tuning = new ArrayList<>(2); diff --git a/service/worker/src/test/java/ai/timefold/solver/service/worker/impl/termination/TerminationServiceTest.java b/service/worker/src/test/java/ai/timefold/solver/service/worker/impl/termination/TerminationServiceTest.java index dc4372a677..03c176fe09 100644 --- a/service/worker/src/test/java/ai/timefold/solver/service/worker/impl/termination/TerminationServiceTest.java +++ b/service/worker/src/test/java/ai/timefold/solver/service/worker/impl/termination/TerminationServiceTest.java @@ -1,6 +1,7 @@ package ai.timefold.solver.service.worker.impl.termination; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; import java.util.Optional; @@ -8,13 +9,14 @@ import ai.timefold.solver.core.config.solver.termination.TerminationCompositionStyle; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.service.definition.api.termination.SolverTerminationConfig; +import ai.timefold.solver.service.definition.internal.error.TimefoldRuntimeException; import org.junit.jupiter.api.Test; class TerminationServiceTest { private static TerminationService service() { - return new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty()); + return new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } @Test @@ -25,6 +27,7 @@ void nullInputUsesPlatformSpentLimitAndDiminishedReturnsDefaults() { assertThat(resolved.getSpentLimit()).isEqualTo(Duration.ofSeconds(10)); assertThat(resolved.getUnimprovedSpentLimit()).isNull(); assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getMoveCountLimit()).isNull(); assertThat(resolved.getDiminishedReturnsConfig()).isNotNull(); // No platform-level tuning anymore: solver-core defaults apply (both null on the config). assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration()).isNull(); @@ -34,13 +37,14 @@ void nullInputUsesPlatformSpentLimitAndDiminishedReturnsDefaults() { @Test void perRequestDiminishedReturnsTuningIsForwarded() { SolverTerminationConfig input = new SolverTerminationConfig( - Duration.ofMinutes(1), null, null, Duration.ofMinutes(5), 0.01); + Duration.ofMinutes(1), null, 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.getMoveCountLimit()).isNull(); assertThat(resolved.getDiminishedReturnsConfig()).isNotNull(); assertThat(resolved.getDiminishedReturnsConfig().getSlidingWindowDuration()) .isEqualTo(Duration.ofMinutes(5)); @@ -50,12 +54,13 @@ void perRequestDiminishedReturnsTuningIsForwarded() { @Test void unimprovedSpentLimitDisablesDiminishedReturns() { SolverTerminationConfig input = new SolverTerminationConfig( - Duration.ofMinutes(1), Duration.ofSeconds(30), null, Duration.ofMinutes(5), 0.01); + Duration.ofMinutes(1), Duration.ofSeconds(30), null, null, Duration.ofMinutes(5), 0.01); TerminationConfig resolved = service().resolveTerminationConfig(input); assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(30)); assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getMoveCountLimit()).isNull(); // diminished-returns tuning on the request is ignored when unimprovedSpentLimit is set. assertThat(resolved.getDiminishedReturnsConfig()).isNull(); } @@ -63,18 +68,47 @@ void unimprovedSpentLimitDisablesDiminishedReturns() { @Test void stepCountLimitDisablesDiminishedReturns() { SolverTerminationConfig input = new SolverTerminationConfig( - Duration.ofMinutes(1), null, 1000, Duration.ofMinutes(5), 0.01); + Duration.ofMinutes(1), null, 1000, null, Duration.ofMinutes(5), 0.01); TerminationConfig resolved = service().resolveTerminationConfig(input); assertThat(resolved.getStepCountLimit()).isEqualTo(1000); + assertThat(resolved.getMoveCountLimit()).isNull(); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void moveCountLimitDisablesDiminishedReturns() { + SolverTerminationConfig input = new SolverTerminationConfig( + Duration.ofMinutes(1), null, null, 100_000L, Duration.ofMinutes(5), 0.01); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + assertThat(resolved.getMoveCountLimit()).isEqualTo(100_000L); + assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + // diminished-returns tuning on the request is ignored when moveCountLimit is set. + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void stepCountLimitAndMoveCountLimitCombine() { + SolverTerminationConfig input = new SolverTerminationConfig( + Duration.ofMinutes(1), null, 1000, 100_000L, null, null); + + TerminationConfig resolved = service().resolveTerminationConfig(input); + + // Both hard limits are OR-composed: whichever is reached first terminates the solver. + assertThat(resolved.getStepCountLimit()).isEqualTo(1000); + assertThat(resolved.getMoveCountLimit()).isEqualTo(100_000L); assertThat(resolved.getUnimprovedSpentLimit()).isNull(); assertThat(resolved.getDiminishedReturnsConfig()).isNull(); } @Test void nullSpentLimitOnRequestFallsBackToPlatformSpentLimit() { - SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, null, null); + SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, null, null, null); TerminationConfig resolved = service().resolveTerminationConfig(input); @@ -84,7 +118,8 @@ void nullSpentLimitOnRequestFallsBackToPlatformSpentLimit() { @Test void platformUnimprovedSpentLimitDisablesDiminishedReturnsWhenNoRequest() { - TerminationService service = new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty()); + TerminationService service = + new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty(), Optional.empty()); TerminationConfig resolved = service.resolveTerminationConfig(null); @@ -94,11 +129,131 @@ void platformUnimprovedSpentLimitDisablesDiminishedReturnsWhenNoRequest() { @Test void platformStepCountLimitDisablesDiminishedReturnsWhenNoRequest() { - TerminationService service = new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(50)); + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(50), Optional.empty()); TerminationConfig resolved = service.resolveTerminationConfig(null); assertThat(resolved.getStepCountLimit()).isEqualTo(50); assertThat(resolved.getDiminishedReturnsConfig()).isNull(); } + + @Test + void platformMoveCountLimitDisablesDiminishedReturnsWhenNoRequest() { + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(500_000L)); + + TerminationConfig resolved = service.resolveTerminationConfig(null); + + assertThat(resolved.getMoveCountLimit()).isEqualTo(500_000L); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void rejectsNegativePlatformStepCountLimit() { + assertThatThrownBy(() -> new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(-1), + Optional.empty())) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining(TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT) + .hasMessageContaining("cannot be negative"); + } + + @Test + void rejectsNegativePlatformMoveCountLimit() { + assertThatThrownBy(() -> new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty(), + Optional.of(-1L))) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining(TerminationConfigParams.TERMINATION_MOVE_COUNT_LIMIT) + .hasMessageContaining("cannot be negative"); + } + + @Test + void acceptsZeroPlatformCountLimits() { + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(0), Optional.of(0L)); + + TerminationConfig resolved = service.resolveTerminationConfig(null); + + assertThat(resolved.getStepCountLimit()).isZero(); + assertThat(resolved.getMoveCountLimit()).isZero(); + assertThat(resolved.getDiminishedReturnsConfig()).isNull(); + } + + @Test + void rejectsPlatformUnimprovedSpentLimitCombinedWithStepCountLimit() { + assertThatThrownBy(() -> new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.of(1000), + Optional.empty())) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining(TerminationConfigParams.TERMINATION_UNIMPROVED_SPENT_LIMIT) + .hasMessageContaining(TerminationConfigParams.TERMINATION_STEP_COUNT_LIMIT); + } + + @Test + void rejectsPlatformUnimprovedSpentLimitCombinedWithMoveCountLimit() { + assertThatThrownBy(() -> new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty(), + Optional.of(100_000L))) + .isInstanceOf(TimefoldRuntimeException.class) + .hasMessageContaining(TerminationConfigParams.TERMINATION_UNIMPROVED_SPENT_LIMIT) + .hasMessageContaining(TerminationConfigParams.TERMINATION_MOVE_COUNT_LIMIT); + } + + @Test + void perRequestStepCountLimitIgnoresPlatformUnimprovedSpentLimit() { + TerminationService service = + new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty(), Optional.empty()); + SolverTerminationConfig input = new SolverTerminationConfig(null, null, 1000); + + TerminationConfig resolved = service.resolveTerminationConfig(input); + + assertThat(resolved.getStepCountLimit()).isEqualTo(1000); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + } + + @Test + void perRequestMoveCountLimitIgnoresPlatformUnimprovedSpentLimit() { + TerminationService service = + new TerminationService("PT10S", Optional.of("PT5S"), Optional.empty(), Optional.empty(), Optional.empty()); + SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, 100_000L); + + TerminationConfig resolved = service.resolveTerminationConfig(input); + + assertThat(resolved.getMoveCountLimit()).isEqualTo(100_000L); + assertThat(resolved.getUnimprovedSpentLimit()).isNull(); + } + + @Test + void perRequestUnimprovedSpentLimitIgnoresPlatformStepCountAndMoveCountLimits() { + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.of(50), Optional.of(500_000L)); + SolverTerminationConfig input = new SolverTerminationConfig(null, Duration.ofSeconds(30)); + + TerminationConfig resolved = service.resolveTerminationConfig(input); + + assertThat(resolved.getUnimprovedSpentLimit()).isEqualTo(Duration.ofSeconds(30)); + assertThat(resolved.getStepCountLimit()).isNull(); + assertThat(resolved.getMoveCountLimit()).isNull(); + } + + @Test + void perRequestStepCountLimitStillInheritsPlatformMoveCountLimit() { + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(500_000L)); + SolverTerminationConfig input = new SolverTerminationConfig(null, null, 1000); + + TerminationConfig resolved = service.resolveTerminationConfig(input); + + assertThat(resolved.getStepCountLimit()).isEqualTo(1000); + assertThat(resolved.getMoveCountLimit()).isEqualTo(500_000L); + } + + @Test + void perRequestMoveCountLimitOverridesPlatformDefault() { + TerminationService service = + new TerminationService("PT10S", Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(500_000L)); + SolverTerminationConfig input = new SolverTerminationConfig(null, null, null, 100_000L); + + TerminationConfig resolved = service.resolveTerminationConfig(input); + + assertThat(resolved.getMoveCountLimit()).isEqualTo(100_000L); + } }