droolsoptaplannerdrools-planner

what causes ConstraintMatchTotal could not add constraintMatch, when issue is tied to a .drl 'or' clause?


In extending code from OptaPlanner nurse rostering sample code. What causes the "constraintMatchTotal could not add constraintMatch" (Illegal state?) error to be thrown, that would be related to the parsing of a .drl rule with an 'or' clause, please? It is occurring immediately at import of data into .drl-based ruleset... but does NOT error if either of the two 'or' clauses is commented out. I believe that as they individually are acceptable, the system should handle them in the 'or' setup.

The rule is below, followed by the error, and the domain object used in the 'or' clause. I confirmed that:

.drl rule:

rule "Highlight irregular shifts"
when
    EmployeeWorkSameShiftTypeSequence(
        employee != null,        
        $firstDayIndex : firstDayIndex,
        $lastDayIndex : lastDayIndex,
        $employee : employee,
        $dayLength : dayLength)

    (
    BoundaryDate(
        dayIndex == $firstDayIndex,
        preferredSequenceStart == false     // does not start on a boundary start date
        )
    or                                      // or
    BoundaryDate(
        dayIndex == $firstDayIndex,
        $dayLength != preferredCoveringLength   // is incorrect length for exactly one block
        )
    )

    StaffRosterParametrization($lastDayIndex >= planningWindowStartDayIndex)    // ignore if assignment is in (fixed) prior data            

    // non-functional identification drives desired indictment display on ShiftAssignment planning objects
    ShiftAssignment(employee == $employee, shiftDateDayIndex >= $firstDayIndex, shiftDateDayIndex <= $lastDayIndex)

then
    scoreHolder.addSoftConstraintMatch(kcontext, -1);
end

