From bee9adad1ec96e93ff7d433b2ad565ec8ceb22af Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 4 Nov 2025 11:59:45 -0500 Subject: [PATCH 1/9] feat: add a producer id to BestSolutionChangedEvent --- .../event/BestSolutionChangedEvent.java | 27 +++++++++- .../DefaultConstructionHeuristicPhase.java | 5 +- .../localsearch/DefaultLocalSearchPhase.java | 3 +- .../solver/core/impl/phase/AbstractPhase.java | 5 ++ .../solver/core/impl/phase/Phase.java | 4 ++ .../impl/phase/custom/DefaultCustomPhase.java | 3 +- .../impl/phase/scope/AbstractPhaseScope.java | 7 ++- .../core/impl/solver/DefaultSolver.java | 7 ++- .../DefaultBestSolutionChangedEvent.java | 6 ++- .../impl/solver/event/SolverEventSupport.java | 5 +- .../solver/recaller/BestSolutionRecaller.java | 18 ++++--- .../NeighborhoodsBasedLocalSearchTest.java | 2 + .../core/impl/solver/DefaultSolverTest.java | 18 +++++-- .../core/testutil/BestScoreChangedEvent.java | 52 +++++++++++++++++++ .../core/testutil/PlannerTestUtils.java | 31 +++++++---- 15 files changed, 160 insertions(+), 33 deletions(-) create mode 100644 core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java index 4cc2f6a005..71a4075928 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.config.solver.SolverConfig; import org.jspecify.annotations.NonNull; @@ -17,8 +18,10 @@ */ // TODO In Solver 2.0, maybe convert this to an interface. public class BestSolutionChangedEvent extends EventObject { + private static final String UNKNOWN_PHASE_ID = "Unknown"; private final Solver solver; + private final String producerId; private final long timeMillisSpent; private final Solution_ newBestSolution; private final Score newBestScore; @@ -31,7 +34,7 @@ public class BestSolutionChangedEvent extends EventObject { @Deprecated(forRemoval = true, since = "1.22.0") public BestSolutionChangedEvent(@NonNull Solver solver, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore) { - this(solver, timeMillisSpent, newBestSolution, newBestScore, true); + this(solver, UNKNOWN_PHASE_ID, timeMillisSpent, newBestSolution, newBestScore, true); } /** @@ -42,8 +45,20 @@ public BestSolutionChangedEvent(@NonNull Solver solver, long timeMill public BestSolutionChangedEvent(@NonNull Solver solver, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore, boolean isNewBestSolutionInitialized) { + this(solver, UNKNOWN_PHASE_ID, timeMillisSpent, newBestSolution, newBestScore, isNewBestSolutionInitialized); + } + + /** + * @param timeMillisSpent {@code >= 0L} + * @deprecated Users should not manually construct instances of this event. + */ + @Deprecated(forRemoval = true, since = "1.28.0") + public BestSolutionChangedEvent(@NonNull Solver solver, String producerId, long timeMillisSpent, + @NonNull Solution_ newBestSolution, @NonNull Score newBestScore, + boolean isNewBestSolutionInitialized) { super(solver); this.solver = solver; + this.producerId = producerId; this.timeMillisSpent = timeMillisSpent; this.newBestSolution = newBestSolution; this.newBestScore = newBestScore; @@ -58,6 +73,16 @@ public long getTimeMillisSpent() { return timeMillisSpent; } + /** + * @return A string identifying what generated the event, either of the form + * "Event" where "Event" is a String describing the event that cause the update (like "Solving started") + * or "Phase (index)", where "Phase" is a String identifying the type of phase (like "Construction Heuristics") + * and index is the index of the phase in the {@link SolverConfig#getPhaseConfigList()}. + */ + public String getProducerId() { + return producerId; + } + /** * Note that: *
    diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java index 9085c5638e..b0fdef3f45 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java @@ -23,6 +23,7 @@ public class DefaultConstructionHeuristicPhase extends AbstractPossiblyInitializingPhase implements ConstructionHeuristicPhase { + public static final String CONSTRUCTION_HEURISTICS_STRING = "Construction Heuristics"; protected final ConstructionHeuristicDecider decider; protected final PlacerBasedMoveRepository moveRepository; @@ -45,7 +46,7 @@ public TerminationStatus getTerminationStatus() { @Override public String getPhaseTypeString() { - return "Construction Heuristics"; + return CONSTRUCTION_HEURISTICS_STRING; } // ************************************************************************ @@ -196,7 +197,7 @@ private void updateBestSolutionAndFire(ConstructionHeuristicPhaseScope extends AbstractPhase implements LocalSearchPhase, LocalSearchPhaseLifecycleListener { + public static final String LOCAL_SEARCH_STRING = "Local Search"; protected final LocalSearchDecider decider; protected final AtomicLong acceptedMoveCountPerStep = new AtomicLong(0); @@ -47,7 +48,7 @@ private DefaultLocalSearchPhase(Builder builder) { @Override public String getPhaseTypeString() { - return "Local Search"; + return LOCAL_SEARCH_STRING; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java index 3c85ff5eb4..d7ed1de6b8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java @@ -119,6 +119,11 @@ protected boolean isNested() { return false; } + @Override + public String getPhaseName() { + return getPhaseTypeString(); + } + @Override public void phaseEnded(AbstractPhaseScope phaseScope) { if (!isNested()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java index db60a14ef5..9a2ad35bd5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java @@ -36,4 +36,8 @@ public interface Phase extends PhaseLifecycleListener { void solve(SolverScope solverScope); + default String getPhaseName() { + return getClass().getSimpleName(); + } + } 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 f9afc30b19..9433df4d7e 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 @@ -23,6 +23,7 @@ public final class DefaultCustomPhase extends AbstractPossiblyInitializingPhase implements CustomPhase { + public static final String CUSTOM_STRING = "Custom"; private final List> customPhaseCommandList; private TerminationStatus terminationStatus = TerminationStatus.NOT_TERMINATED; @@ -39,7 +40,7 @@ public TerminationStatus getTerminationStatus() { @Override public String getPhaseTypeString() { - return "Custom"; + return CUSTOM_STRING; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java index 8c85c8359a..ea70a28368 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java @@ -263,9 +263,14 @@ public int getNextStepIndex() { return getLastCompletedStepScope().getStepIndex() + 1; } + public String getPhaseId() { + return "%s (%d)".formatted( + solverScope.getSolver().getPhaseList().get(phaseIndex).getPhaseName(), phaseIndex); + } + @Override public String toString() { - return getClass().getSimpleName() + "(" + phaseIndex + ")"; + return "%s (%d)".formatted(getClass().getSimpleName(), phaseIndex); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java index 3e33138609..7fe4cfe2df 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java @@ -18,6 +18,7 @@ import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; import ai.timefold.solver.core.impl.solver.change.ProblemChangeAdapter; +import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; import ai.timefold.solver.core.impl.solver.random.RandomFactory; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -226,7 +227,8 @@ public void solvingStarted(SolverScope solverScope) { registerSolverSpecificMetrics(); // Update the best solution, since problem's shadows and score were updated - bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope); + bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, + DefaultBestSolutionChangedEvent.SOLVING_STARTED_EVENT_ID); logger.info("Solving {}: time spent ({}), best score ({}), " + "environment mode ({}), move thread count ({}), random ({}).", @@ -347,7 +349,8 @@ private boolean checkProblemFactChanges() { // Everything is fine, proceed. var score = scoreDirector.calculateScore(); basicPlumbingTermination.endProblemChangesProcessing(); - bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope); + bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, + DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID); logger.info("Real-time problem fact changes done: step total ({}), new best score ({}).", stepIndex, score); return true; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java index ecfa39ba6b..8f97b409d4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java @@ -7,12 +7,14 @@ import org.jspecify.annotations.NonNull; public final class DefaultBestSolutionChangedEvent extends BestSolutionChangedEvent { + public static String SOLVING_STARTED_EVENT_ID = "Solving started"; + public static String PROBLEM_CHANGE_EVENT_ID = "Problem change"; private final int unassignedCount; - public DefaultBestSolutionChangedEvent(@NonNull Solver solver, long timeMillisSpent, + public DefaultBestSolutionChangedEvent(@NonNull Solver solver, String phaseId, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull InnerScore newBestScore) { - super(solver, timeMillisSpent, newBestSolution, newBestScore.raw(), newBestScore.isFullyAssigned()); + super(solver, phaseId, timeMillisSpent, newBestSolution, newBestScore.raw(), newBestScore.isFullyAssigned()); this.unassignedCount = newBestScore.unassignedCount(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java index 5ac0ab180d..551a27c7d3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java @@ -18,12 +18,13 @@ public SolverEventSupport(Solver solver) { this.solver = solver; } - public void fireBestSolutionChanged(SolverScope solverScope, Solution_ newBestSolution) { + public void fireBestSolutionChanged(SolverScope solverScope, String phaseId, + Solution_ newBestSolution) { var it = getEventListeners().iterator(); var timeMillisSpent = solverScope.getBestSolutionTimeMillisSpent(); var bestScore = solverScope.getBestScore(); if (it.hasNext()) { - var event = new DefaultBestSolutionChangedEvent<>(solver, timeMillisSpent, newBestSolution, bestScore); + var event = new DefaultBestSolutionChangedEvent<>(solver, phaseId, timeMillisSpent, newBestSolution, bestScore); do { it.next().bestSolutionChanged(event); } while (it.hasNext()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 401309d08c..921786c5ee 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java @@ -90,7 +90,7 @@ public > void processWorkingSolutionDuringStep(Abst var innerScore = InnerScore.withUnassignedCount( solverScope.getSolutionDescriptor(). getScore(newBestSolution), -stepScope.getScoreDirector().getWorkingInitScore()); - updateBestSolutionAndFire(solverScope, innerScore, newBestSolution); + updateBestSolutionAndFire(solverScope, phaseScope, innerScore, newBestSolution); } else if (assertBestScoreIsUnmodified) { solverScope.assertScoreFromScratch(solverScope.getBestSolution()); } @@ -110,28 +110,30 @@ public > void processWorkingSolutionDuringMove(Inne phaseScope.setBestSolutionStepIndex(stepScope.getStepIndex()); var newBestSolution = solverScope.getScoreDirector().cloneWorkingSolution(); var innerScore = new InnerScore<>(moveScore.raw(), solverScope.getScoreDirector().getWorkingInitScore()); - updateBestSolutionAndFire(solverScope, innerScore, newBestSolution); + updateBestSolutionAndFire(solverScope, phaseScope, innerScore, newBestSolution); } else if (assertBestScoreIsUnmodified) { solverScope.assertScoreFromScratch(solverScope.getBestSolution()); } } - public void updateBestSolutionAndFire(SolverScope solverScope) { + public void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope) { updateBestSolutionWithoutFiring(solverScope); - solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); } - public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope) { + public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, + String eventId) { updateBestSolutionWithoutFiring(solverScope); if (solverScope.isBestSolutionInitialized()) { - solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, eventId, solverScope.getBestSolution()); } } - private void updateBestSolutionAndFire(SolverScope solverScope, InnerScore bestScore, + private void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope, + InnerScore bestScore, Solution_ bestSolution) { updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution); - solverEventSupport.fireBestSolutionChanged(solverScope, bestSolution); + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); } @SuppressWarnings({ "unchecked", "rawtypes" }) diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java index 96992aed71..e66a115e16 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.mock; import java.util.HashSet; +import java.util.List; import java.util.Random; import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; @@ -73,6 +74,7 @@ void changeMoveBasedLocalSearch() { var bestSolutionRecaller = new BestSolutionRecaller(); var solver = mock(AbstractSolver.class); + doReturn(List.of(localSearchPhase)).when(solver).getPhaseList(); doReturn(bestSolutionRecaller).when(solver).getBestSolutionRecaller(); var solverEventSupport = new SolverEventSupport(solver); bestSolutionRecaller.setSolverEventSupport(solverEventSupport); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java index 0d88a47eb6..1d3293c0ab 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java @@ -17,6 +17,7 @@ import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; import java.util.function.BooleanSupplier; @@ -78,6 +79,7 @@ import ai.timefold.solver.core.impl.score.DummySimpleScoreEasyScoreCalculator; import ai.timefold.solver.core.impl.score.constraint.DefaultConstraintMatchTotal; import ai.timefold.solver.core.impl.score.constraint.DefaultIndictment; +import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; import ai.timefold.solver.core.impl.util.Pair; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -138,6 +140,7 @@ import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingScoreCalculator; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; +import ai.timefold.solver.core.testutil.BestScoreChangedEvent; import ai.timefold.solver.core.testutil.NoChangeCustomPhaseCommand; import ai.timefold.solver.core.testutil.PlannerTestUtils; @@ -167,7 +170,8 @@ void solve() { solution.setValueList(Arrays.asList(new TestdataValue("v1"), new TestdataValue("v2"))); solution.setEntityList(Arrays.asList(new TestdataEntity("e1"), new TestdataEntity("e2"))); - solution = PlannerTestUtils.solve(solverConfig, solution); + solution = PlannerTestUtils.solveAssertingEvents(solverConfig, solution, + BestScoreChangedEvent.constructionHeuristic(SimpleScore.ZERO, 0)); assertThat(solution).isNotNull(); assertThat(solution.getEntityList().stream() .filter(e -> e.getValue() == null)).isEmpty(); @@ -379,7 +383,8 @@ void solveEmptyEntityList() { solution.setValueList(Arrays.asList(new TestdataValue("v1"), new TestdataValue("v2"))); solution.setEntityList(Collections.emptyList()); - solution = PlannerTestUtils.solve(solverConfig, solution, true); + solution = PlannerTestUtils.solveAssertingEvents(solverConfig, solution, + BestScoreChangedEvent.solvingStarted(SimpleScore.ZERO)); assertThat(solution).isNotNull(); assertThat(solution.getEntityList().stream() .filter(e -> e.getValue() == null)).isEmpty(); @@ -404,7 +409,8 @@ void solveChainedEmptyEntityList() { solution.setChainedAnchorList(Arrays.asList(new TestdataChainedAnchor("v1"), new TestdataChainedAnchor("v2"))); solution.setChainedEntityList(Collections.emptyList()); - solution = PlannerTestUtils.solve(solverConfig, solution, true); + solution = PlannerTestUtils.solveAssertingEvents(solverConfig, solution, + BestScoreChangedEvent.solvingStarted(SimpleScore.ZERO)); assertThat(solution).isNotNull(); assertThat(solution.getScore()).isNotNull(); } @@ -489,6 +495,7 @@ void solveWithProblemChange() throws InterruptedException { var bestSolution = new AtomicReference(); var solutionWithProblemChangeReceived = new CountDownLatch(1); + var hasProblemChangeBestSolutionEvent = new AtomicBoolean(false); solver.addEventListener(bestSolutionChangedEvent -> { if (bestSolutionChangedEvent.isEveryProblemChangeProcessed()) { var newBestSolution = bestSolutionChangedEvent.getNewBestSolution(); @@ -497,6 +504,9 @@ void solveWithProblemChange() throws InterruptedException { solutionWithProblemChangeReceived.countDown(); } } + if (bestSolutionChangedEvent.getProducerId().equals(DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID)) { + hasProblemChangeBestSolutionEvent.set(true); + } }); var executorService = Executors.newSingleThreadExecutor(); @@ -510,7 +520,7 @@ void solveWithProblemChange() throws InterruptedException { solutionWithProblemChangeReceived.await(); assertThat(bestSolution.get().getValueList()).hasSize(valueCount + 1); - + assertThat(hasProblemChangeBestSolutionEvent.get()).isTrue(); solver.terminateEarly(); } finally { executorService.shutdownNow(); diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java b/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java new file mode 100644 index 0000000000..93750c375b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java @@ -0,0 +1,52 @@ +package ai.timefold.solver.core.testutil; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase; +import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhase; +import ai.timefold.solver.core.impl.phase.custom.DefaultCustomPhase; +import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; + +import org.jspecify.annotations.NonNull; + +/** + * Exists to avoid storing each best solution event's solution. + */ +public record BestScoreChangedEvent>(Score_ newScore, boolean isInitialized, + String eventId) { + @SuppressWarnings("unchecked") + public BestScoreChangedEvent(BestSolutionChangedEvent bestSolutionChangedEvent) { + this((Score_) bestSolutionChangedEvent.getNewBestScore(), bestSolutionChangedEvent.isNewBestSolutionInitialized(), + bestSolutionChangedEvent.getProducerId()); + } + + public BestScoreChangedEvent uninitialized() { + return new BestScoreChangedEvent<>(newScore, false, eventId); + } + + public static > BestScoreChangedEvent solvingStarted(Score_ newScore) { + return new BestScoreChangedEvent<>(newScore, true, DefaultBestSolutionChangedEvent.SOLVING_STARTED_EVENT_ID); + } + + public static > BestScoreChangedEvent problemChange(Score_ newScore) { + return new BestScoreChangedEvent<>(newScore, true, DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID); + } + + public static > BestScoreChangedEvent + constructionHeuristic(Score_ newScore, int index) { + return new BestScoreChangedEvent<>(newScore, true, + "%s (%d)".formatted(DefaultConstructionHeuristicPhase.CONSTRUCTION_HEURISTICS_STRING, index)); + } + + public static > BestScoreChangedEvent custom(Score_ newScore, + int index) { + return new BestScoreChangedEvent<>(newScore, true, + "%s (%d)".formatted(DefaultCustomPhase.CUSTOM_STRING, index)); + } + + public static > BestScoreChangedEvent localSearch(Score_ newScore, + int index) { + return new BestScoreChangedEvent<>(newScore, true, + "%s (%d)".formatted(DefaultLocalSearchPhase.LOCAL_SEARCH_STRING, index)); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java index b1de79c473..560f99f2fd 100644 --- a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java +++ b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.testutil; import static java.util.Arrays.stream; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; @@ -15,7 +15,7 @@ import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; -import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -40,6 +40,8 @@ import ai.timefold.solver.core.testdomain.TestdataSolution; import ai.timefold.solver.core.testdomain.TestdataValue; +import org.assertj.core.api.ListAssert; +import org.jspecify.annotations.NonNull; import org.mockito.AdditionalAnswers; /** @@ -80,19 +82,30 @@ public static synchronized Solution_ solve(SolverConfig solverConfig */ public static synchronized Solution_ solve(SolverConfig solverConfig, Solution_ problem, boolean bestSolutionEventExists) { + return solve(solverConfig, problem, + bestSolutionEventExists ? ListAssert::isNotEmpty : ListAssert::isEmpty); + } + + public static synchronized Solution_ solve(SolverConfig solverConfig, Solution_ problem, + Consumer>> bestSolutionEventsAsserter) { SolverFactory solverFactory = SolverFactory.create(solverConfig); var solver = solverFactory.buildSolver(); - var eventBestSolutionRef = new AtomicReference(); - solver.addEventListener(event -> eventBestSolutionRef.set(event.getNewBestSolution())); + var bestScoreChangedEventList = new ArrayList>(); + solver.addEventListener(event -> bestScoreChangedEventList.add(new BestScoreChangedEvent<>(event))); var finalBestSolution = solver.solve(problem); - if (bestSolutionEventExists) { - assertThat(eventBestSolutionRef).doesNotHaveNullValue(); - } else { - assertThat(eventBestSolutionRef).hasNullValue(); - } + bestSolutionEventsAsserter.accept(ListAssert.assertThatList(bestScoreChangedEventList)); return finalBestSolution; } + @SafeVarargs + public static synchronized > Solution_ solveAssertingEvents( + SolverConfig solverConfig, + Solution_ problem, + BestScoreChangedEvent... events) { + return solve(solverConfig, problem, + listAssert -> listAssert.containsExactly(events)); + } + // ************************************************************************ // Testdata methods // ************************************************************************ From c22d0436195b7ee81742f6c34238cbce70ed727d Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 4 Nov 2025 14:53:40 -0500 Subject: [PATCH 2/9] chore: review comment --- .../event/BestSolutionChangedEvent.java | 20 ++--- .../api/solver/event/EventProducerId.java | 77 +++++++++++++++++++ .../solver/event/PhaseEventProducerId.java | 32 ++++++++ .../core/api/solver/event/PhaseType.java | 47 +++++++++++ .../solver/event/SolveEventProducerId.java | 53 +++++++++++++ .../DefaultConstructionHeuristicPhase.java | 13 +++- .../DefaultExhaustiveSearchPhase.java | 10 ++- .../localsearch/DefaultLocalSearchPhase.java | 12 ++- .../solver/core/impl/phase/AbstractPhase.java | 5 -- .../solver/core/impl/phase/NoChangePhase.java | 8 ++ .../solver/core/impl/phase/Phase.java | 7 +- .../impl/phase/custom/DefaultCustomPhase.java | 12 ++- .../impl/phase/scope/AbstractPhaseScope.java | 6 +- .../core/impl/solver/DefaultSolver.java | 6 +- .../DefaultBestSolutionChangedEvent.java | 6 +- .../impl/solver/event/SolverEventSupport.java | 6 +- .../solver/recaller/BestSolutionRecaller.java | 5 +- .../core/impl/solver/DefaultSolverTest.java | 4 +- .../core/testutil/BestScoreChangedEvent.java | 22 ++---- 19 files changed, 293 insertions(+), 58 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java index 71a4075928..930ce7f051 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java @@ -6,7 +6,6 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; -import ai.timefold.solver.core.config.solver.SolverConfig; import org.jspecify.annotations.NonNull; @@ -18,10 +17,8 @@ */ // TODO In Solver 2.0, maybe convert this to an interface. public class BestSolutionChangedEvent extends EventObject { - private static final String UNKNOWN_PHASE_ID = "Unknown"; - private final Solver solver; - private final String producerId; + private final EventProducerId producerId; private final long timeMillisSpent; private final Solution_ newBestSolution; private final Score newBestScore; @@ -34,7 +31,7 @@ public class BestSolutionChangedEvent extends EventObject { @Deprecated(forRemoval = true, since = "1.22.0") public BestSolutionChangedEvent(@NonNull Solver solver, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore) { - this(solver, UNKNOWN_PHASE_ID, timeMillisSpent, newBestSolution, newBestScore, true); + this(solver, EventProducerId.unknown(), timeMillisSpent, newBestSolution, newBestScore, true); } /** @@ -45,7 +42,7 @@ public BestSolutionChangedEvent(@NonNull Solver solver, long timeMill public BestSolutionChangedEvent(@NonNull Solver solver, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore, boolean isNewBestSolutionInitialized) { - this(solver, UNKNOWN_PHASE_ID, timeMillisSpent, newBestSolution, newBestScore, isNewBestSolutionInitialized); + this(solver, EventProducerId.unknown(), timeMillisSpent, newBestSolution, newBestScore, isNewBestSolutionInitialized); } /** @@ -53,7 +50,7 @@ public BestSolutionChangedEvent(@NonNull Solver solver, long timeMill * @deprecated Users should not manually construct instances of this event. */ @Deprecated(forRemoval = true, since = "1.28.0") - public BestSolutionChangedEvent(@NonNull Solver solver, String producerId, long timeMillisSpent, + public BestSolutionChangedEvent(@NonNull Solver solver, EventProducerId producerId, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore, boolean isNewBestSolutionInitialized) { super(solver); @@ -74,12 +71,11 @@ public long getTimeMillisSpent() { } /** - * @return A string identifying what generated the event, either of the form - * "Event" where "Event" is a String describing the event that cause the update (like "Solving started") - * or "Phase (index)", where "Phase" is a String identifying the type of phase (like "Construction Heuristics") - * and index is the index of the phase in the {@link SolverConfig#getPhaseConfigList()}. + * @return A {@link EventProducerId} identifying what generated the event, either a + * {@link SolveEventProducerId} if the cause is not associated with a Phase, + * or {@link PhaseEventProducerId} if it is. */ - public String getProducerId() { + public EventProducerId getProducerId() { return producerId; } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java new file mode 100644 index 0000000000..118e7ed707 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -0,0 +1,77 @@ +package ai.timefold.solver.core.api.solver.event; + +import java.util.OptionalInt; + +import ai.timefold.solver.core.config.solver.SolverConfig; + +import org.jspecify.annotations.NullMarked; + +/** + * Identifies the producer of a {@link BestSolutionChangedEvent}. + * Will be an instance of {@link PhaseEventProducerId} if the event is associated + * with a phase, or a {@link SolveEventProducerId} otherwise. + */ +@NullMarked +public sealed interface EventProducerId permits PhaseEventProducerId, SolveEventProducerId { + /** + * An unique string identifying what produced the event, either of the form + * "Event" where "Event" is a string describing the event that cause the update (like "Solving started") + * or "Phase (index)", where "Phase" is a string identifying the type of phase (like "Construction Heuristics") + * and index is the index of the phase in the {@link SolverConfig#getPhaseConfigList()}. + * + * @return An unique string identifying what produced the event. + */ + String producerId(); + + /** + * A (non-unique) string describing what produced the event. + * Events from different phases of the same type (for example, + * when multiple Construction Heuristics are configured) + * will return the same value. + * + * @return A (non-unique) string describing what produced the event. + */ + String simpleProducerName(); + + /** + * An optional integer, that if present, identify what index in {@link SolverConfig#getPhaseConfigList()} + * corresponds to the Phase that produced the event. + * + * @return The index of the Phase that produced the event, or {@link OptionalInt#empty()} if the event producer + * is not associated with a phase. + */ + OptionalInt eventPhaseIndex(); + + static EventProducerId unknown() { + return SolveEventProducerId.UNKNOWN; + } + + static EventProducerId solvingStarted() { + return SolveEventProducerId.SOLVING_STARTED; + } + + static EventProducerId problemChange() { + return SolveEventProducerId.PROBLEM_CHANGE; + } + + @Deprecated(forRemoval = true, since = "1.28.0") + static EventProducerId noChange(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.NO_CHANGE, phaseIndex); + } + + static EventProducerId constructionHeuristic(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.CONSTRUCTION_HEURISTIC, phaseIndex); + } + + static EventProducerId localSearch(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.LOCAL_SEARCH, phaseIndex); + } + + static EventProducerId exhaustiveSearch(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.EXHAUSTIVE_SEARCH, phaseIndex); + } + + static EventProducerId customPhase(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.CUSTOM_PHASE, phaseIndex); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java new file mode 100644 index 0000000000..9ca7f9188d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.core.api.solver.event; + +import java.util.OptionalInt; + +import org.jspecify.annotations.NullMarked; + +/** + * {@link EventProducerId} for when a {@link BestSolutionChangedEvent} is + * caused by a phase. + */ +@NullMarked +public record PhaseEventProducerId(PhaseType phaseType, int phaseIndex) implements EventProducerId { + @Override + public String producerId() { + return "%s (%d)".formatted(phaseType.getPhaseName(), phaseIndex); + } + + @Override + public String simpleProducerName() { + return phaseType.getPhaseName(); + } + + @Override + public OptionalInt eventPhaseIndex() { + return OptionalInt.of(phaseIndex); + } + + @Override + public String toString() { + return producerId(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java new file mode 100644 index 0000000000..90f51f7811 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.api.solver.event; + +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.phase.NoChangePhaseConfig; +import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; +import ai.timefold.solver.core.impl.phase.NoChangePhase; + +/** + * The type of phase (for example, a Construction Heuristic). + */ +public enum PhaseType { + /** + * The type of phase associated with {@link NoChangePhaseConfig}. + * + * @deprecated Deprecated on account of {@link NoChangePhase} having no use. + */ + @Deprecated(forRemoval = true, since = "1.28.0") + NO_CHANGE("No Change"), + /** + * The type of phase associated with {@link ConstructionHeuristicPhaseConfig}. + */ + CONSTRUCTION_HEURISTIC("Construction Heuristics"), + /** + * The type of phase associated with {@link LocalSearchPhaseConfig}. + */ + LOCAL_SEARCH("Local Search"), + /** + * The type of phase associated with {@link ExhaustiveSearchPhaseConfig}. + */ + EXHAUSTIVE_SEARCH("Exhaustive Search"), + /** + * The type of phase associated with {@link CustomPhaseConfig}. + */ + CUSTOM_PHASE("Custom Phase"); + + private final String phaseName; + + PhaseType(String phaseName) { + this.phaseName = phaseName; + } + + public String getPhaseName() { + return phaseName; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java new file mode 100644 index 0000000000..317e5a61fb --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java @@ -0,0 +1,53 @@ +package ai.timefold.solver.core.api.solver.event; + +import java.util.OptionalInt; + +import org.jspecify.annotations.NullMarked; + +/** + * {@link EventProducerId} for when a {@link BestSolutionChangedEvent} is not + * caused by a phase. + */ +@NullMarked +public enum SolveEventProducerId implements EventProducerId { + /** + * The cause is unknown. This is the {@link EventProducerId} + * used when one of the deprecated {@link BestSolutionChangedEvent} + * constructors are used. + * + * @deprecated Only used when Users manually construct instances of {@link BestSolutionChangedEvent}. + */ + @Deprecated(forRemoval = true, since = "1.28.0") + UNKNOWN("Unknown"), + + /** + * The solver was started with an initialized solution. + */ + SOLVING_STARTED("Solving started"), + + /** + * One or more problem changes occured that change the best solution. + */ + PROBLEM_CHANGE("Problem change"); + + private final String producerId; + + SolveEventProducerId(String producerId) { + this.producerId = producerId; + } + + @Override + public String producerId() { + return producerId; + } + + @Override + public String simpleProducerName() { + return producerId; + } + + @Override + public OptionalInt eventPhaseIndex() { + return OptionalInt.empty(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java index b0fdef3f45..f867d29db2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java @@ -1,6 +1,10 @@ package ai.timefold.solver.core.impl.constructionheuristic; +import java.util.function.IntFunction; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.constructionheuristic.decider.ConstructionHeuristicDecider; import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacer; @@ -23,8 +27,6 @@ public class DefaultConstructionHeuristicPhase extends AbstractPossiblyInitializingPhase implements ConstructionHeuristicPhase { - public static final String CONSTRUCTION_HEURISTICS_STRING = "Construction Heuristics"; - protected final ConstructionHeuristicDecider decider; protected final PlacerBasedMoveRepository moveRepository; private TerminationStatus terminationStatus = TerminationStatus.NOT_TERMINATED; @@ -46,7 +48,12 @@ public TerminationStatus getTerminationStatus() { @Override public String getPhaseTypeString() { - return CONSTRUCTION_HEURISTICS_STRING; + return PhaseType.CONSTRUCTION_HEURISTIC.getPhaseName(); + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::constructionHeuristic; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java index eed7cf89bf..2b90cf0c1e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java @@ -4,9 +4,12 @@ import java.util.Collections; import java.util.Comparator; import java.util.TreeSet; +import java.util.function.IntFunction; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.exhaustivesearch.decider.ExhaustiveSearchDecider; import ai.timefold.solver.core.impl.exhaustivesearch.node.ExhaustiveSearchLayer; @@ -47,7 +50,12 @@ private DefaultExhaustiveSearchPhase(Builder builder) { @Override public String getPhaseTypeString() { - return "Exhaustive Search"; + return PhaseType.EXHAUSTIVE_SEARCH.getPhaseName(); + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::exhaustiveSearch; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java index 42a976a132..9e0312af59 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java @@ -3,10 +3,13 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.IntFunction; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.constraint.ConstraintMatchTotal; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.localsearch.decider.LocalSearchDecider; @@ -31,8 +34,6 @@ */ public class DefaultLocalSearchPhase extends AbstractPhase implements LocalSearchPhase, LocalSearchPhaseLifecycleListener { - public static final String LOCAL_SEARCH_STRING = "Local Search"; - protected final LocalSearchDecider decider; protected final AtomicLong acceptedMoveCountPerStep = new AtomicLong(0); protected final AtomicLong selectedMoveCountPerStep = new AtomicLong(0); @@ -48,7 +49,12 @@ private DefaultLocalSearchPhase(Builder builder) { @Override public String getPhaseTypeString() { - return LOCAL_SEARCH_STRING; + return PhaseType.LOCAL_SEARCH.getPhaseName(); + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::localSearch; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java index d7ed1de6b8..3c85ff5eb4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java @@ -119,11 +119,6 @@ protected boolean isNested() { return false; } - @Override - public String getPhaseName() { - return getPhaseTypeString(); - } - @Override public void phaseEnded(AbstractPhaseScope phaseScope) { if (!isNested()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java index 5a5cebd631..fddb959ef3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java @@ -1,6 +1,9 @@ package ai.timefold.solver.core.impl.phase; +import java.util.function.IntFunction; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; @@ -22,6 +25,11 @@ public String getPhaseTypeString() { return "No Change"; } + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::noChange; + } + // ************************************************************************ // Worker methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java index 9a2ad35bd5..8386fb516c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/Phase.java @@ -1,7 +1,10 @@ package ai.timefold.solver.core.impl.phase; +import java.util.function.IntFunction; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.Solver; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; @@ -36,8 +39,6 @@ public interface Phase extends PhaseLifecycleListener { void solve(SolverScope solverScope); - default String getPhaseName() { - return getClass().getSimpleName(); - } + IntFunction getEventProducerIdSupplier(); } 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 9433df4d7e..b9e203e405 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 @@ -1,8 +1,11 @@ package ai.timefold.solver.core.impl.phase.custom; import java.util.List; +import java.util.function.IntFunction; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.phase.AbstractPossiblyInitializingPhase; @@ -23,8 +26,6 @@ public final class DefaultCustomPhase extends AbstractPossiblyInitializingPhase implements CustomPhase { - public static final String CUSTOM_STRING = "Custom"; - private final List> customPhaseCommandList; private TerminationStatus terminationStatus = TerminationStatus.NOT_TERMINATED; @@ -40,7 +41,12 @@ public TerminationStatus getTerminationStatus() { @Override public String getPhaseTypeString() { - return CUSTOM_STRING; + return PhaseType.CUSTOM_PHASE.getPhaseName(); + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::customPhase; } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java index ea70a28368..b7ff625440 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/scope/AbstractPhaseScope.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.InnerScore; @@ -263,9 +264,8 @@ public int getNextStepIndex() { return getLastCompletedStepScope().getStepIndex() + 1; } - public String getPhaseId() { - return "%s (%d)".formatted( - solverScope.getSolver().getPhaseList().get(phaseIndex).getPhaseName(), phaseIndex); + public EventProducerId getPhaseId() { + return solverScope.getSolver().getPhaseList().get(phaseIndex).getEventProducerIdSupplier().apply(phaseIndex); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java index 7fe4cfe2df..75521ce630 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java @@ -12,13 +12,13 @@ import ai.timefold.solver.core.api.solver.ProblemFactChange; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; import ai.timefold.solver.core.impl.solver.change.ProblemChangeAdapter; -import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; import ai.timefold.solver.core.impl.solver.random.RandomFactory; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -228,7 +228,7 @@ public void solvingStarted(SolverScope solverScope) { // Update the best solution, since problem's shadows and score were updated bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, - DefaultBestSolutionChangedEvent.SOLVING_STARTED_EVENT_ID); + EventProducerId.solvingStarted()); logger.info("Solving {}: time spent ({}), best score ({}), " + "environment mode ({}), move thread count ({}), random ({}).", @@ -350,7 +350,7 @@ private boolean checkProblemFactChanges() { var score = scoreDirector.calculateScore(); basicPlumbingTermination.endProblemChangesProcessing(); bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, - DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID); + EventProducerId.problemChange()); logger.info("Real-time problem fact changes done: step total ({}), new best score ({}).", stepIndex, score); return true; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java index 8f97b409d4..326d0e08eb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java @@ -2,6 +2,7 @@ import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.impl.score.director.InnerScore; import org.jspecify.annotations.NonNull; @@ -12,9 +13,10 @@ public final class DefaultBestSolutionChangedEvent extends BestSoluti private final int unassignedCount; - public DefaultBestSolutionChangedEvent(@NonNull Solver solver, String phaseId, long timeMillisSpent, + public DefaultBestSolutionChangedEvent(@NonNull Solver solver, EventProducerId eventProducerId, + long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull InnerScore newBestScore) { - super(solver, phaseId, timeMillisSpent, newBestSolution, newBestScore.raw(), newBestScore.isFullyAssigned()); + super(solver, eventProducerId, timeMillisSpent, newBestSolution, newBestScore.raw(), newBestScore.isFullyAssigned()); this.unassignedCount = newBestScore.unassignedCount(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java index 551a27c7d3..9cd9a413d1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolverEventSupport.java @@ -2,6 +2,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.Solver; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.api.solver.event.SolverEventListener; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -18,13 +19,14 @@ public SolverEventSupport(Solver solver) { this.solver = solver; } - public void fireBestSolutionChanged(SolverScope solverScope, String phaseId, + public void fireBestSolutionChanged(SolverScope solverScope, EventProducerId eventProducerId, Solution_ newBestSolution) { var it = getEventListeners().iterator(); var timeMillisSpent = solverScope.getBestSolutionTimeMillisSpent(); var bestScore = solverScope.getBestScore(); if (it.hasNext()) { - var event = new DefaultBestSolutionChangedEvent<>(solver, phaseId, timeMillisSpent, newBestSolution, bestScore); + var event = + new DefaultBestSolutionChangedEvent<>(solver, eventProducerId, timeMillisSpent, newBestSolution, bestScore); do { it.next().bestSolutionChanged(event); } while (it.hasNext()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 921786c5ee..9e64ad3c11 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java @@ -3,6 +3,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; @@ -122,10 +123,10 @@ public void updateBestSolutionAndFire(SolverScope solverScope, Abstra } public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, - String eventId) { + EventProducerId eventProducerId) { updateBestSolutionWithoutFiring(solverScope); if (solverScope.isBestSolutionInitialized()) { - solverEventSupport.fireBestSolutionChanged(solverScope, eventId, solverScope.getBestSolution()); + solverEventSupport.fireBestSolutionChanged(solverScope, eventProducerId, solverScope.getBestSolution()); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java index 1d3293c0ab..7dea164350 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java @@ -33,6 +33,7 @@ import ai.timefold.solver.core.api.score.director.ScoreDirector; import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; @@ -79,7 +80,6 @@ import ai.timefold.solver.core.impl.score.DummySimpleScoreEasyScoreCalculator; import ai.timefold.solver.core.impl.score.constraint.DefaultConstraintMatchTotal; import ai.timefold.solver.core.impl.score.constraint.DefaultIndictment; -import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; import ai.timefold.solver.core.impl.util.Pair; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -504,7 +504,7 @@ void solveWithProblemChange() throws InterruptedException { solutionWithProblemChangeReceived.countDown(); } } - if (bestSolutionChangedEvent.getProducerId().equals(DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID)) { + if (bestSolutionChangedEvent.getProducerId().equals(EventProducerId.problemChange())) { hasProblemChangeBestSolutionEvent.set(true); } }); diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java b/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java index 93750c375b..248652cf34 100644 --- a/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java +++ b/core/src/test/java/ai/timefold/solver/core/testutil/BestScoreChangedEvent.java @@ -2,10 +2,7 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; -import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase; -import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhase; -import ai.timefold.solver.core.impl.phase.custom.DefaultCustomPhase; -import ai.timefold.solver.core.impl.solver.event.DefaultBestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import org.jspecify.annotations.NonNull; @@ -13,7 +10,7 @@ * Exists to avoid storing each best solution event's solution. */ public record BestScoreChangedEvent>(Score_ newScore, boolean isInitialized, - String eventId) { + EventProducerId eventProducerId) { @SuppressWarnings("unchecked") public BestScoreChangedEvent(BestSolutionChangedEvent bestSolutionChangedEvent) { this((Score_) bestSolutionChangedEvent.getNewBestScore(), bestSolutionChangedEvent.isNewBestSolutionInitialized(), @@ -21,32 +18,29 @@ public BestScoreChangedEvent(BestSolutionChangedEvent bestSolutionChangedEven } public BestScoreChangedEvent uninitialized() { - return new BestScoreChangedEvent<>(newScore, false, eventId); + return new BestScoreChangedEvent<>(newScore, false, eventProducerId); } public static > BestScoreChangedEvent solvingStarted(Score_ newScore) { - return new BestScoreChangedEvent<>(newScore, true, DefaultBestSolutionChangedEvent.SOLVING_STARTED_EVENT_ID); + return new BestScoreChangedEvent<>(newScore, true, EventProducerId.solvingStarted()); } public static > BestScoreChangedEvent problemChange(Score_ newScore) { - return new BestScoreChangedEvent<>(newScore, true, DefaultBestSolutionChangedEvent.PROBLEM_CHANGE_EVENT_ID); + return new BestScoreChangedEvent<>(newScore, true, EventProducerId.problemChange()); } public static > BestScoreChangedEvent constructionHeuristic(Score_ newScore, int index) { - return new BestScoreChangedEvent<>(newScore, true, - "%s (%d)".formatted(DefaultConstructionHeuristicPhase.CONSTRUCTION_HEURISTICS_STRING, index)); + return new BestScoreChangedEvent<>(newScore, true, EventProducerId.constructionHeuristic(index)); } public static > BestScoreChangedEvent custom(Score_ newScore, int index) { - return new BestScoreChangedEvent<>(newScore, true, - "%s (%d)".formatted(DefaultCustomPhase.CUSTOM_STRING, index)); + return new BestScoreChangedEvent<>(newScore, true, EventProducerId.customPhase(index)); } public static > BestScoreChangedEvent localSearch(Score_ newScore, int index) { - return new BestScoreChangedEvent<>(newScore, true, - "%s (%d)".formatted(DefaultLocalSearchPhase.LOCAL_SEARCH_STRING, index)); + return new BestScoreChangedEvent<>(newScore, true, EventProducerId.localSearch(index)); } } From 4e5789142f61e1e3b94cd6831a3a2c394e76f8bb Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 4 Nov 2025 15:24:28 -0500 Subject: [PATCH 3/9] feat: partitioned search support --- .../solver/core/api/solver/event/EventProducerId.java | 4 ++++ .../ai/timefold/solver/core/api/solver/event/PhaseType.java | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java index 118e7ed707..ecced5e18b 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -71,6 +71,10 @@ static EventProducerId exhaustiveSearch(int phaseIndex) { return new PhaseEventProducerId(PhaseType.EXHAUSTIVE_SEARCH, phaseIndex); } + static EventProducerId partitionedSearch(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.PARTITIONED_SEARCH, phaseIndex); + } + static EventProducerId customPhase(int phaseIndex) { return new PhaseEventProducerId(PhaseType.CUSTOM_PHASE, phaseIndex); } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java index 90f51f7811..54b23785ee 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java @@ -3,6 +3,7 @@ import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; import ai.timefold.solver.core.config.phase.NoChangePhaseConfig; import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; import ai.timefold.solver.core.impl.phase.NoChangePhase; @@ -30,6 +31,10 @@ public enum PhaseType { * The type of phase associated with {@link ExhaustiveSearchPhaseConfig}. */ EXHAUSTIVE_SEARCH("Exhaustive Search"), + /** + * The type of phase associated with {@link PartitionedSearchPhaseConfig}. + */ + PARTITIONED_SEARCH("Partitioned Search"), /** * The type of phase associated with {@link CustomPhaseConfig}. */ From ab2f72b0cb44f90edf7704b85c7ad2c752f3c739 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 4 Nov 2025 16:49:32 -0500 Subject: [PATCH 4/9] chore: sonar issues --- .../solver/core/api/solver/event/EventProducerId.java | 7 ++++++- .../impl/solver/event/DefaultBestSolutionChangedEvent.java | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java index ecced5e18b..48d57a0096 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -1,8 +1,10 @@ package ai.timefold.solver.core.api.solver.event; +import java.io.Serializable; import java.util.OptionalInt; import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.impl.phase.NoChangePhase; import org.jspecify.annotations.NullMarked; @@ -12,7 +14,7 @@ * with a phase, or a {@link SolveEventProducerId} otherwise. */ @NullMarked -public sealed interface EventProducerId permits PhaseEventProducerId, SolveEventProducerId { +public sealed interface EventProducerId extends Serializable permits PhaseEventProducerId, SolveEventProducerId { /** * An unique string identifying what produced the event, either of the form * "Event" where "Event" is a string describing the event that cause the update (like "Solving started") @@ -54,6 +56,9 @@ static EventProducerId problemChange() { return SolveEventProducerId.PROBLEM_CHANGE; } + /** + * @deprecated Deprecated on account of {@link NoChangePhase} having no use. + */ @Deprecated(forRemoval = true, since = "1.28.0") static EventProducerId noChange(int phaseIndex) { return new PhaseEventProducerId(PhaseType.NO_CHANGE, phaseIndex); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java index 326d0e08eb..72d84f7fd1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/DefaultBestSolutionChangedEvent.java @@ -8,9 +8,6 @@ import org.jspecify.annotations.NonNull; public final class DefaultBestSolutionChangedEvent extends BestSolutionChangedEvent { - public static String SOLVING_STARTED_EVENT_ID = "Solving started"; - public static String PROBLEM_CHANGE_EVENT_ID = "Problem change"; - private final int unassignedCount; public DefaultBestSolutionChangedEvent(@NonNull Solver solver, EventProducerId eventProducerId, From 2a2abf61dc510c0298b8103d0db471e67a1c415b Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Wed, 5 Nov 2025 11:54:45 -0500 Subject: [PATCH 5/9] chore: review comments --- .../event/BestSolutionChangedEvent.java | 4 +- .../api/solver/event/EventProducerId.java | 17 ++------- .../DefaultConstructionHeuristicPhase.java | 6 +-- .../DefaultExhaustiveSearchPhase.java | 6 +-- ...uinRecreateConstructionHeuristicPhase.java | 5 ++- .../localsearch/DefaultLocalSearchPhase.java | 6 +-- .../solver/core/impl/phase/AbstractPhase.java | 8 ++-- .../solver/core/impl/phase/NoChangePhase.java | 4 +- .../event => impl/phase}/PhaseType.java | 37 ++++++++++++------- .../impl/phase/custom/DefaultCustomPhase.java | 6 +-- .../phase}/event/PhaseEventProducerId.java | 11 ++---- .../solver/event/SolveEventProducerId.java | 10 ++--- 12 files changed, 56 insertions(+), 64 deletions(-) rename core/src/main/java/ai/timefold/solver/core/{api/solver/event => impl/phase}/PhaseType.java (51%) rename core/src/main/java/ai/timefold/solver/core/{api/solver => impl/phase}/event/PhaseEventProducerId.java (70%) rename core/src/main/java/ai/timefold/solver/core/{api => impl}/solver/event/SolveEventProducerId.java (85%) diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java index 930ce7f051..71beed3e07 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java @@ -71,9 +71,7 @@ public long getTimeMillisSpent() { } /** - * @return A {@link EventProducerId} identifying what generated the event, either a - * {@link SolveEventProducerId} if the cause is not associated with a Phase, - * or {@link PhaseEventProducerId} if it is. + * @return A {@link EventProducerId} identifying what generated the event */ public EventProducerId getProducerId() { return producerId; diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java index 48d57a0096..36b53d5ed2 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -1,20 +1,20 @@ package ai.timefold.solver.core.api.solver.event; import java.io.Serializable; -import java.util.OptionalInt; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.impl.phase.NoChangePhase; +import ai.timefold.solver.core.impl.phase.PhaseType; +import ai.timefold.solver.core.impl.phase.event.PhaseEventProducerId; +import ai.timefold.solver.core.impl.solver.event.SolveEventProducerId; import org.jspecify.annotations.NullMarked; /** * Identifies the producer of a {@link BestSolutionChangedEvent}. - * Will be an instance of {@link PhaseEventProducerId} if the event is associated - * with a phase, or a {@link SolveEventProducerId} otherwise. */ @NullMarked -public sealed interface EventProducerId extends Serializable permits PhaseEventProducerId, SolveEventProducerId { +public interface EventProducerId extends Serializable { /** * An unique string identifying what produced the event, either of the form * "Event" where "Event" is a string describing the event that cause the update (like "Solving started") @@ -35,15 +35,6 @@ public sealed interface EventProducerId extends Serializable permits PhaseEventP */ String simpleProducerName(); - /** - * An optional integer, that if present, identify what index in {@link SolverConfig#getPhaseConfigList()} - * corresponds to the Phase that produced the event. - * - * @return The index of the Phase that produced the event, or {@link OptionalInt#empty()} if the event producer - * is not associated with a phase. - */ - OptionalInt eventPhaseIndex(); - static EventProducerId unknown() { return SolveEventProducerId.UNKNOWN; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java index f867d29db2..4695f0bde2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java @@ -4,7 +4,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.event.EventProducerId; -import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.constructionheuristic.decider.ConstructionHeuristicDecider; import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacer; @@ -12,6 +11,7 @@ import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicStepScope; import ai.timefold.solver.core.impl.neighborhood.PlacerBasedMoveRepository; import ai.timefold.solver.core.impl.phase.AbstractPossiblyInitializingPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; @@ -47,8 +47,8 @@ public TerminationStatus getTerminationStatus() { } @Override - public String getPhaseTypeString() { - return PhaseType.CONSTRUCTION_HEURISTIC.getPhaseName(); + public PhaseType getPhaseType() { + return PhaseType.CONSTRUCTION_HEURISTIC; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java index 2b90cf0c1e..fc42ba5309 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java @@ -9,7 +9,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.event.EventProducerId; -import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.exhaustivesearch.decider.ExhaustiveSearchDecider; import ai.timefold.solver.core.impl.exhaustivesearch.node.ExhaustiveSearchLayer; @@ -19,6 +18,7 @@ import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.Moves; import ai.timefold.solver.core.impl.phase.AbstractPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; import ai.timefold.solver.core.preview.api.move.Move; @@ -49,8 +49,8 @@ private DefaultExhaustiveSearchPhase(Builder builder) { } @Override - public String getPhaseTypeString() { - return PhaseType.EXHAUSTIVE_SEARCH.getPhaseName(); + public PhaseType getPhaseType() { + return PhaseType.EXHAUSTIVE_SEARCH; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java index f8fa7d59c9..4a5b0a42ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java @@ -11,6 +11,7 @@ import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase; import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope; import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicStepScope; +import ai.timefold.solver.core.impl.phase.PhaseType; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import org.jspecify.annotations.NullMarked; @@ -41,8 +42,8 @@ protected boolean isNested() { } @Override - public String getPhaseTypeString() { - return "Ruin & Recreate Construction Heuristics"; + public PhaseType getPhaseType() { + return PhaseType.RUIN_AND_RECREATE_CONSTRUCTION_HEURISTIC; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java index 9e0312af59..69cf5dc220 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java @@ -9,7 +9,6 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.constraint.ConstraintMatchTotal; import ai.timefold.solver.core.api.solver.event.EventProducerId; -import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.localsearch.decider.LocalSearchDecider; @@ -17,6 +16,7 @@ import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; import ai.timefold.solver.core.impl.phase.AbstractPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.solver.monitoring.ScoreLevels; @@ -48,8 +48,8 @@ private DefaultLocalSearchPhase(Builder builder) { } @Override - public String getPhaseTypeString() { - return PhaseType.LOCAL_SEARCH.getPhaseName(); + public PhaseType getPhaseType() { + return PhaseType.LOCAL_SEARCH; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java index 3c85ff5eb4..eb7b6ea44d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java @@ -77,7 +77,7 @@ public boolean isAssertShadowVariablesAreNotStaleAfterStep() { return assertShadowVariablesAreNotStaleAfterStep; } - public abstract String getPhaseTypeString(); + public abstract PhaseType getPhaseType(); // ************************************************************************ // Lifecycle methods @@ -130,7 +130,7 @@ public void phaseEnded(AbstractPhaseScope phaseScope) { if (assertPhaseScoreFromScratch) { var score = phaseScope.getSolverScope().calculateScore(); try { - phaseScope.assertWorkingScoreFromScratch(score, getPhaseTypeString() + " phase ended"); + phaseScope.assertWorkingScoreFromScratch(score, getPhaseType() + " phase ended"); } catch (ScoreCorruptionException | VariableCorruptionException e) { throw new IllegalStateException(""" Solver corruption was detected. Solutions provided by this solver can not be trusted. @@ -234,7 +234,7 @@ protected void assertWorkingSolutionInitialized(AbstractPhaseScope ph %s phase (%d) needs to start from an initialized solution, but there are (%d) uninitialized entities. Maybe there is no Construction Heuristic configured before this phase to initialize the solution. Or maybe the getter/setters of your planning variables in your domain classes aren't implemented correctly.""" - .formatted(getPhaseTypeString(), phaseIndex, uninitializedEntityCount)); + .formatted(getPhaseType(), phaseIndex, uninitializedEntityCount)); } var unassignedValueCount = initializationStatistics.unassignedValueCount(); if (unassignedValueCount > 0) { @@ -243,7 +243,7 @@ protected void assertWorkingSolutionInitialized(AbstractPhaseScope ph %s phase (%d) needs to start from an initialized solution, \ but planning list variable (%s) has (%d) unexpected unassigned values. Maybe there is no Construction Heuristic configured before this phase to initialize the solution.""" - .formatted(getPhaseTypeString(), phaseIndex, solutionDescriptor.getListVariableDescriptor(), + .formatted(getPhaseType(), phaseIndex, solutionDescriptor.getListVariableDescriptor(), unassignedValueCount)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java index fddb959ef3..c0f8a34ec0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/NoChangePhase.java @@ -21,8 +21,8 @@ private NoChangePhase(Builder builder) { } @Override - public String getPhaseTypeString() { - return "No Change"; + public PhaseType getPhaseType() { + return PhaseType.NO_CHANGE; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java similarity index 51% rename from core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java rename to core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java index 54b23785ee..2b94cdbb32 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java @@ -1,42 +1,46 @@ -package ai.timefold.solver.core.api.solver.event; +package ai.timefold.solver.core.impl.phase; -import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; -import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; -import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; -import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; -import ai.timefold.solver.core.config.phase.NoChangePhaseConfig; -import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; -import ai.timefold.solver.core.impl.phase.NoChangePhase; +import ai.timefold.solver.core.impl.constructionheuristic.ConstructionHeuristicPhase; +import ai.timefold.solver.core.impl.exhaustivesearch.ExhaustiveSearchPhase; +import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhase; +import ai.timefold.solver.core.impl.localsearch.LocalSearchPhase; +import ai.timefold.solver.core.impl.partitionedsearch.PartitionedSearchPhase; +import ai.timefold.solver.core.impl.phase.custom.CustomPhase; /** * The type of phase (for example, a Construction Heuristic). */ public enum PhaseType { /** - * The type of phase associated with {@link NoChangePhaseConfig}. + * The type of phase associated with {@link NoChangePhase}. * * @deprecated Deprecated on account of {@link NoChangePhase} having no use. */ @Deprecated(forRemoval = true, since = "1.28.0") NO_CHANGE("No Change"), /** - * The type of phase associated with {@link ConstructionHeuristicPhaseConfig}. + * The type of phase associated with {@link ConstructionHeuristicPhase}. */ CONSTRUCTION_HEURISTIC("Construction Heuristics"), + + /** + * The type of phase associated with {@link RuinRecreateConstructionHeuristicPhase} + */ + RUIN_AND_RECREATE_CONSTRUCTION_HEURISTIC("Ruin & Recreate Construction Heuristics"), /** - * The type of phase associated with {@link LocalSearchPhaseConfig}. + * The type of phase associated with {@link LocalSearchPhase}. */ LOCAL_SEARCH("Local Search"), /** - * The type of phase associated with {@link ExhaustiveSearchPhaseConfig}. + * The type of phase associated with {@link ExhaustiveSearchPhase}. */ EXHAUSTIVE_SEARCH("Exhaustive Search"), /** - * The type of phase associated with {@link PartitionedSearchPhaseConfig}. + * The type of phase associated with {@link PartitionedSearchPhase}. */ PARTITIONED_SEARCH("Partitioned Search"), /** - * The type of phase associated with {@link CustomPhaseConfig}. + * The type of phase associated with {@link CustomPhase}. */ CUSTOM_PHASE("Custom Phase"); @@ -49,4 +53,9 @@ public enum PhaseType { public String getPhaseName() { return phaseName; } + + @Override + public String toString() { + return phaseName; + } } 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 b9e203e405..09669ca1cb 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 @@ -5,10 +5,10 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.event.EventProducerId; -import ai.timefold.solver.core.api.solver.event.PhaseType; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.phase.AbstractPossiblyInitializingPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; import ai.timefold.solver.core.impl.phase.custom.scope.CustomPhaseScope; import ai.timefold.solver.core.impl.phase.custom.scope.CustomStepScope; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; @@ -40,8 +40,8 @@ public TerminationStatus getTerminationStatus() { } @Override - public String getPhaseTypeString() { - return PhaseType.CUSTOM_PHASE.getPhaseName(); + public PhaseType getPhaseType() { + return PhaseType.CUSTOM_PHASE; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java similarity index 70% rename from core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java rename to core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java index 9ca7f9188d..27bec8d8f0 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/PhaseEventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java @@ -1,6 +1,8 @@ -package ai.timefold.solver.core.api.solver.event; +package ai.timefold.solver.core.impl.phase.event; -import java.util.OptionalInt; +import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.impl.phase.PhaseType; import org.jspecify.annotations.NullMarked; @@ -20,11 +22,6 @@ public String simpleProducerName() { return phaseType.getPhaseName(); } - @Override - public OptionalInt eventPhaseIndex() { - return OptionalInt.of(phaseIndex); - } - @Override public String toString() { return producerId(); diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java similarity index 85% rename from core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java rename to core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java index 317e5a61fb..28228501bc 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolveEventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java @@ -1,6 +1,7 @@ -package ai.timefold.solver.core.api.solver.event; +package ai.timefold.solver.core.impl.solver.event; -import java.util.OptionalInt; +import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import org.jspecify.annotations.NullMarked; @@ -45,9 +46,4 @@ public String producerId() { public String simpleProducerName() { return producerId; } - - @Override - public OptionalInt eventPhaseIndex() { - return OptionalInt.empty(); - } } From 4f87090bd82ba2e6adceb3a96995b550fb596192 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Wed, 5 Nov 2025 12:19:45 -0500 Subject: [PATCH 6/9] fix: flaky tests A Problem Change does not trigger a new best solution event if it occurs before Construction Heuristic finishes. --- .../solver/core/impl/solver/DefaultSolverTest.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java index 7dea164350..b9c66b0eed 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java @@ -17,7 +17,6 @@ import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; import java.util.function.BooleanSupplier; @@ -33,7 +32,6 @@ import ai.timefold.solver.core.api.score.director.ScoreDirector; import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.api.solver.SolverFactory; -import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; @@ -495,7 +493,6 @@ void solveWithProblemChange() throws InterruptedException { var bestSolution = new AtomicReference(); var solutionWithProblemChangeReceived = new CountDownLatch(1); - var hasProblemChangeBestSolutionEvent = new AtomicBoolean(false); solver.addEventListener(bestSolutionChangedEvent -> { if (bestSolutionChangedEvent.isEveryProblemChangeProcessed()) { var newBestSolution = bestSolutionChangedEvent.getNewBestSolution(); @@ -504,9 +501,6 @@ void solveWithProblemChange() throws InterruptedException { solutionWithProblemChangeReceived.countDown(); } } - if (bestSolutionChangedEvent.getProducerId().equals(EventProducerId.problemChange())) { - hasProblemChangeBestSolutionEvent.set(true); - } }); var executorService = Executors.newSingleThreadExecutor(); @@ -520,7 +514,6 @@ void solveWithProblemChange() throws InterruptedException { solutionWithProblemChangeReceived.await(); assertThat(bestSolution.get().getValueList()).hasSize(valueCount + 1); - assertThat(hasProblemChangeBestSolutionEvent.get()).isTrue(); solver.terminateEarly(); } finally { executorService.shutdownNow(); From 7b7091e9ea100a221316108281c51573d60c241b Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Wed, 5 Nov 2025 14:31:42 -0500 Subject: [PATCH 7/9] chore: add back phaseIndex --- core/src/build/revapi-differences.json | 27 +++++++++++++++++++ .../event/BestSolutionChangedEvent.java | 5 +--- .../api/solver/event/EventProducerId.java | 15 +++++++++-- .../phase/event/PhaseEventProducerId.java | 11 ++++++-- .../solver/event/SolveEventProducerId.java | 7 +++++ 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index 537f4e3860..df3de4f0e9 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -450,6 +450,33 @@ "new": "class ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig", "annotation": "@org.jspecify.annotations.NullMarked", "justification": "Update config" + }, + { + "ignore": true, + "code": "java.field.removed", + "old": "field java.util.EventObject.source @ ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "justification": "BestSolutionChangedEvent no longer extends java.util.EventObject" + }, + { + "ignore": true, + "code": "java.method.removed", + "old": "method java.lang.Object java.util.EventObject::getSource() @ ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "justification": "BestSolutionChangedEvent no longer extends java.util.EventObject" + }, + { + "ignore": true, + "code": "java.class.noLongerInheritsFromClass", + "old": "class ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "new": "class ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "justification": "BestSolutionChangedEvent no longer extends java.util.EventObject" + }, + { + "ignore": true, + "code": "java.class.noLongerImplementsInterface", + "old": "class ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "new": "class ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent", + "interface": "java.io.Serializable", + "justification": "BestSolutionChangedEvent no longer extends java.util.EventObject" } ] } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java index 71beed3e07..f694f0ce5e 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/BestSolutionChangedEvent.java @@ -1,7 +1,5 @@ package ai.timefold.solver.core.api.solver.event; -import java.util.EventObject; - import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; @@ -16,7 +14,7 @@ * @param the solution type, the class with the {@link PlanningSolution} annotation */ // TODO In Solver 2.0, maybe convert this to an interface. -public class BestSolutionChangedEvent extends EventObject { +public class BestSolutionChangedEvent { private final Solver solver; private final EventProducerId producerId; private final long timeMillisSpent; @@ -53,7 +51,6 @@ public BestSolutionChangedEvent(@NonNull Solver solver, long timeMill public BestSolutionChangedEvent(@NonNull Solver solver, EventProducerId producerId, long timeMillisSpent, @NonNull Solution_ newBestSolution, @NonNull Score newBestScore, boolean isNewBestSolutionInitialized) { - super(solver); this.solver = solver; this.producerId = producerId; this.timeMillisSpent = timeMillisSpent; diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java index 36b53d5ed2..adfb9f2be7 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -1,7 +1,8 @@ package ai.timefold.solver.core.api.solver.event; -import java.io.Serializable; +import java.util.OptionalInt; +import ai.timefold.solver.core.api.solver.change.ProblemChange; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.impl.phase.NoChangePhase; import ai.timefold.solver.core.impl.phase.PhaseType; @@ -14,7 +15,7 @@ * Identifies the producer of a {@link BestSolutionChangedEvent}. */ @NullMarked -public interface EventProducerId extends Serializable { +public interface EventProducerId { /** * An unique string identifying what produced the event, either of the form * "Event" where "Event" is a string describing the event that cause the update (like "Solving started") @@ -35,6 +36,16 @@ public interface EventProducerId extends Serializable { */ String simpleProducerName(); + /** + * If present, the index of the phase that produced the event in the {@link SolverConfig#getPhaseConfigList()}. + * Is absent when the producer does not correspond to a phase, for instance, + * an event triggered after {@link ProblemChange} were processed. + * + * @return The index of the corresponding phase in {@link SolverConfig#getPhaseConfigList()}, + * or {@link OptionalInt#empty()} if there is no corresponding phase. + */ + OptionalInt phaseIndex(); + static EventProducerId unknown() { return SolveEventProducerId.UNKNOWN; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java index 27bec8d8f0..2d9d238404 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/event/PhaseEventProducerId.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.phase.event; +import java.util.OptionalInt; + import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.impl.phase.PhaseType; @@ -11,10 +13,10 @@ * caused by a phase. */ @NullMarked -public record PhaseEventProducerId(PhaseType phaseType, int phaseIndex) implements EventProducerId { +public record PhaseEventProducerId(PhaseType phaseType, int index) implements EventProducerId { @Override public String producerId() { - return "%s (%d)".formatted(phaseType.getPhaseName(), phaseIndex); + return "%s (%d)".formatted(phaseType.getPhaseName(), index); } @Override @@ -22,6 +24,11 @@ public String simpleProducerName() { return phaseType.getPhaseName(); } + @Override + public OptionalInt phaseIndex() { + return OptionalInt.of(index); + } + @Override public String toString() { return producerId(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java index 28228501bc..64a0f0cdbe 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/event/SolveEventProducerId.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.solver.event; +import java.util.OptionalInt; + import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; import ai.timefold.solver.core.api.solver.event.EventProducerId; @@ -46,4 +48,9 @@ public String producerId() { public String simpleProducerName() { return producerId; } + + @Override + public OptionalInt phaseIndex() { + return OptionalInt.empty(); + } } From bbd3f04dfac5b5080343f421e38c28f582d4b469 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Fri, 7 Nov 2025 08:34:28 -0500 Subject: [PATCH 8/9] chore: use events in the SolverJob consumers instead of passing the solution Having a consumer that accepts the solution was fragile, since whenever we wanted to add additional info, we would need to create an additional overload. Using events allows us to add new methods without affecting user code. --- .../core/api/solver/SolverJobBuilder.java | 83 +++++-- .../solver/core/api/solver/SolverManager.java | 4 +- .../solver/event/FinalBestSolutionEvent.java | 16 ++ .../event/FirstInitializedSolutionEvent.java | 27 +++ .../solver/event/NewBestSolutionEvent.java | 26 +++ .../solver/event/SolverJobStartedEvent.java | 15 ++ .../impl/phase/PossiblyInitializingPhase.java | 5 +- .../BestSolutionContainingProblemChanges.java | 11 +- .../core/impl/solver/BestSolutionHolder.java | 11 +- .../core/impl/solver/ConsumerSupport.java | 72 ++++-- .../core/impl/solver/DefaultSolverJob.java | 23 +- .../impl/solver/DefaultSolverJobBuilder.java | 23 +- .../impl/solver/DefaultSolverManager.java | 21 +- .../core/api/solver/SolverManagerTest.java | 209 +++++++++++------- .../impl/solver/BestSolutionHolderTest.java | 43 +++- .../core/impl/solver/ConsumerSupportTest.java | 14 +- .../impl/solver/ProblemChangeBarrageIT.java | 4 +- .../upgrade-to-latest-version.adoc | 80 +++++++ 18 files changed, 511 insertions(+), 176 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/FinalBestSolutionEvent.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/FirstInitializedSolutionEvent.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/NewBestSolutionEvent.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/solver/event/SolverJobStartedEvent.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java index dbdd30405f..84393a3b6d 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java @@ -6,6 +6,10 @@ import java.util.function.Function; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.SolverJobStartedEvent; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; @@ -57,6 +61,18 @@ public interface SolverJobBuilder { SolverJobBuilder withProblemFinder(@NonNull Function problemFinder); + /** + * As defined by {@link #withBestSolutionEventConsumer(Consumer)}. + * + * @deprecated Use {@link #withBestSolutionEventConsumer(Consumer)} instead. + */ + @Deprecated(forRemoval = true, since = "1.28.0") + @NonNull + default SolverJobBuilder + withBestSolutionConsumer(@NonNull Consumer bestSolutionConsumer) { + return withBestSolutionEventConsumer(event -> bestSolutionConsumer.accept(event.solution())); + } + /** * Sets the best solution consumer, which may be called multiple times during the solving process. *

    @@ -64,34 +80,64 @@ public interface SolverJobBuilder { * The solver's best solution instance is the same as the one in the event, * and any modifications may lead to solver corruption due to its internal reuse. * - * @param bestSolutionConsumer called multiple times for each new best solution on a consumer thread + * @param bestSolutionEventConsumer called multiple times for each new best solution on a consumer thread * @return this */ @NonNull - SolverJobBuilder withBestSolutionConsumer(@NonNull Consumer bestSolutionConsumer); + SolverJobBuilder + withBestSolutionEventConsumer(@NonNull Consumer> bestSolutionEventConsumer); + + /** + * As defined by {@link #withFinalBestSolutionEventConsumer}. + * + * @deprecated Use {@link #withFinalBestSolutionEventConsumer(Consumer)} instead. + */ + @Deprecated(forRemoval = true, since = "1.28.0") + @NonNull + default SolverJobBuilder + withFinalBestSolutionConsumer(@NonNull Consumer finalBestSolutionConsumer) { + return withFinalBestSolutionEventConsumer(event -> finalBestSolutionConsumer.accept(event.solution())); + } /** * Sets the final best solution consumer, which is called at the end of the solving process and returns the final * best solution. * - * @param finalBestSolutionConsumer called only once at the end of the solving process on a consumer thread + * @param finalBestSolutionEventConsumer called only once at the end of the solving process on a consumer thread * @return this */ @NonNull SolverJobBuilder - withFinalBestSolutionConsumer(@NonNull Consumer finalBestSolutionConsumer); + withFinalBestSolutionEventConsumer( + @NonNull Consumer> finalBestSolutionEventConsumer); /** - * As defined by #withFirstInitializedSolutionConsumer(FirstInitializedSolutionConsumer). + * As defined by {@link #withFirstInitializedSolutionEventConsumer(Consumer)}. * - * @deprecated Use {@link #withFirstInitializedSolutionConsumer(FirstInitializedSolutionConsumer)} instead. + * @deprecated Use {@link #withFirstInitializedSolutionEventConsumer(Consumer)} instead. */ @Deprecated(forRemoval = true, since = "1.19.0") @NonNull default SolverJobBuilder withFirstInitializedSolutionConsumer(@NonNull Consumer firstInitializedSolutionConsumer) { - return withFirstInitializedSolutionConsumer( - (solution, isTerminatedEarly) -> firstInitializedSolutionConsumer.accept(solution)); + return withFirstInitializedSolutionEventConsumer( + (event) -> firstInitializedSolutionConsumer.accept(event.solution())); + } + + /** + * As defined by {@link #withFirstInitializedSolutionEventConsumer(Consumer)}. + * + * @deprecated Use {@link #withFirstInitializedSolutionEventConsumer(Consumer)} instead. + * + * @param firstInitializedSolutionConsumer called only once before starting the first Local Search phase + * @return this + */ + @Deprecated(forRemoval = true, since = "1.28.0") + @NonNull + default SolverJobBuilder withFirstInitializedSolutionConsumer( + @NonNull FirstInitializedSolutionConsumer firstInitializedSolutionConsumer) { + return withFirstInitializedSolutionEventConsumer( + event -> firstInitializedSolutionConsumer.accept(event.solution(), event.isTerminatedEarly())); } /** @@ -100,12 +146,21 @@ public interface SolverJobBuilder { * First initialized solution is the solution at the end of the last phase * that immediately precedes the first local search phase. * - * @param firstInitializedSolutionConsumer called only once before starting the first Local Search phase + * @param firstInitializedSolutionEventConsumer called only once before starting the first Local Search phase * @return this */ - @NonNull - SolverJobBuilder withFirstInitializedSolutionConsumer( - @NonNull FirstInitializedSolutionConsumer firstInitializedSolutionConsumer); + SolverJobBuilder withFirstInitializedSolutionEventConsumer( + @NonNull Consumer> firstInitializedSolutionEventConsumer); + + /** + * As defined by {@link #withSolverJobStartedEventConsumer(Consumer)}. + * + * @deprecated Use {@link #withSolverJobStartedEventConsumer(Consumer)} instead. + */ + default SolverJobBuilder + withSolverJobStartedConsumer(Consumer solverJobStartedConsumer) { + return withSolverJobStartedEventConsumer(event -> solverJobStartedConsumer.accept(event.solution())); + } /** * Sets the consumer for when the solver starts its solving process. @@ -113,7 +168,8 @@ SolverJobBuilder withFirstInitializedSolutionConsumer( * @param solverJobStartedConsumer never null, called only once when the solver is starting the solving process * @return this, never null */ - SolverJobBuilder withSolverJobStartedConsumer(Consumer solverJobStartedConsumer); + SolverJobBuilder + withSolverJobStartedEventConsumer(Consumer> solverJobStartedConsumer); /** * Sets the custom exception handler. @@ -166,5 +222,4 @@ interface FirstInitializedSolutionConsumer { void accept(Solution_ solution, boolean isTerminatedEarly); } - } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverManager.java b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverManager.java index 0ec90ae01d..9d6baa6b91 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverManager.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverManager.java @@ -147,7 +147,7 @@ public interface SolverManager extends AutoCloseable { .withProblemId(problemId) .withProblem(problem); if (finalBestSolutionConsumer != null) { - builder.withFinalBestSolutionConsumer(finalBestSolutionConsumer); + builder.withFinalBestSolutionEventConsumer(event -> finalBestSolutionConsumer.accept(event.solution())); } return builder.run(); } @@ -297,7 +297,7 @@ public interface SolverManager extends AutoCloseable { return solveBuilder() .withProblemId(problemId) .withProblem(problem) - .withBestSolutionConsumer(bestSolutionConsumer) + .withBestSolutionEventConsumer(event -> bestSolutionConsumer.accept(event.solution())) .run(); } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/FinalBestSolutionEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/FinalBestSolutionEvent.java new file mode 100644 index 0000000000..f9d272d710 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/FinalBestSolutionEvent.java @@ -0,0 +1,16 @@ +package ai.timefold.solver.core.api.solver.event; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; + +/** + * Delivered in a consumer thread at the end of the solving process and contains the final {@link PlanningSolution best + * solution} found. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public interface FinalBestSolutionEvent { + /** + * @return the {@link PlanningSolution best solution} found by the solver + */ + Solution_ solution(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/FirstInitializedSolutionEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/FirstInitializedSolutionEvent.java new file mode 100644 index 0000000000..af12206598 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/FirstInitializedSolutionEvent.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.api.solver.event; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; + +/** + * Delivered in a consumer thread at the beginning of the actual optimization process. + * First initialized solution is the solution at the end of the last phase + * that immediately precedes the first local search phase. + * + * @param + */ +public interface FirstInitializedSolutionEvent { + /** + * @return The {@link PlanningSolution initialized solution} + */ + Solution_ solution(); + + /** + * @return A {@link EventProducerId} identifying what generated the event + */ + EventProducerId producerId(); + + /** + * @return True if the solver was terminated early, false otherwise + */ + boolean isTerminatedEarly(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/NewBestSolutionEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/NewBestSolutionEvent.java new file mode 100644 index 0000000000..a4fa46f9d2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/NewBestSolutionEvent.java @@ -0,0 +1,26 @@ +package ai.timefold.solver.core.api.solver.event; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; + +/** + * Delivered in a consumer thread multiple times during the solving process, containing the {@link PlanningSolution best + * solution} found so far. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public interface NewBestSolutionEvent { + /** + * The {@link PlanningSolution best solution} found by the solver. + * Don't apply any changes to the solution instance while the solver runs. + * The solver's best solution instance is the same as the one in the event, + * and any modifications may lead to solver corruption due to its internal reuse. + * + * @return the {@link PlanningSolution best solution} found by the solver + */ + Solution_ solution(); + + /** + * @return A {@link EventProducerId} identifying what generated the event + */ + EventProducerId producerId(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolverJobStartedEvent.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolverJobStartedEvent.java new file mode 100644 index 0000000000..dd1cc22d80 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/SolverJobStartedEvent.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.core.api.solver.event; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; + +/** + * Delivered in a consumer thread when the solver starts its solving process. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public interface SolverJobStartedEvent { + /** + * @return The {@link PlanningSolution initial solution} passed to the solver + */ + Solution_ solution(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/PossiblyInitializingPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PossiblyInitializingPhase.java index ad5ff2c2a9..4e2b154ac2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/PossiblyInitializingPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PossiblyInitializingPhase.java @@ -1,8 +1,9 @@ package ai.timefold.solver.core.impl.phase; +import java.util.function.Consumer; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.solver.SolverJobBuilder; -import ai.timefold.solver.core.api.solver.SolverJobBuilder.FirstInitializedSolutionConsumer; import ai.timefold.solver.core.impl.constructionheuristic.ConstructionHeuristicPhase; import ai.timefold.solver.core.impl.localsearch.LocalSearchPhase; import ai.timefold.solver.core.impl.phase.custom.CustomPhase; @@ -24,7 +25,7 @@ public interface PossiblyInitializingPhase extends Phase { * The first initialized solution immediately precedes the first {@link LocalSearchPhase}. * * @return true if the phase is the final phase before the first local search phase. - * @see SolverJobBuilder#withFirstInitializedSolutionConsumer(FirstInitializedSolutionConsumer) + * @see SolverJobBuilder#withFirstInitializedSolutionEventConsumer(Consumer) */ boolean isLastInitializingPhase(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionContainingProblemChanges.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionContainingProblemChanges.java index d477783c91..7651605eec 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionContainingProblemChanges.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionContainingProblemChanges.java @@ -3,12 +3,17 @@ import java.util.List; import java.util.concurrent.CompletableFuture; +import ai.timefold.solver.core.api.solver.event.EventProducerId; + final class BestSolutionContainingProblemChanges { private final Solution_ bestSolution; + private final EventProducerId producerId; private final List> containedProblemChanges; - public BestSolutionContainingProblemChanges(Solution_ bestSolution, List> containedProblemChanges) { + public BestSolutionContainingProblemChanges(Solution_ bestSolution, EventProducerId producerId, + List> containedProblemChanges) { this.bestSolution = bestSolution; + this.producerId = producerId; this.containedProblemChanges = containedProblemChanges; } @@ -16,6 +21,10 @@ public Solution_ getBestSolution() { return bestSolution; } + public EventProducerId getProducerId() { + return producerId; + } + public void completeProblemChanges() { containedProblemChanges.forEach(futureProblemChange -> futureProblemChange.complete(null)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionHolder.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionHolder.java index 0518f0c5ff..f2e37de7a8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionHolder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/BestSolutionHolder.java @@ -14,6 +14,7 @@ import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; @@ -101,7 +102,8 @@ BestSolutionContainingProblemChanges take() { .stream() .flatMap(Collection::stream) .toList(); - return new BestSolutionContainingProblemChanges<>(latestVersionedBestSolution.bestSolution(), containedProblemChanges); + return new BestSolutionContainingProblemChanges<>(latestVersionedBestSolution.bestSolution(), + latestVersionedBestSolution.producerId(), containedProblemChanges); } private synchronized @Nullable VersionedBestSolution resetVersionedBestSolution() { @@ -122,9 +124,10 @@ private synchronized SortedMap>> replac * and thus are contained in this best solution. * * @param bestSolution the new best solution that replaces the previous one if there is any + * @param producerId what produced the best solution event * @param isEveryProblemChangeProcessed a supplier that tells if all problem changes have been processed */ - void set(Solution_ bestSolution, BooleanSupplier isEveryProblemChangeProcessed) { + void set(Solution_ bestSolution, EventProducerId producerId, BooleanSupplier isEveryProblemChangeProcessed) { // The new best solution can be accepted only if there are no pending problem changes // nor any additional changes may come during this operation. // Otherwise, a race condition might occur @@ -133,7 +136,7 @@ void set(Solution_ bestSolution, BooleanSupplier isEveryProblemChangeProcessed) // As a result, CompletableFutures representing these changes would be completed too early. if (isEveryProblemChangeProcessed.getAsBoolean()) { synchronized (this) { - versionedBestSolution = new VersionedBestSolution<>(bestSolution, currentVersion); + versionedBestSolution = new VersionedBestSolution<>(bestSolution, producerId, currentVersion); currentVersion = currentVersion.add(BigInteger.ONE); } } @@ -170,7 +173,7 @@ void cancelPendingChanges() { .forEach(pendingProblemChange -> pendingProblemChange.cancel(false)); } - private record VersionedBestSolution(Solution_ bestSolution, BigInteger version) { + private record VersionedBestSolution(Solution_ bestSolution, EventProducerId producerId, BigInteger version) { } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java index 0ec8d1e4da..c39cea1e12 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/ConsumerSupport.java @@ -8,15 +8,19 @@ import java.util.function.BooleanSupplier; import java.util.function.Consumer; -import ai.timefold.solver.core.api.solver.SolverJobBuilder.FirstInitializedSolutionConsumer; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.SolverJobStartedEvent; final class ConsumerSupport implements AutoCloseable { private final ProblemId_ problemId; - private final Consumer bestSolutionConsumer; - private final Consumer finalBestSolutionConsumer; - private final FirstInitializedSolutionConsumer firstInitializedSolutionConsumer; - private final Consumer solverJobStartedConsumer; + private final Consumer> bestSolutionConsumer; + private final Consumer> finalBestSolutionConsumer; + private final Consumer> firstInitializedSolutionConsumer; + private final Consumer> solverJobStartedConsumer; private final BiConsumer exceptionHandler; private final Semaphore activeConsumption = new Semaphore(1); private final Semaphore firstSolutionConsumption = new Semaphore(1); @@ -26,18 +30,20 @@ final class ConsumerSupport implements AutoCloseable { private Solution_ firstInitializedSolution; private Solution_ initialSolution; - public ConsumerSupport(ProblemId_ problemId, Consumer bestSolutionConsumer, - Consumer finalBestSolutionConsumer, - FirstInitializedSolutionConsumer firstInitializedSolutionConsumer, - Consumer solverJobStartedConsumer, + public ConsumerSupport(ProblemId_ problemId, + Consumer> bestSolutionConsumer, + Consumer> finalBestSolutionConsumer, + Consumer> firstInitializedSolutionConsumer, + Consumer> solverJobStartedConsumer, BiConsumer exceptionHandler, BestSolutionHolder bestSolutionHolder) { this.problemId = problemId; this.bestSolutionConsumer = bestSolutionConsumer; this.finalBestSolutionConsumer = finalBestSolutionConsumer == null ? finalBestSolution -> { } : finalBestSolutionConsumer; - this.firstInitializedSolutionConsumer = firstInitializedSolutionConsumer == null ? (solution, isTerminatedEarly) -> { - } : firstInitializedSolutionConsumer; + this.firstInitializedSolutionConsumer = + firstInitializedSolutionConsumer == null ? event -> { + } : firstInitializedSolutionConsumer; this.solverJobStartedConsumer = solverJobStartedConsumer; this.exceptionHandler = exceptionHandler; this.bestSolutionHolder = bestSolutionHolder; @@ -46,19 +52,21 @@ public ConsumerSupport(ProblemId_ problemId, Consumer bestSol } // Called on the Solver thread. - void consumeIntermediateBestSolution(Solution_ bestSolution, BooleanSupplier isEveryProblemChangeProcessed) { + void consumeIntermediateBestSolution(Solution_ bestSolution, EventProducerId producerId, + BooleanSupplier isEveryProblemChangeProcessed) { /* * If the bestSolutionConsumer is not provided, the best solution is still set for the purpose of recording * problem changes. */ - bestSolutionHolder.set(bestSolution, isEveryProblemChangeProcessed); + bestSolutionHolder.set(bestSolution, producerId, isEveryProblemChangeProcessed); if (bestSolutionConsumer != null) { tryConsumeWaitingIntermediateBestSolution(); } } // Called on the Solver thread. - void consumeFirstInitializedSolution(Solution_ firstInitializedSolution, boolean isTerminatedEarly) { + void consumeFirstInitializedSolution(Solution_ firstInitializedSolution, EventProducerId producerId, + boolean isTerminatedEarly) { try { // Called on the solver thread // During the solving process, this lock is called once, and it won't block the Solver thread @@ -70,7 +78,8 @@ void consumeFirstInitializedSolution(Solution_ firstInitializedSolution, boolean // called on the Consumer thread this.firstInitializedSolution = firstInitializedSolution; scheduleFirstInitializedSolutionConsumption( - solution -> firstInitializedSolutionConsumer.accept(solution, isTerminatedEarly)); + solution -> firstInitializedSolutionConsumer + .accept(new FirstInitializedSolutionEventImpl<>(solution, producerId, isTerminatedEarly))); } // Called on the consumer thread @@ -104,7 +113,7 @@ void consumeFinalBestSolution(Solution_ finalBestSolution) { } consumerExecutor.submit(() -> { try { - finalBestSolutionConsumer.accept(finalBestSolution); + finalBestSolutionConsumer.accept(new FinalBestSolutionEventImpl<>(finalBestSolution)); } catch (Throwable throwable) { exceptionHandler.accept(problemId, throwable); } finally { @@ -143,7 +152,9 @@ private CompletableFuture scheduleIntermediateBestSolutionConsumption() { BestSolutionContainingProblemChanges bestSolutionContainingProblemChanges = bestSolutionHolder.take(); if (bestSolutionContainingProblemChanges != null) { try { - bestSolutionConsumer.accept(bestSolutionContainingProblemChanges.getBestSolution()); + bestSolutionConsumer + .accept(new NewBestSolutionEventImpl<>(bestSolutionContainingProblemChanges.getBestSolution(), + bestSolutionContainingProblemChanges.getProducerId())); bestSolutionContainingProblemChanges.completeProblemChanges(); } catch (Throwable throwable) { if (exceptionHandler != null) { @@ -162,8 +173,9 @@ private CompletableFuture scheduleIntermediateBestSolutionConsumption() { * Don't call without locking firstSolutionConsumption, * because the consumption may not be executed before the final best solution is executed. */ - private void scheduleFirstInitializedSolutionConsumption(Consumer firstInitializedSolutionConsumer) { - scheduleConsumption(firstSolutionConsumption, firstInitializedSolutionConsumer, firstInitializedSolution); + private void scheduleFirstInitializedSolutionConsumption( + Consumer solutionConsumer) { + scheduleConsumption(firstSolutionConsumption, solutionConsumer, firstInitializedSolution); } /** @@ -172,10 +184,14 @@ private void scheduleFirstInitializedSolutionConsumption(Consumer solverJobStartedConsumer.accept(new SolverJobStartedEventImpl<>(solution)), + initialSolution); } - private void scheduleConsumption(Semaphore semaphore, Consumer consumer, Solution_ solution) { + private void scheduleConsumption(Semaphore semaphore, Consumer consumer, + Solution_ solution) { CompletableFuture.runAsync(() -> { try { if (consumer != null && solution != null) { @@ -224,4 +240,18 @@ public void close() { private void disposeConsumerThread() { consumerExecutor.shutdownNow(); } + + record NewBestSolutionEventImpl(Solution_ solution, + EventProducerId producerId) implements NewBestSolutionEvent { + } + + record FirstInitializedSolutionEventImpl(Solution_ solution, EventProducerId producerId, + boolean isTerminatedEarly) implements FirstInitializedSolutionEvent { + } + + record FinalBestSolutionEventImpl(Solution_ solution) implements FinalBestSolutionEvent { + } + + record SolverJobStartedEventImpl(Solution_ solution) implements SolverJobStartedEvent { + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java index 90e469b5fc..78d140182a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJob.java @@ -21,10 +21,13 @@ import ai.timefold.solver.core.api.solver.ProblemSizeStatistics; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.SolverJob; -import ai.timefold.solver.core.api.solver.SolverJobBuilder.FirstInitializedSolutionConsumer; import ai.timefold.solver.core.api.solver.SolverStatus; import ai.timefold.solver.core.api.solver.change.ProblemChange; import ai.timefold.solver.core.api.solver.event.BestSolutionChangedEvent; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.SolverJobStartedEvent; import ai.timefold.solver.core.impl.phase.AbstractPhase; import ai.timefold.solver.core.impl.phase.PossiblyInitializingPhase; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; @@ -49,10 +52,10 @@ public final class DefaultSolverJob implements SolverJob< private final DefaultSolver solver; private final ProblemId_ problemId; private final Function problemFinder; - private final Consumer bestSolutionConsumer; - private final Consumer finalBestSolutionConsumer; - private final FirstInitializedSolutionConsumer firstInitializedSolutionConsumer; - private final Consumer solverJobStartedConsumer; + private final Consumer> bestSolutionConsumer; + private final Consumer> finalBestSolutionConsumer; + private final Consumer> firstInitializedSolutionConsumer; + private final Consumer> solverJobStartedConsumer; private final BiConsumer exceptionHandler; private volatile SolverStatus solverStatus; @@ -68,10 +71,10 @@ public DefaultSolverJob( DefaultSolverManager solverManager, Solver solver, ProblemId_ problemId, Function problemFinder, - Consumer bestSolutionConsumer, - Consumer finalBestSolutionConsumer, - FirstInitializedSolutionConsumer firstInitializedSolutionConsumer, - Consumer solverJobStartedConsumer, + Consumer> bestSolutionConsumer, + Consumer> finalBestSolutionConsumer, + Consumer> firstInitializedSolutionConsumer, + Consumer> solverJobStartedConsumer, BiConsumer exceptionHandler) { this.solverManager = solverManager; this.problemId = problemId; @@ -151,6 +154,7 @@ public Solution_ call() { private void onBestSolutionChangedEvent(BestSolutionChangedEvent bestSolutionChangedEvent) { consumerSupport.consumeIntermediateBestSolution(bestSolutionChangedEvent.getNewBestSolution(), + bestSolutionChangedEvent.getProducerId(), bestSolutionChangedEvent::isEveryProblemChangeProcessed); } @@ -345,6 +349,7 @@ public void phaseEnded(AbstractPhaseScope phaseScope) { // but the consumption is done asynchronously by the Consumer thread. // Only happens if the phase initializes the solution. consumerSupport.consumeFirstInitializedSolution(phaseScope.getWorkingSolution(), + phaseScope.getPhaseId(), possiblyInitializingPhase.getTerminationStatus().early()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJobBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJobBuilder.java index 3f67b5444e..b80e5d162a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJobBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverJobBuilder.java @@ -10,6 +10,10 @@ import ai.timefold.solver.core.api.solver.SolverConfigOverride; import ai.timefold.solver.core.api.solver.SolverJob; import ai.timefold.solver.core.api.solver.SolverJobBuilder; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.SolverJobStartedEvent; import org.jspecify.annotations.NonNull; @@ -22,10 +26,10 @@ public final class DefaultSolverJobBuilder implements Sol private final DefaultSolverManager solverManager; private ProblemId_ problemId; private Function problemFinder; - private Consumer bestSolutionConsumer; - private Consumer finalBestSolutionConsumer; - private FirstInitializedSolutionConsumer initializedSolutionConsumer; - private Consumer solverJobStartedConsumer; + private Consumer> bestSolutionConsumer; + private Consumer> finalBestSolutionConsumer; + private Consumer> initializedSolutionConsumer; + private Consumer> solverJobStartedConsumer; private BiConsumer exceptionHandler; private SolverConfigOverride solverConfigOverride; @@ -48,7 +52,7 @@ public DefaultSolverJobBuilder(DefaultSolverManager solve @Override public @NonNull SolverJobBuilder - withBestSolutionConsumer(@NonNull Consumer bestSolutionConsumer) { + withBestSolutionEventConsumer(@NonNull Consumer> bestSolutionConsumer) { this.bestSolutionConsumer = Objects.requireNonNull(bestSolutionConsumer, "Invalid bestSolutionConsumer (null) given to SolverJobBuilder."); return this; @@ -56,7 +60,8 @@ public DefaultSolverJobBuilder(DefaultSolverManager solve @Override public @NonNull SolverJobBuilder - withFinalBestSolutionConsumer(@NonNull Consumer finalBestSolutionConsumer) { + withFinalBestSolutionEventConsumer( + @NonNull Consumer> finalBestSolutionConsumer) { this.finalBestSolutionConsumer = Objects.requireNonNull(finalBestSolutionConsumer, "Invalid finalBestSolutionConsumer (null) given to SolverJobBuilder."); return this; @@ -64,8 +69,8 @@ public DefaultSolverJobBuilder(DefaultSolverManager solve @Override public @NonNull SolverJobBuilder - withFirstInitializedSolutionConsumer( - @NonNull FirstInitializedSolutionConsumer firstInitializedSolutionConsumer) { + withFirstInitializedSolutionEventConsumer( + @NonNull Consumer> firstInitializedSolutionConsumer) { this.initializedSolutionConsumer = Objects.requireNonNull(firstInitializedSolutionConsumer, "Invalid initializedSolutionConsumer (null) given to SolverJobBuilder."); return this; @@ -73,7 +78,7 @@ public DefaultSolverJobBuilder(DefaultSolverManager solve @Override public SolverJobBuilder - withSolverJobStartedConsumer(Consumer solverJobStartedConsumer) { + withSolverJobStartedEventConsumer(Consumer> solverJobStartedConsumer) { this.solverJobStartedConsumer = Objects.requireNonNull(solverJobStartedConsumer, "Invalid startSolverJobHandler (null) given to SolverJobBuilder."); return this; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverManager.java index 25d7316311..4ef9c25074 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverManager.java @@ -18,10 +18,13 @@ import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.api.solver.SolverJob; import ai.timefold.solver.core.api.solver.SolverJobBuilder; -import ai.timefold.solver.core.api.solver.SolverJobBuilder.FirstInitializedSolutionConsumer; import ai.timefold.solver.core.api.solver.SolverManager; import ai.timefold.solver.core.api.solver.SolverStatus; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.SolverJobStartedEvent; import ai.timefold.solver.core.config.solver.SolverManagerConfig; import ai.timefold.solver.core.config.util.ConfigUtils; @@ -78,10 +81,10 @@ private DefaultSolverJob getSolverJob(ProblemId_ problemI SolverJob solveAndListen(ProblemId_ problemId, Function problemFinder, - Consumer bestSolutionConsumer, - Consumer finalBestSolutionConsumer, - FirstInitializedSolutionConsumer initializedSolutionConsumer, - Consumer solverJobStartedConsumer, + Consumer> bestSolutionConsumer, + Consumer> finalBestSolutionConsumer, + Consumer> initializedSolutionConsumer, + Consumer> solverJobStartedConsumer, BiConsumer exceptionHandler, SolverConfigOverride solverConfigOverride) { if (bestSolutionConsumer == null) { @@ -93,10 +96,10 @@ SolverJob solveAndListen(ProblemId_ problemId, SolverJob solve(ProblemId_ problemId, Function problemFinder, - Consumer bestSolutionConsumer, - Consumer finalBestSolutionConsumer, - FirstInitializedSolutionConsumer initializedSolutionConsumer, - Consumer solverJobStartedConsumer, + Consumer> bestSolutionConsumer, + Consumer> finalBestSolutionConsumer, + Consumer> initializedSolutionConsumer, + Consumer> solverJobStartedConsumer, BiConsumer exceptionHandler, SolverConfigOverride configOverride) { var solver = solverFactory.buildSolver(configOverride); diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java index c9a4997ff4..5eee2c7d89 100644 --- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java @@ -38,7 +38,10 @@ import java.util.stream.IntStream; import ai.timefold.solver.core.api.score.director.ScoreDirector; -import ai.timefold.solver.core.api.solver.SolverJobBuilder.FirstInitializedSolutionConsumer; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.FinalBestSolutionEvent; +import ai.timefold.solver.core.api.solver.event.FirstInitializedSolutionEvent; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; @@ -51,6 +54,7 @@ import ai.timefold.solver.core.impl.solver.DefaultSolverJob; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; +import ai.timefold.solver.core.impl.util.MutableReference; import ai.timefold.solver.core.testdomain.TestdataConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -233,7 +237,7 @@ void exceptionInConsumer() throws InterruptedException { var solverJob1 = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFinalBestSolutionConsumer(bestSolution -> { + .withFinalBestSolutionEventConsumer(bestSolution -> { throw new IllegalStateException("exceptionInConsumer"); }) .withExceptionHandler((problemId, throwable) -> { @@ -260,12 +264,12 @@ void solveGenerics() throws ExecutionException, InterruptedException { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); try (var solverManager = createDefaultSolverManager(solverConfig)) { BiConsumer exceptionHandler = (o1, o2) -> fail("Solving failed."); - Consumer finalBestSolutionConsumer = o -> { + Consumer> finalBestSolutionConsumer = o -> { }; var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFinalBestSolutionConsumer(finalBestSolutionConsumer) + .withFinalBestSolutionEventConsumer(finalBestSolutionConsumer) .withExceptionHandler(exceptionHandler) .run(); solverJob.getFinalBestSolution(); @@ -275,9 +279,9 @@ void solveGenerics() throws ExecutionException, InterruptedException { @Test @Timeout(60) void firstInitializedSolutionConsumerWithDefaultPhases() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // Default configuration var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) @@ -287,19 +291,20 @@ void firstInitializedSolutionConsumerWithDefaultPhases() throws ExecutionExcepti var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isTrue(); + assertThat(initialSolutionEvent.isInitialized()).isTrue(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.constructionHeuristic(0)); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWithSingleCHPhase() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // Only CH var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) @@ -308,20 +313,21 @@ void firstInitializedSolutionConsumerWithSingleCHPhase() throws ExecutionExcepti var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isFalse(); - hasInitializedSolution.setFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); + initialSolutionEvent.isInitializedRef.setFalse(); + assertThat(initialSolutionEvent.producerId()).isNull(); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWithSingleLSPhase() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // Only LS var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) .withPhases(new LocalSearchPhaseConfig()) @@ -334,12 +340,13 @@ void firstInitializedSolutionConsumerWithSingleLSPhase() throws ExecutionExcepti var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(o -> initializedSolution) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) - .withFinalBestSolutionConsumer(ignore -> { + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) + .withFinalBestSolutionEventConsumer(event -> { }) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); + assertThat(initialSolutionEvent.producerId()).isNull(); } } @@ -347,11 +354,12 @@ void firstInitializedSolutionConsumerWithSingleLSPhase() throws ExecutionExcepti @Timeout(60) void firstInitializedSolutionConsumerEarlyTerminatedCH() throws InterruptedException { var consumerCalled = new AtomicBoolean(); - var hasInitializedSolution = new AtomicBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = (ignore, isTerminatedEarly) -> { - consumerCalled.set(true); - hasInitializedSolution.set(!isTerminatedEarly); - }; + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + (event) -> { + consumerCalled.set(true); + initialSolutionEvent.readFromEvent(event); + }; var solverConfig = new SolverConfig() .withSolutionClass(TestdataSolution.class) @@ -373,7 +381,7 @@ void firstInitializedSolutionConsumerEarlyTerminatedCH() throws InterruptedExcep var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(problemFinder) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); try { solverJob.getFinalBestSolution(); @@ -388,7 +396,8 @@ void firstInitializedSolutionConsumerEarlyTerminatedCH() throws InterruptedExcep .hasMessageContaining("needs to start from an initialized solution"); } finally { assertThat(consumerCalled).isTrue(); - assertThat(hasInitializedSolution).isFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.constructionHeuristic(0)); } } } @@ -397,11 +406,12 @@ void firstInitializedSolutionConsumerEarlyTerminatedCH() throws InterruptedExcep @Timeout(60) void firstInitializedSolutionConsumerEarlyTerminatedCHListVar() throws InterruptedException, ExecutionException { var consumerCalled = new AtomicBoolean(); - var hasInitializedSolution = new AtomicBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = (ignore, isTerminatedEarly) -> { - consumerCalled.set(true); - hasInitializedSolution.set(!isTerminatedEarly); - }; + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + (event) -> { + consumerCalled.set(true); + initialSolutionEvent.readFromEvent(event); + }; var solverConfig = new SolverConfig() .withSolutionClass(TestdataAllowsUnassignedValuesListSolution.class) @@ -429,20 +439,21 @@ void firstInitializedSolutionConsumerEarlyTerminatedCHListVar() throws Interrupt var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(problemFinder) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); // LS will start, but terminate immediately. assertThat(consumerCalled).isTrue(); - assertThat(hasInitializedSolution).isFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.constructionHeuristic(0)); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWith2CHAndLS() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // CH - CH - LS var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) @@ -450,30 +461,31 @@ void firstInitializedSolutionConsumerWith2CHAndLS() throws ExecutionException, I new LocalSearchPhaseConfig()) .withTerminationConfig(new TerminationConfig() .withUnimprovedMillisecondsSpentLimit(1L)); - hasInitializedSolution.setFalse(); + initialSolutionEvent.isInitializedRef.setFalse(); try (var solverManager = createDefaultSolverManager(solverConfig)) { var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isTrue(); + assertThat(initialSolutionEvent.isInitialized()).isTrue(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.constructionHeuristic(1)); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWithCustomAndCHAndLS() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // CS - CH - LS var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) .withPhases(new CustomPhaseConfig() .withCustomPhaseCommands((scoreDirector, - isPhaseTerminated) -> assertThat(hasInitializedSolution.booleanValue()).isFalse()), + isPhaseTerminated) -> assertThat(initialSolutionEvent.isInitialized()).isFalse()), new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()) .withTerminationConfig(new TerminationConfig() @@ -482,19 +494,20 @@ void firstInitializedSolutionConsumerWithCustomAndCHAndLS() throws ExecutionExce var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isTrue(); + assertThat(initialSolutionEvent.isInitialized()).isTrue(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.constructionHeuristic(1)); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWithCHAndCustomAndLS() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // CH - CS - LS var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) @@ -503,7 +516,7 @@ void firstInitializedSolutionConsumerWithCHAndCustomAndLS() throws ExecutionExce new CustomPhaseConfig() .withCustomPhaseCommands( (scoreDirector, isPhaseTerminated) -> { - assertThat(hasInitializedSolution.booleanValue()).isFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); }), new LocalSearchPhaseConfig()) .withTerminationConfig(new TerminationConfig() @@ -512,37 +525,39 @@ void firstInitializedSolutionConsumerWithCHAndCustomAndLS() throws ExecutionExce var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isTrue(); + assertThat(initialSolutionEvent.isInitialized()).isTrue(); + assertThat(initialSolutionEvent.producerId()).isEqualTo(EventProducerId.customPhase(1)); } } @Test @Timeout(60) void firstInitializedSolutionConsumerWith2Custom() throws ExecutionException, InterruptedException { - var hasInitializedSolution = new MutableBoolean(); - FirstInitializedSolutionConsumer initializedSolutionConsumer = - (ignore, isTerminatedEarly) -> hasInitializedSolution.setValue(!isTerminatedEarly); + var initialSolutionEvent = new InitialSolutionEvent(); + Consumer> initializedSolutionConsumer = + initialSolutionEvent::readFromEvent; // CS (CH) - CS (LS) var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) .withPhases( new CustomPhaseConfig().withCustomPhaseCommands((scoreDirector, - isPhaseTerminated) -> assertThat(hasInitializedSolution.booleanValue()).isFalse()), + isPhaseTerminated) -> assertThat(initialSolutionEvent.isInitialized()).isFalse()), new CustomPhaseConfig().withCustomPhaseCommands((scoreDirector, - isPhaseTerminated) -> assertThat(hasInitializedSolution.booleanValue()).isFalse())) + isPhaseTerminated) -> assertThat(initialSolutionEvent.isInitialized()).isFalse())) .withTerminationConfig(new TerminationConfig() .withUnimprovedMillisecondsSpentLimit(1L)); try (var solverManager = createDefaultSolverManager(solverConfig)) { var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFirstInitializedSolutionConsumer(initializedSolutionConsumer) + .withFirstInitializedSolutionEventConsumer(initializedSolutionConsumer) .run(); solverJob.getFinalBestSolution(); - assertThat(hasInitializedSolution.booleanValue()).isFalse(); + assertThat(initialSolutionEvent.isInitialized()).isFalse(); + assertThat(initialSolutionEvent.producerId()).isNull(); } } @@ -555,7 +570,7 @@ void testStartJobConsumer() throws ExecutionException, InterruptedException { var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withSolverJobStartedConsumer(solution -> started.increment()) + .withSolverJobStartedEventConsumer(event -> started.increment()) .run(); solverJob.getFinalBestSolution(); assertThat(started.getValue()).isOne(); @@ -730,7 +745,7 @@ void testProblemSizeStatisticsForWaitingJob() throws InterruptedException, Execu var waitingSolverJob = solverManager.solveBuilder() .withProblemId(secondProblemId) .withProblemFinder(id -> PlannerTestUtils.generateTestdataSolution("s2", entityAndValueCount)) - .withBestSolutionConsumer(bestSolution::set) + .withBestSolutionEventConsumer(event -> bestSolution.set(event.solution())) .run(); var problemSizeStatistics = waitingSolverJob.getProblemSizeStatistics(); @@ -766,10 +781,10 @@ void testSolveBuilderForExistingSolvingMethods() { doReturn(solverJobBuilder).when(solverManager).solveBuilder(); doReturn(solverJobBuilder).when(solverJobBuilder).withProblemId(anyLong()); doReturn(solverJobBuilder).when(solverJobBuilder).withProblem(any()); - doReturn(solverJobBuilder).when(solverJobBuilder).withFinalBestSolutionConsumer(any()); + doReturn(solverJobBuilder).when(solverJobBuilder).withFinalBestSolutionEventConsumer(any()); doReturn(solverJobBuilder).when(solverJobBuilder).withExceptionHandler(any()); doReturn(solverJobBuilder).when(solverJobBuilder).withProblemFinder(any()); - doReturn(solverJobBuilder).when(solverJobBuilder).withBestSolutionConsumer(any()); + doReturn(solverJobBuilder).when(solverJobBuilder).withBestSolutionEventConsumer(any()); doCallRealMethod().when(solverManager).solve(any(Long.class), any(TestdataSolution.class)); solverManager.solve(1L, mock(TestdataSolution.class)); @@ -780,14 +795,14 @@ void testSolveBuilderForExistingSolvingMethods() { solverManager.solve(1L, mock(TestdataSolution.class), mock(Consumer.class)); verify(solverJobBuilder, times(2)).withProblemId(anyLong()); verify(solverJobBuilder, times(2)).withProblem(any()); - verify(solverJobBuilder, times(1)).withFinalBestSolutionConsumer(any()); + verify(solverJobBuilder, times(1)).withFinalBestSolutionEventConsumer(any()); doCallRealMethod().when(solverManager).solveAndListen(any(Long.class), any(TestdataSolution.class), any(Consumer.class)); solverManager.solveAndListen(1L, mock(TestdataSolution.class), mock(Consumer.class)); verify(solverJobBuilder, times(3)).withProblemId(anyLong()); verify(solverJobBuilder, times(3)).withProblem(any()); - verify(solverJobBuilder, times(1)).withBestSolutionConsumer(any()); + verify(solverJobBuilder, times(1)).withBestSolutionEventConsumer(any()); } @Test @@ -799,8 +814,8 @@ void solveWithBuilder() throws InterruptedException, BrokenBarrierException { try (var solverManager = createDefaultSolverManager(solverConfig)) { BiConsumer exceptionHandler = (o1, o2) -> fail("Solving failed."); var finalBestSolution = new MutableObject(); - Consumer finalBestConsumer = o -> { - finalBestSolution.setValue(o); + Consumer> finalBestConsumer = event -> { + finalBestSolution.setValue(event.solution()); try { startedBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { @@ -810,7 +825,7 @@ void solveWithBuilder() throws InterruptedException, BrokenBarrierException { solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFinalBestSolutionConsumer(finalBestConsumer) + .withFinalBestSolutionEventConsumer(finalBestConsumer) .withExceptionHandler(exceptionHandler) .run(); @@ -827,8 +842,8 @@ void solveAndListenWithBuilder() throws InterruptedException, BrokenBarrierExcep try (var solverManager = createDefaultSolverManager(solverConfig)) { BiConsumer exceptionHandler = (o1, o2) -> fail("Solving failed."); var finalBestSolution = new MutableObject(); - Consumer finalBestConsumer = o -> { - finalBestSolution.setValue(o); + Consumer> finalBestConsumer = event -> { + finalBestSolution.setValue(event.solution()); try { startedBarrier.await(); } catch (InterruptedException | BrokenBarrierException e) { @@ -836,18 +851,23 @@ void solveAndListenWithBuilder() throws InterruptedException, BrokenBarrierExcep } }; var bestSolution = new MutableObject(); - Consumer bestConsumer = bestSolution::setValue; + var producerId = new MutableObject(); + Consumer> bestConsumer = event -> { + bestSolution.setValue(event.solution()); + producerId.setValue(event.producerId()); + }; solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(DEFAULT_PROBLEM_FINDER) - .withFinalBestSolutionConsumer(finalBestConsumer) - .withBestSolutionConsumer(bestConsumer) + .withFinalBestSolutionEventConsumer(finalBestConsumer) + .withBestSolutionEventConsumer(bestConsumer) .withExceptionHandler(exceptionHandler) .run(); startedBarrier.await(); assertThat(finalBestSolution.getValue()).isNotNull(); assertThat(bestSolution.getValue()).isNotNull(); + assertThat(producerId.getValue()).isNotNull(); } } @@ -896,20 +916,20 @@ void skipAhead() throws ExecutionException, InterruptedException { var solverJob = solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(problemId -> PlannerTestUtils.generateTestdataSolution("s1", 4)) - .withBestSolutionConsumer(bestSolution -> { + .withBestSolutionEventConsumer(event -> { var isFirstReceivedSolution = bestSolutionCount.incrementAndGet() == 1; - if (bestSolution.getEntityList().get(1).getValue() == null) { + if (event.solution().getEntityList().get(1).getValue() == null) { // This best solution may be skipped as well. try { latch.await(); } catch (InterruptedException e) { fail("Latch failed."); } - } else if (bestSolution.getEntityList().get(2).getValue() == null && !isFirstReceivedSolution) { + } else if (event.solution().getEntityList().get(2).getValue() == null && !isFirstReceivedSolution) { fail("No skip ahead occurred: both e2 and e3 are null in a best solution event."); } }) - .withFinalBestSolutionConsumer(finalBestSolution -> { + .withFinalBestSolutionEventConsumer(event -> { finalBestSolutionCount.incrementAndGet(); finalBestSolutionConsumed.countDown(); }) @@ -1045,15 +1065,15 @@ private void assertSolveWithConsumer(int problemCount, SolverManager PlannerTestUtils.generateTestdataSolution(solutionName, 2)) - .withBestSolutionConsumer(consumedBestSolutions::add) - .withFinalBestSolutionConsumer(finalBestSolution -> finalBestSolutionConsumed.countDown()) + .withBestSolutionEventConsumer(event -> consumedBestSolutions.add(event.solution())) + .withFinalBestSolutionEventConsumer(event -> finalBestSolutionConsumed.countDown()) .run()); } else { jobs.add(solverManager.solveBuilder() .withProblemId(id) .withProblemFinder(problemId -> PlannerTestUtils.generateTestdataSolution(solutionName, 2)) - .withFinalBestSolutionConsumer(finalBestSolution -> { - consumedBestSolutions.add(finalBestSolution); + .withFinalBestSolutionEventConsumer(event -> { + consumedBestSolutions.add(event.solution()); finalBestSolutionConsumed.countDown(); }) .run()); @@ -1134,7 +1154,7 @@ void addProblemChange() throws InterruptedException, ExecutionException { solverManager.solveBuilder() .withProblemId(problemId) .withProblemFinder(id -> PlannerTestUtils.generateTestdataSolution("s1", entityAndValueCount)) - .withBestSolutionConsumer(bestSolution::set) + .withBestSolutionEventConsumer(event -> bestSolution.set(event.solution())) .run(); var futureChange = solverManager @@ -1157,7 +1177,7 @@ void addProblemChangeToNonExistingProblem_failsFast() { solverManager.solveBuilder() .withProblemId(1L) .withProblemFinder(id -> PlannerTestUtils.generateTestdataSolution("s1", 4)) - .withBestSolutionConsumer(testdataSolution -> { + .withBestSolutionEventConsumer(event -> { }) .run(); @@ -1198,7 +1218,7 @@ void addProblemChangeToWaitingSolver() throws InterruptedException, ExecutionExc solverManager.solveBuilder() .withProblemId(secondProblemId) .withProblemFinder(id -> PlannerTestUtils.generateTestdataSolution("s2", entityAndValueCount)) - .withBestSolutionConsumer(bestSolution::set) + .withFinalBestSolutionEventConsumer(event -> bestSolution.set(event.solution())) .run(); var futureChange = solverManager @@ -1294,4 +1314,23 @@ void threadFactoryIsUsed() throws ExecutionException, InterruptedException { assertThat(result).isNotNull(); } } + + record InitialSolutionEvent(MutableBoolean isInitializedRef, MutableReference producerIdRef) { + InitialSolutionEvent() { + this(new MutableBoolean(false), new MutableReference<>(null)); + } + + void readFromEvent(FirstInitializedSolutionEvent event) { + isInitializedRef.setValue(!event.isTerminatedEarly()); + producerIdRef.setValue(event.producerId()); + } + + boolean isInitialized() { + return isInitializedRef.getValue(); + } + + EventProducerId producerId() { + return producerIdRef.getValue(); + } + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/BestSolutionHolderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/BestSolutionHolderTest.java index cb97c37fd6..a8d7c2f6ff 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/BestSolutionHolderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/BestSolutionHolderTest.java @@ -1,21 +1,30 @@ package ai.timefold.solver.core.impl.solver; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.from; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import java.util.Comparator; import java.util.List; import java.util.concurrent.CompletableFuture; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.api.solver.event.EventProducerId; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.junit.jupiter.api.Test; import org.mockito.Mockito; class BestSolutionHolderTest { + private static final Comparator IDENTITY_COMPARATOR = (a, b) -> { + if (a == b) { + return 0; + } + return System.identityHashCode(a) < System.identityHashCode(b) ? -1 : 1; + }; @Test void setBestSolution() { @@ -25,17 +34,27 @@ void setBestSolution() { TestdataSolution solution1 = TestdataSolution.generateSolution(); TestdataSolution solution2 = TestdataSolution.generateSolution(); - bestSolutionHolder.set(solution1, () -> true); - assertThat(bestSolutionHolder.take().getBestSolution()).isSameAs(solution1); + bestSolutionHolder.set(solution1, EventProducerId.constructionHeuristic(0), () -> true); + assertThat(bestSolutionHolder.take()) + .usingComparatorForType(IDENTITY_COMPARATOR, TestdataSolution.class) + .returns(solution1, from(BestSolutionContainingProblemChanges::getBestSolution)) + .returns(EventProducerId.constructionHeuristic(0), from(BestSolutionContainingProblemChanges::getProducerId)); assertThat(bestSolutionHolder.take()).isNull(); - bestSolutionHolder.set(solution1, () -> true); - bestSolutionHolder.set(solution2, () -> false); - assertThat(bestSolutionHolder.take().getBestSolution()).isSameAs(solution1); + bestSolutionHolder.set(solution1, EventProducerId.constructionHeuristic(1), () -> true); + bestSolutionHolder.set(solution2, EventProducerId.localSearch(2), () -> false); + assertThat(bestSolutionHolder.take()) + .usingComparatorForType(IDENTITY_COMPARATOR, TestdataSolution.class) + .returns(solution1, from(BestSolutionContainingProblemChanges::getBestSolution)) + .returns(EventProducerId.constructionHeuristic(1), from(BestSolutionContainingProblemChanges::getProducerId)); + + bestSolutionHolder.set(solution1, EventProducerId.customPhase(3), () -> true); + bestSolutionHolder.set(solution2, EventProducerId.customPhase(4), () -> true); + assertThat(bestSolutionHolder.take()) + .usingComparatorForType(IDENTITY_COMPARATOR, TestdataSolution.class) + .returns(solution2, from(BestSolutionContainingProblemChanges::getBestSolution)) + .returns(EventProducerId.customPhase(4), from(BestSolutionContainingProblemChanges::getProducerId)); - bestSolutionHolder.set(solution1, () -> true); - bestSolutionHolder.set(solution2, () -> true); - assertThat(bestSolutionHolder.take().getBestSolution()).isSameAs(solution2); } @Test @@ -43,7 +62,7 @@ void completeProblemChanges() { BestSolutionHolder bestSolutionHolder = new BestSolutionHolder<>(); CompletableFuture problemChange1 = addProblemChange(bestSolutionHolder); - bestSolutionHolder.set(TestdataSolution.generateSolution(), () -> true); + bestSolutionHolder.set(TestdataSolution.generateSolution(), EventProducerId.constructionHeuristic(0), () -> true); CompletableFuture problemChange2 = addProblemChange(bestSolutionHolder); bestSolutionHolder.take().completeProblemChanges(); @@ -51,8 +70,8 @@ void completeProblemChanges() { assertThat(problemChange2).isNotCompleted(); CompletableFuture problemChange3 = addProblemChange(bestSolutionHolder); - bestSolutionHolder.set(TestdataSolution.generateSolution(), () -> true); - bestSolutionHolder.set(TestdataSolution.generateSolution(), () -> true); + bestSolutionHolder.set(TestdataSolution.generateSolution(), EventProducerId.constructionHeuristic(1), () -> true); + bestSolutionHolder.set(TestdataSolution.generateSolution(), EventProducerId.localSearch(2), () -> true); CompletableFuture problemChange4 = addProblemChange(bestSolutionHolder); bestSolutionHolder.take().completeProblemChanges(); @@ -67,7 +86,7 @@ void cancelPendingChanges_noChangesRetrieved() { BestSolutionHolder bestSolutionHolder = new BestSolutionHolder<>(); CompletableFuture problemChange = addProblemChange(bestSolutionHolder); - bestSolutionHolder.set(TestdataSolution.generateSolution(), () -> true); + bestSolutionHolder.set(TestdataSolution.generateSolution(), EventProducerId.constructionHeuristic(0), () -> true); bestSolutionHolder.cancelPendingChanges(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java index 38cbf563c8..7ab5e6d4d1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/ConsumerSupportTest.java @@ -17,6 +17,8 @@ import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.api.solver.event.NewBestSolutionEvent; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.junit.jupiter.api.AfterEach; @@ -41,12 +43,12 @@ void skipAhead() throws InterruptedException { AtomicReference error = new AtomicReference<>(); List consumedSolutions = Collections.synchronizedList(new ArrayList<>()); BestSolutionHolder bestSolutionHolder = new BestSolutionHolder<>(); - consumerSupport = new ConsumerSupport<>(1L, testdataSolution -> { + consumerSupport = new ConsumerSupport<>(1L, event -> { try { consumptionStarted.countDown(); consumptionPaused.await(); - consumedSolutions.add(testdataSolution); - if (testdataSolution.getEntityList().size() == 3) { // The last best solution. + consumedSolutions.add(event.solution()); + if (event.solution().getEntityList().size() == 3) { // The last best solution. consumptionCompleted.countDown(); } } catch (InterruptedException e) { @@ -78,7 +80,7 @@ void problemChangesComplete_afterFinalBestSolutionIsConsumed() throws ExecutionE BestSolutionHolder bestSolutionHolder = new BestSolutionHolder<>(); AtomicReference finalBestSolutionRef = new AtomicReference<>(); consumerSupport = new ConsumerSupport<>(1L, null, - finalBestSolution -> finalBestSolutionRef.set(finalBestSolution), null, null, null, bestSolutionHolder); + event -> finalBestSolutionRef.set(event.solution()), null, null, null, bestSolutionHolder); CompletableFuture futureProblemChange = addProblemChange(bestSolutionHolder); @@ -96,7 +98,7 @@ void problemChangesComplete_afterFinalBestSolutionIsConsumed() throws ExecutionE void problemChangesCompleteExceptionally_afterExceptionInConsumer() { BestSolutionHolder bestSolutionHolder = new BestSolutionHolder<>(); final String errorMessage = "Test exception"; - Consumer errorneousConsumer = bestSolution -> { + Consumer> errorneousConsumer = bestSolution -> { throw new RuntimeException(errorMessage); }; consumerSupport = new ConsumerSupport<>(1L, errorneousConsumer, null, null, null, null, bestSolutionHolder); @@ -136,6 +138,6 @@ private CompletableFuture addProblemChange(BestSolutionHolder true); + consumerSupport.consumeIntermediateBestSolution(bestSolution, EventProducerId.constructionHeuristic(0), () -> true); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/ProblemChangeBarrageIT.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/ProblemChangeBarrageIT.java index 16fcfb9f5f..12a4021dd2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/ProblemChangeBarrageIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/ProblemChangeBarrageIT.java @@ -44,10 +44,10 @@ void problemChangeBarrageIntermediateBestSolutionConsumer() throws InterruptedEx var solverJob = solverManager.solveBuilder() .withProblemId(UUID.randomUUID()) .withProblem(solution) - .withFirstInitializedSolutionConsumer((testdataSolution, isTerminatedEarly) -> { + .withFirstInitializedSolutionEventConsumer(event -> { solverStartedLatch.countDown(); }) - .withBestSolutionConsumer(testdataSolution -> { + .withBestSolutionEventConsumer(event -> { // No need to do anything. }) .run(); diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest-version.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest-version.adoc index de79033a06..e417398c2f 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest-version.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest-version.adoc @@ -61,6 +61,86 @@ We kindly ask Kotlin users to translate the changes accordingly. === Upgrade from 1.27.0 to 1.28.0 +.icon:exclamation-triangle[role=red] `SolverJobBuilder` `with...SolutionConsumer` methods deprecated. +[%collapsible%open] +==== +The `SolverJobBuilder` 's `withBestSolutionConsumer`, `withFinalBestSolutionConsumer`, `withFirstInitializedSolutionConsumer` and `withSolverJobStartedConsumer` methods have been deprecated in favor of the new methods `withBestSolutionEventConsumer`, `withFinalBestSolutionEventConsumer`, `withFirstInitializedSolutionEventConsumer`, and `withSolverJobStartedEventConsumer` respectively. +The new `with...EventConsumer` methods accept their own `...Event` object, with methods to access the best solution and different additional properties depending on the event. +To migrate, change your `Consumer` to accept the event instead of the solution: + +Before in `*.java`: + +[source,java] +---- +SolverManager solverManager = SolverManager.create(solverConfig); + +void runJob(Timetable timetable) { + solverManager.solveBuilder() + .withProblemId(1L) + .withProblem(timetable) + .withSolverJobStartedConsumer(this::onJobStart) + .withFirstInitializedSolutionConsumer(this::onInitializedSolution) + .withBestSolutionConsumer(this::onNewBestSolution) + .withFinalBestSolutionConsumer(this::onFinalBestSolution) + .run(); +} + +void onJobStart(Timetable timetable) { + // ... +} + +void onInitializedSolution(Timetable timetable) { + // ... +} + +void onFinalBestSolution(Timetable timetable) { + // ... +} + +void onJobStart(Timetable timetable) { + // ... +} +---- + +After in `*.java`: + +[source,java] +---- +SolverManager solverManager = SolverManager.create(solverConfig); + +void runJob(Timetable timetable) { + solverManager.solveBuilder() + .withProblemId(1L) + .withProblem(timetable) + .withSolverJobStartedEventConsumer(this::onJobStart) + .withFirstInitializedSolutionEventConsumer(this::onInitializedSolution) + .withBestSolutionEventConsumer(this::onNewBestSolution) + .withFinalBestSolutionEventConsumer(this::onFinalBestSolution) + .run(); +} + +void onJobStart(SolverJobStartedEvent event) { + var timetable = event.solution(); + // ... +} + +void onInitializedSolution(FirstInitializedSolutionEvent event) { + var timetable = event.solution(); + // ... +} + +void onNewBestSolution(NewBestSolutionEvent event) { + var timetable = event.solution(); + // ... +} + +void onFinalBestSolution(FinalBestSolutionEvent event) { + var timetable = event.solution(); + // ... +} +---- +==== + .icon:info-circle[role=yellow] `SelectionSorterWeightFactory` deprecated for removal [%collapsible%open] ==== From 68d58b0581586e21b52e2eea9648c6c0538596b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 8 Nov 2025 07:26:53 +0100 Subject: [PATCH 9/9] Review comments --- .../solver/core/api/solver/SolverJobBuilder.java | 4 ++-- .../ai/timefold/solver/core/impl/phase/PhaseType.java | 6 +++--- .../solver/core/api/solver/SolverManagerTest.java | 11 +++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java index 84393a3b6d..1c7a6d2be6 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/SolverJobBuilder.java @@ -120,8 +120,7 @@ public interface SolverJobBuilder { @NonNull default SolverJobBuilder withFirstInitializedSolutionConsumer(@NonNull Consumer firstInitializedSolutionConsumer) { - return withFirstInitializedSolutionEventConsumer( - (event) -> firstInitializedSolutionConsumer.accept(event.solution())); + return withFirstInitializedSolutionEventConsumer(event -> firstInitializedSolutionConsumer.accept(event.solution())); } /** @@ -157,6 +156,7 @@ SolverJobBuilder withFirstInitializedSolutionEventConsume * * @deprecated Use {@link #withSolverJobStartedEventConsumer(Consumer)} instead. */ + @Deprecated(forRemoval = true, since = "1.28.0") default SolverJobBuilder withSolverJobStartedConsumer(Consumer solverJobStartedConsumer) { return withSolverJobStartedEventConsumer(event -> solverJobStartedConsumer.accept(event.solution())); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java index 2b94cdbb32..a027381577 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java @@ -17,16 +17,16 @@ public enum PhaseType { * @deprecated Deprecated on account of {@link NoChangePhase} having no use. */ @Deprecated(forRemoval = true, since = "1.28.0") - NO_CHANGE("No Change"), + NO_CHANGE("No Change Phase"), /** * The type of phase associated with {@link ConstructionHeuristicPhase}. */ - CONSTRUCTION_HEURISTIC("Construction Heuristics"), + CONSTRUCTION_HEURISTIC("Construction Heuristic"), /** * The type of phase associated with {@link RuinRecreateConstructionHeuristicPhase} */ - RUIN_AND_RECREATE_CONSTRUCTION_HEURISTIC("Ruin & Recreate Construction Heuristics"), + RUIN_AND_RECREATE_CONSTRUCTION_HEURISTIC("Ruin & Recreate Construction Heuristic"), /** * The type of phase associated with {@link LocalSearchPhase}. */ diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java index 5eee2c7d89..94364d9376 100644 --- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolverManagerTest.java @@ -355,11 +355,10 @@ void firstInitializedSolutionConsumerWithSingleLSPhase() throws ExecutionExcepti void firstInitializedSolutionConsumerEarlyTerminatedCH() throws InterruptedException { var consumerCalled = new AtomicBoolean(); var initialSolutionEvent = new InitialSolutionEvent(); - Consumer> initializedSolutionConsumer = - (event) -> { - consumerCalled.set(true); - initialSolutionEvent.readFromEvent(event); - }; + Consumer> initializedSolutionConsumer = event -> { + consumerCalled.set(true); + initialSolutionEvent.readFromEvent(event); + }; var solverConfig = new SolverConfig() .withSolutionClass(TestdataSolution.class) @@ -408,7 +407,7 @@ void firstInitializedSolutionConsumerEarlyTerminatedCHListVar() throws Interrupt var consumerCalled = new AtomicBoolean(); var initialSolutionEvent = new InitialSolutionEvent(); Consumer> initializedSolutionConsumer = - (event) -> { + event -> { consumerCalled.set(true); initialSolutionEvent.readFromEvent(event); };