package org.optaplanner.examples.vehiclerouting.optional.score;
dialect "java"
import ...;
global HardSoftLongScoreHolder scoreHolder;
rule "vehicleCapacity"
when
...
then
...
end
...
For a couple of years now, the Constraint Streams API (CS) has been fully featured and widely adopted. It can handle any use case written in score DRL. It’s also faster and easier to use. Therefore, the scoreDRL API is deprecated.
OptaPlanner 8 users can continue using score DRL because we will maintain score DRL support for the lifetime of OptaPlanner 8. However, if you use score DRL, start planning your migration from score DRL to some other scoring mechanism, preferably CS. Migration to CS is simple. Review the real-world examples of constraints found in OptaPlanner Examples.
Constraint Streams (CS) is a modern full-featured API for writing OptaPlanner constraints, offering several benefits over score DRL:
Developers do not need to learn any new language. CS is plain Java.
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.
CS and DRL are very similar in their approach and in fact both use Drools to execute the constraint logic. 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.
The DRL right-hand side allows for arbitrary Java code execution that goes far beyond calculating the match weight. This anti-pattern 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.
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 a standard Java source code:
package org.optaplanner.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 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:
<solver>
...
<scoreDirectorFactory>
<constraintProviderClass>org.optaplanner.examples.vehiclerouting.score.VehicleRoutingConstraintProvider</constraintProviderClass>
</scoreDirectorFactory>
...
</solver>
Many constraints follow a simple pattern of picking an entity and immediately penalizing it. One such case can be found in the Vehicle Routing example:
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("distanceToPreviousStandstill",
HardSoftLongScore.ONE_SOFT,
customer -> customer.getDistanceFromPreviousStandstill());
}
Note that:
forEach(Customer.class)
serves the same purpose as Customer(…)
in DRL.
There is no need to check if a planning variable is initialized (previousStandstill != null
), because forEach(…)
does it automatically.
If this behavior is not what you want, use forEachIncludingNullVars(…)
instead.
The right-hand side of the rule (the part after 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:
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 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 following example shows the score DRL:
scoreHolder.penalize(kcontext, $penalty);
In CS, this situation maps to penalizeConfigurable(…)
and similarly for rewards.
For more information, see penalties and rewards in the OptaPlanner documentation.
In the same Vehicle Routing example, we can 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("distanceFromLastCustomerToDepot",
HardSoftLongScore.ONE_SOFT,
customer -> {
Vehicle vehicle = customer.getVehicle();
return customer.getDistanceTo(vehicle);
});
}
For more information, see the filtering section in the OptaPlanner 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.
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("Horizontal conflict", SimpleScore.ONE);
}
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("Horizontal conflict", SimpleScore.ONE);
}
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 the OptaPlanner 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_PREREQUISITE_TALKS, Talk::combinedDurationInMinutes);
}
CS only supports up to three joins natively. If you need four or more joins, refer to mapping tuples in the OptaPlanner 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("computerCost",
HardSoftScore.ONE_SOFT,
CloudComputer::getCost);
}
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("Distance from last visit to domicile",
SimpleLongScore.ONE,
Visit::getDistanceTo);
}
For more information on ifExists()
and ifNotExists()
, see conditional propagation in the OptaPlanner 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("requiredCpuPowerTotal",
HardSoftScore.ONE_HARD,
(computer, requiredCpuPower) -> requiredCpuPower - computer.getCpuPower());
}
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 the OptaPlanner documentation.
In the OptaPlanner Examples package section, every example has both a score DRL file and an equivalent ConstraintProvider
implementation.
Browse these examples, contrast respective DRL and CS implementations, and use the information to help with your own migration.