Published in Blog
Migrate from score DRL to Constraint Streams
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:
-
Developers do not need to learn any new language. CS is plain Java (or Kotlin).
-
CS provides full IDE support including syntax highlighting and refactoring.
-
CS provides comprehensive support for unit testing.
-
In most use cases, CS performs considerably faster than score DRL.
Limits of this guide
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:
-
The DRL
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 forinsertLogical()
was detecting sequences of consecutive shifts in the Nurse Rostering example. This particular use case can be handled by using the consecutive constraint collector. -
The DRL right-hand side allows for arbitrary Java code execution that goes far beyond calculating the match weight. This is not possible in CS, where each constraint can only result in either a score reward or a penalty.
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.
Create and configure a ConstraintProvider
class
In 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.
Solver configuration
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>
Migrating trivial constraints
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 asCustomer(…)
in DRL. -
There is no need to check if a planning variable is initialized (
previousStandstill != null
), becauseforEach(…)
does it automatically. If this behavior is not what you want, useforEachIncludingNullVars(…)
instead. -
The right-hand side of the rule (the part after
then
) is replaced by a call topenalizeLong(…)
. The size of the penalty is now determined by the constraint weight (HardSoftLongScore.ONE_SOFT
) and match weight (the call to a getter onCustomer
).
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:
-
A penalty or reward. A penalty has a negative impact on the score, while a reward impacts the score positively.
-
A constant constraint weight, such as
HardSoftScore.ONE_SOFT
,HardMediumSoftScore.ONE_HARD
. Constraint weights can be either fixed or configurable. -
A dynamic match weight. This applies to any individual match and is typically specified by a lambda (for example
customer -> customer.getDistanceFromPreviousStandstill()
). If not specified, it defaults to1
.
The impact of each constraint match is calculated using the following formula:
(isReward ? 1 : -1) * (constraint weight) * (match weight)
Applying rewards instead of penalties
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())
.
Applying different penalty types
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.
Applying configurable constraint weights
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.
Migrating constraints with filters
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.
Migrating 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.
Migrating constraints with joins
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.
Applying filters while joining
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);
}
Migrating large joins
CS only supports up to three joins natively. If you need four or more joins, refer to mapping tuples in Timefold Solver Documentation.
Migrating 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 propagation
in Timefold Solver Documentation.
Migrating 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:
-
We split the processes into buckets ("groups") by their computer (
CloudProcess::getComputer
). If two or more processes have the same computer, they belong to the same group. -
For each such group, we apply a
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.
Conclusion
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.