Learn how to upgrade from Optaplanner to Timefold.
Discover how you can use Timefold Solver, our Open Source AI library, to optimize Santa’s travel route. Leave no gift un-gifted!
Migrate from OptaPlanner’s score DRL to Constraint Streams in Timefold Solver for better performance and maintenance.
Some of the users coming to Timefold from OptaPlanner might still be using score DRL to define their constraints. Since score DRL is the only thing we have not brought with us when we forked OptaPlanner, it is time to explain how to migrate from score DRL to Constraint Streams.
Constraint Streams (CS) is a modern full-featured API for writing Timefold Solver constraints, offering several benefits over score DRL:
CS and DRL are very similar in their approach to processing your data model. Because of this, migrating many parts of score DRL to CS is straight-forward, even mechanical. However, this guide does not help you if your score DRL uses the following constructs, because there is no direct mapping between DRL and CS in these cases:
insertLogical()
attribute allows information to be transferred between rules. This is not possible in CS, where each constraint is isolated and stands on its own. One typical use case for insertLogical()
was detecting sequences of consecutive shifts in the Nurse Rostering example. This particular use case can be handled by using the consecutive constraint collector.If you are using either of these constructs, we recommend that you first refactor them out of your DRL and then check back with this migration guide.
With that out of the way, let’s get the migration started.
ConstraintProvider
classIn score DRL, all your constraints are typically written in a single text file, for example:
package org.optaplanner.examples.vehiclerouting.optional.score;
dialect "java"
import ...;
global HardSoftLongScoreHolder scoreHolder;
rule "vehicleCapacity"
when
...
then
...
end
...
In this file, each rule represents one or more constraints. In CS, the DRL file is replaced by standard Java source code:
package ai.timefold.solver.examples.vehiclerouting.score;
import ...;
public class VehicleRoutingConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory factory) {
return new Constraint[] {
vehicleCapacity(factory),
...
};
}
protected Constraint vehicleCapacity(ConstraintFactory factory) {
...
}
...
}
The method defineConstraints(…)
, a single method on the ConstraintProvider
, lists all your constraints. Each constraint is then typically represented by a method.
Quarkus and Spring users most likely do not need to worry about solver configuration. Just remove the score DRL and create a ConstraintProvider
implementation.
In OptaPlanner’s solver configuration XML however, score DRL is configured by pointing the solver to the DRL file:
<solver>
...
<scoreDirectorFactory>
<scoreDrl>org/optaplanner/examples/vehiclerouting/optional/score/vehicleRoutingConstraints.drl</scoreDrl>
</scoreDirectorFactory>
...
</solver>
Constraint Streams are selected by pointing the solver to an implementation of the ConstraintProvider
interface from above:
<solver>
...
<scoreDirectorFactory>
<constraintProviderClass>ai.timefold.solver.examples.vehiclerouting.score.VehicleRoutingConstraintProvider</constraintProviderClass>
</scoreDirectorFactory>
...
</solver>
Many constraints follow a simple pattern of picking an entity and immediately penalizing it. Let’s look at an example from the field of vehicle routing:
rule "distanceToPreviousStandstill"
when
Customer(previousStandstill != null, $distanceFromPreviousStandstill : distanceFromPreviousStandstill)
then
scoreHolder.addSoftConstraintMatch(kcontext, - $distanceFromPreviousStandstill);
end
Here, each initialized Customer
instance incurs a soft penalty equivalent to the value of its distanceFromPreviousStandstill
field. Here’s how the same result is achieved in CS:
Constraint distanceToPreviousStandstill(ConstraintFactory factory) {
return factory.forEach(Customer.class)
.penalizeLong(HardSoftLongScore.ONE_SOFT, Customer::getDistanceFromPreviousStandstill)
.asConstraint("distanceToPreviousStandstill");
}
Note that:
forEach(Customer.class)
serves the same purpose as Customer(…)
in DRL.previousStandstill != null
), because forEach(…)
does it automatically. If this behavior is not what you want, use forEachIncludingNullVars(…)
instead.then
) is replaced by a call to penalizeLong(…)
. The size of the penalty is now determined by the constraint weight (HardSoftLongScore.ONE_SOFT
) and match weight (the call to a getter on Customer
).The match weight is a key difference between DRL and CS. In DRL, each rule adds a constraint match together with a total penalty. In CS, each constraint applies a reward or a penalty based on several factors:
HardSoftScore.ONE_SOFT
, HardMediumSoftScore.ONE_HARD
. Constraint weights can be either fixed or configurable.customer -> customer.getDistanceFromPreviousStandstill()
). If not specified, it defaults to 1
.The impact of each constraint match is calculated using the following formula:
(isReward ? 1 : -1) * (constraint weight) * (match weight)
In the example above, score DRL applies a penalty by adding a negative constraint match, for example:
scoreHolder.addSoftConstraintMatch(kcontext, - $distanceFromPreviousStandstill)
.
CS makes this more explicit by using the keyword penalize
instead of add…
, while keeping the match weight positive:
penalizeLong(…, …, customer -> customer.getDistanceFromPreviousStandstill())
.
You can accomplish a positive impact without changing the match weight if you replace penalize
by reward
:
rewardLong(…, …, customer -> customer.getDistanceFromPreviousStandstill())
.
In the example above, distanceFromPreviousStandstill
is of the type long
and therefore the DRL scoreHolder.addSoftConstraintMatch(kcontext, - $distanceFromPreviousStandstill)
maps to the CS penalizeLong(…, …, customer -> customer.getDistanceFromPreviousStandstill())
.
If the type was int
, it would map to penalize(…)
instead. Similarly, if the type was BigDecimal
, it would map to penalizeBigDecimal(…)
. No types other than int
, long
, and BigDecimal
are supported.
The same applies to rewards.
In some cases, such as in the Conference Scheduling example, constraint weights are specified in a @ConstraintConfiguration
annotated class and not in the score DRL. The relevant right-hand side of the score DRL would look like this:
scoreHolder.penalize(kcontext, $penalty);
In CS, this situation maps to penalizeConfigurable(…)
and similarly for rewards.
For more information, see penalties and rewards in Timefold Solver Documentation.
In the vehicle routing field, we could also find the following rule:rule "distanceFromLastCustomerToDepot"
when
$customer : Customer(previousStandstill != null, nextCustomer == null)
then
Vehicle vehicle = $customer.getVehicle();
scoreHolder.addSoftConstraintMatch(kcontext, - $customer.getDistanceTo(vehicle));
end
There are many similarities to the previous rule, but this time we penalize Customer
only when the nextCustomer
field is null
. To do the same in CS, we introduce a filter(…)
call where we check the return value of a getter for null
.
Constraint distanceFromLastCustomerToDepot(ConstraintFactory factory) {
return factory.forEach(Customer.class)
.filter(customer -> customer.getNextCustomer() == null)
.penalizeLong(HardSoftLongScore.ONE_SOFT,
customer -> {
Vehicle vehicle = customer.getVehicle();
return customer.getDistanceTo(vehicle);
})
.asConstraint("distanceFromLastCustomerToDepot");
}
For more information, see filtering section in Timefold Solver Documentation.
eval(…)
The eval(…)
construct allows us to execute an arbitrary piece of code that returns boolean
. As such, it is functionally equivalent to the CS filter(…)
construct as described previously. However, we discourage the use of eval(…)
because executing custom code, such as calling external services or processing collections iteratively, is likely to slow your constraints down.
Some constraints penalize based on a combination of entities or facts, such as in the NQueens example:
rule "Horizontal conflict"
when
Queen($id : id, row != null, $i : rowIndex)
Queen(id > $id, rowIndex == $i)
then
scoreHolder.addConstraintMatch(kcontext, -1);
end
Here, we select a pair of different queens (second Queen.id
greater than first Queen.id
) which share the same row (second Queen.rowIndex
equal to first Queen.rowIndex
). Each pair is then penalized by 1
.
Here’s how to do the same thing in CS, using a join(…)
call with some Joiners
:
Constraint horizontalConflict(ConstraintFactory factory) {
return factory.forEach(Queen.class)
.join(Queen.class,
Joiners.greaterThan(Queen::getId),
Joiners.equal(Queen::getRowIndex))
.penalize(SimpleScore.ONE)
.asConstraint("Horizontal conflict");
}
The Joiners.greaterThan(Queen::getId)
statement is a way of expressing the DRL queen.id > $id
statement in Java. Similarly, Joiners.equal(Queen::getRowIndex)
represents the DRL queen.rowIndex == $i
statement.
However, in this case, we can go further and use some CS syntactic sugar:
Constraint horizontalConflict(ConstraintFactory factory) {
return factory.forEachUniquePair(Queen.class,
equal(Queen::getRowIndex))
.penalize(SimpleScore.ONE)
.asConstraint("Horizontal conflict");
}
Using forEachUniquePair(Queen.class)
, the greaterThan(…)
joiner is inserted automatically, and we only need to match the row indexes.
For more information, see joining in Timefold Solver Documentation.
In certain cases, you might need to apply a filter while joining, such as in the case of the Conference Scheduling example:
rule "Talk prerequisite talks"
when
$talk1 : Talk(timeslot != null)
$talk2 : Talk(timeslot != null,
!getTimeslot().startsAfter($talk1.getTimeslot()),
getPrerequisiteTalkSet().contains($talk1))
then
scoreHolder.penalize(kcontext,
$talk1.getDurationInMinutes() + $talk2.getDurationInMinutes());
end
Note that the second Talk
is only selected if its prerequisiteTalkSet
contains the first Talk
. Because there is no CS joiner for this specific operation yet, we need to use a generic filtering joiner:
Constraint talkPrerequisiteTalks(ConstraintFactory factory) {
return factory.forEach(Talk.class)
.join(Talk.class,
Joiners.greaterThan(
talk1 -> talk1.getTimeslot().getEndDateTime(),
talk2 -> talk2.getTimeslot().getStartDateTime()),
Joiners.filtering((talk1, talk2) -> talk2.getPrerequisiteTalkSet().contains(talk1)))
.penalizeConfigurable(Talk::combinedDurationInMinutes)
.asConstraint(TALK_PREREQUISITE_TALKS);
}
CS only supports up to three joins natively. If you need four or more joins, refer to mapping tuples in Timefold Solver Documentation.
exists
and not
The DRL exists
keyword can be converted to CS much like the join above. Consider this rule from the Cloud Balancing example:
rule "computerCost"
when
$computer : CloudComputer($cost : cost)
exists CloudProcess(computer == $computer)
then
scoreHolder.addSoftConstraintMatch(kcontext, - $cost);
end
Here, only penalize a computer if a process exists that runs on that particular computer. An equivalent constraint stream looks like this:
Constraint computerCost(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(CloudComputer.class)
.ifExists(CloudProcess.class,
Joiners.equal(Function.identity(), CloudProcess::getComputer))
.penalize(HardSoftScore.ONE_SOFT, CloudComputer::getCost)
.asConstraint("computerCost");
}
Notice how the ifExists(…)
call uses the Joiners
class to define the relationship between CloudProcess
and CloudComputer
.
For the use of the DRL not
keyword, consider this rule from the Traveling Sales Person (TSP) example:
rule "distanceFromLastVisitToDomicile"
when
$visit : Visit(previousStandstill != null)
not Visit(previousStandstill == $visit)
$domicile : Domicile()
then
scoreHolder.addConstraintMatch(kcontext, - $visit.getDistanceTo($domicile));
end
A visit is only penalized if it is the final visit of the journey. The same can be achieved in CS using the ifNotExists(…)
building block:
Constraint distanceFromLastVisitToDomicile(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Visit.class)
.ifNotExists(Visit.class,
Joiners.equal(visit -> visit, Visit::getPreviousStandstill))
.join(Domicile.class)
.penalizeLong(SimpleLongScore.ONE, Visit::getDistanceTo)
.asConstraint("Distance from last visit to domicile");
}
For more information on ifExists()
and ifNotExists()
, see conditional propagationin Timefold Solver Documentation.
accumulate
CS does not have a concept that maps mechanically to the DRL accumulate
keyword. However, it does have a very powerful groupBy(…)
concept. To understand the differences between the two, consider the following rule taken from the Cloud Balancing example:
rule "requiredCpuPowerTotal"
when
$computer : CloudComputer($cpuPower : cpuPower)
accumulate(
CloudProcess(
computer == $computer,
$requiredCpuPower : requiredCpuPower);
$requiredCpuPowerTotal : sum($requiredCpuPower);
$requiredCpuPowerTotal > $cpuPower
)
then
scoreHolder.addHardConstraintMatch(kcontext, $cpuPower - $requiredCpuPowerTotal);
end
For each CloudComputer
, it computes a sum of CPU power required by CloudProcess
instances ($requiredCpuPowerTotal : sum($requiredCpuPower)
) running on that computer (CloudProcess(computer == $computer)
), and only penalizes those computers where the total power required exceeds the power available ($requiredCpuPowerTotal > $cpuPower
).
For comparison, let us now see how the same is accomplished in CS using groupBy(…)
:
Constraint requiredCpuPowerTotal(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(CloudProcess.class)
.groupBy(
CloudProcess::getComputer,
ConstraintCollectors.sum(CloudProcess::getRequiredCpuPower))
.filter((computer, requiredCpuPower) -> requiredCpuPower > computer.getCpuPower())
.penalize(HardSoftScore.ONE_HARD,
(computer, requiredCpuPower) -> requiredCpuPower - computer.getCpuPower())
.asConstraint("requiredCpuPowerTotal");
}
First, we select all CloudProcess
instances (forEach(CloudProcess.class)
). Then we apply groupBy
in two steps:
CloudProcess::getComputer
). If two or more processes have the same computer, they belong to the same group.ConstraintCollectors.sum(…)
to get a sum total of power required by all processes in such group.The result of that operation is a pair ("tuple") of facts: a CloudComputer
and an int
representing the sum total of power required by all processes running on that computer. We then take all such tuples and filter(…)
out all those where the sum total is <=
that computer’s available power. Finally, we penalize the positive difference between the required power and the available power, the overconsumption.
As you can see, groupBy(…)
accomplishes the same result, but goes about it differently. This is why mapping DRL accumulate
to CS groupBy
, while always possible, is not necessarily straight-forward or mechanical.
For more information on groupBy(…)
, see grouping and collectors in Timefold Solver Documentation.
Users of Timefold Solver can no longer rely on score DRL to define their constraints. After migration to Constraint Streams, your constraints are likely to perform faster and be easier to maintain. Migrating most types of constraints to Constraint Streams is straightforward.
Learn how to upgrade from Optaplanner to Timefold.
Discover how you can use Timefold Solver, our Open Source AI library, to optimize Santa’s travel route. Leave no gift un-gifted!