Search code examples
optaplannertimefold

OptaPlanner/TimeFold groupBy with list only ever has one element


This is a follow-up to this question.

I have two PlanningEntity classes like this:

@PlanningEntity
class Location {
    private static long nextId = 0;    
    private long id = nextId++;

    @PlanningVariable
    Coordinates coordinates;
}

@PlanningEntity
class LocationList {
    private static long nextId = 0;    
    private long id = nextId++;

    @ShadowVariable(
            variableListenerClass = LocationListUpdatingVariableListener.class,
            sourceVariableName = "coordinates",
            sourceEntityClass = Location.class)
    List<Location> locations = ArrayList<>();
    int weight;
}

The solution class looks like this:

@PlanningSolution
class Solution {
    @PlanningEntityCollectionProperty
    List<Location> locations;

    @PlanningEntityCollectionProperty
    List<LocationList> locationLists;

    @ValueRangeProvider
    @ProblemFactCollectionProperty
    List<Coordinates> possibleCoordinates;
}

The shadow variable updater looks like this:

class LocationListUpdatingVariableListener implements VariableListener<Solution, Location> {

    @Override
    public void beforeEntityAdded(ScoreDirector<Solution> scoreDirector, Location location) {
        // do nothing
    }

    @Override
    public void afterEntityAdded(ScoreDirector<Solution> scoreDirector, Location location) {
        updateLocationLists(scoreDirector, location);
    }

    @Override
    public void beforeEntityRemoved(ScoreDirector<Solution> scoreDirector, Location location) {
        // do nothing
    }

    @Override
    public void afterEntityRemoved(ScoreDirector<Solution> scoreDirector, Location location) {
        updateLocationLists(scoreDirector, location);
    }

    @Override
    public void beforeVariableChanged(ScoreDirector<Solution> scoreDirector, Location location) {
        // do nothing
    }

    @Override
    public void afterVariableChanged(ScoreDirector<Solution> scoreDirector, Location location) {
        updateLocationLists(scoreDirector, location);
    }
    
    protected void updateLocationLists(ScoreDirector<Solution> scoreDirector, Location location) {
        Solution solution = scoreDirector.getWorkingSolution();
        
        solution.locationLists.forEach(locationList -> {
            
            for(int i = 0; i < locationList.locations.size(); i++) {
                var locationListLocation = locationList.locations.get(i);
                
                if(locationListLocation.id == location.id) {
                    scoreDirector.beforeVariableChanged(locationList, "locations");
                    locationList.locations.set(i, location);
                    scoreDirector.afterVariableChanged(locationList, "locations");
                }
            }
        });
        
    }
}

I have a constraint like this:

Constraint myConstraint(ConstraintFactory constraintFactory) {
    return constraintFactory.forEach(LocationList.class)
           .filter(locationList -> locationList.locations.size() == 2)
           .join(Location.class)
           .filter((locationList, location) -> locationList.locations.contains(location))
           .groupby((locationList, location) -> locationList, ConstraintCollectors.toList((locationList, location) -> child))
           .penalize(HardSoftScore.ONE_HARD, (locationList, locationsInList) -> weightedDistance(locationList.weight, locationsInList))
           .asConstraint("Shorter total distance is better");
}

The weightedDistance does something like add up all the distances between the assigned coordinates and multiplies by the weight. What it actually does is more complex, but the point is that it needs all the locations at once -- it can't be handled one at a time.

The part where I filter on 2 elements is not part of the actual constraint. I just put it in there to demonstrate the problem -- that the code makes it past there but somehow still only ends up with 1 location in locationsInList.

I can actually pull the locations out of locationList directly and it works, but I'm concerned that because locationsInList doesn't have the right locations in it that this won't be tracked correctly by the solver.


Solution

  • It turns out this was caused by a timefold bug: https://github.com/TimefoldAI/timefold-solver/issues/309

    If I run without brute force everything works as expected.