Search code examples
nullconstraintsoptaplanner

Optaplanner Penalty Not Working With a Nullable Variable


I am using OptaPlanner with two planning variables, one of them defined with nullable=true. Following a Meeting example (for simplicity), say the Room could be null but the Time can't be null.

I defined a constraint on the non-null, time variable, but it seems that a penalty only works when the nullable room variable is not null, and fails otherwise.

Below is is snippet of my code:

@PlanningEntity
public class Meeting {

   @PlanningVariable(valueRangeProviderRefs = "time")
   private LocalDateTime time;

   @PlanningVariable(valueRangeProviderRefs = "availableRooms", nullable = true)
   private Room room;

   private long personId;
   ...
}

In my Constraint Provider class, I defined the following constraint for making sure one person can't be at two separate meetings:

protected Constraint samePersonAndTimeConflict(ConstraintFactory constraintFactory) {
        return constraintFactory
                // Select each pair of 2 different meetings ...
                .forEachUniquePair(Meeting.class,
                        // ... for the same person ...
                        equal(Meeting::getPersonId),
                        // ... in the same time ...
                        equal(Meeting::getDateTime))
                // ... and penalize each pair with a hard weight.
                .penalize(SAME_PERSON_AND_TIME_CONFLICT, HardSoftScore.ONE_HARD);
    } 

When creating two objects with non-null values, this constraint will work fine and penalize with one hard score if the same person is supposed to be in two separate meetings at the same time. However, when working with an object for which the nullable room variable is indeed null- there is no penalty. The outcome is that I am left with a solution in which many instances have the same time assignment for the same person.

I tried manipulating the constraint in other ways as well, such as using the "forEachIncludingNullVars" and the "forEach", but I'm seeing the same result:

protected Constraint samePersonAndTimeConflict(ConstraintFactory constraintFactory) {
        return constraintFactory
                .forEachIncludingNullVars(Meeting.class)
                //.filter(meeting -> meeting.getDateTime() != null)
                .join(Meeting.class,
                        lessThan(Meeting::getId),
                        equal(Meeting::getTime),
                        equal(Meeting::getPersonId)) //,
                .penalize(SAME_PERSON_AND_TIME_CONFLICT, HardSoftScore.ONE_HARD);

I also tried changing the score class to the HardMediumSoftScore and penalize by ONE_MEDIUM. Still no penalty for meetings with null rooms.

It seems that OptaPlanner is simply not working as it should. At this point I don't know what else I can try. Please advise.

*** Editing ***

Following the advice below for using a nested constraintFactory with an additional forEachIncludingNullVars clause, I ended up implementing my constraint as follows - and it now works:

protected Constraint samePersonAndTimeConflict(ConstraintFactory constraintFactory) {
        final Constraint constraint = constraintFactory.forEachIncludingNullVars(Meeting.class)
                .join(
                        constraintFactory.forEachIncludingNullVars(Meeting.class)
                                .filter(meeting -> meeting.getTime() != null),
                        lessThan(WorkDay::getId),
                        equal(WorkDay::getEmployeeId),
                        equal(WorkDay::getDate))
                .penalize("Same person and time conflict", HardMediumSoftScore.ONE_HARD);
        return constraint;
    }

Solution

  • forEach() will not work here, as that will only give you entities where none of the variables are null. forEachIncludingNullVars() is the way to go here. But you also need to understand that join has the same behavior as forEach- it does not include entities with null variables.

    To take that into account, you need to join with a nested stream, like so:

    protected Constraint samePersonAndTimeConflict(ConstraintFactory constraintFactory) {
        return constraintFactory
                .forEachIncludingNullVars(Meeting.class)
                .join(
                    constraintFactory.forEachIncludingNullVars(Meeting.class),
                        // add your joiners here
                ).penalize(SAME_PERSON_AND_TIME_CONFLICT, HardSoftScore.ONE_HARD);
    }
    

    This way, the join will create a cross-product of all entities, regardless of whether any of their variables are null or not. It then becomes a question of filtering out the parts where you do not wish to see nulls.