0

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

Radical
  • 1,003
  • 1
  • 9
  • 25

2 Answers2

0

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?

Radical
  • 1,003
  • 1
  • 9
  • 25
  • `ScoreDirector` in 7.29 was a private API in an `impl` package, which means users did not have any sort of API stability guarantees. In later versions, we moved it to an `api` package which comes with those stability guarantees, and that necessitated some changes. – Lukáš Petrovický Jul 31 '23 at 16:12
0

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.

Lukáš Petrovický
  • 3,945
  • 1
  • 11
  • 20
  • You're totally right of course, this should not happen in the solver. However, the performance (I'm guessing due to the lack of incremental scoring) of this seems unworkable at the moment (I stopped my timer after it passed a 1000x slowdown). I'm still looking to see if there's a problem on my side causing this, but in the meantime: Is there a way to work with incremental score calculation outside of the solver? (If not I'm happy to create a GitHub issue, if you feel this is a useful feature request) – Radical Aug 02 '23 at 07:44
  • 1
    I think we already have one: https://github.com/TimefoldAI/timefold-solver/issues/123. Personally, I do see value of the feature, and I think it will eventually show up in the solver. In the meantime, there is no way to do this incrementally within the public API. – Lukáš Petrovický Aug 02 '23 at 08:00
  • Super! I will keep an eye on that issue, and work my way around it in the meantime – Radical Aug 02 '23 at 08:18