diff --git a/docs/src/modules/ROOT/nav.adoc b/docs/src/modules/ROOT/nav.adoc index e5d0282032..df232bd653 100644 --- a/docs/src/modules/ROOT/nav.adoc +++ b/docs/src/modules/ROOT/nav.adoc @@ -23,8 +23,9 @@ ** xref:running-timefold-solver/overview.adoc[Overview] ** 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/model-config-overrides.adoc[Model configuration overrides] *** xref:running-timefold-solver/service/model-enrichment.adoc[leveloffset=+1] -*** xref:running-timefold-solver/service/constraint-overrides.adoc[Constraint weights] + *** xref:running-timefold-solver/service/demo-data.adoc[leveloffset=+1] *** xref:running-timefold-solver/service/exposing-metrics.adoc[leveloffset=+1] *** xref:running-timefold-solver/service/consumer-guide.adoc[Service consumer guide] 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 b83b710a7a..ae18340cbf 100644 --- a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc +++ b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc @@ -230,7 +230,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..31d945c8e7 --- /dev/null +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/model-config-overrides.adoc @@ -0,0 +1,308 @@ += Model configuration overrides +: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: +: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 constraint weight multiplier field to a specific constraint. + +| `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; + + // Global config — not constraint-specific, no annotation required + private double 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, + + val travelTimeAdjustment: Double? = 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`. + +|=== + +[#_weight_multipliers] +=== Weight multipliers + +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. +Do keep the value significantly below `Long.MAX_VALUE`, since multiple constraints keep adding to the score impact across all their matches. + +[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; +---- + + +[#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.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.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.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 1fdf6ea226..7a9e6d2c99 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 @@ -157,7 +157,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