Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
35a3605
feat: Allow custom options in the RunConfiguration
cristianonicolai Aug 13, 2026
2e651b5
feat: store execution profile in metadata
diogodanielsoaresferreira Sep 1, 2026
2b6b5b6
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 2, 2026
8e22ec4
feat: remove execution profiles from metadata
diogodanielsoaresferreira Sep 4, 2026
f6e0240
Merge branch 'execution_profiles' of github.com:diogodanielsoaresferr…
diogodanielsoaresferreira Sep 4, 2026
b16cfe5
feat: rename execution profile artifacts to execution artifacts
diogodanielsoaresferreira Sep 4, 2026
dcb620a
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 4, 2026
9c1479c
feat: add execution profiles to solver
diogodanielsoaresferreira Sep 4, 2026
ce245cd
feat: Remove default execution profile
diogodanielsoaresferreira Sep 7, 2026
a91a294
feat: move ExecutionProfileRegistry to internal package
diogodanielsoaresferreira Sep 7, 2026
29902da
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 7, 2026
a1a595d
feat: remove applicationScoped from execution profile registry and us…
diogodanielsoaresferreira Sep 8, 2026
9b1de8d
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 8, 2026
09c3c32
feat: Add seed execution profile
diogodanielsoaresferreira Sep 9, 2026
93fa617
feat: add name to execution profiles
diogodanielsoaresferreira Sep 11, 2026
b78efb4
feat: add name to execution profiles
diogodanielsoaresferreira Sep 11, 2026
cc08d65
Merge branch 'execution_profiles' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 11, 2026
609e890
Merge #2590 (custom options in RunConfiguration) into create_profile_…
diogodanielsoaresferreira Sep 14, 2026
da9707f
feat: execution profile parameters given by the caller
diogodanielsoaresferreira Sep 14, 2026
e6d05ae
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 15, 2026
57fe853
fix: sonarlog issues
diogodanielsoaresferreira Sep 15, 2026
90bb6b2
remove toProperties from execution profiles
diogodanielsoaresferreira Sep 15, 2026
52390fa
Fail if seed parameter is not supplied
diogodanielsoaresferreira Sep 15, 2026
5c806b4
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 17, 2026
17eea68
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 23, 2026
1be9439
feat: Update docs
diogodanielsoaresferreira Sep 23, 2026
6d0c846
documentation: add options in documentation
diogodanielsoaresferreira Sep 23, 2026
9fcfeaf
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 28, 2026
d384840
feat: make options in RunConfigurations visible
diogodanielsoaresferreira Sep 30, 2026
831adb5
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 30, 2026
a1e352b
feat: make execution profile options input invisible to client
diogodanielsoaresferreira Sep 30, 2026
cbf1d22
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Oct 1, 2026
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
8 changes: 8 additions & 0 deletions service/definition/src/build/revapi-differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@
"new": "interface ai.timefold.solver.service.definition.api.ModelConvertor<Score_ extends ai.timefold.solver.core.api.score.Score<Score_>, ModelInput_ extends ai.timefold.solver.service.definition.api.ModelInput, ModelConfigurationOverrides_ extends ai.timefold.solver.service.definition.api.ModelConfigOverrides, SolverModel_ extends ai.timefold.solver.service.definition.api.SolverModel<Score_>, ModelOutput_ extends ai.timefold.solver.service.definition.api.ModelOutput>",
"annotationType": "org.jspecify.annotations.NullMarked",
"justification": "Documents existing nullability contract; not a behavioral or binary-incompatible change."
},
{
"ignore": true,
"code": "java.annotation.removed",
"old": "class ai.timefold.solver.service.definition.api.domain.RunConfiguration",
"new": "class ai.timefold.solver.service.definition.api.domain.RunConfiguration",
"annotationType": "org.eclipse.microprofile.openapi.annotations.media.Schema",
"justification": "RunConfiguration now carries a free-form options map, so the class-level @Schema(additionalProperties = Schema.False.class) was intentionally removed to allow arbitrary option keys."
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.timefold.solver.service.definition.api.domain;

import java.util.Map;
import java.util.Set;

import jakarta.validation.constraints.Positive;
Expand All @@ -11,7 +12,6 @@

import com.fasterxml.jackson.annotation.JsonInclude;

@Schema(additionalProperties = Schema.False.class)
Comment thread
rsynek marked this conversation as resolved.
public record RunConfiguration(
@Schema(nullable = true,
description = "Optional name to be given to the dataset. If not provided, the name will be generated.") @Size(
Expand All @@ -21,13 +21,11 @@
description = "Optional maximum number of threads to be used for solving.",
minimum = "1") @JsonInclude(JsonInclude.Include.NON_EMPTY) @Positive Integer maxThreadCount,
@JsonInclude(JsonInclude.Include.NON_NULL) @Schema(
description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set<String> tags) {
description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set<String> tags,
@JsonInclude(JsonInclude.Include.NON_NULL) @Schema(hidden = true) Map<String, String> options) {

public RunConfiguration(String name, SolverTerminationConfig termination, Integer maxThreadCount, Set<String> tags) {
this.name = name;
this.termination = termination;
this.tags = tags;
this.maxThreadCount = maxThreadCount;
this(name, termination, maxThreadCount, tags, null);
}

public RunConfiguration(String name, SolverTerminationConfig termination) {
Expand All @@ -49,14 +47,15 @@
* @return a copy of this instance with given termination, never null
*/
public RunConfiguration withTermination(SolverTerminationConfig termination) {
return new RunConfiguration(name(), termination, maxThreadCount(), tags());
return new RunConfiguration(name(), termination, maxThreadCount(), tags(), options());
}

public RunConfiguration override(RunConfiguration configuration) {
String finalName = name;
SolverTerminationConfig finalTermination = termination;
Integer finalMaxThreadCount = maxThreadCount;
Set<String> finalTags = tags;
Map<String, String> finalOptions = options;

Check warning on line 58 in service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move the declaration of "finalOptions" closer to the code that uses it.

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

if (configuration == null) {
return this;
Expand All @@ -70,16 +69,20 @@
finalMaxThreadCount = configuration.maxThreadCount();
}

if (finalOptions == null) {
finalOptions = configuration.options();
}

if (finalTermination == null) {
finalTermination = configuration.termination();
} else {
finalTermination = finalTermination.override(configuration.termination());
}

if ((finalTags == null || !finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) {
finalTags = configuration.tags;
if ((finalTags == null || finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) {
finalTags = configuration.tags();
}

return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags);
return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags, finalOptions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package ai.timefold.solver.service.definition.impl.executionprofile;

import java.util.Map;

import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile;

/**
* Runs the solver with a fixed random seed, making a run reproducible.
* <p>
* The seed is taken from the required {@code seed} run option; selecting this profile without supplying it is rejected. The
* profile never invents a seed. The supplied seed is applied by mapping it to the Timefold Quarkus property
* {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the solver pod applies to its
* {@code SolverConfig} at startup.
*/
public final class SeedExecutionProfile implements ExecutionProfile {

static final String PARAMETER_SEED = "seed";

/**
* Environment-variable form of {@code quarkus.timefold.solver.default.random-seed}, consumed by the Timefold Quarkus
* extension. The {@code default} segment is the solver name ({@code TimefoldRuntimeConfig.DEFAULT_SOLVER_NAME}); it is
* correct for the usual single, unnamed solver a model defines. Note this property lives under a solver-name-keyed map,
* so injecting it purely via an environment variable may not be honored by SmallRye - see the profile's notes.
*/
static final String ENV_QUARKUS_RANDOM_SEED = "QUARKUS_TIMEFOLD_SOLVER_DEFAULT_RANDOM_SEED";

@Override
public String id() {
return "seed";
}

@Override
public String name() {
return "Fixed random seed";
}

@Override
public String description() {
return "Runs the solver with a fixed random seed for reproducible results. "
+ "The seed must be supplied as the 'seed' run option.";
}

@Override
public Map<String, String> toEnvironment(Map<String, String> options) {
String seed = options == null ? null : options.get(PARAMETER_SEED);
if (seed == null) {
throw new IllegalArgumentException(
"Execution profile '" + id() + "' requires the '" + PARAMETER_SEED + "' option to be supplied.");
}
try {
Long.parseLong(seed);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"Execution profile '" + id() + "' requires option '" + PARAMETER_SEED + "' to be a long, but was: "
+ seed);
}
return Map.of(ENV_QUARKUS_RANDOM_SEED, seed);
Comment thread
diogodanielsoaresferreira marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@

/**
* A named, predefined runtime configuration a run can be started with.
* <p>
* Execution profiles describe how a run executes (diagnostics, logging, profiling, ...). This is an internal contract:
* model developers are not expected to implement or reference it. Implementations are provided by the solver service and
* the platform, and are discovered via {@link java.util.ServiceLoader}, so adding a new profile does not require editing
* any central registry. A run may activate several profiles at once.
*/
public interface ExecutionProfile {

Expand All @@ -29,11 +24,13 @@ public interface ExecutionProfile {
String description();

/**
* Additional configuration contributed by this profile, applied to the run's environment - each entry is injected as
* an environment variable into the solver pod. Keys must be valid environment-variable names. When multiple profiles
* are activated and define the same key, the resulting value is unspecified. Defaults to no extra configuration.
* Reads the values this profile recognizes from the run's options (as supplied via {@code RunConfiguration.options}),
* validates them, and maps them to environment variables injected into the solver pod. The profile picks out only the
* keys it recognizes and ignores the rest, since the options map is shared with other run configuration. Keys must
* be valid environment-variable names. If two activated profiles map to the same environment variable, the run is
* rejected. Defaults to no environment variables.
*/
default Map<String, String> properties() {
default Map<String, String> toEnvironment(Map<String, String> options) {
return Map.of();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ai.timefold.solver.service.definition.impl.executionprofile.SeedExecutionProfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package ai.timefold.solver.service.definition.api.domain;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.util.Map;
import java.util.Set;

import ai.timefold.solver.service.definition.api.termination.SolverTerminationConfig;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

class RunConfigurationTest {

private final ObjectMapper mapper = new ObjectMapper();

@Test
void convenienceConstructorsLeaveOptionsNull() {
assertThat(new RunConfiguration("dataset", null, 4, Set.of("a")).options()).isNull();
assertThat(new RunConfiguration("dataset", null).options()).isNull();
assertThat(new RunConfiguration(4, null).options()).isNull();
assertThat(new RunConfiguration("dataset").options()).isNull();
}

@Test
void overrideFillsMissingOptionsFromFallback() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null);
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast"));

RunConfiguration merged = primary.override(fallback);

assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "fast"));
}

@Test
void overrideKeepsPrimaryOptionsWhenPresent() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate"));
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast"));

RunConfiguration merged = primary.override(fallback);

// Options are replaced wholesale, never merged key-by-key.
assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate"));
}

@Test
void overrideKeepsPrimaryOptionsWhenPresentWithDisjointFallbackKeys() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate"));
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("logLevel", "debug"));

RunConfiguration merged = primary.override(fallback);

assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate"));
}

@Test
void overrideKeepsPrimaryEmptyOptionsInsteadOfInheriting() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of());
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast"));

RunConfiguration merged = primary.override(fallback);

// Only a null options map inherits from the fallback; an empty one is a deliberate "no options".
assertThat(merged.options()).isEmpty();
}

@Test
void overrideWithNullConfigurationKeepsOptions() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "fast"));

assertThat(primary.override(null).options()).containsExactlyEntriesOf(Map.of("solver", "fast"));
}

@Test
void withTerminationPreservesOptions() {
RunConfiguration configuration =
new RunConfiguration("dataset", null, 4, Set.of("nightly"), Map.of("solver", "fast"));

RunConfiguration copy = configuration.withTermination(new SolverTerminationConfig(Duration.ofMinutes(1), null));

assertThat(copy.options()).containsExactlyEntriesOf(Map.of("solver", "fast"));
assertThat(copy.termination().spentLimit()).isEqualTo(Duration.ofMinutes(1));
assertThat(copy.name()).isEqualTo("dataset");
assertThat(copy.maxThreadCount()).isEqualTo(4);
assertThat(copy.tags()).containsExactly("nightly");
}

@Test
void deserializesOptionsFromJson() throws JsonProcessingException {
String json = """
{
"name": "dataset",
"options": {
"solver": "fast",
"logLevel": "debug"
}
}
""";

RunConfiguration configuration = mapper.readValue(json, RunConfiguration.class);

assertThat(configuration.name()).isEqualTo("dataset");
assertThat(configuration.options())
.containsExactlyInAnyOrderEntriesOf(Map.of("solver", "fast", "logLevel", "debug"));
}

@Test
void omitsNullOptionsFromJson() throws JsonProcessingException {
String json = mapper.writeValueAsString(new RunConfiguration("dataset"));

assertThat(json).doesNotContain("options");
}

@Test
void serializesOptionsWhenPresent() throws JsonProcessingException {
RunConfiguration configuration =
new RunConfiguration(null, null, null, null, Map.of("solver", "fast"));

String json = mapper.writeValueAsString(configuration);

assertThat(json).contains("\"options\":{\"solver\":\"fast\"}");
}

@Test
void overrideFillsNullTagsFromFallback() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, null, null);
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null);

