Search code examples
kotlinexceptionoptaplannertimefold

Why does a constraint on linked planning entities cause the following score corruption exception?


I'm currently working on solving a VRP with pickups and deliveries. In the current model a Vehicle is my first @PlanningEntity, containing a @PlanningListVariable of LoadJobs, which can either be of type PICKUP or DROPOFF. Every LoadJob has the reference to the Load it is delivering and the Load class has references to both its PICKUP and DROPOFF LoadJob.

I've written the following constraint to ensure both LoadJobs are put onto the same Vehicle (of course, it would not make sense to try delivering a load that has never been picked up and it also would not make sense to pick up a load without ever delivering it):

fun pickupAndDropoffOnSameVehicle(constraintFactory: ConstraintFactory): Constraint {
    return constraintFactory
        .forEach(LoadJob::class.java)
        .filter { it.load.pickup.vehicle != it.load.dropoff.vehicle }
        .penalizeConfigurable()
        .asConstraint(PICKUP_AND_DROPOFF_ON_SAME_VEHICLE)
}

When I run in FULL_ASSERT mode, I get the following exception:

Caused by: java.lang.IllegalStateException: Score corruption (100hard): the workingScore (-19init/-100hard/0medium/-11670soft) is not the uncorruptedScore (-19init/-200hard/0medium/-11670soft) after completedAction (LoadJob(id=DROPOFF-loDKrYTAqF5kIfjFM6n4) {null -> Vehicle(idx=0)[0]}):
Score corruption analysis:
  The corrupted scoreDirector has no ConstraintMatch(s) which are in excess.
  The corrupted scoreDirector has 1 ConstraintMatch(s) which are missing:
    com.cargonexx.vehiclerouting.solver.constraint/pickupAndDropoffOnSameVehicle/[LoadJob(id=PICKUP-loDKrYTAqF5kIfjFM6n4)]=-100hard/0medium/0soft
  Maybe there is a bug in the score constraints of those ConstraintMatch(s).
  Maybe a score constraint doesn't select all the entities it depends on, but finds some through a reference in a selected entity. This corrupts incremental score calculation, because the constraint is not re-evaluated if such a non-selected entity changes.
Shadow variable corruption in the corrupted scoreDirector:
  None
    at org.optaplanner.core.impl.score.director.AbstractScoreDirector.assertScoreFromScratch(AbstractScoreDirector.java:637)
    at org.optaplanner.core.impl.score.director.AbstractScoreDirector.assertWorkingScoreFromScratch(AbstractScoreDirector.java:613)
    at org.optaplanner.core.impl.score.director.AbstractScoreDirector.doAndProcessMove(AbstractScoreDirector.java:204)
    at org.optaplanner.core.impl.heuristic.thread.MoveThreadRunner.run(MoveThreadRunner.java:131)
    at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:577)
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
    at java.base/java.lang.Thread.run(Thread.java:1589)

Because of the error message, my guess is this might be caused by the transitive relation between two LoadJobs through the Load class. I've tried filtering for only PICKUPs before filtering for the same vehicle as well as joining LoadJob::class.java, then filtering for the same Load on both load jobs and only then filtering for the same vehicle, all to no avail.

What is the cause of this exception and how can I fix it?


Solution

  • It's because of how incremental score calculation interacts with

    .filter { it.load.pickup.vehicle != it.load.dropoff.vehicle }
    

    Neither pickup or dropoff are selected, so the ConstraintStreams implementation (regardless if it's OptaPlanner CS Drools impl, or Timefold's faster impl), doesn't know that when the pickup or dropoff's vehicle variable changes, this constraint needs to be re-evaluated (in a delta manner for scalability).

    Solution

    Something like:

    fun pickupAndDropoffOnSameVehicle(constraintFactory: ConstraintFactory): Constraint {
        val pickupStream =
            constraintFactory.forEach(LoadJob::class.java).filter { it.type == LoadJobType.PICKUP }
    
        val dropoffStream =
            constraintFactory.forEach(LoadJob::class.java).filter { it.type == LoadJobType.DROPOFF }
    
        return pickupStream
            .join(dropoffStream, Joiners.equal { it.load.id })
            .filter { pickup, dropoff -> pickup.vehicle != dropoff.vehicle }
            .penalizeConfigurable()
            .asConstraint(PICKUP_AND_DROPOFF_ON_SAME_VEHICLE)
    }