From 3ae399060b23911ca0ae26da34c429226dc672fa Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Wed, 15 Jul 2026 12:09:08 +0200 Subject: [PATCH 1/7] docs: change constraint-overrides to module configuration overrides Solves #2347 --- docs/src/modules/ROOT/nav.adoc | 2 +- .../pages/deploying-to-platform/guide.adoc | 2 +- .../deploying-to-platform/model-metadata.adoc | 6 +- .../quickstart/service/getting-started.adoc | 2 +- .../service/constraint-overrides.adoc | 214 ----------- .../service/model-config-overrides.adoc | 362 ++++++++++++++++++ .../service/rest-api.adoc | 2 +- 7 files changed, 369 insertions(+), 221 deletions(-) delete mode 100644 docs/src/modules/ROOT/pages/running-timefold-solver/service/constraint-overrides.adoc create mode 100644 docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc diff --git a/docs/src/modules/ROOT/nav.adoc b/docs/src/modules/ROOT/nav.adoc index 595d41136d..dcb31d86d7 100644 --- a/docs/src/modules/ROOT/nav.adoc +++ b/docs/src/modules/ROOT/nav.adoc @@ -24,7 +24,7 @@ ** xref:running-timefold-solver/service/overview.adoc[As a service] *** xref:running-timefold-solver/service/rest-api.adoc[leveloffset=+1] *** xref:running-timefold-solver/service/modeling-changes.adoc[leveloffset=+1] -*** xref:running-timefold-solver/service/constraint-overrides.adoc[Constraint weights] +*** xref:running-timefold-solver/service/model-config-overrides.adoc[Model configuration overrides] *** xref:running-timefold-solver/service/demo-data.adoc[leveloffset=+1] *** xref:running-timefold-solver/service/exposing-metrics.adoc[leveloffset=+1] ** xref:running-timefold-solver/library/library-integration.adoc[As a library] diff --git a/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc b/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc index 8bda8494ba..2b80d7dec4 100644 --- a/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc +++ b/docs/src/modules/ROOT/pages/deploying-to-platform/guide.adoc @@ -126,7 +126,7 @@ If it's missing, double check that `handleSubscription` is set to `true` in the * *Constraints show up by name in score analysis*: the score analysis breakdown should show your constraints under human-readable names, not raw identifiers. This requires the richer constraint metadata described in xref:deploying-to-platform/model-metadata.adoc#_constraint_descriptions_and_groups[Constraint descriptions and groups]. * *Input and output metrics show up*: the dataset view should display the metrics you exposed, as described in xref:deploying-to-platform/metrics.adoc[Using metrics]. -* *You can add a configuration profile*: add a new configuration profile in the UI and override one of your xref:running-timefold-solver/service/constraint-overrides.adoc[constraint weights], then confirm a re-solve picks up the new weight. +* *You can add a configuration profile*: add a new configuration profile in the UI and override one of your xref:running-timefold-solver/service/model-config-overrides.adoc[constraint weights], then confirm a re-solve picks up the new weight. See xref:deploying-to-platform/model-metadata.adoc#_configuration_profiles[Configuration profiles] for the properties that define the default profile shipped with your model. [sectnums!] diff --git a/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc b/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc index f06706b8a9..a5bc0a7831 100644 --- a/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc +++ b/docs/src/modules/ROOT/pages/deploying-to-platform/model-metadata.adoc @@ -59,7 +59,7 @@ To give consumers a better score analysis and configuration profile experience, * `ConstraintInfo(id, name, description, constraintGroup)`: `id` is the stable identifier of the constraint, `name` is the human-readable name shown in the UI, `description` explains the constraint's goal, and `constraintGroup` optionally assigns the constraint to a `ConstraintGroupInfo`. * `ConstraintGroupInfo(id, name, description, icon, tags)`: groups related constraints under a shared category. `icon` accepts any icon name from https://tabler.io/icons[Tabler Icons], and `tags` are optional labels you can use to classify or filter groups. -Extending the `TimetableConstraintProvider` from xref:running-timefold-solver/service/constraint-overrides.adoc[Adjusting constraint weights] with names, descriptions, and a group: +Extending the `TimetableConstraintProvider` from xref:running-timefold-solver/service/model-config-overrides.adoc[Adjusting constraint weights] with names, descriptions, and a group: .The ConstraintProvider class, with ConstraintInfo attached. [tabs] @@ -140,7 +140,7 @@ class TimetableConstraintProvider : ConstraintProvider { -- ==== -The `id` you pass into `ConstraintInfo` is still the identifier used by `@ConstraintReference` in your `ModelConfigOverrides`, as described in xref:running-timefold-solver/service/constraint-overrides.adoc[Adjusting constraint weights]. +The `id` you pass into `ConstraintInfo` is still the identifier used by `@ConstraintReference` in your `ModelConfigOverrides`, as described in xref:running-timefold-solver/service/model-config-overrides.adoc[Adjusting constraint weights]. The `name`, `description`, and `constraintGroup` are purely presentational: they are what the platform's score analysis view and configuration profile editor use to show your constraints to consumers, instead of falling back to the raw ID. The UI groups constraints that don't specify a `constraintGroup` under a default group. @@ -187,7 +187,7 @@ This profile is what consumers get out of the box, before they add any profile o |=== Once your model is deployed, tenant users can add additional configuration profiles on top of this default directly in the platform UI, without you making any code changes. -A configuration profile is where per-request xref:running-timefold-solver/service/constraint-overrides.adoc[constraint weight overrides], thread count, memory, and termination limits are set for a specific use case. +A configuration profile is where per-request xref:running-timefold-solver/service/model-config-overrides.adoc[constraint weight overrides], thread count, memory, and termination limits are set for a specific use case. See https://docs.timefold.ai/timefold-platform/latest/how-tos/configuration-parameters-and-profiles[Configuration parameters and profiles] for how tenant users manage profiles from the platform side. [#_visualization] diff --git a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc index 0eec8b6354..f56ae8c949 100644 --- a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc @@ -229,7 +229,7 @@ However, this class is also the output of the solution: NOTE: The `getConstraintWeightOverrides()` method is required by the `SolverModel` interface (implemented by `AbstractSimpleModel`), and `setConstraintWeightOverrides()` is optional. For now `getConstraintWeightOverrides()` is left as a stub — `none()` means no constraint weights can be overridden per request. -Configuring this properly is covered in the xref:running-timefold-solver/service/constraint-overrides.adoc#serviceWeightOverrides[constraint weights] section and is not part of this guide. +Configuring this properly is covered in the xref:running-timefold-solver/service/model-config-overrides.adoc#serviceWeightOverrides[constraint weights] section and is not part of this guide. include::../shared/school-timetabling/_school-timetabling-solution-value-range-providers.adoc[leveloffset=+1] diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/constraint-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/constraint-overrides.adoc deleted file mode 100644 index cf8162f8b9..0000000000 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/constraint-overrides.adoc +++ /dev/null @@ -1,214 +0,0 @@ -[#serviceConstraintWeightOverrides] -= Adjusting constraint weights -:page-aliases: service/constraint-weights.adoc, service/constraint-overrides.adoc -:doctype: book -:sectnums: -:icons: font - - - -include::../../constraints-and-score/_constraint-config-shared.adoc[] - -[#applyingOverrides] -[#serviceWeightOverrides] -== Applying overrides - -include::_preview-note.adoc[] - -Constraint weights are always an interpretation by the modeler. -It might be that the consumer of the model would like to see the constraints weighed differently. -`ModelConfigOverrides` allows consumers of a model to tailor constraint weights to their use case. - -[NOTE] -Be careful not to make your model overly configurable as that impacts usability. -Usually, it doesn't make sense to allow weight overrides for _hard_ constraints. - -Implement the `ModelConfigOverrides` interface. This is a marker interface, meaning it has no methods but can be discovered by the SDK. -The implementation should have fields that refer to specific constraints using the `@ConstraintReference` annotation, -referencing the static string constants defined in the `ConstraintProvider`: - -.The ConstraintProvider class. -[tabs] -==== -Java:: -+ --- -[source,java,options="nowrap"] ----- -public class TimetableConstraintProvider implements ConstraintProvider { - - public static final String TEACHER_CONFLICT = "Teacher conflict"; - public static final String ROOM_CONFLICT = "Room conflict"; - - Constraint roomConflict(ConstraintFactory constraintFactory) { - return constraintFactory - // constraint implementation excluded - .asConstraint(ROOM_CONFLICT); - } - - Constraint teacherConflict(ConstraintFactory constraintFactory) { - return constraintFactory - // constraint implementation excluded - .asConstraint(TEACHER_CONFLICT); - } - - // other constraints excluded -} ----- --- - -Kotlin:: -+ --- -[source,kotlin,options="nowrap"] ----- -class TimetableConstraintProvider : ConstraintProvider { - - companion object { - const val TEACHER_CONFLICT = "Teacher conflict" - const val ROOM_CONFLICT = "Room conflict" - } - - fun roomConflict(constraintFactory: ConstraintFactory): Constraint { - return constraintFactory - // constraint implementation excluded - .asConstraint(ROOM_CONFLICT) - } - - fun teacherConflict(constraintFactory: ConstraintFactory): Constraint { - return constraintFactory - // constraint implementation excluded - .asConstraint(TEACHER_CONFLICT) - } - - // other constraints excluded -} ----- --- -==== - -.The ModelConfigOverrides class. -[tabs] -==== -Java:: -+ --- -[source,java,options="nowrap"] ----- -public final class TimetableConfigOverrides implements ModelConfigOverrides { - - public static final long DEFAULT_WEIGHT_ZERO = 0L; - public static final long DEFAULT_WEIGHT_ONE = 1L; - - @ConstraintReference(TimetableConstraintProvider.TEACHER_CONFLICT) - private long teacherConflictWeight = DEFAULT_WEIGHT_ONE; - - @ConstraintReference(TimetableConstraintProvider.ROOM_CONFLICT) - private long roomConflictWeight = DEFAULT_WEIGHT_ONE; - - // getter/setter excluded - -} ----- --- - -Kotlin:: -+ --- -[source,kotlin,options="nowrap"] ----- -data class TimetableConfigOverrides( - @ConstraintReference(TimetableConstraintProvider.TEACHER_CONFLICT) - val teacherConflictWeight: Long = DEFAULT_WEIGHT_ONE, - @ConstraintReference(TimetableConstraintProvider.ROOM_CONFLICT) - val roomConflictWeight: Long = DEFAULT_WEIGHT_ONE -) : ModelConfigOverrides { - - companion object { - const val DEFAULT_WEIGHT_ZERO = 0L - const val DEFAULT_WEIGHT_ONE = 1L - } - -} ----- --- -==== - -The default constraint weight for these constraints is `1`. -This can now be overridden by the consumer by passing the overrides object in a request. -For example, to make the Teacher conflict 10 times more impactful, override the weight to 10: - -.Example request to change the weight. -[source,json] ----- -{ - "config": { - "run": { - "name": "run name", - - }, - "model": { - "overrides": { - "teacherConflictWeight": 10 - } - } - }, - "modelInput" : "" -} ----- - -Next, in the xref:running-timefold-solver/service/rest-api.adoc#modelConverter[model converter], map the overrides -to a `ConstraintWeightOverrides` object and set it on the `@PlanningSolution` class -xref:#enablingWeightOverrides[as described above]: - -[IMPORTANT] -==== -This requires a custom `ModelConvertor`. -If your `SolverModel` also serves as `ModelInput` and `ModelOutput`, for example because you extend `AbstractSimpleModel`, the framework uses a trivial `ModelConvertor` by default, which ignores `modelConfig` entirely. -With only the trivial convertor in place, constraint weight overrides submitted in a request are silently dropped: the request succeeds, but the override never reaches the solver. -Provide your own `ModelConvertor` implementation, as shown below, to actually apply them. -==== - -.As part of the ModelConverter. -[tabs] -==== -Java:: -+ --- -[source,java,options="nowrap"] ----- -TimetableConfigOverrides modelConfigOverrides = modelConfig.overrides(); - -ConstraintWeightOverrides constraintWeightOverrides = ConstraintWeightOverrides.of( - Map.ofEntries( - Map.entry(TimetableConstraintProvider.TEACHER_CONFLICT, - HardMediumSoftScore.ofHard(modelConfigOverrides.getTeacherConflictWeight())), - Map.entry(TimetableConstraintProvider.ROOM_CONFLICT, - HardMediumSoftScore.ofSoft(modelConfigOverrides.getRoomConflictWeight())) - ) -); - -solverModel.setConstraintWeightOverrides(constraintWeightOverrides); ----- --- - -Kotlin:: -+ --- -[source,kotlin,options="nowrap"] ----- -val modelConfigOverrides = modelConfig.overrides() - -val constraintWeightOverrides = ConstraintWeightOverrides.of( - mapOf( - TimetableConstraintProvider.TEACHER_CONFLICT to - HardMediumSoftScore.ofHard(modelConfigOverrides.teacherConflictWeight), - TimetableConstraintProvider.ROOM_CONFLICT to - HardMediumSoftScore.ofSoft(modelConfigOverrides.roomConflictWeight) - ) -) - -solverModel.constraintWeightOverrides = constraintWeightOverrides ----- --- -==== diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc new file mode 100644 index 0000000000..1e8055f395 --- /dev/null +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -0,0 +1,362 @@ += Model configuration overrides +:page-aliases: service/constraint-weights.adoc, service/constraint-overrides.adoc +:description: Learn how to implement per-request solver configuration using ModelConfigOverrides and @ConstraintReference. +:doctype: book +:sectnums: +:icons: font + +Model configuration overrides allow callers to tune the solver's objective function per request — +adjusting how much weight is given to individual constraints, and passing constraint-specific parameters — +without modifying the solver model itself. + +Three components work together to deliver this: + +[cols="1,3",options="header"] +|=== +| Component | Role + +| `ModelConfigOverrides` +| Marker interface. Your implementing class carries the tunable fields callers can set per request. + +| `@ConstraintReference` +| Links a field to a specific constraint, either as a weight multiplier or as a typed parameter value. + +| `ModelConfig` +| Runtime container. Carries the caller's overrides instance into `ModelConvertor.toSolverModel()`. +|=== + +include::../../constraints-and-score/_constraint-config-shared.adoc[] + +[#_model_config_overrides] +== ModelConfigOverrides + +`ModelConfigOverrides` is a marker interface from `ai.timefold.solver.service.definition.api`. +Implement it with a plain class that declares all fields a caller can override per request. + +Fields linked to a specific constraint are annotated with `@ConstraintReference`. +Fields that represent global configuration, not tied to any single constraint, require no annotation. + +[tabs] +==== +Java:: ++ +-- +[source,java,options="nowrap"] +---- +public final class MyPlanConfigOverrides implements ModelConfigOverrides { + + // Constraint weight fields + @ConstraintReference(MyConstraints.MINIMIZE_TRAVEL_TIME) + private long minimizeTravelTimeWeight = 1L; + + @ConstraintReference(MyConstraints.PREFER_EARLY_COMPLETION) + private long preferEarlyCompletionWeight = 0L; + + // Constraint parameter field + @ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) + private Duration bufferBeforeShiftEnd = Duration.ZERO; + + // Global config — not constraint-specific, no annotation required + private TravelTimeAdjustment travelTimeAdjustment; + + // Standard getters, setters, and optional fluent "with" builders ... +} +---- +-- + +Kotlin:: ++ +-- +[source,kotlin,options="nowrap"] +---- +data class MyPlanConfigOverrides( + @ConstraintReference(MyConstraints.MINIMIZE_TRAVEL_TIME) + val minimizeTravelTimeWeight: Long = 1L, + + @ConstraintReference(MyConstraints.PREFER_EARLY_COMPLETION) + val preferEarlyCompletionWeight: Long = 0L, + + @ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) + val bufferBeforeShiftEnd: Duration = Duration.ZERO, + + val travelTimeAdjustment: TravelTimeAdjustment? = null +) : ModelConfigOverrides +---- +-- +==== + +[NOTE] +==== +Non-constraint global configuration that needs to vary per request can also live in this class. +==== + +[NOTE] +==== +Be careful not to make your model overly configurable as that impacts usability. +Usually, it doesn't make sense to allow weight overrides for _hard_ constraints. +==== + +[#_constraint_reference] +== @ConstraintReference + +`@ConstraintReference` wires a field in your `ModelConfigOverrides` implementation to a named constraint. + +[cols="1,1,1,3",options="header"] +|=== +| Attribute | Type | Default | Purpose + +| `value` +| `String` +| _(required)_ +| The constraint name. Must match the identifier used in your `ConstraintProvider`. + +| `kind` +| `ParameterKind` +| `WEIGHT` +| Whether this field is a weight multiplier or a typed parameter passed into the constraint's logic. +|=== + +[#_weight_mode] +=== Weight mode + +The default mode. Annotate a `long` field to make it a weight multiplier for that constraint. +The value scales the constraint's score contribution relative to other soft constraints. + +A value of `0` effectively disables the constraint for that request. +Higher values increase the constraint's priority relative to others. + +[source,java] +---- +// Active by default +@ConstraintReference(MyConstraints.MINIMIZE_TRAVEL_TIME) +private long minimizeTravelTimeWeight = 1L; + +// Disabled by default; callers can enable it by setting the weight above 0 +@ConstraintReference(MyConstraints.PREFER_GROUPING_CO_LOCATED_VISITS) +private long preferGroupingCoLocatedVisitsWeight = 0L; +---- + +[#_parameter_mode] +=== Parameter mode + +When `kind = ParameterKind.PARAMETER`, the field supplies a typed value into the constraint's logic +rather than scaling its score. +Any serializable Java type is supported: `Duration`, `String`, `Double`, custom enums, and so on. + +[source,java] +---- +@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) +private Duration bufferBeforeShiftEnd = Duration.ZERO; + +@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) +private String minimumPriorityLevel = "medium"; +---- + +[#_multiple_references] +=== Multiple references to the same constraint + +A single constraint can be referenced by multiple fields simultaneously — +one as a weight and any number as parameters. +This is the standard pattern when a constraint is both tunable in importance and requires domain-specific threshold values. + +[source,java] +---- +// Controls importance +@ConstraintReference(MyConstraints.MINIMIZE_COMPLETION_RISK) +private long minimizeCompletionRiskWeight = 1L; + +// Controls threshold value — same constraint, different kind +@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) +private Duration bufferBeforeShiftEnd = Duration.ZERO; + +@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) +private String minimumPriorityLevel = "medium"; +---- + +[TIP] +==== +Define all constraint names as `public static final String` constants in your `ConstraintProvider` +and reference them symbolically in `@ConstraintReference`. +This prevents typos and enables IDE navigation between the annotation and the constraint definition. +==== + + +[#applyingOverrides] +[#serviceWeightOverrides] +== Applying overrides in ModelConvertor + +The `ModelConvertor` implementation translates the caller's overrides into the data structures the +solver engine expects. +It receives a `ModelConfig` in `toSolverModel()` and must handle the case where the caller +provided no overrides at all — `modelConfig` itself, or `modelConfig.overrides()`, may be `null`. + +[IMPORTANT] +==== +This requires a custom `ModelConvertor`. +If your `SolverModel` also serves as `ModelInput` and `ModelOutput`, for example because you extend `AbstractSimpleModel`, the framework uses a trivial `ModelConvertor` by default, which ignores `modelConfig` entirely. +With only the trivial convertor in place, constraint weight overrides submitted in a request are silently dropped: the request succeeds, but the override never reaches the solver. +Provide your own `ModelConvertor` implementation, as shown below, to actually apply them. +==== + +[#_reading_constraint_weights] +=== Reading constraint weights + +Build a `Map` from constraint names to score values, then pass it to +`ConstraintWeightOverrides.of(...)` when constructing the solver model. + +When no overrides are provided, fall back to a default-constructed instance of your overrides class +so that all defaults are centralised in one place. + +[tabs] +==== +Java:: ++ +-- +[source,java,options="nowrap"] +---- +private static Map + getConstraintWeightOverrides(ModelConfig modelConfig) { + + MyPlanConfigOverrides overrides = + modelConfig != null && modelConfig.overrides() != null + ? modelConfig.overrides() + : new MyPlanConfigOverrides(); // defaults come from field initialisers + + return Map.ofEntries( + Map.entry(MyConstraints.MINIMIZE_TRAVEL_TIME, + HardMediumSoftScore.ofSoft(overrides.getMinimizeTravelTimeWeight())), + Map.entry(MyConstraints.PREFER_EARLY_COMPLETION, + HardMediumSoftScore.ofSoft(overrides.getPreferEarlyCompletionWeight())), + Map.entry(MyConstraints.MINIMIZE_COMPLETION_RISK, + HardMediumSoftScore.ofSoft(overrides.getMinimizeCompletionRiskWeight())) + ); +} +---- +-- + +Kotlin:: ++ +-- +[source,kotlin,options="nowrap"] +---- +private fun getConstraintWeightOverrides( + modelConfig: ModelConfig?): Map { + + val overrides = modelConfig?.overrides() ?: MyPlanConfigOverrides() + + return mapOf( + MyConstraints.MINIMIZE_TRAVEL_TIME to + HardMediumSoftScore.ofSoft(overrides.minimizeTravelTimeWeight), + MyConstraints.PREFER_EARLY_COMPLETION to + HardMediumSoftScore.ofSoft(overrides.preferEarlyCompletionWeight), + MyConstraints.MINIMIZE_COMPLETION_RISK to + HardMediumSoftScore.ofSoft(overrides.minimizeCompletionRiskWeight) + ) +} +---- +-- +==== + +[#_reading_constraint_parameters] +=== Reading constraint parameters + +Extract parameter fields individually into a value object, or pass them directly to the solver model. +Guard against null the same way as for weights. + +[source,java] +---- +private static ConstraintParameters + getConstraintParameters(ModelConfig modelConfig) { + + if (modelConfig == null || modelConfig.overrides() == null) { + return null; + } + MyPlanConfigOverrides overrides = modelConfig.overrides(); + return new ConstraintParameters( + overrides.getBufferBeforeShiftEnd(), + overrides.getMinimumPriorityLevel() + ); +} +---- + +Apply both in `toSolverModel()`: + +[source,java] +---- +@Override +public MySolverModel toSolverModel( + MyPlanInput input, + ModelConfig modelConfig, + Optional previousSolution) { + + Map weightOverrides = getConstraintWeightOverrides(modelConfig); + ConstraintParameters constraintParams = getConstraintParameters(modelConfig); + + return new MySolverModel(...) + .withConstraintWeightOverrides(ConstraintWeightOverrides.of(weightOverrides)) + .withConstraintParameters(constraintParams); +} +---- + +[#_constraint_parameters_in_solver_model] +=== ConstraintParameters in the solver model + +The `ConstraintParameters` object is stored on the planning solution class and annotated with +`@ProblemFactProperty`, which makes it visible to the constraint engine. + +[source,java] +---- +@ProblemFactProperty +private ConstraintParameters constraintParameters; +---- + +Because it is a problem fact and not a planning entity, the constraint engine does not iterate over +it automatically. +Constraints that need the parameter values must explicitly join against `ConstraintParameters.class` +in their constraint stream. + +[source,java] +---- +protected Constraint minimizeCompletionRisk(ConstraintFactory factory) { + return factory.forEach(MyVisit.class) + .join(MyShift.class, + Joiners.equal(MyVisit::getShift, Function.identity())) + .join(ConstraintParameters.class) // <1> + .filter((visit, shift, params) -> + visit.getDepartureTime().isAfter( + shift.getEndTime().minus(params.bufferBeforeShiftEnd()))) + .penalize(HardMediumSoftScore.ONE_SOFT, ...) + .asConstraint(MyConstraints.MINIMIZE_COMPLETION_RISK); +} +---- +<1> The join has no `Joiners` condition because there is exactly one `ConstraintParameters` instance. +Every tuple from the preceding stream is paired with it unconditionally. + +[NOTE] +==== +Constraints that do not use any parameter values do not need this join. +Only add it where the constraint logic actually reads from `ConstraintParameters`. +==== + +[#_conventions] +== Conventions + +* *Weight fields are `long` with a positive default.* + Use `0L` to disable a constraint by default; use `1L` or higher to enable it. + Weight values must be non-negative. + +* *Parameter fields carry a sensible domain default.* + Use `null` only when the absence of a value is meaningful to the constraint. + Otherwise initialise to a safe zero-value. e.g. `Duration.ZERO`, `0.0`, the lowest tier string, and so on. + +* *Constraint names are constants.* + Define every constraint name as a `public static final String` in your `ConstraintProvider`. + Never use inline string literals in `@ConstraintReference`. + +* *Global configuration is separate from constraints.* + Fields not linked to a specific constraint require no `@ConstraintReference` annotation. + Extract and apply them independently in the convertor. + +* *Fluent builders are optional but encouraged.* + Providing `withX()` methods on your overrides class makes test setup and programmatic request + construction significantly more readable. diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc index 7ee13460ad..c290b2b80c 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc @@ -154,7 +154,7 @@ This termination is desirable since it terminates based on the relative rate of `unimprovedSpentLimit` should be set only when necessary. The `model` configuration is a model-specific field that contains additional global model configuration attributes. -See xref:running-timefold-solver/service/constraint-overrides.adoc#serviceWeightOverrides[constraint weights] for more information. +See xref:running-timefold-solver/service/model-config-overrides.adoc#serviceWeightOverrides[constraint weights] for more information. [#solvingResponse] === Structured solving response From b25cfc65eb6b5a4080b2e1f9a6c2ab242bb57d0a Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Wed, 15 Jul 2026 12:17:27 +0200 Subject: [PATCH 2/7] docs: add missing page aliases --- .../running-timefold-solver/service/model-config-overrides.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 1e8055f395..809fbb2d81 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -1,5 +1,5 @@ = Model configuration overrides -:page-aliases: service/constraint-weights.adoc, service/constraint-overrides.adoc +:page-aliases: service/constraint-weights.adoc, service/constraint-overrides.adoc, running-timefold-solver/service/constraint-weights.adoc, running-timefold-solver/service/constraint-overrides.adoc :description: Learn how to implement per-request solver configuration using ModelConfigOverrides and @ConstraintReference. :doctype: book :sectnums: From dfaebc8df654303538ccf1aea8b5c9f8dc0d4f8d Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Thu, 16 Jul 2026 11:45:31 +0200 Subject: [PATCH 3/7] docs: remove the Parameter style overrides Solves #2347 --- .../service/model-config-overrides.adoc | 54 +------------------ 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 809fbb2d81..907b82044a 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -19,7 +19,7 @@ Three components work together to deliver this: | Marker interface. Your implementing class carries the tunable fields callers can set per request. | `@ConstraintReference` -| Links a field to a specific constraint, either as a weight multiplier or as a typed parameter value. +| Links a constraint weight multiplier field to a specific constraint. | `ModelConfig` | Runtime container. Carries the caller's overrides instance into `ModelConvertor.toSolverModel()`. @@ -52,10 +52,6 @@ public final class MyPlanConfigOverrides implements ModelConfigOverrides { @ConstraintReference(MyConstraints.PREFER_EARLY_COMPLETION) private long preferEarlyCompletionWeight = 0L; - // Constraint parameter field - @ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) - private Duration bufferBeforeShiftEnd = Duration.ZERO; - // Global config — not constraint-specific, no annotation required private TravelTimeAdjustment travelTimeAdjustment; @@ -76,9 +72,6 @@ data class MyPlanConfigOverrides( @ConstraintReference(MyConstraints.PREFER_EARLY_COMPLETION) val preferEarlyCompletionWeight: Long = 0L, - @ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) - val bufferBeforeShiftEnd: Duration = Duration.ZERO, - val travelTimeAdjustment: TravelTimeAdjustment? = null ) : ModelConfigOverrides ---- @@ -124,6 +117,7 @@ The value scales the constraint's score contribution relative to other soft cons A value of `0` effectively disables the constraint for that request. Higher values increase the constraint's priority relative to others. +Do keep the value significantly below `Long.MAX_VALUE`, since multiple constraints keep adding to the score impact across all their matches. [source,java] ---- @@ -136,50 +130,6 @@ private long minimizeTravelTimeWeight = 1L; private long preferGroupingCoLocatedVisitsWeight = 0L; ---- -[#_parameter_mode] -=== Parameter mode - -When `kind = ParameterKind.PARAMETER`, the field supplies a typed value into the constraint's logic -rather than scaling its score. -Any serializable Java type is supported: `Duration`, `String`, `Double`, custom enums, and so on. - -[source,java] ----- -@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) -private Duration bufferBeforeShiftEnd = Duration.ZERO; - -@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) -private String minimumPriorityLevel = "medium"; ----- - -[#_multiple_references] -=== Multiple references to the same constraint - -A single constraint can be referenced by multiple fields simultaneously — -one as a weight and any number as parameters. -This is the standard pattern when a constraint is both tunable in importance and requires domain-specific threshold values. - -[source,java] ----- -// Controls importance -@ConstraintReference(MyConstraints.MINIMIZE_COMPLETION_RISK) -private long minimizeCompletionRiskWeight = 1L; - -// Controls threshold value — same constraint, different kind -@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) -private Duration bufferBeforeShiftEnd = Duration.ZERO; - -@ConstraintReference(value = MyConstraints.MINIMIZE_COMPLETION_RISK, kind = ParameterKind.PARAMETER) -private String minimumPriorityLevel = "medium"; ----- - -[TIP] -==== -Define all constraint names as `public static final String` constants in your `ConstraintProvider` -and reference them symbolically in `@ConstraintReference`. -This prevents typos and enables IDE navigation between the annotation and the constraint definition. -==== - [#applyingOverrides] [#serviceWeightOverrides] From f6bbd7cd88b40c8258d6b2301adec832c29894c0 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Thu, 16 Jul 2026 14:39:26 +0200 Subject: [PATCH 4/7] docs: remove mention of "kind" for ConstraintReference annotations --- .../service/model-config-overrides.adoc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 907b82044a..4c0c7b155c 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -103,16 +103,12 @@ Usually, it doesn't make sense to allow weight overrides for _hard_ constraints. | _(required)_ | The constraint name. Must match the identifier used in your `ConstraintProvider`. -| `kind` -| `ParameterKind` -| `WEIGHT` -| Whether this field is a weight multiplier or a typed parameter passed into the constraint's logic. |=== -[#_weight_mode] -=== Weight mode +[#_weight_multipliers] +=== Weight multipliers -The default mode. Annotate a `long` field to make it a weight multiplier for that constraint. +Annotate a `long` field to make it a weight multiplier for that constraint. The value scales the constraint's score contribution relative to other soft constraints. A value of `0` effectively disables the constraint for that request. From 52819717899c38ea18a76690b6f5634725164704 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Thu, 16 Jul 2026 14:46:20 +0200 Subject: [PATCH 5/7] docs: remove object from global configuration --- .../service/model-config-overrides.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 4c0c7b155c..36f6189ada 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -53,7 +53,7 @@ public final class MyPlanConfigOverrides implements ModelConfigOverrides { private long preferEarlyCompletionWeight = 0L; // Global config — not constraint-specific, no annotation required - private TravelTimeAdjustment travelTimeAdjustment; + private double travelTimeAdjustment; // Standard getters, setters, and optional fluent "with" builders ... } @@ -72,7 +72,7 @@ data class MyPlanConfigOverrides( @ConstraintReference(MyConstraints.PREFER_EARLY_COMPLETION) val preferEarlyCompletionWeight: Long = 0L, - val travelTimeAdjustment: TravelTimeAdjustment? = null + val travelTimeAdjustment: Double? = null ) : ModelConfigOverrides ---- -- From 186aa0b3ccacff8d0630f8f46bcd4f252d40dd32 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Thu, 16 Jul 2026 14:49:02 +0200 Subject: [PATCH 6/7] docs: fix non-nullability --- .../service/model-config-overrides.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 36f6189ada..1d0357a856 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -134,7 +134,7 @@ private long preferGroupingCoLocatedVisitsWeight = 0L; The `ModelConvertor` implementation translates the caller's overrides into the data structures the solver engine expects. It receives a `ModelConfig` in `toSolverModel()` and must handle the case where the caller -provided no overrides at all — `modelConfig` itself, or `modelConfig.overrides()`, may be `null`. +provided no overrides at all — `modelConfig.overrides()`, may be `null`. [IMPORTANT] ==== @@ -164,7 +164,7 @@ private static Map getConstraintWeightOverrides(ModelConfig modelConfig) { MyPlanConfigOverrides overrides = - modelConfig != null && modelConfig.overrides() != null + modelConfig.overrides() != null ? modelConfig.overrides() : new MyPlanConfigOverrides(); // defaults come from field initialisers @@ -214,7 +214,7 @@ Guard against null the same way as for weights. private static ConstraintParameters getConstraintParameters(ModelConfig modelConfig) { - if (modelConfig == null || modelConfig.overrides() == null) { + if (modelConfig.overrides() == null) { return null; } MyPlanConfigOverrides overrides = modelConfig.overrides(); From 28f4ad5c0ddfbc5f2034ee8ec6234c8fd9df7dd9 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Fri, 17 Jul 2026 13:38:33 +0200 Subject: [PATCH 7/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../running-timefold-solver/service/model-config-overrides.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc index 1d0357a856..31d945c8e7 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -134,7 +134,7 @@ private long preferGroupingCoLocatedVisitsWeight = 0L; The `ModelConvertor` implementation translates the caller's overrides into the data structures the solver engine expects. It receives a `ModelConfig` in `toSolverModel()` and must handle the case where the caller -provided no overrides at all — `modelConfig.overrides()`, may be `null`. +provided no overrides at all — `modelConfig.overrides()` may be `null`. [IMPORTANT] ====