diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/CustomPhaseCommand.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/CustomPhaseCommand.java index c7b6bf6bf6..bbddf5ac36 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/CustomPhaseCommand.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/CustomPhaseCommand.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.api.solver.ProblemFactChange; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; /** @@ -35,4 +36,26 @@ public interface CustomPhaseCommand { */ void changeWorkingSolution(ScoreDirector scoreDirector); + /** + * By default, + * when the solution returned by the custom phase is worse than the {@link AbstractPhaseScope#getStartingScore() starting + * solution} from the phase, + * it is expected to be ignored as it needs to improve the current best solution. + *

+ * However, in some cases, + * the current best solution needs to be updated with a new one to avoid losing the result + * and ending up in an inconsistent state for the next phase. + *

+ * For example, let's consider a custom construction heuristic phase for a model + * using a planning list variable that accepts unassigned values. + * The initial score might be better than the result of the custom phase, as some constraints may be violated. + * That doesn't mean the solution should not be accepted, as the phase is building an initial solution. + * + * @return false, update the best solution only if it is improved; + * otherwise, update it whichever the score is. + */ + default boolean requireUpdateBestSolution() { + return false; + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java index 488503fb2c..97defcbe78 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java @@ -55,7 +55,11 @@ private void doStep(CustomStepScope stepScope, CustomPhaseCommand scoreDirector = stepScope.getScoreDirector(); customPhaseCommand.changeWorkingSolution(scoreDirector); calculateWorkingStepScore(stepScope, customPhaseCommand); - solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); + if (customPhaseCommand.requireUpdateBestSolution()) { + solver.getBestSolutionRecaller().processWorkingSolutionDuringConstructionHeuristicsStep(stepScope); + } else { + solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); + } } public void stepEnded(CustomStepScope stepScope) { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/AllowsUnassignedValuesListVariableSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/AllowsUnassignedValuesListVariableSolverTest.java index 4d2fbbd7f6..7721c21a3a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/AllowsUnassignedValuesListVariableSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/AllowsUnassignedValuesListVariableSolverTest.java @@ -1,7 +1,9 @@ package ai.timefold.solver.core.impl.solver; +import java.util.List; import java.util.stream.IntStream; +import ai.timefold.solver.core.api.score.director.ScoreDirector; import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.heuristic.selector.move.MoveSelectorConfig; @@ -14,15 +16,18 @@ import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.decider.acceptor.LocalSearchAcceptorConfig; import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchForagerConfig; +import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand; import ai.timefold.solver.core.impl.testdata.domain.list.allows_unassigned.TestdataAllowsUnassignedValuesListEasyScoreCalculator; import ai.timefold.solver.core.impl.testdata.domain.list.allows_unassigned.TestdataAllowsUnassignedValuesListEntity; import ai.timefold.solver.core.impl.testdata.domain.list.allows_unassigned.TestdataAllowsUnassignedValuesListSolution; import ai.timefold.solver.core.impl.testdata.domain.list.allows_unassigned.TestdataAllowsUnassignedValuesListValue; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.params.ParameterizedTest; @@ -69,6 +74,63 @@ void runSolver(ListVariableMoveType moveType) { Assertions.assertThat(bestSolution).isNotNull(); } + @Test + void runSolverWithCustomConstructionHeuristic() { + // Generate solution. + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(IntStream.range(0, 5) + .mapToObj(i -> new TestdataAllowsUnassignedValuesListEntity("e" + i)) + .toList()); + solution.setValueList(IntStream.range(0, 25) + .mapToObj(i -> new TestdataAllowsUnassignedValuesListValue("v" + i)) + .toList()); + + // Generate deterministic, fully asserted solver. + var solverConfig = new SolverConfig() + .withEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT) + .withSolutionClass(TestdataAllowsUnassignedValuesListSolution.class) + .withEntityClasses(TestdataAllowsUnassignedValuesListEntity.class, + TestdataAllowsUnassignedValuesListValue.class) + .withEasyScoreCalculatorClass(TestdataAllowsUnassignedValuesListEasyScoreCalculator.class) + .withPhases(new CustomPhaseConfig() + .withCustomPhaseCommandList(List.of(new TestdataFirstEntityInitializer())), + new LocalSearchPhaseConfig() + .withAcceptorConfig(new LocalSearchAcceptorConfig() + .withEntityTabuSize(1) + .withValueTabuSize(1) + .withMoveTabuSize(1)) + .withForagerConfig(new LocalSearchForagerConfig() + .withAcceptedCountLimit(1)) + .withTerminationConfig(new TerminationConfig().withStepCountLimit(1))); + var solverFactory = SolverFactory.create(solverConfig); + var solver = solverFactory.buildSolver(); + + // Run solver. + var bestSolution = solver.solve(solution); + Assertions.assertThat(bestSolution).isNotNull(); + Assertions.assertThat(((TestdataAllowsUnassignedValuesListSolution) bestSolution).getEntityList().stream() + .filter(e -> !e.getValueList().isEmpty())).hasSize(1); + } + + public static class TestdataFirstEntityInitializer + implements CustomPhaseCommand { + + @Override + public void changeWorkingSolution(ScoreDirector scoreDirector) { + TestdataAllowsUnassignedValuesListSolution solution = scoreDirector.getWorkingSolution(); + TestdataAllowsUnassignedValuesListValue firstValue = solution.getValueList().get(0); + scoreDirector.beforeListVariableChanged(solution.getEntityList().get(0), "valueList", 0, 0); + solution.getEntityList().get(0).setValueList(List.of(firstValue)); + scoreDirector.afterListVariableChanged(solution.getEntityList().get(0), "valueList", 0, 1); + scoreDirector.triggerVariableListeners(); + } + + @Override + public boolean requireUpdateBestSolution() { + return true; + } + } + enum ListVariableMoveType { CHANGE_AND_SWAP(new ChangeMoveSelectorConfig(), new SwapMoveSelectorConfig()), diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/optimization-algorithms.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/optimization-algorithms.adoc index 7a02e2b317..cd8073c823 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/optimization-algorithms.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/optimization-algorithms.adoc @@ -723,10 +723,12 @@ The `CustomPhaseCommand` interface appears as follows: [source,java,options="nowrap"] ---- public interface CustomPhaseCommand { - ... void changeWorkingSolution(ScoreDirector scoreDirector); - + ... + default boolean requireUpdateBestSolution() { + return false; + } } ---- @@ -742,6 +744,13 @@ That will corrupt the `Solver` because any previous score or solution was for a To do that, read about xref:responding-to-change/responding-to-change.adoc[repeated planning] and do it with a xref:responding-to-change/responding-to-change.adoc#problemChange[ProblemChange] instead. ==== +[NOTE] +==== +When initializing the solution for a planning list variable model that accepts unassigned values, +the custom command may override `requireUpdateBestSolution` +to return `true` in order to ensure the best solution is updated. +==== + Configure the `CustomPhaseCommand` in the solver configuration: [source,xml,options="nowrap"]