By

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 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.

  • 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 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)

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.

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:

  1. 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.

  2. 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.

Continue reading

  • Java versus Python performance benchmarks on PlanningAI models

    Discover the techniques Timefold Engineers deploy to make your Python code faster.

  • Simplify the Shadow Variable Listener Implementation

    Learn how to simplify creating listeners for planning list variables with the new shadow variable @CascadingUpdateShadowVariable.

  • Load balancing and fairness in constraints

    Discover how to bring fairness to your Timefold solution

  • Optimize routing and scheduling in Python: a new open source solver Timefold

    Automate and optimize your operations scheduling in Python with Timefold AI

  • Timefold Solver Python live in Alpha

    Empowering developers to solve planning problems

  • How to speed up Timefold Solver Startup Time by 20x with native images

    Discover how to build a Spring native image and the benefits from doing so.

Sign up for our newsletter

And stay up to date with announcements, the latest news, events, roadmap progress & product updates from Timefold!

We care about the protection of your data. Read our Privacy Policy.

Stay In-The-Know

Sign Up for Our Newsletter

We care about the protection of your data. Read our Privacy Policy.

Timefold

Timefold is an AI planning optimization platform, built on powerful open-source solver technology, enabling software builders to tackle real-world, complex and high-impact operational planning problems. It delivers significant economic value in optimization operations like extended VRP, maintenance scheduling, field service routing, task sequencing, etc.

© 2024 Timefold BV