Adjusting constraints at runtime
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 and overriding constraint weights
Let’s define three constraints:
-
Constraint with a name of
Vehicle capacityand a weight of `1hard'. -
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 {
...
@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");
}
}
Without anything else, the constraint weights are fixed to the values we’ve given them in our ConstraintProvider.
To be able to override these weights at runtime, we need to introduce the ConstraintWeightOverrides class
to our planning solution class:
-
Java
@PlanningSolution
public class VehicleRoutePlan {
...
ConstraintWeightOverrides<HardSoftScore> constraintWeightOverrides;
void setConstraintWeightOverrides(ConstraintWeightOverrides<HardSoftScore> constraintWeightOverrides) {
this.constraintWeightOverrides = constraintWeightOverrides;
}
ConstraintWeightOverrides<HardSoftScore> getConstraintWeightOverrides() {
return constraintWeightOverrides;
}
...
}
We’ve just introduced a new field of type ConstraintWeightOverrides,
and we provided a getter and a setter for it.
The field will be automatically exposed as a problem fact,
there is no need to add a @ProblemFactProperty annotation.
But we need to fill it with the desired constraint weights:
-
Java
...
var constraintWeightOverrides = ConstraintWeightOverrides.of(
Map.of(
"Vehicle capacity", HardSoftScore.ofHard(2),
"Service finished after max end time", HardSoftScore.ZERO
)
);
var solution = new VehicleRoutePlan();
solution.setConstraintWeightOverrides(constraintWeightOverrides);
...
The Vehicle capacity constraint in this planning solution has a weight of 2hard,
as opposed to its original 1hard.
The Service finished after max end time constraint has a weight of 0hard,
and therefore will be disabled entirely.
In this way, you can solve the same problem by applying different constraint weights to each instance. Once solved, you can compare the results and decide which set of weights is the most suitable for your use case.
1.1. Sending overrides over the wire
Overrides are part of the planning solution, and as such they are automatically serialized into JSON using Jackson, assuming either of the following conditions are met:
-
You use Timefold Solver’s Quarkus integration,
-
you use Timefold Solver’s Spring Boot integration,
-
or you directly included the
timefold-solver-jacksonmodule in your project.
Overrides doesn’t natively deserialize from JSON back to Java objects.
This is because we have no way of knowing which Score implementation you may be using.
However, deserialization is easy to implement yourself by extending AbstractConstraintWeightOverridesDeserializer
and registering it with Jackson’s ObjectMapper.
2. Passing parameters to constraints
In some cases, constraints need to be parameterized as different data sets may have different requirements for the same constraint. For example, a constraint may have to switch the minimum required pause length between two shifts, based on the laws of the country that the data set is dealing with.
To achieve this, you could have many variants of the same constraint in ConstraintProvider
and disable some of them using overrides.
To avoid the code duplication that this would have caused,
it is arguably better to have a single constraint that can be parameterized.
This section shows how to achieve this
using the Constraint Streams API.
First, create a new class to hold the parameters for the constraint.
For this document, we call it ConstraintParameters,
but you’re free to choose any name you like:
-
Java
public record ConstraintParameters(int minimumPauseInMinutes) {
}
Then, add a field of type ConstraintParameters to your planning solution
and annotate it with @ProblemFactProperty:
-
Java
@PlanningSolution
public class MyPlanningSolution {
...
@ProblemFactProperty
ConstraintParameters constraintParameters;
...
}
This will expose the ConstraintParameters as a problem fact,
making it available to the constraints.
Finally, use the join building block
to adjust the constraint implementation to use the parameters:
-
Java
public class MyConstraintProvider implements ConstraintProvider {
...
Constraint minimumPauseBetweenShifts(ConstraintFactory factory) {
return factory.forEach(Shift.class)
.join(ConstraintParameters.class)
.penalize(HardSoftScore.ONE_HARD, (shift, parameters) -> {
var pauseInMinutes = shift.getPauseInMinutes();
return Math.max(0, pauseInMinutes - constraintParameters.minimumPauseInMinutes());
})
.asConstraint("Minimum pause between shifts");
}
...
}