Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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))

Check warning on line 14 in model/definition/src/test/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfigTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ6SObugGZfSXgYa44yy&open=AZ6SObugGZfSXgYa44yy&pullRequest=2324
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("minimumImprovementRatio")
.hasMessageContaining("must be strictly positive");
}

@Test
void rejectsNegativeMinimumImprovementRatio() {
assertThatThrownBy(() -> new SolverTerminationConfig(Duration.ofMinutes(1), null, null, null, -0.01))

Check warning on line 22 in model/definition/src/test/java/ai/timefold/solver/model/definition/api/termination/SolverTerminationConfigTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ6SObugGZfSXgYa44yz&open=AZ6SObugGZfSXgYa44yz&pullRequest=2324
.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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,22 +48,24 @@

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);

Check warning on line 53 in model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename "spentLimit" which hides the field declared at line 30.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ6SPWg_3VX7bzmt34FI&open=AZ6SPWg_3VX7bzmt34FI&pullRequest=2324
// unimprovedSpentLimit may be null
Duration unimprovedSpentLimit =
var unimprovedSpentLimit =

Check warning on line 55 in model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename "unimprovedSpentLimit" which hides the field declared at line 31.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ6SPWg_3VX7bzmt34FJ&open=AZ6SPWg_3VX7bzmt34FJ&pullRequest=2324
terminationConfig.unimprovedSpentLimit() != null ? terminationConfig.unimprovedSpentLimit()
: this.unimprovedSpentLimit;
Integer stepCountLimit =
var stepCountLimit =

Check warning on line 58 in model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename "stepCountLimit" which hides the field declared at line 33.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ6SPWg_3VX7bzmt34FK&open=AZ6SPWg_3VX7bzmt34FK&pullRequest=2324
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);
Expand All @@ -73,8 +77,23 @@
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<String> 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));

Check warning on line 95 in model/worker/src/main/java/ai/timefold/solver/model/worker/impl/termination/TerminationService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Invoke method(s) only conditionally.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ50V0I1z_CoqT7n1yAk&open=AZ50V0I1z_CoqT7n1yAk&pullRequest=2324
}
}

return terminationConfig;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading