OptaPlanner logo
  • Download
  • Learn
    • Documentation
    • Videos

    • Use cases
    • Compatibility
    • Testimonials and case studies
  • Get help
  • Blog
  • Source
  • Team
  • Services
  • Star
  • T
  • L
  • F
  • YT
Fork me on GitHub

Migrate from score DRL to Constraint Streams in OptaPlanner 8.x

Table of Contents
  • Introduction
    • What does deprecating score DRL mean?
    • Benefits of Constraint Streams
    • Limits of this guide
  • Create and configure a ConstraintProvider class
    • Solver configuration
  • Migrating trivial constraints
    • Applying rewards instead of penalties
    • Applying different penalty types
    • Applying configurable constraint weights
  • Migrating constraints with filters
    • Migrating eval(…​)
  • Migrating constraints with joins
    • Applying filters while joining
    • Migrating large joins
  • Migrating exists and not
  • Migrating accumulate
  • More resources

Introduction

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.

What does deprecating score DRL mean?

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.

Benefits of Constraint Streams

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.

Limits of this guide

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.

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

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

Migrating trivial constraints

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)

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

Migrating constraints with filters

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.

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.

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

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_PREREQUISITE_TALKS, Talk::combinedDurationInMinutes);
    }

Migrating large joins

CS only supports up to three joins natively. If you need four or more joins, refer to mapping tuples in the OptaPlanner 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("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.

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("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:

  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 the OptaPlanner documentation.

More resources

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.

Latest release
  • 9.44.0.Final released
    Wed 6 September 2023
Upcoming events
    Add event / Archive
Latest blog posts
  • Scaling Up Vehicle Routing Problem with planning list variable and Nearby Selector
    Thu 27 April 2023
    Anna Dupliak
  • OptaPlanner 9 has been released
    Mon 24 April 2023
    Radovan Synek
  • OptaPlanner 9 is coming
    Tue 21 February 2023
    Lukáš Petrovický
  • Farewell - a new lead
    Tue 15 November 2022
    Geoffrey De Smet
  • Run OptaPlanner workloads on OpenShift, part II
    Wed 9 November 2022
    Radovan Synek
  • Bavet - A faster score engine for OptaPlanner
    Tue 6 September 2022
    Geoffrey De Smet
  • Run OptaPlanner workloads on OpenShift, part I.
    Thu 9 June 2022
    Radovan Synek
  • Blog archive
Latest videos
  • The Vehicle Routing Problem
    Fri 23 September 2022
    Geoffrey De Smet
  • Introduction to OptaPlanner AI constraint solver
    Thu 25 August 2022
    Anna Dupliak
  • On schedule: Artificial Intelligence plans that meet expectations
    Sat 23 July 2022
    Geoffrey De Smet
  • Host your OptaPlanner app on OpenShift (Kubernetes)
    Mon 7 February 2022
    Geoffrey De Smet
  • OptaPlanner - A fast, easy-to-use, open source AI constraint solver for software developers
    Mon 31 January 2022
  • Order picking planning with OptaPlanner
    Fri 31 December 2021
    Anna Dupliak
  • AI lesson scheduling on Quarkus with OptaPlanner
    Thu 18 November 2021
    Geoffrey De Smet
  • Video archive

OptaPlanner is open. All dependencies of this project are available under the Apache Software License 2.0 or a compatible license. OptaPlanner is trademarked.

This website was built with JBake and is open source.

Community

  • Blog
  • Get Help
  • Team
  • Governance
  • Academic research

Code

  • Build from source
  • Issue tracker
  • Release notes
  • Upgrade recipes
  • Logo and branding
CC by 3.0 | Privacy Policy
Sponsored by Red Hat