assertThat(primary.override(fallback).tags()).containsExactly("nightly");
}

@Test
void overrideFillsEmptyTagsFromFallback() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null);
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null);

assertThat(primary.override(fallback).tags()).containsExactly("nightly");
}

@Test
void overrideKeepsPrimaryTagsWhenPresent() {
RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of("adhoc"), null);
RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null);

assertThat(primary.override(fallback).tags()).containsExactly("adhoc");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package ai.timefold.solver.service.definition.impl.executionprofile;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.Map;

import org.junit.jupiter.api.Test;

class SeedExecutionProfileTest {

private final SeedExecutionProfile profile = new SeedExecutionProfile();

@Test
void mapsSuppliedSeedToEnvironment() {
assertThat(profile.toEnvironment(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42")))
.containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "42"));
}

@Test
void ignoresUnrelatedOptions() {
// The seed profile only reads its own option key; other run options are left alone.
assertThat(profile.toEnvironment(Map.of("solver", "fast", SeedExecutionProfile.PARAMETER_SEED, "7")))
.containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "7"));
}

@Test
void failsWhenSeedMissing() {
// Selecting the seed profile without supplying a seed is an error - the profile never invents one.
assertThatThrownBy(() -> profile.toEnvironment(Map.of()))
.isInstanceOf(IllegalArgumentException.class);
}

@Test
void rejectsNonLongSeed() {
Map<String, String> options = Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number");
assertThatThrownBy(() -> profile.toEnvironment(options))
.isInstanceOf(IllegalArgumentException.class);
}
}
Loading
Loading