Exception executing consequence for rule "Highlight irregular shifts" in westgranite.staffrostering.solver: java.lang.IllegalStateException: The constraintMatchTotal (westgranite.staffrostering.solver/Highlight irregular shifts=0hard/-274soft) could not add constraintMatch (westgranite.staffrostering.solver/Highlight irregular shifts/[2020-01-02/D/6, 2018-12-25 - 2020-01-06, 2020-01-02, ... [continues on with a list of constraint matches]

The BoundaryData.java is below, so the methods being called from the rule are visible:

package westgranite.staffrostering.domain;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import com.thoughtworks.xstream.annotations.XStreamAlias;

import westgranite.common.domain.AbstractPersistable;

@XStreamAlias("BoundaryDate")
public class BoundaryDate extends AbstractPersistable {
    /**
     * 
     */
    private static final long serialVersionUID = -7393276689810490427L;

    private static final DateTimeFormatter LABEL_FORMATTER = DateTimeFormatter.ofPattern("E d MMM");

    private int dayIndex; 
    private LocalDate date;

    private boolean preferredSequenceStart; // true means "this date is a preferred start to assignment sequences"
    private boolean preferredSequenceEnd;   // true means "this date is a preferred end for assignment sequences"
    private int nextPreferredStartDayIndex; // MAX_VALUE means "none"; if preferredSequenceStart is true, then this ref is still to the FUTURE next pref start date
    private int prevPreferredStartDayIndex; // MIN_VALUE means "none"; if preferredSequenceStart is true, then this ref is still to the PREVIOUS next pref start date

    // magic value that is beyond reasonable dayIndex range and still allows delta of indices to be an Integer
    public static final int noNextPreferredDayIndex = Integer.MAX_VALUE/3;
    public static final int noPrevPreferredDayIndex = Integer.MIN_VALUE/3;

    public int getDayIndex() {
        return dayIndex;
    }

    public void setDayIndex(int dayIndex) {
        this.dayIndex = dayIndex;
    }

    public LocalDate getDate() {
        return date;
    }

    public void setDate(LocalDate date) {
        this.date = date;
    }

    public boolean isPreferredSequenceStart() {
        return preferredSequenceStart;
    }

    public void setPreferredSequenceStart(boolean preferredSequenceStart) {
        this.preferredSequenceStart = preferredSequenceStart;
    }

    public boolean isPreferredSequenceEnd() {
        return preferredSequenceEnd;
    }

    public void setPreferredSequenceEnd(boolean preferredSequenceEnd) {
        this.preferredSequenceEnd = preferredSequenceEnd;
    }

    public int getNextPreferredStartDayIndex() {
        return nextPreferredStartDayIndex;
    }

    public void setNextPreferredStartDayIndex(int nextPreferredStartDayIndex) {
        this.nextPreferredStartDayIndex = nextPreferredStartDayIndex;
    }

    public int getPrevPreferredStartDayIndex() {
        return prevPreferredStartDayIndex;
    }

    public void setPrevPreferredStartDayIndex(int prevPreferredStartDayIndex) {
        this.prevPreferredStartDayIndex = prevPreferredStartDayIndex;
    }

    // ===================== COMPLEX METHODS ===============================
    public int getCurrOrPrevPreferredStartDayIndex() {
        return (isPreferredSequenceStart() ? dayIndex : prevPreferredStartDayIndex);
    }

    public int getCurrOrNextPreferredStartDayIndex() {
        return (isPreferredSequenceStart() ? dayIndex : nextPreferredStartDayIndex);
    }

    public int getCurrOrPrevPreferredEndDayIndex() {
        return (isPreferredSequenceEnd() ? dayIndex : (isPreferredSequenceStart() ? dayIndex-1 : prevPreferredStartDayIndex-1));
    }

    public int getCurrOrNextPreferredEndDayIndex() {
        return (isPreferredSequenceEnd() ? dayIndex : nextPreferredStartDayIndex-1);
    }

    public boolean isNoNextPreferred() {
        return getNextPreferredStartDayIndex() == noNextPreferredDayIndex;
    }

    public boolean isNoPrevPreferred() {
        return getPrevPreferredStartDayIndex() == noPrevPreferredDayIndex;
    }

    /**
     * @return if this is a preferred start date, then the sequence length that will fill from this date through the next end date; otherwise the days filling the past preferred start date through next end date
     */
    public int getPreferredCoveringLength() {
        if (isPreferredSequenceStart()) {
            return nextPreferredStartDayIndex - dayIndex;
        }
        return nextPreferredStartDayIndex - prevPreferredStartDayIndex;
    }

    /**
     * @return if this is a preferred start boundary, then "today", else day of most recent start boundary
     */
    public DayOfWeek getPreferredStartDayOfWeek() {
        if (isPreferredSequenceStart()) {
            return getDayOfWeek();
        }
        if (isNoPrevPreferred()) {
            throw new IllegalStateException("No prev preferred day of week available for " + toString());
        }
        return date.minusDays(dayIndex - getPrevPreferredStartDayIndex()).getDayOfWeek();
    }

    public DayOfWeek getPreferredEndDayOfWeek() {
        if (isPreferredSequenceEnd()) {
            return getDayOfWeek();
        }
        if (isNoNextPreferred()) {
            throw new IllegalStateException("No next preferred day of week available for " + toString());
        }
        return date.plusDays((getNextPreferredStartDayIndex()-1) - dayIndex).getDayOfWeek();
    }

    public DayOfWeek getDayOfWeek() {
        return date.getDayOfWeek();
    }

    public int getMostRecentDayIndexOf(DayOfWeek targetDayOfWeek) {
        return dayIndex - getBackwardDaysToReach(targetDayOfWeek);
    }

    public int getUpcomingDayIndexOf(DayOfWeek targetDayOfWeek) {
        return dayIndex + getForwardDaysToReach(targetDayOfWeek);
    }

    public LocalDate getMostRecentDateOf(DayOfWeek targetDayOfWeek) {
        return date.minusDays(getBackwardDaysToReach(targetDayOfWeek));
    }

    public LocalDate getUpcomingDateOf(DayOfWeek targetDayOfWeek) {
        return date.plusDays(getForwardDaysToReach(targetDayOfWeek));
    }

    public int getForwardDaysToReach(DayOfWeek targetDayOfWeek) {
        return getForwardDaysToReach(this.getDayOfWeek(), targetDayOfWeek);
    }

    public static int getForwardDaysToReach(DayOfWeek startDayOfWeek, DayOfWeek targetDayOfWeek) {
        if (startDayOfWeek == targetDayOfWeek) {
            return 0;
        }
        int forwardDayCount = 1;
        while (startDayOfWeek.plus(forwardDayCount) != targetDayOfWeek) {
            forwardDayCount++;

            if (forwardDayCount > 10) {
                throw new IllegalStateException("counting forward in days from " + startDayOfWeek + " never found target day of week: " + targetDayOfWeek);
            }
        }
        return forwardDayCount;
    }

    public int getBackwardDaysToReach(DayOfWeek targetDayOfWeek) {
        return getBackwardDaysToReach(this.getDayOfWeek(), targetDayOfWeek);
    }

    public static int getBackwardDaysToReach(DayOfWeek startDayOfWeek, DayOfWeek targetDayOfWeek) {
        if (startDayOfWeek == targetDayOfWeek) {
            return 0;
        }
        int backwardDayCount = 1;
        while (startDayOfWeek.minus(backwardDayCount) != targetDayOfWeek) {
            backwardDayCount++;

            if (backwardDayCount > 10) {
                throw new IllegalStateException("counting backward in days from " + startDayOfWeek + " never found target day of week: " + targetDayOfWeek);
            }
        }
        return backwardDayCount;
    }

    public String getLabel() {
        return date.format(LABEL_FORMATTER);
    }

    @Override
    public String toString() {
        return date.format(DateTimeFormatter.ISO_DATE);
    }

}

Solution

  • If the same object being tested in the rule can match in multiple parts of an 'or' condition, then Optaplanner throws this IllegalStateException, at least through 7.15.0. See details explored in optaplanner jira 1433.

    Workaround is to always add terms to later terms of 'or' expressions that ensure the matching object can not be the same one that matched earlier parts of the 'or'. For the original posting above, the 'preferredSequenceStart == true' achieved this exclusion.

    Note that the use of the 'exists' keyword in the terms of the 'or' can cause trouble with this workaround; try to avoid using 'exists' in this situation.