Model configuration overrides
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:
| Component | Role |
|---|---|
|
Marker interface. Your implementing class carries the tunable fields callers can set per request. |
|
Links a field to a specific constraint, either as a weight multiplier or as a typed parameter value. |
|
Runtime container. Carries the caller’s overrides instance into |
Deciding the correct weight and level for each constraint is not easy. It often involves negotiating with different stakeholders and their priorities. Furthermore, quantifying the impact of soft constraints is often a new experience for business managers, so they’ll need a number of iterations to get it right.
Don’t get stuck between a rock and a hard place. Provide a UI to adjust the constraint weights and visualize the resulting solution, so the business managers can tweak the constraint weights themselves:
1. Defining constraint weights
Let’s define three constraints:
-
Constraint with a name of
Vehicle capacityand a weight of1hard. -
Constraint with a name of
Service finished after max end time, also with a weight of1hard. -
Constraint with a name of
Minimize travel timeand a weight of1soft.
Using the Constraint Streams API, this is done as follows:
-
Java
public class VehicleRoutingConstraintProvider implements ConstraintProvider {
public static final String VEHICLE_CAPACITY = "Vehicle capacity";
public static final String SERVICE_FINISHED_AFTER_MAX_END_TIME = "Service finished after max end time";
public static final String MINIMIZE_TRAVEL_TIME = "Minimize travel time";
...
@Override
public Constraint[] defineConstraints(ConstraintFactory factory) {
return new Constraint[] {
vehicleCapacity(factory),
serviceFinishedAfterMaxEndTime(factory),
minimizeTravelTime(factory)
};
}
Constraint vehicleCapacity(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
...
.penalize(HardSoftScore.ONE_HARD, ...)
.asConstraint(VEHICLE_CAPACITY);
}
Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) {
return factory.forEach(Visit.class)
...
.penalize(HardSoftScore.ONE_HARD, ...)
.asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME);
}
Constraint minimizeTravelTime(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
...
.penalize(HardSoftScore.ONE_SOFT, ...)
.asConstraint(MINIMIZE_TRAVEL_TIME);
}
}
Using static string constants for constraint names is recommended.
It prevents typos and ensures the constraint name is consistent across the ConstraintProvider
and any code that references constraints by name, such as when applying overrides.
|
2. Enabling weight overrides
Without anything else, the constraint weights are fixed to the values specified in the ConstraintProvider.
To be able to override these weights at runtime, add a ConstraintWeightOverrides field
to the planning solution class:
-
Java
@PlanningSolution
public class VehicleRoutePlan {
...
ConstraintWeightOverrides<HardSoftScore> constraintWeightOverrides;
void setConstraintWeightOverrides(ConstraintWeightOverrides<HardSoftScore> constraintWeightOverrides) {
this.constraintWeightOverrides = constraintWeightOverrides;
}
ConstraintWeightOverrides<HardSoftScore> getConstraintWeightOverrides() {
return constraintWeightOverrides;
}
...
}
The field will be automatically exposed as a problem fact,
there is no need to add a @ProblemFactProperty annotation.
3. 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.
-
Java
-
Kotlin
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 ...
}
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
|
Non-constraint global configuration that needs to vary per request can also live in this class. |
|
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. |
4. @ConstraintReference
@ConstraintReference wires a field in your ModelConfigOverrides implementation to a named constraint.
| Attribute | Type | Default | Purpose |
|---|---|---|---|
|
|
(required) |
The constraint name. Must match the identifier used in your |
|
|
|
Whether this field is a weight multiplier or a typed parameter passed into the constraint’s logic. |
4.1. 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.
// 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;
4.2. 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.
@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";
4.3. 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.
// 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";
|
Define all constraint names as |
5. Applying overrides in ModelConvertor
The ModelConvertor implementation translates the caller’s overrides into the data structures the
solver engine expects.
It receives a ModelConfig<T> in toSolverModel() and must handle the case where the caller
provided no overrides at all — modelConfig itself, or modelConfig.overrides(), may be null.
|
This requires a custom |
5.1. Reading constraint weights
Build a Map<String, Score> 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.
-
Java
-
Kotlin
private static Map<String, HardMediumSoftScore>
getConstraintWeightOverrides(ModelConfig<MyPlanConfigOverrides> 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()))
);
}
private fun getConstraintWeightOverrides(
modelConfig: ModelConfig<MyPlanConfigOverrides>?): Map<String, HardMediumSoftScore> {
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)
)
}
5.2. 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.
private static ConstraintParameters
getConstraintParameters(ModelConfig<MyPlanConfigOverrides> modelConfig) {
if (modelConfig == null || modelConfig.overrides() == null) {
return null;
}
MyPlanConfigOverrides overrides = modelConfig.overrides();
return new ConstraintParameters(
overrides.getBufferBeforeShiftEnd(),
overrides.getMinimumPriorityLevel()
);
}
Apply both in toSolverModel():
@Override
public MySolverModel toSolverModel(
MyPlanInput input,
ModelConfig<MyPlanConfigOverrides> modelConfig,
Optional<MyPlanOutput> previousSolution) {
Map<String, HardMediumSoftScore> weightOverrides = getConstraintWeightOverrides(modelConfig);
ConstraintParameters constraintParams = getConstraintParameters(modelConfig);
return new MySolverModel(...)
.withConstraintWeightOverrides(ConstraintWeightOverrides.of(weightOverrides))
.withConstraintParameters(constraintParams);
}
5.3. 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.
@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.
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. |
|
Constraints that do not use any parameter values do not need this join.
Only add it where the constraint logic actually reads from |
6. Conventions
-
Weight fields are
longwith a positive default. Use0Lto disable a constraint by default; use1Lor higher to enable it. Weight values must be non-negative. -
Parameter fields carry a sensible domain default. Use
nullonly 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 Stringin yourConstraintProvider. Never use inline string literals in@ConstraintReference. -
Global configuration is separate from constraints. Fields not linked to a specific constraint require no
@ConstraintReferenceannotation. 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.