Radical
Radical

Reputation: 1073

Timefold: 'Construction' of a solution, when initial solution is already feasable

I have a @PlanningEntity called a Participation, where the @PlanningVariable enrolled indicates whether Student is attending the Appointment in question:

@PlanningEntity
public class Participation {
    private Student student;
    private Appointment appointment;

    @PlanningVariable
    private Boolean enrolled = false;
}

In my specific use-case, the best solution is usually one where most (but not all) of the Participation are attended. In fact, if I initialize my PlanningVariable to null instead of false and start with a FIRST_FIT construction phase, I get a performance improvement of around 3-4x to find the same solution (or the same score at least). I imagine because most enrolled values are set to true more efficiently than is possible during a LocalSearchPhase.

The data-model backing my PlanningSolution currently does not differentiate between a Participation not being attended (enrolled = false) and a choice not yet having been made (enrolled = null). So even though this is a possible solution, ideally I would like to prevent changing the underlying data by initializing my PlanningEntitys with a fake enrolled value (setting it to null preemptively).

I have tried experimenting with a custom Forager config, fox example by using .withPickEarlyType(LocalSearchPickEarlyType.FIRST_BEST_SCORE_IMPROVING) and implementing a custom SelectionFilter that only selects entities with enrolled==false, but this does not seem to make a lot of difference.

So, the question: is it possible to do a FIRST_FIT-like construction on a solution where the current solution is already initialized? I would basically like to tell Timefold to look at each entity exactly once, and greedily set enrolled=true if this results in a better solution score.

EDIT: Another attempt was implementing a CustomPhaseCommand as the first phase of my solver. This intuitively seems as a logical way to go, however I don't know how to check the impact of a change on the score of my solution. If I implement changeWorkingSolution to set all enrolled values to true, the Phase is not executed since setting every value to true does not improve the solution score. Is there a way to check for an improvement from within a CustomPhaseCommand?

EDIT2: See my 'answer' below

Upvotes: 0

Views: 500

Answers (2)

I am not entirely sure why you need the solver to do this. To quote you:

So, the question: is it possible to do a FIRST_FIT-like construction on a solution where the current solution is already initialized? I would basically like to tell Timefold to look at each entity exactly once, and greedily set enrolled=true if this results in a better solution score.

This is not really a problem for the local search, or for a constraint solver. This is a simple loop that modifies the solution and calculates the score at each step. I suggest you check out SolutionManager#update(Solution), and use that in your loop.

You will lose all the performance benefits of incremental calculation. If it's necessary to do this very fast, doing the same thing the SolutionManager does through InnerScoreDirector is possible. Be advised though that InnerScoreDirector and associated APIs are private and are not guaranteed to be stable.

Upvotes: 0

Radical
Radical

Reputation: 1073

I have found a solution that works exactly as expected through the CustomPhaseCommand. I have implemented the command as follows:

    class InitialConstructionPhase implements CustomPhaseCommand<Timetable> {
        @Override
        public void changeWorkingSolution(ScoreDirector<Timetable> scoreDirector) {
            InnerScoreDirector<Timetable, HardSoftScore> innerScoreDirector = (InnerScoreDirector<Timetable, HardSoftScore>) scoreDirector;

            scoreDirector.getWorkingSolution().getParticipations().stream().filter(participation -> {
                return !participation.getEnrolled();
            }).forEach(participation -> {
                HardSoftScore oldScore = innerScoreDirector.calculateScore();
                
                setParticipationEnrollment(scoreDirector, participation, true);

                HardSoftScore newScore = innerScoreDirector.calculateScore();

                if(oldScore.compareTo(newScore) > 0) {
                    setParticipationEnrollment(scoreDirector, participation, false);
                }
            });
        }
        
        private void setParticipationEnrollment(ScoreDirector<Timetable> scoreDirector, Participation participation, boolean enrolled) {
            scoreDirector.beforeVariableChanged(participation, "enrolled");
            participation.setEnrolled(enrolled);
            scoreDirector.afterVariableChanged(participation, "enrolled");
            scoreDirector.triggerVariableListeners();
        }
    }

I found the case to InnerScoreDirector in https://github.com/TimefoldAI/timefold-solver/blob/7bb9cc2b67c51e88f1ef72bfa940b5df0de9bb91/core/core-impl/src/test/java/ai/timefold/solver/core/config/solver/EnvironmentModeTest.java#L276C13-L277C77 . Is this the recommended way to achieve this? Doing a cast to InnerScoreDirector like this feels weird, but I don't see another way to calculate the problem score during the CustomPhaseCommand.

Notably, I see that in Optaplanner 7.29.0.Final, a calculateScore method was available on a ScoreDirector, but I guess this was removed?

Upvotes: 0

Related Questions