From 32961e30511ecbee954e527e784f20d2dcce7e84 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 7 Aug 2026 14:58:53 -0300 Subject: [PATCH 01/20] feat: add environment mode per phase --- core/src/build/revapi-differences.json | 11 +++ .../solver/core/config/phase/PhaseConfig.java | 19 +++++ .../TimefoldSolverEnterpriseService.java | 4 +- .../DefaultConstructionHeuristicPhase.java | 15 ++-- ...aultConstructionHeuristicPhaseFactory.java | 12 ++-- .../DefaultExhaustiveSearchPhase.java | 13 ++-- .../DefaultExhaustiveSearchPhaseFactory.java | 29 ++++---- .../AbstractExhaustiveSearchDecider.java | 14 ++-- .../impl/heuristic/HeuristicConfigPolicy.java | 10 +-- ...eateConstructionHeuristicPhaseBuilder.java | 7 +- ...eateConstructionHeuristicPhaseFactory.java | 6 +- .../localsearch/DefaultLocalSearchPhase.java | 13 ++-- .../DefaultLocalSearchPhaseFactory.java | 69 ++++++++++--------- .../decider/acceptor/AcceptorFactory.java | 38 +++++----- .../acceptor/tabu/AbstractTabuAcceptor.java | 8 ++- .../DefaultPartitionedSearchPhaseFactory.java | 3 +- .../solver/core/impl/phase/AbstractPhase.java | 14 +++- .../core/impl/phase/AbstractPhaseFactory.java | 5 ++ .../AbstractPossiblyInitializingPhase.java | 9 +-- .../solver/core/impl/phase/Phase.java | 2 + .../impl/phase/custom/DefaultCustomPhase.java | 14 ++-- .../custom/DefaultCustomPhaseFactory.java | 7 +- .../core/impl/solver/DefaultSolver.java | 11 +-- .../solver/recaller/BestSolutionRecaller.java | 19 ++--- .../recaller/BestSolutionRecallerFactory.java | 8 +-- core/src/main/resources/solver.xsd | 2 + .../decider/acceptor/AcceptorFactoryTest.java | 26 ++++--- .../impl/neighborhood/NeighborhoodsTest.java | 6 +- .../core/impl/solver/DefaultSolverTest.java | 43 ++++++++++++ .../src/main/resources/benchmark.xsd | 3 + 30 files changed, 273 insertions(+), 167 deletions(-) diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index e0b9f8792e9..3a001e47cc1 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -51,6 +51,17 @@ "old": "method void ai.timefold.solver.core.api.solver.ProblemSizeStatistics::(long, long, long, double)", "new": "method void ai.timefold.solver.core.api.solver.ProblemSizeStatistics::(long, java.util.SequencedMap, java.lang.Long>, long, long, java.util.SequencedMap, java.util.SequencedMap>, double)", "justification": "Type is not supposed to be constructed by user; safe." + }, + { + "ignore": true, + "code": "java.annotation.attributeValueChanged", + "old": "class ai.timefold.solver.core.config.phase.PhaseConfig>", + "new": "class ai.timefold.solver.core.config.phase.PhaseConfig>", + "annotationType": "jakarta.xml.bind.annotation.XmlType", + "attribute": "propOrder", + "oldValue": "{\"terminationConfig\"}", + "newValue": "{\"environmentMode\", \"terminationConfig\"}", + "justification": "Environment mode per phase" } ] } diff --git a/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java index f4d46e5b8cd..151b813b720 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java @@ -10,6 +10,7 @@ import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.config.util.ConfigUtils; @@ -24,6 +25,7 @@ PartitionedSearchPhaseConfig.class }) @XmlType(propOrder = { + "environmentMode", "terminationConfig" }) public abstract class PhaseConfig> extends AbstractConfig { @@ -31,6 +33,9 @@ public abstract class PhaseConfig> extends // Warning: all fields are null (and not defaulted) because they can be inherited // and also because the input config file should match the output config file + // Per phase environment + protected EnvironmentMode environmentMode = null; + @XmlElement(name = "termination") protected TerminationConfig terminationConfig = null; @@ -38,6 +43,14 @@ public abstract class PhaseConfig> extends // Constructors and simple getters/setters // ************************************************************************ + public EnvironmentMode getEnvironmentMode() { + return environmentMode; + } + + public void setEnvironmentMode(EnvironmentMode environmentMode) { + this.environmentMode = environmentMode; + } + public @Nullable TerminationConfig getTerminationConfig() { return terminationConfig; } @@ -50,6 +63,11 @@ public void setTerminationConfig(@Nullable TerminationConfig terminationConfig) // With methods // ************************************************************************ + public @NonNull Config_ withEnvironmentMode(@NonNull EnvironmentMode environmentMode) { + this.setEnvironmentMode(environmentMode); + return (Config_) this; + } + public @NonNull Config_ withTerminationConfig(@NonNull TerminationConfig terminationConfig) { this.setTerminationConfig(terminationConfig); return (Config_) this; @@ -57,6 +75,7 @@ public void setTerminationConfig(@Nullable TerminationConfig terminationConfig) @Override public @NonNull Config_ inherit(@NonNull Config_ inheritedConfig) { + environmentMode = ConfigUtils.inheritOverwritableProperty(environmentMode, inheritedConfig.getEnvironmentMode()); terminationConfig = ConfigUtils.inheritConfig(terminationConfig, inheritedConfig.getTerminationConfig()); return (Config_) this; } diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 1434aaf5edf..68f7a14840e 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -196,8 +196,8 @@ LocalSearchDecider buildLocalSearch(int moveThreadCount, EnvironmentMode environmentMode, HeuristicConfigPolicy configPolicy); PartitionedSearchPhase buildPartitionedSearch(int phaseIndex, - PartitionedSearchPhaseConfig phaseConfig, HeuristicConfigPolicy solverConfigPolicy, - SolverTermination solverTermination, + PartitionedSearchPhaseConfig phaseConfig, EnvironmentMode environmentMode, + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, BiFunction, SolverTermination, PhaseTermination> phaseTerminationFunction); EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, 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 cae734d261f..b947342442d 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 @@ -191,11 +191,12 @@ public void phaseEnded(ConstructionHeuristicPhaseScope phaseScope) { if (decider.isLoggingEnabled() && logger.isInfoEnabled()) { logger.info( """ - {}Construction Heuristic phase ({}) ended: time spent ({}), best score ({}), \ + {}Construction Heuristic phase ({}) ended: time spent ({}), environment mode ({}), best score ({}), \ {}move evaluation speed ({}/sec), step total ({}).""", logIndentation, phaseIndex, phaseScope.calculateSolverTimeMillisSpentUpToNow(), + environmentMode.name(), phaseScope.getBestScore().raw(), // Multithreaded solving uses "effective" move evaluation speed, since not all evaluated moves // are foraged @@ -232,17 +233,17 @@ public static class DefaultConstructionHeuristicPhaseBuilder private final EntityPlacer entityPlacer; private final ConstructionHeuristicDecider decider; - public DefaultConstructionHeuristicPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, String logIndentation, - PhaseTermination phaseTermination, EntityPlacer entityPlacer, - ConstructionHeuristicDecider decider) { - super(phaseIndex, lastInitializingPhase, logIndentation, phaseTermination); + public DefaultConstructionHeuristicPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, + EnvironmentMode environmentMode, String logIndentation, PhaseTermination phaseTermination, + EntityPlacer entityPlacer, ConstructionHeuristicDecider decider) { + super(phaseIndex, lastInitializingPhase, environmentMode, logIndentation, phaseTermination); this.entityPlacer = entityPlacer; this.decider = decider; } @Override - public DefaultConstructionHeuristicPhaseBuilder enableAssertions(EnvironmentMode environmentMode) { - super.enableAssertions(environmentMode); + public DefaultConstructionHeuristicPhaseBuilder enableAssertions() { + super.enableAssertions(); return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java index 67aec712c0d..f81fd9e3648 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java @@ -17,6 +17,7 @@ import ai.timefold.solver.core.config.heuristic.selector.move.composite.UnionMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase.DefaultConstructionHeuristicPhaseBuilder; @@ -69,10 +70,11 @@ protected DefaultConstructionHeuristicPhaseBuilder createBuilder( HeuristicConfigPolicy phaseConfigPolicy, SolverTermination solverTermination, int phaseIndex, boolean lastInitializingPhase, EntityPlacer entityPlacer) { var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); - return new DefaultConstructionHeuristicPhaseBuilder<>(phaseIndex, lastInitializingPhase, + var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); + return new DefaultConstructionHeuristicPhaseBuilder<>(phaseIndex, lastInitializingPhase, environmentMode, phaseConfigPolicy.getLogIndentation(), phaseTermination, entityPlacer, - buildDecider(phaseConfigPolicy, phaseTermination)) - .enableAssertions(phaseConfigPolicy.getEnvironmentMode()); + buildDecider(phaseConfigPolicy, environmentMode, phaseTermination)) + .enableAssertions(); } @Override @@ -158,14 +160,14 @@ public static EntityPlacerConfig buildListVariableQueuedValuePlacerConfig(Heuris } protected ConstructionHeuristicDecider buildDecider(HeuristicConfigPolicy configPolicy, - PhaseTermination termination) { + EnvironmentMode environmentMode, PhaseTermination termination) { var forager = buildForager(configPolicy); var moveThreadCount = configPolicy.getMoveThreadCount(); var decider = (moveThreadCount == null) ? new ConstructionHeuristicDecider<>(configPolicy.getLogIndentation(), termination, forager) : TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.MULTITHREADED_SOLVING) .buildConstructionHeuristic(termination, forager, configPolicy); - decider.enableAssertions(configPolicy.getEnvironmentMode()); + decider.enableAssertions(environmentMode); return decider; } 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 419903bcdbf..945bf0707e6 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 @@ -101,11 +101,12 @@ private void phaseEnded(ExhaustiveSearchPhaseScope phaseScope) { decider.phaseEnded(phaseScope); phaseScope.endingNow(); logger.info(""" - {}Exhaustive Search phase ({}) ended: time spent ({}), best score ({}),\ + {}Exhaustive Search phase ({}) ended: time spent ({}), environment mode ({}), best score ({}),\ move evaluation speed ({}/sec), step total ({}).""", logIndentation, phaseIndex, phaseScope.calculateSolverTimeMillisSpentUpToNow(), + environmentMode.name(), phaseScope.getBestScore().raw(), phaseScope.getPhaseMoveEvaluationSpeed(), phaseScope.getNextStepIndex()); @@ -141,17 +142,17 @@ public static class Builder extends AbstractPhaseBuilder { private boolean assertWorkingSolutionScoreFromScratch = false; private boolean assertExpectedWorkingSolutionScore = false; - public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, - Comparator> nodeComparator, + public Builder(int phaseIndex, EnvironmentMode environmentMode, String logIndentation, + PhaseTermination phaseTermination, Comparator> nodeComparator, AbstractExhaustiveSearchDecider> decider) { - super(phaseIndex, logIndentation, phaseTermination); + super(phaseIndex, environmentMode, logIndentation, phaseTermination); this.nodeComparator = nodeComparator; this.decider = decider; } @Override - public Builder enableAssertions(EnvironmentMode environmentMode) { - super.enableAssertions(environmentMode); + public Builder enableAssertions() { + super.enableAssertions(); assertWorkingSolutionScoreFromScratch = environmentMode.isFullyAsserted(); assertExpectedWorkingSolutionScore = environmentMode.isIntrusivelyAsserted(); return this; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java index 9a44c060f06..d0269da2004 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java @@ -70,19 +70,20 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); var scoreBounderEnabled = exhaustiveSearchType.isScoreBounderEnabled(); var nodeExplorationType = getNodeExplorationType(exhaustiveSearchType, phaseConfig); + var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); AbstractExhaustiveSearchDecider> decider; if (isMixedModel) { var basicVarEntitySelectorConfig = buildEntitySelectorConfig(phaseConfigPolicy, false); var basicVarEntitySelector = EntitySelectorFactory. create(basicVarEntitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); var basicVarDecider = - buildDecider(phaseConfigPolicy, basicVarEntitySelector, bestSolutionRecaller, phaseTermination, - scoreBounderEnabled, false); + buildDecider(phaseConfigPolicy, basicVarEntitySelector, bestSolutionRecaller, environmentMode, + phaseTermination, scoreBounderEnabled, false); var listVarEntitySelectorConfig = buildEntitySelectorConfig(phaseConfigPolicy, true); var listVarEntitySelector = EntitySelectorFactory. create(listVarEntitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - var listVarDecider = buildDecider(phaseConfigPolicy, listVarEntitySelector, bestSolutionRecaller, phaseTermination, - scoreBounderEnabled, true); + var listVarDecider = buildDecider(phaseConfigPolicy, listVarEntitySelector, bestSolutionRecaller, environmentMode, + phaseTermination, scoreBounderEnabled, true); decider = new MixedVariableExhaustiveSearchDecider<>(basicVarDecider, listVarDecider); } else { var isListVariable = solverConfigPolicy.getSolutionDescriptor().getListVariableDescriptor() != null; @@ -90,12 +91,12 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI var entitySelector = EntitySelectorFactory. create(entitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - decider = buildDecider(phaseConfigPolicy, entitySelector, bestSolutionRecaller, phaseTermination, + decider = buildDecider(phaseConfigPolicy, entitySelector, bestSolutionRecaller, environmentMode, phaseTermination, scoreBounderEnabled, isListVariable); } - return new DefaultExhaustiveSearchPhase.Builder<>(phaseIndex, solverConfigPolicy.getLogIndentation(), phaseTermination, - nodeExplorationType.buildNodeComparator(scoreBounderEnabled), decider) - .enableAssertions(phaseConfigPolicy.getEnvironmentMode()).build(); + return new DefaultExhaustiveSearchPhase.Builder<>(phaseIndex, environmentMode, solverConfigPolicy.getLogIndentation(), + phaseTermination, nodeExplorationType.buildNodeComparator(scoreBounderEnabled), decider) + .enableAssertions().build(); } private static NodeExplorationType getNodeExplorationType(ExhaustiveSearchType exhaustiveSearchType, @@ -158,8 +159,8 @@ protected EntityDescriptor deduceEntityDescriptor(SolutionDescriptor< private AbstractExhaustiveSearchDecider> buildDecider( HeuristicConfigPolicy configPolicy, EntitySelector sourceEntitySelector, - BestSolutionRecaller bestSolutionRecaller, PhaseTermination termination, - boolean scoreBounderEnabled, boolean isListVariable) { + BestSolutionRecaller bestSolutionRecaller, EnvironmentMode environmentMode, + PhaseTermination termination, boolean scoreBounderEnabled, boolean isListVariable) { var manualEntityMimicRecorder = new ManualEntityMimicRecorder<>(sourceEntitySelector); var entityClassName = sourceEntitySelector.getEntityDescriptor().getEntityClass().getName(); var mimicSelectorId = ConfigUtils.addRandomSuffix(entityClassName, configPolicy.getRandom().factoryUsage()); @@ -200,13 +201,7 @@ protected EntityDescriptor deduceEntityDescriptor(SolutionDescriptor< new MoveSelectorBasedMoveRepository<>(moveSelector), scoreBounderEnabled, scoreBounder); } - EnvironmentMode environmentMode = configPolicy.getEnvironmentMode(); - if (environmentMode.isFullyAsserted()) { - decider.setAssertMoveScoreFromScratch(true); - } - if (environmentMode.isIntrusivelyAsserted()) { - decider.setAssertExpectedUndoMoveScore(true); - } + decider.enableAssertions(environmentMode); return decider; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/AbstractExhaustiveSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/AbstractExhaustiveSearchDecider.java index ac5bd431722..93e35bdbf8c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/AbstractExhaustiveSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/AbstractExhaustiveSearchDecider.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.exhaustivesearch.event.ExhaustiveSearchPhaseLifecycleListener; import ai.timefold.solver.core.impl.exhaustivesearch.node.ExhaustiveSearchLayer; import ai.timefold.solver.core.impl.exhaustivesearch.node.ExhaustiveSearchNode; @@ -57,19 +58,16 @@ public abstract sealed class AbstractExhaustiveSearchDecider getScoreBounder() { return (ScoreBounder) scoreBounder; } - public void setAssertMoveScoreFromScratch(boolean assertMoveScoreFromScratch) { - this.assertMoveScoreFromScratch = assertMoveScoreFromScratch; - } - - public void setAssertExpectedUndoMoveScore(boolean assertExpectedUndoMoveScore) { - this.assertExpectedUndoMoveScore = assertExpectedUndoMoveScore; - } - protected void enableAcceptUninitializedSolutions() { acceptUninitializedSolutions = true; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java index a40baa67dbc..79f0d8ad4f2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java @@ -130,15 +130,15 @@ public Builder cloneBuilder() { return new Builder() .withPreviewFeatureSet(previewFeatureSet) .withEnvironmentMode(environmentMode) + .withLogIndentation(logIndentation) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(moveThreadBufferSize) .withThreadFactoryClass(threadFactoryClass) - .withNearbyDistanceMeterClass(nearbyDistanceMeterClass) - .withRandom(random) .withInitializingScoreTrend(initializingScoreTrend) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(classInstanceCache) - .withLogIndentation(logIndentation); + .withNearbyDistanceMeterClass(nearbyDistanceMeterClass) + .withRandom(random); } public HeuristicConfigPolicy copyConfigPolicy() { @@ -150,7 +150,7 @@ public HeuristicConfigPolicy copyConfigPolicy() { .build(); } - public HeuristicConfigPolicy createPhaseConfigPolicy() { + public HeuristicConfigPolicy copyPhaseConfigPolicy() { return cloneBuilder().build(); } @@ -160,7 +160,7 @@ public HeuristicConfigPolicy copyConfigPolicyWithoutNearbySetting() { .build(); } - public HeuristicConfigPolicy createChildThreadConfigPolicy(ChildThreadType childThreadType) { + public HeuristicConfigPolicy copyChildThreadConfigPolicy() { return cloneBuilder() .withLogIndentation(logIndentation + " ") .build(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java index 09fdfca8711..51c9bb538cd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java @@ -55,7 +55,8 @@ public static RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseFactory, PhaseTermination phaseTermination, EntityPlacer entityPlacer, ConstructionHeuristicDecider decider) { - super(0, false, "", phaseTermination, entityPlacer, decider); + // The R&R uses the root solver environment mode by default + super(0, false, configPolicy.getEnvironmentMode(), "", phaseTermination, entityPlacer, decider); this.configPolicy = configPolicy; this.constructionHeuristicPhaseFactory = constructionHeuristicPhaseFactory; this.phaseTermination = phaseTermination; @@ -73,7 +74,9 @@ public static RuinRecreateConstructionHeuristicPhaseBuilder(configPolicy, constructionHeuristicPhaseFactory, phaseTermination, super.getEntityPlacer().copy(), - constructionHeuristicPhaseFactory.buildDecider(configPolicy, phaseTermination)); + // The R&R decider uses the root solver environment mode by default + constructionHeuristicPhaseFactory.buildDecider(configPolicy, configPolicy.getEnvironmentMode(), + phaseTermination)); } return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java index fe3d81a17f4..c96d0dbbc13 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacer; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; @@ -20,14 +21,15 @@ protected RuinRecreateConstructionHeuristicPhaseBuilder createBuilder HeuristicConfigPolicy phaseConfigPolicy, SolverTermination solverTermination, int phaseIndex, boolean lastInitializingPhase, EntityPlacer entityPlacer) { var phaseTermination = PhaseTermination.bridge(new BasicPlumbingTermination(false)); + // The R&R decider uses the root solver environment mode by default return new RuinRecreateConstructionHeuristicPhaseBuilder<>(phaseConfigPolicy, this, phaseTermination, entityPlacer, - buildDecider(phaseConfigPolicy, phaseTermination)); + buildDecider(phaseConfigPolicy, phaseConfigPolicy.getEnvironmentMode(), phaseTermination)); } @Override protected RuinRecreateConstructionHeuristicDecider buildDecider(HeuristicConfigPolicy configPolicy, - PhaseTermination termination) { + EnvironmentMode environmentMode, PhaseTermination termination) { return new RuinRecreateConstructionHeuristicDecider<>(termination, buildForager(configPolicy)); } 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 c3bfe0dbd95..330c53e4b60 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 @@ -228,11 +228,12 @@ public void phaseEnded(LocalSearchPhaseScope phaseScope) { decider.phaseEnded(phaseScope); phaseScope.endingNow(); logger.info(""" - {}Local Search phase ({}) ended: time spent ({}), best score ({}), \ + {}Local Search phase ({}) ended: time spent ({}), environment mode ({}), best score ({}), \ {}move evaluation speed ({}/sec), step total ({}).""", logIndentation, phaseIndex, phaseScope.calculateSolverTimeMillisSpentUpToNow(), + environmentMode.name(), phaseScope.getBestScore().raw(), // Multithreaded solving uses "effective" move evaluation speed, since not all evaluated moves // are foraged @@ -257,15 +258,15 @@ public static class Builder extends AbstractPhaseBuilder { private final LocalSearchDecider decider; - public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, - LocalSearchDecider decider) { - super(phaseIndex, logIndentation, phaseTermination); + public Builder(int phaseIndex, EnvironmentMode environmentMode, String logIndentation, + PhaseTermination phaseTermination, LocalSearchDecider decider) { + super(phaseIndex, environmentMode, logIndentation, phaseTermination); this.decider = decider; } @Override - public Builder enableAssertions(EnvironmentMode environmentMode) { - super.enableAssertions(environmentMode); + public Builder enableAssertions() { + super.enableAssertions(); return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java index 5b8c95be25a..8a0d869c4c9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java @@ -21,6 +21,7 @@ import ai.timefold.solver.core.config.localsearch.decider.acceptor.LocalSearchAcceptorConfig; import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchForagerConfig; import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchPickEarlyType; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; @@ -61,16 +62,17 @@ public DefaultLocalSearchPhaseFactory(LocalSearchPhaseConfig phaseConfig) { public LocalSearchPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.createPhaseConfigPolicy(); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(); var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); - var decider = buildDecider(phaseConfigPolicy, phaseTermination); - return new DefaultLocalSearchPhase.Builder<>(phaseIndex, solverConfigPolicy.getLogIndentation(), phaseTermination, - decider).enableAssertions(phaseConfigPolicy.getEnvironmentMode()).build(); + var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); + var decider = buildDecider(phaseConfigPolicy, environmentMode, phaseTermination); + return new DefaultLocalSearchPhase.Builder<>(phaseIndex, environmentMode, solverConfigPolicy.getLogIndentation(), + phaseTermination, decider).enableAssertions().build(); } @SuppressWarnings({ "unchecked", "rawtypes" }) private LocalSearchDecider buildDecider(HeuristicConfigPolicy phaseConfigPolicy, - PhaseTermination phaseTermination) { + EnvironmentMode environmentMode, PhaseTermination phaseTermination) { var neighborhoodsEnabled = phaseConfigPolicy.isPreviewFeatureEnabled(PreviewFeature.NEIGHBORHOODS); var neighborhoodProviderClass = phaseConfig. getNeighborhoodProviderClass(); if (neighborhoodsEnabled) { @@ -96,33 +98,34 @@ The neighborhoodProviderClass (%s) can only be used if the %s preview feature is var moveSelectorConfig = phaseConfig.getMoveSelectorConfig(); if (moveSelectorConfig != null) { if (neighborhoodsEnabled) { - return buildMixedDecider(phaseConfigPolicy, phaseTermination, neighborhoodProviderClass); + return buildMixedDecider(phaseConfigPolicy, environmentMode, phaseTermination, neighborhoodProviderClass); } else { - return buildMoveSelectorBasedDecider(phaseConfigPolicy, phaseTermination); + return buildMoveSelectorBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination); } } else if (neighborhoodsEnabled) { - return buildNeighborhoodsBasedDecider(phaseConfigPolicy, phaseTermination, neighborhoodProviderClass); + return buildNeighborhoodsBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination, + neighborhoodProviderClass); } else { // The default branch; for now, it is move selectors. - return buildMoveSelectorBasedDecider(phaseConfigPolicy, phaseTermination); + return buildMoveSelectorBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination); } } private LocalSearchDecider buildMoveSelectorBasedDecider(HeuristicConfigPolicy configPolicy, - PhaseTermination termination) { + EnvironmentMode environmentMode, PhaseTermination termination) { var moveRepository = new MoveSelectorBasedMoveRepository<>(buildMoveSelector(configPolicy, false)); - return buildDecider(moveRepository, configPolicy, termination); + return buildDecider(moveRepository, configPolicy, environmentMode, termination); } private LocalSearchDecider buildNeighborhoodsBasedDecider(HeuristicConfigPolicy configPolicy, - PhaseTermination termination, + EnvironmentMode environmentMode, PhaseTermination termination, Class> neighborhoodProviderClass) { - return buildDecider(buildNeighborhoodsBasedMoveRepository(configPolicy, neighborhoodProviderClass), configPolicy, - termination); + return buildDecider(buildNeighborhoodsBasedMoveRepository(configPolicy, environmentMode, neighborhoodProviderClass), + configPolicy, environmentMode, termination); } @SuppressWarnings("unchecked") private NeighborhoodsBasedMoveRepository buildNeighborhoodsBasedMoveRepository( - HeuristicConfigPolicy configPolicy, + HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, Class> neighborhoodProviderClass) { if (phaseConfig.getLocalSearchType() == LocalSearchType.VARIABLE_NEIGHBORHOOD_DESCENT) { throw new IllegalArgumentException( @@ -139,30 +142,33 @@ The localSearchType (%s) does not support the Neighborhoods API. "neighborhoodProviderClass", neighborhoodProviderClass); var solutionDescriptor = configPolicy.getSolutionDescriptor(); var neighborhoodBuilder = new DefaultNeighborhoodBuilder<>(solutionDescriptor.getMetaModel()); - var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, configPolicy.getEnvironmentMode()); + var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, environmentMode); return new NeighborhoodsBasedMoveRepository<>(moveStreamFactory, ((DefaultNeighborhood) neighborhoodProvider.defineNeighborhood(neighborhoodBuilder)) .getMoveProviderList()); } private LocalSearchDecider buildMixedDecider(HeuristicConfigPolicy configPolicy, - PhaseTermination termination, + EnvironmentMode environmentMode, PhaseTermination termination, Class> neighborhoodProviderClass) { var legacyMoveSelector = buildMoveSelector(configPolicy, neighborhoodProviderClass != null); if (legacyMoveSelector == null) { // There were no move selectors configured. - return buildNeighborhoodsBasedDecider(configPolicy, termination, neighborhoodProviderClass); + return buildNeighborhoodsBasedDecider(configPolicy, environmentMode, termination, neighborhoodProviderClass); } var neighborhoodsMoveSelector = - new NeighborhoodsMoveSelector<>(buildNeighborhoodsBasedMoveRepository(configPolicy, neighborhoodProviderClass)); + new NeighborhoodsMoveSelector<>( + buildNeighborhoodsBasedMoveRepository(configPolicy, environmentMode, neighborhoodProviderClass)); var moveSelector = new MixedMoveSelector<>(legacyMoveSelector, neighborhoodsMoveSelector); var moveRepository = new MoveSelectorBasedMoveRepository<>(moveSelector); - return buildDecider(moveRepository, configPolicy, termination); + return buildDecider(moveRepository, configPolicy, environmentMode, termination); } private LocalSearchDecider buildDecider(MoveRepository moveRepository, - HeuristicConfigPolicy configPolicy, PhaseTermination termination) { - var acceptor = buildAcceptor(configPolicy, moveRepository instanceof NeighborhoodsBasedMoveRepository); - var forager = buildForager(configPolicy); + HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, + PhaseTermination termination) { + var acceptor = buildAcceptor(configPolicy, environmentMode, + moveRepository instanceof NeighborhoodsBasedMoveRepository); + var forager = buildForager(); if (moveRepository.isNeverEnding() && !forager.supportsNeverEndingMoveSelector()) { throw new IllegalStateException(""" The move repository (%s) is neverEnding (%s), but the forager (%s) does not support it. @@ -170,7 +176,6 @@ The move repository (%s) is neverEnding (%s), but the forager (%s) does not supp moveRepository.isNeverEnding(), forager)); } var moveThreadCount = configPolicy.getMoveThreadCount(); - var environmentMode = configPolicy.getEnvironmentMode(); var decider = moveThreadCount == null ? new LocalSearchDecider<>(configPolicy.getLogIndentation(), termination, moveRepository, acceptor, forager) : TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.MULTITHREADED_SOLVING) @@ -180,7 +185,8 @@ The move repository (%s) is neverEnding (%s), but the forager (%s) does not supp return decider; } - protected Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, boolean neighborhoodsEnabled) { + protected Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, + boolean neighborhoodsEnabled) { var acceptorConfig = phaseConfig.getAcceptorConfig(); var localSearchType = phaseConfig.getLocalSearchType(); if (acceptorConfig != null) { @@ -189,7 +195,7 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con "The localSearchType (%s) must not be configured if the acceptorConfig (%s) is explicitly configured." .formatted(localSearchType, acceptorConfig)); } - return buildAcceptor(acceptorConfig, configPolicy); + return buildAcceptor(acceptorConfig, configPolicy, environmentMode); } else { var localSearchType_ = Objects.requireNonNullElse(localSearchType, LocalSearchType.LATE_ACCEPTANCE); var acceptorConfig_ = new LocalSearchAcceptorConfig(); @@ -220,11 +226,11 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con } private Acceptor buildAcceptor(LocalSearchAcceptorConfig acceptorConfig, - HeuristicConfigPolicy configPolicy) { - return AcceptorFactory. create(acceptorConfig).buildAcceptor(configPolicy); + HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode) { + return AcceptorFactory. create(acceptorConfig).buildAcceptor(configPolicy, environmentMode); } - protected LocalSearchForager buildForager(HeuristicConfigPolicy configPolicy) { + protected LocalSearchForager buildForager() { LocalSearchForagerConfig foragerConfig_; if (phaseConfig.getForagerConfig() != null) { if (phaseConfig.getLocalSearchType() != null) { @@ -245,10 +251,7 @@ protected LocalSearchForager buildForager(HeuristicConfigPolicy buildAcceptor(HeuristicConfigPolicy configPolicy) { + public Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode) { List> acceptorList = Stream.of( buildHillClimbingAcceptor(), buildStepCountingHillClimbingAcceptor(), - buildEntityTabuAcceptor(configPolicy), - buildValueTabuAcceptor(configPolicy), - buildMoveTabuAcceptor(configPolicy), + buildEntityTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), + buildValueTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), + buildMoveTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), buildSimulatedAnnealingAcceptor(configPolicy), buildLateAcceptanceAcceptor(), buildDiversifiedLateAcceptanceAcceptor(configPolicy), @@ -93,7 +94,8 @@ private Optional> buildStepCountingH return Optional.empty(); } - private Optional> buildEntityTabuAcceptor(HeuristicConfigPolicy configPolicy) { + private Optional> buildEntityTabuAcceptor(EnvironmentMode environmentMode, + String logIndentation) { var entityTabuSize = acceptorConfig.getEntityTabuSize(); var entityTabuRatio = acceptorConfig.getEntityTabuRatio(); var fadingEntityTabuSize = acceptorConfig.getFadingEntityTabuSize(); @@ -101,7 +103,7 @@ private Optional> buildEntityTabuAcceptor(Heuristi if (acceptorTypeListsContainsAcceptorType(AcceptorType.ENTITY_TABU) || entityTabuSize != null || entityTabuRatio != null || fadingEntityTabuSize != null || fadingEntityTabuRatio != null) { - var acceptor = new EntityTabuAcceptor(configPolicy.getLogIndentation()); + var acceptor = new EntityTabuAcceptor(logIndentation); if (entityTabuSize != null) { if (entityTabuRatio != null) { throw new IllegalArgumentException( @@ -124,15 +126,14 @@ private Optional> buildEntityTabuAcceptor(Heuristi } else if (fadingEntityTabuRatio != null) { acceptor.setFadingTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(fadingEntityTabuRatio)); } - if (configPolicy.getEnvironmentMode().isFullyAsserted()) { - acceptor.setAssertTabuHashCodeCorrectness(true); - } + acceptor.enableAssertions(environmentMode); return Optional.of(acceptor); } return Optional.empty(); } - private Optional> buildValueTabuAcceptor(HeuristicConfigPolicy configPolicy) { + private Optional> buildValueTabuAcceptor(EnvironmentMode environmentMode, + String logIndentation) { var valueTabuSize = acceptorConfig.getValueTabuSize(); var fadingValueTabuSize = acceptorConfig.getFadingValueTabuSize(); if (acceptorTypeListsContainsAcceptorType(AcceptorType.VALUE_TABU) @@ -142,27 +143,26 @@ private Optional> buildValueTabuAcceptor(HeuristicC "The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured." .formatted(AcceptorType.VALUE_TABU)); } - var acceptor = new ValueTabuAcceptor(configPolicy.getLogIndentation()); - configureFixedSizeTabuAcceptor(acceptor, configPolicy, valueTabuSize, fadingValueTabuSize); + var acceptor = new ValueTabuAcceptor(logIndentation); + configureFixedSizeTabuAcceptor(acceptor, environmentMode, valueTabuSize, fadingValueTabuSize); return Optional.of(acceptor); } return Optional.empty(); } private static void configureFixedSizeTabuAcceptor(AbstractTabuAcceptor acceptor, - HeuristicConfigPolicy configPolicy, Integer tabuSize, Integer fadingTabuSize) { + EnvironmentMode environmentMode, Integer tabuSize, Integer fadingTabuSize) { if (tabuSize != null) { acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(tabuSize)); } if (fadingTabuSize != null) { acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingTabuSize)); } - if (configPolicy.getEnvironmentMode().isFullyAsserted()) { - acceptor.setAssertTabuHashCodeCorrectness(true); - } + acceptor.enableAssertions(environmentMode); } - private Optional> buildMoveTabuAcceptor(HeuristicConfigPolicy configPolicy) { + private Optional> buildMoveTabuAcceptor(EnvironmentMode environmentMode, + String logIndentation) { var moveTabuSize = acceptorConfig.getMoveTabuSize(); var fadingMoveTabuSize = acceptorConfig.getFadingMoveTabuSize(); if (acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU) @@ -172,8 +172,8 @@ private Optional> buildMoveTabuAcceptor(HeuristicCon "The acceptorType (%s) requires either moveTabuSize or fadingMoveTabuSize to be configured." .formatted(AcceptorType.MOVE_TABU)); } - var acceptor = new MoveTabuAcceptor(configPolicy.getLogIndentation()); - configureFixedSizeTabuAcceptor(acceptor, configPolicy, moveTabuSize, fadingMoveTabuSize); + var acceptor = new MoveTabuAcceptor(logIndentation); + configureFixedSizeTabuAcceptor(acceptor, environmentMode, moveTabuSize, fadingMoveTabuSize); return Optional.of(acceptor); } return Optional.empty(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java index 91b782d2193..349abf48ad7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java @@ -4,7 +4,9 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.AbstractAcceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.Acceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.tabu.size.TabuSizeStrategy; @@ -54,8 +56,8 @@ public void setAspirationEnabled(boolean aspirationEnabled) { this.aspirationEnabled = aspirationEnabled; } - public void setAssertTabuHashCodeCorrectness(boolean assertTabuHashCodeCorrectness) { - this.assertTabuHashCodeCorrectness = assertTabuHashCodeCorrectness; + public void enableAssertions(EnvironmentMode environmentMode) { + assertTabuHashCodeCorrectness = environmentMode.isFullyAsserted(); } // ************************************************************************ @@ -100,7 +102,7 @@ protected void adjustTabuList(int tabuStepIndex, Collection<@Nullable Object> ta var oldTabuStepIndexInteger = tabuToStepIndexMap.get(oldTabu); if (oldTabuStepIndexInteger == null) { // oldTabu not null here, as null is a valid key and therefore has a valid corresponding value. - throw createHashcodeStabilityViolationException(oldTabu); + throw createHashcodeStabilityViolationException(Objects.requireNonNull(oldTabu)); } var oldTabuStepCount = tabuStepIndex - oldTabuStepIndexInteger; // at least 1 if (oldTabuStepCount < totalTabuListSize) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java index 8037e578172..ac628e39a66 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java @@ -18,8 +18,9 @@ public DefaultPartitionedSearchPhaseFactory(PartitionedSearchPhaseConfig phaseCo public PartitionedSearchPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); return TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.PARTITIONED_SEARCH) - .buildPartitionedSearch(phaseIndex, phaseConfig, solverConfigPolicy, solverTermination, + .buildPartitionedSearch(phaseIndex, phaseConfig, environmentMode, solverConfigPolicy, solverTermination, this::buildPhaseTermination); } 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 b1d10f08e46..c99e39ed014 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 @@ -34,6 +34,7 @@ public abstract class AbstractPhase implements Phase { protected final transient Logger logger = LoggerFactory.getLogger(getClass()); protected final int phaseIndex; + protected final EnvironmentMode environmentMode; protected final String logIndentation; // Called "phaseTermination" to clearly distinguish from "solverTermination" inside AbstractSolver. @@ -49,6 +50,7 @@ public abstract class AbstractPhase implements Phase { protected AbstractPhase(AbstractPhaseBuilder builder) { phaseIndex = builder.phaseIndex; + environmentMode = builder.environmentMode; logIndentation = builder.logIndentation; phaseTermination = builder.phaseTermination; assertPhaseScoreFromScratch = builder.assertPhaseScoreFromScratch; @@ -83,6 +85,11 @@ public boolean isAssertShadowVariablesAreNotStaleAfterStep() { // Lifecycle methods // ************************************************************************ + @Override + public EnvironmentMode getEnvironmentMode() { + return environmentMode; + } + @Override public void solvingStarted(SolverScope solverScope) { phaseLifecycleSupport.fireSolvingStarted(solverScope); @@ -256,6 +263,7 @@ but planning list variable (%s) has (%d) unexpected unassigned values. public abstract static class AbstractPhaseBuilder { private final int phaseIndex; + protected final EnvironmentMode environmentMode; private final String logIndentation; private final PhaseTermination phaseTermination; @@ -264,13 +272,15 @@ public abstract static class AbstractPhaseBuilder { private boolean assertExpectedStepScore = false; private boolean assertShadowVariablesAreNotStaleAfterStep = false; - protected AbstractPhaseBuilder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination) { + protected AbstractPhaseBuilder(int phaseIndex, EnvironmentMode environmentMode, String logIndentation, + PhaseTermination phaseTermination) { this.phaseIndex = phaseIndex; + this.environmentMode = environmentMode; this.logIndentation = logIndentation; this.phaseTermination = phaseTermination; } - public AbstractPhaseBuilder enableAssertions(EnvironmentMode environmentMode) { + public AbstractPhaseBuilder enableAssertions() { assertPhaseScoreFromScratch = environmentMode.isAsserted(); assertStepScoreFromScratch = environmentMode.isFullyAsserted(); assertExpectedStepScore = environmentMode.isIntrusivelyAsserted(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java index 63cff4e91dc..53a9cae1011 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhaseFactory.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; import ai.timefold.solver.core.config.phase.PhaseConfig; import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope; import ai.timefold.solver.core.impl.exhaustivesearch.scope.ExhaustiveSearchPhaseScope; @@ -34,6 +35,10 @@ public AbstractPhaseFactory(PhaseConfig_ phaseConfig) { this.phaseConfig = phaseConfig; } + protected EnvironmentMode resolveEnvironmentMode(HeuristicConfigPolicy phaseConfigPolicy) { + return Objects.requireNonNullElse(phaseConfig.getEnvironmentMode(), phaseConfigPolicy.getEnvironmentMode()); + } + protected PhaseTermination buildPhaseTermination(HeuristicConfigPolicy configPolicy, SolverTermination solverTermination) { var terminationConfig_ = Objects.requireNonNullElseGet(phaseConfig.getTerminationConfig(), TerminationConfig::new); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java index cf3c7203e71..3c482f962cc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.phase; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.phase.custom.CustomPhase; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; @@ -53,14 +54,14 @@ protected void ensureCorrectTermination(AbstractPhaseScope phaseScope } } - public static abstract class AbstractPossiblyInitializingPhaseBuilder + public abstract static class AbstractPossiblyInitializingPhaseBuilder extends AbstractPhaseBuilder { private final boolean lastInitializingPhase; - protected AbstractPossiblyInitializingPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, String phaseName, - PhaseTermination phaseTermination) { - super(phaseIndex, phaseName, phaseTermination); + protected AbstractPossiblyInitializingPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, + EnvironmentMode environmentMode, String phaseName, PhaseTermination phaseTermination) { + super(phaseIndex, environmentMode, phaseName, phaseTermination); this.lastInitializingPhase = lastInitializingPhase; } 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 8386fb516c3..2cd369b3a7f 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 @@ -5,6 +5,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.config.solver.EnvironmentMode; 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; @@ -41,4 +42,5 @@ public interface Phase extends PhaseLifecycleListener { IntFunction getEventProducerIdSupplier(); + EnvironmentMode getEnvironmentMode(); } 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 3496d7ac328..4f8093bb086 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 @@ -110,11 +110,12 @@ public void phaseEnded(CustomPhaseScope phaseScope) { super.phaseEnded(phaseScope); ensureCorrectTermination(phaseScope, logger); phaseScope.endingNow(); - logger.info("{}Custom phase ({}) ended: time spent ({}), best score ({})," + logger.info("{}Custom phase ({}) ended: time spent ({}), environment mode ({}), best score ({})," + " move evaluation speed ({}/sec), step total ({}).", logIndentation, phaseIndex, phaseScope.calculateSolverTimeMillisSpentUpToNow(), + environmentMode.name(), phaseScope.getBestScore().raw(), phaseScope.getPhaseMoveEvaluationSpeed(), phaseScope.getNextStepIndex()); @@ -125,15 +126,16 @@ public static final class DefaultCustomPhaseBuilder private final List> customPhaseCommandList; - public DefaultCustomPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, String logIndentation, - PhaseTermination phaseTermination, List> customPhaseCommandList) { - super(phaseIndex, lastInitializingPhase, logIndentation, phaseTermination); + public DefaultCustomPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, EnvironmentMode environmentMode, + String logIndentation, PhaseTermination phaseTermination, + List> customPhaseCommandList) { + super(phaseIndex, lastInitializingPhase, environmentMode, logIndentation, phaseTermination); this.customPhaseCommandList = List.copyOf(customPhaseCommandList); } @Override - public DefaultCustomPhaseBuilder enableAssertions(EnvironmentMode environmentMode) { - super.enableAssertions(environmentMode); + public DefaultCustomPhaseBuilder enableAssertions() { + super.enableAssertions(); return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java index ab393e0ca25..2519c722438 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java @@ -21,9 +21,10 @@ public DefaultCustomPhaseFactory(CustomPhaseConfig phaseConfig) { public CustomPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.createPhaseConfigPolicy(); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(); var customPhaseCommandClassList = phaseConfig.getCustomPhaseCommandClassList(); var customPhaseCommandList = phaseConfig.getCustomPhaseCommandList(); + var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); if (ConfigUtils.isEmptyCollection(customPhaseCommandClassList) && ConfigUtils.isEmptyCollection(customPhaseCommandList)) { throw new IllegalArgumentException( @@ -45,10 +46,10 @@ The customPhaseCommandClass (%s) cannot be null in the customPhase (%s). if (customPhaseCommandList != null) { customPhaseCommandList_.addAll((Collection) customPhaseCommandList); } - return new DefaultCustomPhase.DefaultCustomPhaseBuilder<>(phaseIndex, lastInitializingPhase, + return new DefaultCustomPhase.DefaultCustomPhaseBuilder<>(phaseIndex, lastInitializingPhase, environmentMode, solverConfigPolicy.getLogIndentation(), buildPhaseTermination(phaseConfigPolicy, solverTermination), customPhaseCommandList_) - .enableAssertions(phaseConfigPolicy.getEnvironmentMode()) + .enableAssertions() .build(); } 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 a9372f59000..881fb518572 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 @@ -9,6 +9,7 @@ import ai.timefold.solver.core.api.domain.common.PlanningId; 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.change.ProblemChange; import ai.timefold.solver.core.api.solver.event.EventProducerId; @@ -71,8 +72,8 @@ public RandomSource getRandomSource() { return randomFactory.get(); } - public ScoreDirectorFactory getScoreDirectorFactory() { - return solverScope.getScoreDirector().getScoreDirectorFactory(); + public > ScoreDirectorFactory getScoreDirectorFactory() { + return solverScope. getScoreDirector().getScoreDirectorFactory(); } public SolverScope getSolverScope() { @@ -313,7 +314,7 @@ public void solvingEnded(SolverScope solverScope) { } public void outerSolvingEnded(SolverScope solverScope) { - LOGGER.info("Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), " + logger.info("Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), " + "phase total ({}), environment mode ({}), move thread count ({}).", solverScope.getTimeMillisSpent(), solverScope.getBestScore().raw(), @@ -340,7 +341,7 @@ private boolean checkProblemChanges() { while (problemChange != null) { problemChange.doChange(solverScope.getWorkingSolution(), solverScope.getProblemChangeDirector()); solverScope.getScoreDirector().updateShadowVariables(); - LOGGER.debug(" Real-time problem change applied; step index ({}).", stepIndex); + logger.debug(" Real-time problem change applied; step index ({}).", stepIndex); stepIndex++; problemChange = problemChangeQueue.poll(); } @@ -352,7 +353,7 @@ private boolean checkProblemChanges() { basicPlumbingTermination.endProblemChangesProcessing(); bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, EventProducerId.problemChange()); - LOGGER.info("Real-time problem fact changes done: step total ({}), new best score ({}).", + 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/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 91f645962b5..b04651d32b8 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 @@ -4,6 +4,7 @@ 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.config.solver.EnvironmentMode; 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; @@ -24,22 +25,16 @@ public class BestSolutionRecaller extends PhaseLifecycleListenerAdapt protected SolverEventSupport solverEventSupport; - public void setAssertInitialScoreFromScratch(boolean assertInitialScoreFromScratch) { - this.assertInitialScoreFromScratch = assertInitialScoreFromScratch; - } - - public void setAssertShadowVariablesAreNotStale(boolean assertShadowVariablesAreNotStale) { - this.assertShadowVariablesAreNotStale = assertShadowVariablesAreNotStale; - } - - public void setAssertBestScoreIsUnmodified(boolean assertBestScoreIsUnmodified) { - this.assertBestScoreIsUnmodified = assertBestScoreIsUnmodified; - } - public void setSolverEventSupport(SolverEventSupport solverEventSupport) { this.solverEventSupport = solverEventSupport; } + public void enableAssertions(EnvironmentMode environmentMode) { + assertInitialScoreFromScratch = environmentMode.isFullyAsserted(); + assertShadowVariablesAreNotStale = environmentMode.isFullyAsserted(); + assertBestScoreIsUnmodified = environmentMode.isFullyAsserted(); + } + // ************************************************************************ // Worker methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java index c56f244290b..ce037d34254 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecallerFactory.java @@ -9,12 +9,8 @@ public static BestSolutionRecallerFactory create() { } public BestSolutionRecaller buildBestSolutionRecaller(EnvironmentMode environmentMode) { - BestSolutionRecaller bestSolutionRecaller = new BestSolutionRecaller<>(); - if (environmentMode.isFullyAsserted()) { - bestSolutionRecaller.setAssertInitialScoreFromScratch(true); - bestSolutionRecaller.setAssertShadowVariablesAreNotStale(true); - bestSolutionRecaller.setAssertBestScoreIsUnmodified(true); - } + var bestSolutionRecaller = new BestSolutionRecaller(); + bestSolutionRecaller.enableAssertions(environmentMode); return bestSolutionRecaller; } } diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index a5d10b20c96..6848a910978 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -291,6 +291,8 @@ + + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java index c00a3978f73..2625aeb1902 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java @@ -54,7 +54,8 @@ void buildCompositeAcceptor() { when(heuristicConfigPolicy.getScoreDefinition()).thenReturn(scoreDefinition); AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - Acceptor acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); + Acceptor acceptor = + acceptorFactory.buildAcceptor(heuristicConfigPolicy, heuristicConfigPolicy.getEnvironmentMode()); assertThat(acceptor).isExactlyInstanceOf(CompositeAcceptor.class); CompositeAcceptor compositeAcceptor = (CompositeAcceptor) acceptor; assertThat(compositeAcceptor.acceptorList) @@ -67,7 +68,9 @@ void buildCompositeAcceptor() { @Test void noAcceptorConfigured_throwsException() { AcceptorFactory acceptorFactory = AcceptorFactory.create(new LocalSearchAcceptorConfig()); - assertThatIllegalArgumentException().isThrownBy(() -> acceptorFactory.buildAcceptor(mock(HeuristicConfigPolicy.class))) + assertThatIllegalArgumentException() + .isThrownBy( + () -> acceptorFactory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)) .withMessageContaining("The acceptor does not specify any acceptorType"); } @@ -77,13 +80,13 @@ void lateAcceptanceAcceptor() { .withAcceptorTypeList(List.of(AcceptorType.LATE_ACCEPTANCE)); HeuristicConfigPolicy heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); + var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); assertThat(acceptor).isExactlyInstanceOf(LateAcceptanceAcceptor.class); localSearchAcceptorConfig = new LocalSearchAcceptorConfig() .withLateAcceptanceSize(10); acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); + acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); assertThat(acceptor).isExactlyInstanceOf(LateAcceptanceAcceptor.class); } @@ -93,14 +96,14 @@ void diversifiedLateAcceptanceAcceptor() { .withAcceptorTypeList(List.of(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)); HeuristicConfigPolicy heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); + var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); assertThat(acceptor).isExactlyInstanceOf(DiversifiedLateAcceptanceAcceptor.class); localSearchAcceptorConfig = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)) .withLateAcceptanceSize(10); acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); + acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); assertThat(acceptor).isExactlyInstanceOf(DiversifiedLateAcceptanceAcceptor.class); doThrow(new IllegalStateException()).when(heuristicConfigPolicy).ensurePreviewFeature(any()); @@ -108,24 +111,25 @@ void diversifiedLateAcceptanceAcceptor() { .withAcceptorTypeList(List.of(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)) .withLateAcceptanceSize(10); AcceptorFactory badAcceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - assertThatIllegalStateException().isThrownBy(() -> badAcceptorFactory.buildAcceptor(heuristicConfigPolicy)); + assertThatIllegalStateException() + .isThrownBy(() -> badAcceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT)); } @Test - void valueTabuWithoutSizes_throwsException() { + void valueTabuWithoutSizes_throwsException() { var config = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.VALUE_TABU)); var factory = AcceptorFactory.create(config); assertThatIllegalArgumentException() - .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)); } @Test - void moveTabuWithoutSizes_throwsException() { + void moveTabuWithoutSizes_throwsException() { var config = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.MOVE_TABU)); var factory = AcceptorFactory.create(config); assertThatIllegalArgumentException() - .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java index 5fcfec9505a..97c829327a0 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java @@ -69,11 +69,13 @@ void changeMoveBasedLocalSearch() { List.of(new ChangeMoveProvider<>(variableMetaModel))); var acceptor = AcceptorFactory. create(new LocalSearchAcceptorConfig().withLateAcceptanceSize(400)) - .buildAcceptor(heuristicConfigPolicy); + .buildAcceptor(heuristicConfigPolicy, heuristicConfigPolicy.getEnvironmentMode()); var forager = LocalSearchForagerFactory . create(new LocalSearchForagerConfig().withAcceptedCountLimit(1)).buildForager(); var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager); - var localSearchPhase = new DefaultLocalSearchPhase.Builder<>(0, "", termination, localSearchDecider).build(); + var localSearchPhase = + new DefaultLocalSearchPhase.Builder<>(0, EnvironmentMode.PHASE_ASSERT, "", termination, localSearchDecider) + .build(); // Generates a solution whose entities' values are all set to the second value. // The easy calculator penalizes this. 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 af7e60b60da..63b87376caa 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 @@ -68,6 +68,7 @@ import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.heuristic.move.AbstractSelectorBasedMove; import ai.timefold.solver.core.impl.heuristic.selector.move.factory.MoveIteratorFactory; +import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.score.DummySimpleScoreEasyScoreCalculator; import ai.timefold.solver.core.impl.score.director.ScoreDirector; import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector; @@ -2354,6 +2355,48 @@ void solveCorruptedIncrementalInitialized() { .hasMessageContaining("Score corruption analysis:"); } + @Test + void assertDefaultEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + var solverFactory = SolverFactory. create(solverConfig); + DefaultSolver solver = (DefaultSolver) solverFactory.buildSolver(); + assertThat(solver.getPhaseList().stream().map(Phase::getEnvironmentMode).toList()) + .containsOnly(EnvironmentMode.PHASE_ASSERT); + } + + @Test + void assertUpdatedDefaultEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + solverConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var solverFactory = SolverFactory. create(solverConfig); + DefaultSolver solver = (DefaultSolver) solverFactory.buildSolver(); + assertThat(solver.getPhaseList().stream().map(Phase::getEnvironmentMode).toList()) + .containsOnly(EnvironmentMode.FULL_ASSERT); + } + + @Test + void assertPhaseEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + solverConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // LS with NO_ASSERT + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); + var solverFactory = SolverFactory. create(solverConfig); + DefaultSolver solver = (DefaultSolver) solverFactory.buildSolver(); + assertThat(solver.getPhaseList().stream().map(Phase::getEnvironmentMode).toList()) + .containsExactly(EnvironmentMode.FULL_ASSERT, EnvironmentMode.TRACKED_FULL_ASSERT); + } + + @Test + void assertDefaultPhaseEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // LS with FULL_ASSERT + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var solverFactory = SolverFactory. create(solverConfig); + DefaultSolver solver = (DefaultSolver) solverFactory.buildSolver(); + assertThat(solver.getPhaseList().stream().map(Phase::getEnvironmentMode).toList()) + .containsExactly(EnvironmentMode.PHASE_ASSERT, EnvironmentMode.FULL_ASSERT); + } + @NullMarked public static class CorruptedIncrementalScoreCalculator implements AnalyzableIncrementalScoreCalculator { diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 87ef47a9121..008f2a3fd67 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -728,6 +728,9 @@ + + + From b80b2f1605a3785060412fe465fba9f97761e4e6 Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 10 Aug 2026 16:25:18 -0300 Subject: [PATCH 02/20] chore: running the phases with different environment modes --- .../score/director/AbstractScoreDirector.java | 72 ++++++-- .../AbstractScoreDirectorFactory.java | 56 +----- ...java => DelegateScoreDirectorFactory.java} | 56 ++++-- .../score/director/InnerScoreDirector.java | 9 + .../score/director/ScoreDirectorFactory.java | 22 +-- .../core/impl/solver/AbstractSolver.java | 18 -- .../core/impl/solver/DefaultSolver.java | 114 ++++++++++-- .../impl/solver/DefaultSolverFactory.java | 162 +++++++++++++----- .../core/impl/solver/scope/SolverScope.java | 2 +- ... => DelegateScoreDirectorFactoryTest.java} | 21 ++- .../easy/EasyScoreDirectorSemanticsTest.java | 10 +- ...IncrementalScoreDirectorSemanticsTest.java | 8 +- .../IncrementalScoreDirectorTest.java | 14 ++ ...treamsBavetScoreDirectorSemanticsTest.java | 8 +- .../impl/solver/DefaultSolverFactoryTest.java | 53 ++++++ .../core/impl/solver/DefaultSolverTest.java | 10 ++ .../core/impl/solver/SolverMetricsIT.java | 28 +-- 17 files changed, 461 insertions(+), 202 deletions(-) rename core/src/main/java/ai/timefold/solver/core/impl/score/director/{ScoreDirectorFactoryFactory.java => DelegateScoreDirectorFactory.java} (75%) rename core/src/test/java/ai/timefold/solver/core/impl/score/director/{ScoreDirectorFactoryFactoryTest.java => DelegateScoreDirectorFactoryTest.java} (87%) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 6251f0001cc..80b427537d0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -63,6 +63,7 @@ public abstract class AbstractScoreDirector moveRepository; protected AbstractScoreDirector(AbstractScoreDirectorBuilder builder) { + this.environmentMode = builder.scoreDirectorFactory.getEnvironmentMode(); this.scoreDirectorFactory = builder.scoreDirectorFactory; // Needs early init, as supplies will need the instance to exist. this.neighborhoodsElementUpdateNotifier = new NeighborhoodNotifier<>(); @@ -111,7 +114,10 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder(solutionDescriptor); this.shadowVariableSupport = ShadowVariableSupport.create(this); this.shadowVariableSupport.linkShadowVariables(); - this.solutionTracker = this.scoreDirectorFactory.isTrackingWorkingSolution() + // When true, a snapshot of the solution is created before, after and after the undo of a move. + // In {@link EnvironmentMode#TRACKED_FULL_ASSERT}, the snapshots are compared when corruption is detected, + // allowing us to report exactly what variables are different. + this.solutionTracker = environmentMode.isTracking() ? new SolutionTracker<>(getSolutionDescriptor(), getSupplyManager()) : null; this.valueRangeManager = new ValueRangeManager<>(solutionDescriptor); @@ -122,8 +128,9 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder createChildThreadScoreDirector(ChildThreadType childThreadType) { // Most score directors don't need derived status; CS will override this. - if (childThreadType == ChildThreadType.PART_THREAD) { - var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) - .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); - // ScoreCalculationCountTermination takes into account previous phases - // but the calculationCount of partitions is maxed, not summed. - childThreadScoreDirector.calculationCount = calculationCount; - return childThreadScoreDirector; - } else if (childThreadType == ChildThreadType.MOVE_THREAD) { - var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) - .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); - childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); - return childThreadScoreDirector; - } else { - throw new IllegalStateException("The childThreadType (" + childThreadType + ") is not implemented."); + switch (childThreadType) { + case ChildThreadType.PART_THREAD -> { + var childThreadScoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + // ScoreCalculationCountTermination takes into account previous phases + // but the calculationCount of partitions is maxed, not summed. + childThreadScoreDirector.calculationCount = calculationCount; + return childThreadScoreDirector; + } + case ChildThreadType.MOVE_THREAD -> { + var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); + return childThreadScoreDirector; + } + default -> throw new IllegalStateException("The childThreadType (" + childThreadType + ") is not implemented."); } } @@ -665,6 +675,32 @@ public void afterProblemFactRemoved(Object problemFact) { // Assert methods // ************************************************************************ + /** + * Asserts that if the {@link Score} is calculated for the parameter solution, + * it would be equal to the score of that parameter. + * + * @param solution never null + * @see InnerScoreDirector#assertWorkingScoreFromScratch(InnerScore, Object) + */ + @Override + public void assertScoreFromScratch(Solution_ solution) { + // Get the score before uncorruptedScoreDirector.calculateScore() modifies it + var score = getSolutionDescriptor(). getScore(solution); + // Most score directors don't need derived status; CS will override this. + try (var uncorruptedScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .buildDerived()) { + uncorruptedScoreDirector.setWorkingSolution(solution); + var uncorruptedScore = uncorruptedScoreDirector.calculateScore() + .raw(); + if (!score.equals(uncorruptedScore)) { + throw new IllegalStateException( + "Score corruption (%s): the solution's score (%s) is not the uncorruptedScore (%s)." + .formatted(score.subtract(uncorruptedScore).toShortString(), score, uncorruptedScore)); + } + } + } + @Override public void assertExpectedWorkingScore(InnerScore expectedWorkingScore, Object completedAction) { var workingScore = calculateScore(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java index 9de1add6391..971ccef092b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.score.director; +import java.util.Objects; + import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.config.solver.EnvironmentMode; @@ -7,7 +9,6 @@ import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; @@ -34,15 +35,17 @@ public abstract class AbstractScoreDirectorFactory assertionScoreDirectorFactory = null; - protected boolean assertClonedSolution = false; - protected boolean trackingWorkingSolution = false; - - public AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { + protected AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { this.solutionDescriptor = solutionDescriptor; - this.environmentMode = environmentMode; + this.environmentMode = Objects.requireNonNull(environmentMode); this.listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); } + @Override + public EnvironmentMode getEnvironmentMode() { + return environmentMode; + } + @Override public SolutionDescriptor getSolutionDescriptor() { return solutionDescriptor; @@ -70,47 +73,6 @@ public void setAssertionScoreDirectorFactory(ScoreDirectorFactory getScore(solution); - // Most score directors don't need derived status; CS will override this. - try (var uncorruptedScoreDirector = createScoreDirectorBuilder() - .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .buildDerived()) { - uncorruptedScoreDirector.setWorkingSolution(solution); - var uncorruptedScore = uncorruptedScoreDirector.calculateScore() - .raw(); - if (!score.equals(uncorruptedScore)) { - throw new IllegalStateException( - "Score corruption (%s): the solution's score (%s) is not the uncorruptedScore (%s)." - .formatted(score.subtract(uncorruptedScore).toShortString(), score, uncorruptedScore)); - } - } - } - public EntityDescriptor validateEntity(ScoreDirector scoreDirector, Object entity) { if (listVariableDescriptor == null) { // Only basic variables. var entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(entity.getClass()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java similarity index 75% rename from core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java rename to core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java index 08f3c3aa547..0870b7d1aa6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java @@ -7,19 +7,43 @@ import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; -public class ScoreDirectorFactoryFactory> { +import org.jspecify.annotations.NullMarked; + +/** + * The delegate score factory creates a {@link ScoreDirectorFactory} based on the specified environment mode. + * This functionality enables the creation of different score director factories using the delegate. + * It is necessary because the solver phases may operate under various environment modes, + * requiring the creation of different factories. + */ +@NullMarked +public class DelegateScoreDirectorFactory> { private final ScoreDirectorFactoryConfig config; + private final boolean hasMetricRequiringConstraintMatch; + + public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config) { + this(config, false); + } - public ScoreDirectorFactoryFactory(ScoreDirectorFactoryConfig config) { + public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, boolean hasMetricRequiringConstraintMatch) { this.config = config; + this.hasMetricRequiringConstraintMatch = hasMetricRequiringConstraintMatch; + assertCorrectDirectorFactory(config); } + /** + * Build a score director factory according to the given environment mode. + * + * @param environmentMode the environment mode + * @param solutionDescriptor the solution descriptor + * @return a new instance of the score director factory compatible with the environment mode and solver configuration. + */ public ScoreDirectorFactory buildScoreDirectorFactory(EnvironmentMode environmentMode, SolutionDescriptor solutionDescriptor) { var scoreDirectorFactory = decideMultipleScoreDirectorFactories(solutionDescriptor, environmentMode); @@ -37,7 +61,7 @@ public ScoreDirectorFactory buildScoreDirectorFactory(Environ .formatted(assertionScoreDirectorFactory, environmentMode, EnvironmentMode.STEP_ASSERT)); } var assertionScoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(assertionScoreDirectorFactory); + new DelegateScoreDirectorFactory(assertionScoreDirectorFactory); scoreDirectorFactory.setAssertionScoreDirectorFactory(assertionScoreDirectorFactoryFactory .buildScoreDirectorFactory(EnvironmentMode.NON_REPRODUCIBLE, solutionDescriptor)); } @@ -45,19 +69,27 @@ public ScoreDirectorFactory buildScoreDirectorFactory(Environ config.getInitializingScoreTrend() == null ? InitializingScoreTrendLevel.ANY.name() : config.getInitializingScoreTrend(), solutionDescriptor.getScoreDefinition().getLevelsSize())); - if (environmentMode.isFullyAsserted()) { - scoreDirectorFactory.setAssertClonedSolution(true); - } - if (environmentMode.isTracking()) { - scoreDirectorFactory.setTrackingWorkingSolution(true); - } return scoreDirectorFactory; } - protected AbstractScoreDirectorFactory decideMultipleScoreDirectorFactories( - SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - assertCorrectDirectorFactory(config); + /** + * Creates a new instance of the score director. + * + * @param scoreScoreDirectorFactory the factory to be used to create the score director instance. + */ + public InnerScoreDirector + createScoreDirector(ScoreDirectorFactory scoreScoreDirectorFactory) { + var isConstraintMatchEnabled = + hasMetricRequiringConstraintMatch || scoreScoreDirectorFactory.getEnvironmentMode().isStepAssertOrMore(); + return scoreScoreDirectorFactory.createScoreDirectorBuilder() + .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. + .withConstraintMatchPolicy( + isConstraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) + .build(); + } + private AbstractScoreDirectorFactory decideMultipleScoreDirectorFactories( + SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { // At this point, we are guaranteed to have at most one score director factory selected. if (config.getEasyScoreCalculatorClass() != null) { return EasyScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index 6f3caa961e9..7d5a272d891 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -225,6 +225,15 @@ default Solution_ cloneWorkingSolution() { InnerScoreDirector createChildThreadScoreDirector(ChildThreadType childThreadType); + /** + * Asserts that if the {@link Score} is calculated for the parameter solution, + * it would be equal to the score of that parameter. + * + * @param solution never null + * @see InnerScoreDirector#assertWorkingScoreFromScratch(InnerScore, Object) + */ + void assertScoreFromScratch(Solution_ solution); + /** * Do not waste performance by propagating changes to step (or higher) mechanisms. * diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java index b73ed455239..c1195d4b90e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java @@ -2,8 +2,10 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; +import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector.AbstractScoreDirectorBuilder; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; /** @@ -22,24 +24,24 @@ public interface ScoreDirectorFactory> { */ ScoreDefinition getScoreDefinition(); - AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder(); + , Builder_ extends AbstractScoreDirectorBuilder> + AbstractScoreDirectorBuilder + createScoreDirectorBuilder(); - default AbstractScoreDirector buildScoreDirector() { - return createScoreDirectorBuilder().build(); + default > + AbstractScoreDirector buildScoreDirector() { + AbstractScoreDirectorBuilder builder = createScoreDirectorBuilder(); + return builder.build(); } /** * @return never null */ - InitializingScoreTrend getInitializingScoreTrend(); + EnvironmentMode getEnvironmentMode(); /** - * Asserts that if the {@link Score} is calculated for the parameter solution, - * it would be equal to the score of that parameter. - * - * @param solution never null - * @see InnerScoreDirector#assertWorkingScoreFromScratch(InnerScore, Object) + * @return never null */ - void assertScoreFromScratch(Solution_ solution); + InitializingScoreTrend getInitializingScoreTrend(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index d6dd5f496d1..eebce6ed365 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -77,24 +77,6 @@ public void solvingStarted(SolverScope solverScope) { } } - protected void runPhases(SolverScope solverScope) { - if (!solverScope.getSolutionDescriptor().hasMovableEntities(solverScope.getScoreDirector())) { - LOGGER.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", - phaseList.size(), solverScope.getWorkingEntityCount()); - return; - } - Iterator> it = phaseList.iterator(); - while (!globalTermination.isSolverTerminated(solverScope) && it.hasNext()) { - Phase phase = it.next(); - phase.solve(solverScope); - // If there is a next phase, it starts from the best solution, which might differ from the working solution. - // If there isn't, no need to planning clone the best solution to the working solution. - if (it.hasNext()) { - solverScope.setWorkingSolutionFromBestSolution(); - } - } - } - public void solvingEnded(SolverScope solverScope) { for (Phase phase : phaseList) { phase.solvingEnded(solverScope); 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 881fb518572..93a586d513b 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 @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.solver; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,8 +18,10 @@ import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; 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.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.random.RandomSource; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -40,40 +43,44 @@ @NullMarked public class DefaultSolver extends AbstractSolver { - protected final EnvironmentMode environmentMode; - protected final Supplier randomFactory; - protected final BasicPlumbingTermination basicPlumbingTermination; - protected final AtomicBoolean solving = new AtomicBoolean(false); - protected final SolverScope solverScope; + private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; + private final Supplier randomFactory; + private final BasicPlumbingTermination basicPlumbingTermination; + private final AtomicBoolean solving = new AtomicBoolean(false); + private final SolverScope solverScope; private final String moveThreadCountDescription; + private final SolverContext defaultSolverContext; + private SolverContext currentContext; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - public DefaultSolver(EnvironmentMode environmentMode, Supplier randomFactory, - BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, - UniversalTermination termination, List> phaseList, - SolverScope solverScope, String moveThreadCountDescription) { + public DefaultSolver(EnvironmentMode environmentMode, + DelegateScoreDirectorFactory delegateScoreDirectorFactory, + Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, + BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, + List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { super(bestSolutionRecaller, termination, phaseList); - this.environmentMode = environmentMode; + this.delegateScoreDirectorFactory = delegateScoreDirectorFactory; this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; this.solverScope = solverScope; solverScope.setSolver(this); this.moveThreadCountDescription = moveThreadCountDescription; - } - - public EnvironmentMode getEnvironmentMode() { - return environmentMode; + this.defaultSolverContext = SolverContext.of(environmentMode, solverScope); + this.currentContext = defaultSolverContext; } public RandomSource getRandomSource() { return randomFactory.get(); } + @SuppressWarnings({ "unchecked", "resource" }) public > ScoreDirectorFactory getScoreDirectorFactory() { - return solverScope. getScoreDirector().getScoreDirectorFactory(); + InnerScoreDirector scoreDirector = + (InnerScoreDirector) defaultSolverContext.scoreDirector(); + return scoreDirector.getScoreDirectorFactory(); } public SolverScope getSolverScope() { @@ -186,6 +193,57 @@ public final Solution_ solve(Solution_ problem) { return solverScope.getBestSolution(); } + protected void runPhases(SolverScope solverScope) { + if (!solverScope.getSolutionDescriptor().hasMovableEntities(solverScope.getScoreDirector())) { + logger.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", + phaseList.size(), solverScope.getWorkingEntityCount()); + return; + } + Iterator> it = phaseList.iterator(); + while (!globalTermination.isSolverTerminated(solverScope) && it.hasNext()) { + Phase phase = it.next(); + preparePhase(phase); + phase.solve(solverScope); + // If there is a next phase, it starts from the best solution, which might differ from the working solution. + // If there isn't, no need to planning clone the best solution to the working solution. + if (it.hasNext()) { + solverScope.setWorkingSolutionFromBestSolution(); + } + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private void preparePhase(Phase phase) { + // The environment modes match, and there is no need for any changes. + if (phase.getEnvironmentMode() == currentContext.environmentMode()) { + return; + } + // The phase environment mode matches default, so we will restore it. + if (phase.getEnvironmentMode() == defaultSolverContext.environmentMode()) { + // Release the current context + currentContext.release(); + // Update and load the default context + currentContext = defaultSolverContext; + currentContext.load(solverScope); + return; + } + // Since the current logic does not cache any solver context other than the default, + // we need to create a new solver context + // because the required environment mode differs from both the current and the default modes. + ScoreDirectorFactory newScoreDirectorFactory = delegateScoreDirectorFactory + .buildScoreDirectorFactory(phase.getEnvironmentMode(), solverScope.getSolutionDescriptor()); + var newScoreDirector = delegateScoreDirectorFactory.createScoreDirector(newScoreDirectorFactory); + var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, + new DefaultProblemChangeDirector<>(newScoreDirector), currentContext.bestSolutionRecaller); + // Release the current context + if (currentContext != defaultSolverContext) { + currentContext.release(); + } + // Update and load the new context + currentContext = newSolverContext; + currentContext.load(solverScope); + } + public void outerSolvingStarted(SolverScope solverScope) { solving.set(true); basicPlumbingTermination.resetTerminateEarly(); @@ -212,7 +270,7 @@ public void solvingStarted(SolverScope solverScope) { (startingSolverCount == 1 ? "started" : "restarted"), solverScope.calculateTimeMillisSpentUpToNow(), solverScope.getBestScore().raw(), - environmentMode.name(), + defaultSolverContext.environmentMode().name(), moveThreadCountDescription, randomFactory); if (LOGGER.isInfoEnabled()) { // Formatting is expensive here. @@ -320,7 +378,7 @@ public void outerSolvingEnded(SolverScope solverScope) { solverScope.getBestScore().raw(), solverScope.getMoveEvaluationSpeed(), phaseList.size(), - environmentMode.name(), + defaultSolverContext.environmentMode().name(), moveThreadCountDescription); // Must be kept open for doProblemFactChange solverScope.getScoreDirector().close(); @@ -358,4 +416,26 @@ private boolean checkProblemChanges() { return true; } } + + private record SolverContext>(EnvironmentMode environmentMode, + InnerScoreDirector scoreDirector, DefaultProblemChangeDirector problemChangeDirector, + BestSolutionRecaller bestSolutionRecaller) { + + static > SolverContext + of(EnvironmentMode environmentMode, SolverScope solverScope) { + return new SolverContext<>(environmentMode, solverScope. getScoreDirector(), + solverScope.getProblemChangeDirector(), solverScope.getSolver().getBestSolutionRecaller()); + } + + void load(SolverScope solverScope) { + solverScope.setScoreDirector(scoreDirector); + solverScope.setProblemChangeDirector(problemChangeDirector); + solverScope.setWorkingSolutionFromBestSolution(); + bestSolutionRecaller.enableAssertions(environmentMode); + } + + void release() { + scoreDirector.close(); + } + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index e3db6fba7a1..6997f3982c5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -12,9 +12,11 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.stream.ConstraintMetaModel; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.SolverConfigOverride; import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.api.solver.SolverManager; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; @@ -33,9 +35,8 @@ import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.PhaseFactory; -import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; -import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.random.DefaultRandomSource; import ai.timefold.solver.core.impl.solver.random.RandomSource; @@ -55,6 +56,25 @@ import io.micrometer.core.instrument.Tags; /** + * The default solver factory must maintain a default score director factory, + * as some solver components depend on this factory, + * including {@link SolverManager} and {@code TimefoldSolverBeanFactory}. + *

+ * The proposed approach establishes that the configuration defines a root environment mode, + * which is used to create the default score director factory. + * Since the phases can override the environment, + * the delegate factory will enable the creation of separate factories + * while maintaining a default one that is used for all other components. + *

+ * The necessity for a default environment mode can be illustrated by the following use case. + * Imagine a configuration that includes multiple phases, each with a different environment mode. + * If a Quarkus application needs to inject a {@link ConstraintMetaModel} + * instance, this instance depends on the score director factory, + * which in turn relies on the environment mode. + * If multiple phase environments exist, + * selecting one of these environments is not possible + * as this injection point is decoupled from the solving life cycle. + * * @param the solution type, the class with the {@link PlanningSolution} annotation * @see SolverFactory */ @@ -67,7 +87,9 @@ public final class DefaultSolverFactory implements SolverFactory solutionDescriptor; - private final ScoreDirectorFactory scoreDirectorFactory; + private final EnvironmentMode defaultEnvironmentMode; + private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; + private final ScoreDirectorFactory defaultScoreDirectorFactory; private final DomainAccessType domainAccessType; public DefaultSolverFactory(SolverConfig solverConfig) { @@ -77,10 +99,31 @@ public DefaultSolverFactory(SolverConfig solverConfig) { public DefaultSolverFactory(SolverConfig solverConfig, DomainAccessType domainAccessType) { this.domainAccessType = domainAccessType; this.clock = Objects.requireNonNullElse(solverConfig.getClock(), Clock.systemDefaultZone()); - this.solverConfig = Objects.requireNonNull(solverConfig, "The solverConfig (" + solverConfig + ") cannot be null."); + this.solverConfig = + Objects.requireNonNull(solverConfig, "The solverConfig (%s) cannot be null.".formatted(solverConfig)); + this.defaultEnvironmentMode = assertEnvironmentModeConfiguration(solverConfig); this.solutionDescriptor = buildSolutionDescriptor(); - // Caching score director factory as it potentially does expensive things. - this.scoreDirectorFactory = buildScoreDirectorFactory(); + var scoreDirectorFactoryConfig = + Objects.requireNonNullElseGet(solverConfig.getScoreDirectorFactoryConfig(), ScoreDirectorFactoryConfig::new); + var hasMetricRequiringConstraintMatch = hasMetricRequiringConstraintMatch(solverConfig); + this.delegateScoreDirectorFactory = new DelegateScoreDirectorFactory<>( + Objects.requireNonNull(scoreDirectorFactoryConfig), hasMetricRequiringConstraintMatch); + // Caching score director factory as it potentially does expensive things + this.defaultScoreDirectorFactory = + this.delegateScoreDirectorFactory.buildScoreDirectorFactory(defaultEnvironmentMode, solutionDescriptor); + } + + private static boolean hasMetricRequiringConstraintMatch(SolverConfig solverConfig) { + var monitoringConfig = solverConfig.determineMetricConfig(); + var solverMetricList = Objects.requireNonNull(monitoringConfig.getSolverMetricList()); + var metricsRequiringConstraintMatch = false; + if (!solverMetricList.isEmpty()) { + metricsRequiringConstraintMatch = !solverMetricList.stream() + .filter(SolverMetric::isMetricConstraintMatchBased) + .toList() + .isEmpty(); + } + return metricsRequiringConstraintMatch; } public Clock getClock() { @@ -93,7 +136,7 @@ public SolutionDescriptor getSolutionDescriptor() { @SuppressWarnings("unchecked") public > ScoreDirectorFactory getScoreDirectorFactory() { - return (ScoreDirectorFactory) scoreDirectorFactory; + return (ScoreDirectorFactory) defaultScoreDirectorFactory; } @Override @@ -115,26 +158,20 @@ public Solver buildSolver(SolverConfigOverride configOverride) { } else { solverScope.setSolverMetricSet(EnumSet.noneOf(SolverMetric.class)); } - - var environmentMode = solverConfig.determineEnvironmentMode(); - var isStepAssertOrMore = environmentMode.isStepAssertOrMore(); + var isStepAssertOrMore = defaultEnvironmentMode.isStepAssertOrMore(); var constraintMatchEnabled = !metricsRequiringConstraintMatchSet.isEmpty() || isStepAssertOrMore; if (constraintMatchEnabled && !isStepAssertOrMore) { LOGGER.info( "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", metricsRequiringConstraintMatchSet); } - var castScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() - .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. - .withConstraintMatchPolicy( - constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) - .build(); - solverScope.setScoreDirector(castScoreDirector); - solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(castScoreDirector)); - + var scoreDirector = delegateScoreDirectorFactory.createScoreDirector(getScoreDirectorFactory()); + solverScope.setScoreDirector(scoreDirector); + solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(scoreDirector)); var moveThreadCount = resolveMoveThreadCount(true); - var bestSolutionRecaller = BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(environmentMode); - var randomFactory = buildRandomSupplier(environmentMode); + var bestSolutionRecaller = + BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(defaultEnvironmentMode); + var randomFactory = buildRandomSupplier(defaultEnvironmentMode); var previewFeaturesEnabled = solverConfig.getEnablePreviewFeatureSet(); var scoreDirectorFactoryConfig = solverConfig.getScoreDirectorFactoryConfig(); @@ -149,13 +186,13 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var configPolicy = new HeuristicConfigPolicy.Builder() .withPreviewFeatureSet(previewFeaturesEnabled) - .withEnvironmentMode(environmentMode) + .withEnvironmentMode(defaultEnvironmentMode) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(solverConfig.getMoveThreadBufferSize()) .withThreadFactoryClass(solverConfig.getThreadFactoryClass()) .withNearbyDistanceMeterClass(solverConfig.getNearbyDistanceMeterClass()) .withRandom(randomFactory.get()) - .withInitializingScoreTrend(scoreDirectorFactory.getInitializingScoreTrend()) + .withInitializingScoreTrend(defaultScoreDirectorFactory.getInitializingScoreTrend()) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(ClassInstanceCache.create()) .build(); @@ -163,8 +200,8 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var termination = buildTermination(basicPlumbingTermination, configPolicy, configOverride); var phaseList = buildPhaseList(configPolicy, bestSolutionRecaller, termination); - return new DefaultSolver<>(environmentMode, randomFactory, bestSolutionRecaller, basicPlumbingTermination, - (UniversalTermination) termination, phaseList, solverScope, + return new DefaultSolver<>(defaultEnvironmentMode, delegateScoreDirectorFactory, randomFactory, bestSolutionRecaller, + basicPlumbingTermination, (UniversalTermination) termination, phaseList, solverScope, moveThreadCount == null ? SolverConfig.MOVE_THREAD_COUNT_NONE : Integer.toString(moveThreadCount)); } @@ -182,7 +219,7 @@ private SolverTermination buildTermination(BasicPlumbingTermination configPolicy, SolverConfigOverride solverConfigOverride) { var terminationConfig = Objects.requireNonNullElseGet(solverConfigOverride.getTerminationConfig(), () -> Objects.requireNonNullElseGet(solverConfig.getTerminationConfig(), TerminationConfig::new)); - return TerminationFactory. create(terminationConfig) + return TerminationFactory. create(Objects.requireNonNull(terminationConfig)) .buildTermination(configPolicy, basicPlumbingTermination); } @@ -205,22 +242,14 @@ private SolutionDescriptor buildSolutionDescriptor() { solverConfig.getEntityClassList()); } - private > ScoreDirectorFactory buildScoreDirectorFactory() { - var environmentMode = solverConfig.determineEnvironmentMode(); - var scoreDirectorFactoryConfig_ = - Objects.requireNonNullElseGet(solverConfig.getScoreDirectorFactoryConfig(), ScoreDirectorFactoryConfig::new); - var scoreDirectorFactoryFactory = new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig_); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(environmentMode, solutionDescriptor); - } - - public Supplier buildRandomSupplier(EnvironmentMode environmentMode_) { - var randomSeed_ = solverConfig.getRandomSeed(); - if (randomSeed_ == null && environmentMode_ != EnvironmentMode.NON_REPRODUCIBLE) { - randomSeed_ = DEFAULT_RANDOM_SEED; - } else if (randomSeed_ == null) { - randomSeed_ = RandomGenerator.getDefault().nextLong(); + Supplier buildRandomSupplier(EnvironmentMode environmentMode) { + var randomSeed = solverConfig.getRandomSeed(); + if (randomSeed == null && environmentMode != EnvironmentMode.NON_REPRODUCIBLE) { + randomSeed = DEFAULT_RANDOM_SEED; + } else if (randomSeed == null) { + randomSeed = RandomGenerator.getDefault().nextLong(); } - return DefaultRandomSource.seededSupplier(randomSeed_); + return DefaultRandomSource.seededSupplier(randomSeed); } public List> buildPhaseList(HeuristicConfigPolicy configPolicy, @@ -272,6 +301,59 @@ public void ensurePreviewFeature(PreviewFeature previewFeature) { HeuristicConfigPolicy.ensurePreviewFeature(previewFeature, solverConfig.getEnablePreviewFeatureSet()); } + private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig solverConfig) { + var defaultEnvironmentMode = solverConfig.determineEnvironmentMode(); + var phaseConfigList = solverConfig.getPhaseConfigList(); + if (ConfigUtils.isEmptyCollection(phaseConfigList)) { + return defaultEnvironmentMode; + } + var phaseEnvironmentList = + phaseConfigList.stream() + .map(phaseConfig -> Objects.requireNonNullElse(phaseConfig.getEnvironmentMode(), + defaultEnvironmentMode)) + .toList(); + if (defaultEnvironmentMode == EnvironmentMode.NON_REPRODUCIBLE + && phaseEnvironmentList.stream().anyMatch(environmentMode -> environmentMode != defaultEnvironmentMode)) { + // If the default environment is non-reproducible, + // then all phase environment modes must also be non-reproducible + throw new IllegalStateException( + "The default environment mode is (%s), and all phase environments [%s] must also be non-reproducible." + .formatted(defaultEnvironmentMode.name(), + String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()))); + } + // If none of the phase environments use the default environment, we fail fast. + var checkDefaultEnvironment = phaseEnvironmentList.isEmpty(); + for (var phaseEnvironment : phaseEnvironmentList) { + if (phaseEnvironment == defaultEnvironmentMode) { + checkDefaultEnvironment = true; + break; + } + } + if (!checkDefaultEnvironment) { + throw new IllegalStateException(""" + The default environment mode (%s) is not used in any of the defined phases environment modes [%s]. + Maybe adjust the solver config's default environment mode. + Maybe adjust at least one of the phase environment modes to match the default environment mode (%s)""" + .formatted( + defaultEnvironmentMode.name(), + String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()), + defaultEnvironmentMode.name())); + } + var invalidPhaseEnvironmentList = new ArrayList(phaseConfigList.size()); + for (var phaseEnvironment : phaseEnvironmentList) { + if (phaseEnvironment.ordinal() > defaultEnvironmentMode.ordinal()) { + invalidPhaseEnvironmentList.add(phaseEnvironment.name()); + } + } + if (!invalidPhaseEnvironmentList.isEmpty()) { + // The phase environments must have an assertion level greater than or equal to the default environment level + throw new IllegalStateException( + "The phase environments must have an assertion level higher than or equal to the default environment level (%s). The following phase environment modes are not valid: [%s]." + .formatted(defaultEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); + } + return defaultEnvironmentMode; + } + // Required for testability as final classes cannot be mocked. static class MoveThreadCountResolver { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java index f041e445c0b..b70210dbc6c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java @@ -193,7 +193,7 @@ public > InnerScore calculateScore() { } public void assertScoreFromScratch(Solution_ solution) { - scoreDirector.getScoreDirectorFactory().assertScoreFromScratch(solution); + scoreDirector.assertScoreFromScratch(solution); } @SuppressWarnings("unchecked") diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java similarity index 87% rename from core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java index 1f2076cfe1a..ce72a964391 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java @@ -13,15 +13,17 @@ import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.testconstraint.DummyConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; -class ScoreDirectorFactoryFactoryTest { +class DelegateScoreDirectorFactoryTest { @Test void multipleScoreCalculations_throwsException() { @@ -35,7 +37,7 @@ void multipleScoreCalculations_throwsException() { private ScoreDirectorFactory buildTestdataScoreDirectoryFactory(ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { - return new ScoreDirectorFactoryFactory(config) + return new DelegateScoreDirectorFactory(config) .buildScoreDirectorFactory(environmentMode, TestdataSolution.buildSolutionDescriptor()); } @@ -44,6 +46,17 @@ void multipleScoreCalculations_throwsException() { return buildTestdataScoreDirectoryFactory(config, EnvironmentMode.PHASE_ASSERT); } + @Test + void constraintMatchEnabledPerPhaseEnvironmentMode() { + var config = new ScoreDirectorFactoryConfig().withConstraintProviderClass(DummyConstraintProvider.class); + var delegateScoreDirectorFactory = new DelegateScoreDirectorFactory(config, false); + var phaseScoreDirectorFactory = delegateScoreDirectorFactory.buildScoreDirectorFactory(EnvironmentMode.FULL_ASSERT, + TestdataSolution.buildSolutionDescriptor()); + try (var scoreDirector = delegateScoreDirectorFactory.createScoreDirector(phaseScoreDirectorFactory)) { + assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.ENABLED); + } + } + @Test void constraintStreamsBavet() { var config = new ScoreDirectorFactoryConfig() @@ -167,15 +180,17 @@ public void setIntProperty(int intProperty) { @Override public void resetWorkingSolution(TestdataSolution workingSolution) { - + // No actions } @Override public void beforeVariableChanged(Object entity, String variableName) { + // No actions } @Override public void afterVariableChanged(Object entity, String variableName) { + // No actions } @Override diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java index 619f9507f57..eb6399abf6f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java @@ -10,8 +10,8 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorSemanticsTest; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; -import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; import ai.timefold.solver.core.testdomain.TestdataSolution; import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesEasyScoreCalculator; import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesSolution; @@ -32,7 +32,7 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataConstraintWeightOverridesEasyScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( + new DelegateScoreDirectorFactory( scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -44,7 +44,7 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedListEasyScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -55,7 +55,7 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedWithIndexListEasyScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -79,7 +79,7 @@ void easyScoreCalculatorWithCustomProperties() { private ScoreDirectorFactory buildTestdataScoreDirectoryFactory( ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { - return new ScoreDirectorFactoryFactory(config) + return new DelegateScoreDirectorFactory(config) .buildScoreDirectorFactory(environmentMode, TestdataSolution.buildSolutionDescriptor()); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java index 3aa3c4b50f8..b773d5c899f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java @@ -13,8 +13,8 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorSemanticsTest; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; -import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesSolution; import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListEntity; @@ -34,7 +34,7 @@ final class IncrementalScoreDirectorSemanticsTest extends AbstractScoreDirectorS var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataConstraintWeightOverridesIncrementalScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( + new DelegateScoreDirectorFactory( scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -45,7 +45,7 @@ protected ScoreDirectorFactory buildSco var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedListIncrementalScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -56,7 +56,7 @@ protected ScoreDirectorFactory buildSco var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedWithIndexListIncrementalScoreCalculator.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java index e5f0d0e1ab8..cd195be2f63 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java @@ -12,6 +12,7 @@ import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; import ai.timefold.solver.core.api.score.stream.ConstraintRef; import ai.timefold.solver.core.api.score.stream.DefaultConstraintJustification; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.definition.SimpleScoreDefinition; @@ -61,6 +62,7 @@ private IncrementalScoreDirectorFactory mockIncrementalScor when(factory.getScoreDefinition()).thenReturn(new SimpleScoreDefinition()); SolutionDescriptor solutionDescriptor = mock(SolutionDescriptor.class); when(factory.getSolutionDescriptor()).thenReturn(solutionDescriptor); + when(factory.getEnvironmentMode()).thenReturn(EnvironmentMode.PHASE_ASSERT); return factory; } @@ -124,10 +126,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override @@ -168,10 +172,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override @@ -213,10 +219,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override @@ -264,10 +272,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override @@ -311,10 +321,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override @@ -358,10 +370,12 @@ public void resetWorkingSolution(Object workingSolution) { @Override public void beforeVariableChanged(Object entity, String variableName) { + // No action needed } @Override public void afterVariableChanged(Object entity, String variableName) { + // No action needed } @Override diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java index 08bacb8906f..d577c4682d5 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java @@ -5,8 +5,8 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorSemanticsTest; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; -import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesConstraintProvider; import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesSolution; import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListConstraintProvider; @@ -23,7 +23,7 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataConstraintWeightOverridesConstraintProvider.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( + new DelegateScoreDirectorFactory( scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -35,7 +35,7 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedListConstraintProvider.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } @@ -46,7 +46,7 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedWithIndexListConstraintProvider.class); var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java index e1b5108e66c..c8afc141ea2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java @@ -18,6 +18,7 @@ import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; import ai.timefold.solver.core.testdomain.invalid.noentity.TestdataNoEntitySolution; +import ai.timefold.solver.core.testutil.PlannerTestUtils; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; @@ -165,4 +166,56 @@ void testInvalidConstraintProfilingWithoutEnterprise() { "remove constraintStreamProfilingEnabled from the solver configuration"); } + @Test + void assertEnvironmentModeWithoutPhases() { + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEasyScoreCalculatorClass(DummyEasyScoreCalculator.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT); + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)).doesNotThrowAnyException(); + } + + @Test + void assertEnvironmentModeWithValidPhases() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // The default environment mode must be used by at least one phase, + // and every phase must be at least as strict as the default. + solverConfig.getPhaseConfigList().getFirst().setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)).doesNotThrowAnyException(); + } + + @Test + void assertEnvironmentWithNonReproducibleAndMismatchingPhase() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NO_ASSERT); + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) + .hasMessageContaining("must also be non-reproducible"); + } + + @Test + void assertEnvironmentModeWithDefaultNotUsedByAnyPhase() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NON_INTRUSIVE_FULL_ASSERT); + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) + .hasMessageContaining("is not used in any of the defined phases environment modes"); + } + + @Test + void assertEnvironmentModeWithPhaseLessStrictThanDefault() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NO_ASSERT); + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) + .hasMessageContaining( + "must have an assertion level higher than or equal to the default environment level"); + } + } 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 63b87376caa..e85d80bce07 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 @@ -2397,6 +2397,16 @@ void assertDefaultPhaseEnvironmentMode() { .containsExactly(EnvironmentMode.PHASE_ASSERT, EnvironmentMode.FULL_ASSERT); } + @Test + void solveWithPhaseEnvironmentModeOverride() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // LS phase overridden to FULL_ASSERT + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var problem = TestdataSolution.generateSolution(2, 2); + var bestSolution = PlannerTestUtils.solve(solverConfig, problem); + assertThat(bestSolution).isNotNull(); + } + @NullMarked public static class CorruptedIncrementalScoreCalculator implements AnalyzableIncrementalScoreCalculator { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java index 6cc8d2f0a7f..38b194dab07 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java @@ -3,7 +3,7 @@ import static ai.timefold.solver.core.testutil.PlannerAssert.assertCode; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.util.ArrayList; import java.util.Arrays; @@ -55,7 +55,6 @@ import ai.timefold.solver.core.testutil.AbstractMeterTest; import ai.timefold.solver.core.testutil.PlannerTestUtils; -import org.assertj.core.api.Assertions; import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.Test; @@ -136,12 +135,7 @@ void checkDefaultMeters() { latch.countDown(); }); solver.solve(solution); - - try { - latch.await(10, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Assertions.fail("Failed waiting for the event to happen.", e); - } + assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -231,11 +225,7 @@ void checkDefaultMetersTags() { }); solver.solve(solution); - try { - latch.await(10, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Assertions.fail("Failed waiting for the event to happen.", e); - } + assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -298,11 +288,7 @@ void solveMetrics() { }); solution = solver.solve(solution); - try { - latch.await(10, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Assertions.fail("Failed waiting for the event to happen.", e); - } + assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); meterRegistry.publish(); assertThat(solution).isNotNull(); assertThat(solution.getEntityList().stream() @@ -451,11 +437,7 @@ void solveBestScoreMetrics() { }); solution = solver.solve(solution); - try { - latch.await(10, TimeUnit.SECONDS); - } catch (InterruptedException e) { - fail("Failed waiting for the event to happen.", e); - } + assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); assertThat(step.get()).isEqualTo(2); meterRegistry.publish(); assertThat(solution).isNotNull(); From 87424899e5bc5164e66fea763ccebcc099d0e038 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 11 Aug 2026 15:03:00 -0300 Subject: [PATCH 03/20] fix: avoid stale references of `ListVariableStateSupply` in the move selector --- .../list/ElementDestinationSelector.java | 14 +-- .../selector/list/RandomSubListSelector.java | 19 ++-- .../generic/list/ListChangeMoveSelector.java | 22 +++-- .../generic/list/ListSwapMoveSelector.java | 14 +-- .../list/kopt/KOptListMoveSelector.java | 14 +-- .../ruin/ListRuinRecreateMoveSelector.java | 17 ++-- .../core/impl/solver/AbstractSolver.java | 97 ++++++++++++++++++- .../core/impl/solver/DefaultSolver.java | 84 +--------------- .../list/ElementDestinationSelectorTest.java | 24 +++-- .../list/RandomSubListSelectorTest.java | 9 +- .../list/ListChangeMoveSelectorTest.java | 16 +-- .../list/ListSwapMoveSelectorTest.java | 12 ++- .../list/RandomListChangeIteratorTest.java | 5 +- .../RandomSubListChangeMoveSelectorTest.java | 15 ++- .../RandomSubListSwapMoveSelectorTest.java | 15 ++- .../core/impl/solver/DefaultSolverTest.java | 17 +++- 16 files changed, 231 insertions(+), 163 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java index 0037a9f9812..2151c7f2ec5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java @@ -15,7 +15,7 @@ import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.util.MappingIterator; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; @@ -93,15 +93,17 @@ private IterableValueSelector filterUnassignedValues( } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // The phase may operate in a different environment mode, which uses a new score director. + // We must ensure that the list variable state supply remains up to date. + var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); } @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java index 05df568df39..56dbbe85865 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java @@ -11,7 +11,7 @@ import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; public class RandomSubListSelector extends AbstractSelector implements SubListSelector { @@ -53,16 +53,19 @@ private ListVariableStateSupply getListVariableStateS } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, workingRandom); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, workingRandom); + // The phase may run under a different environment mode, which swaps in a new score director + // (and thus a new SupplyManager); re-demand so the supply doesn't go stale. + var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); + this.listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); } @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + triangleElementFactory = null; listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java index 02549522ab7..e50f122cf69 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java @@ -10,7 +10,7 @@ import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement; import ai.timefold.solver.core.preview.api.move.Move; @@ -38,13 +38,21 @@ private ListVariableStateSupply getListVariableStateS } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // The phase may operate in a different environment mode, which uses a new score director. + // We must ensure that the list variable state supply remains up to date. var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); + var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); this.listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); } + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + listVariableStateSupply = null; + } + public static IterableValueSelector filterPinnedListPlanningVariableValuesWithIndex( IterableValueSelector sourceValueSelector, Supplier> listVariableStateSupplier) { @@ -68,12 +76,6 @@ public static IterableValueSelector filterPinnedListPlann }); } - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - listVariableStateSupply = null; - } - @Override public long getSize() { return sourceValueSelector.getSize() * destinationSelector.getSize(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java index d1413ca29d9..014e98fc0a8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java @@ -9,7 +9,7 @@ import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.preview.api.move.Move; public class ListSwapMoveSelector extends GenericMoveSelector { @@ -38,16 +38,18 @@ private ListVariableStateSupply getListVariableStateS } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // The phase may operate in a different environment mode, which uses a new score director. + // We must ensure that the list variable state supply remains up to date. var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); + var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); } @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java index d01197fce94..58161d2be00 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java @@ -11,7 +11,7 @@ import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.util.MathUtils; import ai.timefold.solver.core.preview.api.move.Move; @@ -56,15 +56,17 @@ private ListVariableStateSupply getListVariableStateS } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // The phase may operate in a different environment mode, which uses a new score director. + // We must ensure that the list variable state supply remains up to date. + var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); } @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java index 0de734404d4..e82ed77a39c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java @@ -64,24 +64,21 @@ public boolean isNeverEnding() { } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - this.solverScope = solverScope; - this.listVariableStateSupply = solverScope.getScoreDirector() + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + this.solverScope = phaseScope.getSolverScope(); + // The phase may operate in a different environment mode, which uses a new score director. + // We must ensure that the list variable state supply remains up to date. + this.listVariableStateSupply = phaseScope.getScoreDirector() .getSupplyManager() .demand(listVariableDescriptor.getStateDemand()); } - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - this.listVariableStateSupply = null; - } - @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); this.solverScope = null; + this.listVariableStateSupply = null; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index eebce6ed365..42779183f06 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -6,13 +6,19 @@ import java.util.random.RandomGenerator; 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.SolverEventListener; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleSupport; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; +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.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; import ai.timefold.solver.core.impl.solver.random.DefaultRandomSource; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; @@ -39,6 +45,8 @@ public abstract class AbstractSolver implements Solver { protected final transient Logger LOGGER = LoggerFactory.getLogger(getClass()); + protected final SolverContext defaultSolverContext; + private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; private final SolverEventSupport solverEventSupport = new SolverEventSupport<>(this); private final PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); @@ -49,17 +57,78 @@ public abstract class AbstractSolver implements Solver { protected final List> phaseList; private RandomGenerator.@Nullable SplittableGenerator savedRandom; + private SolverContext currentContext; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - protected AbstractSolver(BestSolutionRecaller bestSolutionRecaller, - UniversalTermination globalTermination, List> phaseList) { + protected AbstractSolver(SolverContext defaultSolverContext, + DelegateScoreDirectorFactory delegateScoreDirectorFactory, + BestSolutionRecaller bestSolutionRecaller, UniversalTermination globalTermination, + List> phaseList) { + this.delegateScoreDirectorFactory = delegateScoreDirectorFactory; this.bestSolutionRecaller = bestSolutionRecaller; this.globalTermination = globalTermination; bestSolutionRecaller.setSolverEventSupport(solverEventSupport); this.phaseList = List.copyOf(phaseList); + this.defaultSolverContext = defaultSolverContext; + this.currentContext = defaultSolverContext; + } + + protected void runPhases(SolverScope solverScope) { + if (!solverScope.getSolutionDescriptor().hasMovableEntities(solverScope.getScoreDirector())) { + logger.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", + phaseList.size(), solverScope.getWorkingEntityCount()); + return; + } + Iterator> it = phaseList.iterator(); + while (!globalTermination.isSolverTerminated(solverScope) && it.hasNext()) { + Phase phase = it.next(); + preparePhase(solverScope, phase); + phase.solve(solverScope); + // If there is a next phase, it starts from the best solution, which might differ from the working solution. + // If there isn't, no need to planning clone the best solution to the working solution. + if (it.hasNext()) { + solverScope.setWorkingSolutionFromBestSolution(); + } + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private void preparePhase(SolverScope solverScope, Phase phase) { + // The environment modes match, and there is no need for any changes. + if (phase.getEnvironmentMode() == currentContext.environmentMode()) { + return; + } + // The phase environment mode matches default, so we will restore it. + if (phase.getEnvironmentMode() == defaultSolverContext.environmentMode()) { + // Update and load the default context + loadContext(currentContext, defaultSolverContext, solverScope); + return; + } + // Since the current logic does not cache any solver context other than the default, + // we need to create a new solver context + // because the required environment mode differs from both the current and the default modes. + ScoreDirectorFactory newScoreDirectorFactory = delegateScoreDirectorFactory + .buildScoreDirectorFactory(phase.getEnvironmentMode(), solverScope.getSolutionDescriptor()); + var newScoreDirector = delegateScoreDirectorFactory.createScoreDirector(newScoreDirectorFactory); + var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, + new DefaultProblemChangeDirector<>(newScoreDirector), currentContext.bestSolutionRecaller); + loadContext(currentContext, newSolverContext, solverScope); + } + + private void loadContext(SolverContext oldSolverContext, SolverContext newSolverContext, + SolverScope solverScope) { + solverScope.setScoreDirector(newSolverContext.scoreDirector()); + solverScope.setProblemChangeDirector(newSolverContext.problemChangeDirector()); + // We will use the same working solution set from the previous phase, as it has already been cloned + newSolverContext.scoreDirector().setWorkingSolution(oldSolverContext.scoreDirector().getWorkingSolution()); + bestSolutionRecaller.enableAssertions(newSolverContext.environmentMode()); + if (oldSolverContext != defaultSolverContext) { + oldSolverContext.release(); + } + currentContext = newSolverContext; } public void solvingStarted(SolverScope solverScope) { @@ -84,6 +153,10 @@ public void solvingEnded(SolverScope solverScope) { bestSolutionRecaller.solvingEnded(solverScope); globalTermination.solvingEnded(solverScope); phaseLifecycleSupport.fireSolvingEnded(solverScope); + if (currentContext != defaultSolverContext) { + // Release the last context if it is not the default one + currentContext.release(); + } } public void solvingError(SolverScope solverScope, Exception exception) { @@ -165,8 +238,28 @@ public BestSolutionRecaller getBestSolutionRecaller() { return bestSolutionRecaller; } + @SuppressWarnings("unchecked") + public > DelegateScoreDirectorFactory getDelegateScoreDirectorFactory() { + return (DelegateScoreDirectorFactory) delegateScoreDirectorFactory; + } + public List> getPhaseList() { return phaseList; } + public record SolverContext>(EnvironmentMode environmentMode, + InnerScoreDirector scoreDirector, DefaultProblemChangeDirector problemChangeDirector, + BestSolutionRecaller bestSolutionRecaller) { + + public static > SolverContext of( + EnvironmentMode environmentMode, + SolverScope solverScope, BestSolutionRecaller bestSolutionRecaller) { + return new SolverContext<>(environmentMode, solverScope. getScoreDirector(), + solverScope.getProblemChangeDirector(), bestSolutionRecaller); + } + + void release() { + scoreDirector.close(); + } + } } 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 93a586d513b..627e1aba889 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 @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.solver; import java.util.Collections; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -21,7 +20,6 @@ import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; 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.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.random.RandomSource; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -43,14 +41,11 @@ @NullMarked public class DefaultSolver extends AbstractSolver { - private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; private final Supplier randomFactory; private final BasicPlumbingTermination basicPlumbingTermination; private final AtomicBoolean solving = new AtomicBoolean(false); private final SolverScope solverScope; private final String moveThreadCountDescription; - private final SolverContext defaultSolverContext; - private SolverContext currentContext; // ************************************************************************ // Constructors and simple getters/setters @@ -61,15 +56,13 @@ public DefaultSolver(EnvironmentMode environmentMode, Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { - super(bestSolutionRecaller, termination, phaseList); - this.delegateScoreDirectorFactory = delegateScoreDirectorFactory; + super(SolverContext.of(environmentMode, solverScope, bestSolutionRecaller), delegateScoreDirectorFactory, + bestSolutionRecaller, termination, phaseList); this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; this.solverScope = solverScope; solverScope.setSolver(this); this.moveThreadCountDescription = moveThreadCountDescription; - this.defaultSolverContext = SolverContext.of(environmentMode, solverScope); - this.currentContext = defaultSolverContext; } public RandomSource getRandomSource() { @@ -193,57 +186,6 @@ public final Solution_ solve(Solution_ problem) { return solverScope.getBestSolution(); } - protected void runPhases(SolverScope solverScope) { - if (!solverScope.getSolutionDescriptor().hasMovableEntities(solverScope.getScoreDirector())) { - logger.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", - phaseList.size(), solverScope.getWorkingEntityCount()); - return; - } - Iterator> it = phaseList.iterator(); - while (!globalTermination.isSolverTerminated(solverScope) && it.hasNext()) { - Phase phase = it.next(); - preparePhase(phase); - phase.solve(solverScope); - // If there is a next phase, it starts from the best solution, which might differ from the working solution. - // If there isn't, no need to planning clone the best solution to the working solution. - if (it.hasNext()) { - solverScope.setWorkingSolutionFromBestSolution(); - } - } - } - - @SuppressWarnings({ "rawtypes", "unchecked" }) - private void preparePhase(Phase phase) { - // The environment modes match, and there is no need for any changes. - if (phase.getEnvironmentMode() == currentContext.environmentMode()) { - return; - } - // The phase environment mode matches default, so we will restore it. - if (phase.getEnvironmentMode() == defaultSolverContext.environmentMode()) { - // Release the current context - currentContext.release(); - // Update and load the default context - currentContext = defaultSolverContext; - currentContext.load(solverScope); - return; - } - // Since the current logic does not cache any solver context other than the default, - // we need to create a new solver context - // because the required environment mode differs from both the current and the default modes. - ScoreDirectorFactory newScoreDirectorFactory = delegateScoreDirectorFactory - .buildScoreDirectorFactory(phase.getEnvironmentMode(), solverScope.getSolutionDescriptor()); - var newScoreDirector = delegateScoreDirectorFactory.createScoreDirector(newScoreDirectorFactory); - var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, - new DefaultProblemChangeDirector<>(newScoreDirector), currentContext.bestSolutionRecaller); - // Release the current context - if (currentContext != defaultSolverContext) { - currentContext.release(); - } - // Update and load the new context - currentContext = newSolverContext; - currentContext.load(solverScope); - } - public void outerSolvingStarted(SolverScope solverScope) { solving.set(true); basicPlumbingTermination.resetTerminateEarly(); @@ -416,26 +358,4 @@ private boolean checkProblemChanges() { return true; } } - - private record SolverContext>(EnvironmentMode environmentMode, - InnerScoreDirector scoreDirector, DefaultProblemChangeDirector problemChangeDirector, - BestSolutionRecaller bestSolutionRecaller) { - - static > SolverContext - of(EnvironmentMode environmentMode, SolverScope solverScope) { - return new SolverContext<>(environmentMode, solverScope. getScoreDirector(), - solverScope.getProblemChangeDirector(), solverScope.getSolver().getBestSolutionRecaller()); - } - - void load(SolverScope solverScope) { - solverScope.setScoreDirector(scoreDirector); - solverScope.setProblemChangeDirector(problemChangeDirector); - solverScope.setWorkingSolutionFromBestSolution(); - bestSolutionRecaller.enableAssertions(environmentMode); - } - - void release() { - scoreDirector.close(); - } - } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java index 6e323af90f5..835c18d067b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java @@ -96,7 +96,8 @@ void original() { var selector = new ElementDestinationSelector<>(entitySelector, valueSelector, false); - solvingStarted(selector, scoreDirector); + var solverScope = solvingStarted(selector, scoreDirector); + phaseStarted(selector, solverScope); // Entity order: [A, B, C] // Value order: [3, 1, 2] @@ -147,7 +148,8 @@ void random() { 2, // => C[0] -1); // (not tested) - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); // Initial state: // - A [1, 2] @@ -560,7 +562,9 @@ void emptyIfThereAreNoEntities() { mockIterableValueSelector(TestdataListEntity.buildVariableDescriptorForValueList(), v1, v2, v3); var randomSelector = new ElementDestinationSelector<>(entitySelector, valueSelector, true); - solvingStarted(randomSelector, scoreDirector); + var solverScope = solvingStarted(randomSelector, scoreDirector); + phaseStarted(randomSelector, solverScope); + assertEmptyNeverEndingIterableSelector(randomSelector, 0); var originalSelector = new ElementDestinationSelector<>(entitySelector, valueSelector, false); @@ -589,7 +593,9 @@ void notEmptyIfThereAreEntities() { var randomSelector = new ElementDestinationSelector<>(entitySelector, valueSelector, true); var random = new TestRandom(0, 1); - solvingStarted(randomSelector, scoreDirector, random); + var solverScope = solvingStarted(randomSelector, scoreDirector, random); + phaseStarted(randomSelector, solverScope); + // Do not assert all codes to prevent exhausting the iterator. assertCodesOfNeverEndingIterableSelector(randomSelector, 2, "A[0]"); } @@ -617,7 +623,8 @@ void notEmptyIfThereAreEntitiesWithPinning() { var randomSelector = new ElementDestinationSelector<>(entitySelector, valueSelector, true); var random = new TestRandom(0, 1); - solvingStarted(randomSelector, scoreDirector, random); + var solverScope = solvingStarted(randomSelector, scoreDirector, random); + phaseStarted(randomSelector, solverScope); // Do not assert all codes to prevent exhausting the iterator. assertCodesOfNeverEndingIterableSelector(randomSelector, 2, "A[0]"); } @@ -678,7 +685,9 @@ void discardOldValues() { // Picks value selector twice var random = new TestRandom(5, 5, 5, 5); - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); + assertAllCodesOfIterator(selector.iterator(), "B[1]", "B[1]"); // Even using only the value selector, @@ -707,7 +716,8 @@ void discardOldValuesAndResetState() { var selector = new ElementDestinationSelector<>(entitySelector, replayingValueSelector, valueSelector, true, false); // Value 0 makes the iterator to always request an entity from the related iterator var random = new TestRandom(0, 0); - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); var iterator = selector.iterator(); // entityIterator returns a assertThat(iterator.hasNext()).isTrue(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java index 5fb7b0c035b..982e377b647 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java @@ -67,7 +67,8 @@ void randomUnrestricted() { var random = new TestRandom(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0); - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); // Every possible subList is selected. assertCodesOfNeverEndingIterableSelector(selector, subListCount, @@ -153,7 +154,8 @@ void randomAllowsUnassignedValues() { var random = new TestRandom(0, 1, 2, 3, 4, 5, 0); - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); // Every possible subList is selected. assertCodesOfNeverEndingIterableSelector(selector, subListCount, @@ -192,7 +194,8 @@ void randomWithSubListSizeBounds() { var random = new TestRandom(0, 1, 2, 3, 4, 5, 6, 0); - solvingStarted(selector, scoreDirector, random); + var solverScope = solvingStarted(selector, scoreDirector, random); + phaseStarted(selector, solverScope); // Every possible subList is selected. assertCodesOfNeverEndingIterableSelector(selector, subListCount, diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorTest.java index 01615518f44..c6620af0932 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorTest.java @@ -79,7 +79,8 @@ void original() { ElementPosition.of(a, 1)), false); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); // Value order: [3, 1, 2] // Entity order: [A, B, C] @@ -134,7 +135,7 @@ void originalWithEntityValueRange() { var moveSelector = new ListChangeMoveSelector<>(mimicRecordingValueSelector, destinationSelector, false); var solverScope = solvingStarted(moveSelector, scoreDirector, mimicRecordingValueSelector, destinationSelector); - phaseStarted(solverScope, mimicRecordingValueSelector, destinationSelector); + phaseStarted(solverScope, moveSelector, mimicRecordingValueSelector, destinationSelector); // Not testing size; filtering selector doesn't and can't report correct size unless iterating over all values. assertAllCodesOfMoveSelectorWithoutSize(moveSelector, @@ -269,7 +270,8 @@ void originalAllowsUnassignedValues() { ElementPosition.unassigned()), false); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(solverScope, moveSelector); // First try all destinations for v3 (which is originally at C[0]), // then v1 (originally at A[1]), @@ -330,7 +332,7 @@ void originalAllowsUnassignedValuesWithEntityValueRange() { var moveSelector = new ListChangeMoveSelector<>(mimicRecordingValueSelector, destinationSelector, false); var solverScope = solvingStarted(moveSelector, scoreDirector, mimicRecordingValueSelector, destinationSelector); - phaseStarted(solverScope, mimicRecordingValueSelector, destinationSelector); + phaseStarted(solverScope, moveSelector, mimicRecordingValueSelector, destinationSelector); // Not testing size; filtering selector doesn't and can't report correct size unless iterating over all values. assertAllCodesOfMoveSelectorWithoutSize(moveSelector, "1 {A[1]->A[0]}", @@ -372,7 +374,8 @@ void random() { ElementPosition.of(a, 2)), true); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); // Initial state: // - A [1, 2] @@ -575,7 +578,8 @@ void randomAllowsUnassignedValues() { ElementPosition.unassigned()), true); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, "2 {A[1]->B[0]}", diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelectorTest.java index 6516a77331b..55d62cdac28 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelectorTest.java @@ -66,7 +66,8 @@ void original() { mockIterableValueSelector(listVariableDescriptor, v3, v1, v2), false); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); // Value order: [3, 1, 2] // Entity order: [A, B, C] @@ -220,7 +221,8 @@ void originalAllowsUnassignedValues() { mockIterableValueSelector(listVariableDescriptor, v4, v3, v2, v1), false); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); // Tests each move from the product of the two value selectors. assertAllCodesOfMoveSelectorWithoutSize(moveSelector, @@ -297,7 +299,8 @@ void random() { mockIterableValueSelector(listVariableDescriptor, v1, v2, v3, v1, v2, v3, v1, v2, v3, v1), true); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, "2 {A[1]} <-> 1 {A[0]}", @@ -502,7 +505,8 @@ void randomAllowsUnassignedValues() { mockIterableValueSelector(listVariableDescriptor, v1, v2, v3, v4, v1, v2, v3, v4, v1, v2, v3, v1, v4), true); - solvingStarted(moveSelector, scoreDirector); + var solverScope = solvingStarted(moveSelector, scoreDirector); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, "2 {A[0]} <-> 1 {A[1]}", diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomListChangeIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomListChangeIteratorTest.java index ef4750255b2..ec5d7c66580 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomListChangeIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomListChangeIteratorTest.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic.list; +import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.phaseStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.solvingStarted; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.mockEntitySelector; @@ -46,7 +47,9 @@ void iterator() { var destinationSelector = new ElementDestinationSelector<>(entitySelector, destinationValueSelector, true); var random = new TestRandom(3, 0, 1); - solvingStarted(destinationSelector, scoreDirector, random); + var solverScope = solvingStarted(destinationSelector, scoreDirector, random); + phaseStarted(destinationSelector, solverScope); + var randomListChangeIterator = new RandomListChangeIterator<>( scoreDirector.getSupplyManager().demand(listVariableDescriptor.getStateDemand()), sourceValueSelector, diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java index 4d106a2ad16..81981990653 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java @@ -68,7 +68,8 @@ void randomUnrestricted() { var random = new TestRandom(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); // Every possible subList is selected. assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * destinationSize, @@ -139,7 +140,8 @@ void randomAllowsUnassignedValues() { 2, 2, 2, 2, 2, 2, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); // Every possible subList is selected. assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * destinationSize, @@ -218,7 +220,8 @@ void randomReversing() { 9, 0, -1, -1); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); // Every possible subList is selected; some moves are reversing. assertCodesOfNeverEndingMoveSelector(moveSelector, moveSelectorSize, @@ -266,7 +269,8 @@ void randomWithSubListSizeBounds() { var random = new TestRandom(0, 1, 2, 3, 4, -1); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); // Only subLists bigger than 1 and smaller than 4 are selected. assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * destinationSize, @@ -339,7 +343,8 @@ void skipSubListsSmallerThanMinimumSize() { var random = new TestRandom(0, 1, -1); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); // Only subLists of size 2 are selected. assertCodesOfNeverEndingMoveSelector(moveSelector, diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java index b6f84690b2f..64942d3643b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java @@ -89,7 +89,8 @@ void sameEntityUnrestricted() { 9, 0, 0, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * subListCount, "{A[0+4]} <-> {A[0+4]}", @@ -162,7 +163,8 @@ void reversing() { 3, 0, 1, 0, 0, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * subListCount * 2, "{A[0+3]} <-reversing-> {B[1+1]}", @@ -222,7 +224,8 @@ void sameEntityWithSubListSizeBounds() { 4, 0, 0, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * subListCount, "{A[0+3]} <-> {A[0+3]}", @@ -317,7 +320,8 @@ void skipSubListsSmallerThanMinimumSize() { 1, 1, 0, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, subListCount * subListCount, "{A[0+2]} <-> {A[0+2]}", @@ -375,7 +379,8 @@ void allowsUnassignedValues() { 0, 0, 0, 1, 0, 2, 0, 0, 0, 0); - solvingStarted(moveSelector, scoreDirector, random); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); assertCodesOfNeverEndingMoveSelector(moveSelector, (long) subListCount * subListCount, "{A[0+2]} <-> {A[0+2]}", 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 e85d80bce07..44896f4426c 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 @@ -2400,13 +2400,26 @@ void assertDefaultPhaseEnvironmentMode() { @Test void solveWithPhaseEnvironmentModeOverride() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); - // LS phase overridden to FULL_ASSERT - solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // LS phase overridden to TRACKED_FULL_ASSERT + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); var problem = TestdataSolution.generateSolution(2, 2); var bestSolution = PlannerTestUtils.solve(solverConfig, problem); assertThat(bestSolution).isNotNull(); } + @Test + void solveListVariableWithPhaseEnvironmentModeOverride() { + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataListSolution.class, TestdataListEntity.class, TestdataListValue.class); + // LS phase overridden to TRACKED_FULL_ASSERT + var localSearchPhaseConfig = solverConfig.getPhaseConfigList().get(1); + localSearchPhaseConfig.setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); + localSearchPhaseConfig.setTerminationConfig(new TerminationConfig().withStepCountLimit(50)); + var problem = TestdataListSolution.generateUninitializedSolution(20, 5); + var bestSolution = PlannerTestUtils.solve(solverConfig, problem); + assertThat(bestSolution).isNotNull(); + } + @NullMarked public static class CorruptedIncrementalScoreCalculator implements AnalyzableIncrementalScoreCalculator { From 7f0e2b5a090066d83753066fa066c9b84e0dab35 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 13 Aug 2026 14:32:59 -0300 Subject: [PATCH 04/20] chore: bug fixes --- .../list/ElementDestinationSelector.java | 1 + .../selector/list/RandomSubListSelector.java | 1 + .../generic/list/ListChangeMoveSelector.java | 2 + .../generic/list/ListSwapMoveSelector.java | 2 + .../list/kopt/KOptListMoveSelector.java | 1 + .../ruin/ListRuinRecreateMoveSelector.java | 1 + .../score/director/AbstractScoreDirector.java | 4 +- .../score/director/InnerScoreDirector.java | 6 +- .../core/impl/solver/AbstractSolver.java | 21 ++--- .../core/impl/solver/DefaultSolver.java | 4 +- .../impl/solver/DefaultSolverFactory.java | 28 +++---- .../core/impl/solver/DefaultSolverTest.java | 76 +++++++++++++++++++ 12 files changed, 114 insertions(+), 33 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java index 2151c7f2ec5..e400b256193 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java @@ -104,6 +104,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java index 56dbbe85865..d9a9d9c1d73 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java @@ -65,6 +65,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); triangleElementFactory = null; listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java index e50f122cf69..6d00a428e9b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java @@ -50,6 +50,8 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java index 014e98fc0a8..6b031324bb9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java @@ -50,6 +50,8 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java index 58161d2be00..a9508a6a6d5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java @@ -67,6 +67,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java index e82ed77a39c..10c39d1099e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java @@ -77,6 +77,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); this.solverScope = null; this.listVariableStateSupply = null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 80b427537d0..756cf3d7345 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -213,8 +213,8 @@ public void resetCalculationCount() { } @Override - public void incrementCalculationCount() { - this.calculationCount++; + public void incrementCalculationCount(long count) { + this.calculationCount += count; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index 7d5a272d891..0cc3df4f03a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -209,7 +209,11 @@ default Solution_ cloneWorkingSolution() { void resetCalculationCount(); - void incrementCalculationCount(); + default void incrementCalculationCount() { + incrementCalculationCount(1L); + } + + void incrementCalculationCount(long count); /** * @return never null diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 42779183f06..8a50aecf297 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -114,7 +114,7 @@ private void preparePhase(SolverScope solverScope, Phase phase) { .buildScoreDirectorFactory(phase.getEnvironmentMode(), solverScope.getSolutionDescriptor()); var newScoreDirector = delegateScoreDirectorFactory.createScoreDirector(newScoreDirectorFactory); var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, - new DefaultProblemChangeDirector<>(newScoreDirector), currentContext.bestSolutionRecaller); + new DefaultProblemChangeDirector<>(newScoreDirector)); loadContext(currentContext, newSolverContext, solverScope); } @@ -125,6 +125,9 @@ private void loadContext(SolverContext oldSolverContext, SolverCon // We will use the same working solution set from the previous phase, as it has already been cloned newSolverContext.scoreDirector().setWorkingSolution(oldSolverContext.scoreDirector().getWorkingSolution()); bestSolutionRecaller.enableAssertions(newSolverContext.environmentMode()); + // Ensure that the score calculation count is consistent for the new director + newSolverContext.scoreDirector().resetCalculationCount(); + newSolverContext.scoreDirector().incrementCalculationCount(oldSolverContext.scoreDirector().getCalculationCount()); if (oldSolverContext != defaultSolverContext) { oldSolverContext.release(); } @@ -154,8 +157,9 @@ public void solvingEnded(SolverScope solverScope) { globalTermination.solvingEnded(solverScope); phaseLifecycleSupport.fireSolvingEnded(solverScope); if (currentContext != defaultSolverContext) { - // Release the last context if it is not the default one - currentContext.release(); + // Restore the default context + // so solverScope operate on the original score director + loadContext(currentContext, defaultSolverContext, solverScope); } } @@ -248,14 +252,13 @@ public List> getPhaseList() { } public record SolverContext>(EnvironmentMode environmentMode, - InnerScoreDirector scoreDirector, DefaultProblemChangeDirector problemChangeDirector, - BestSolutionRecaller bestSolutionRecaller) { + InnerScoreDirector scoreDirector, + DefaultProblemChangeDirector problemChangeDirector) { - public static > SolverContext of( - EnvironmentMode environmentMode, - SolverScope solverScope, BestSolutionRecaller bestSolutionRecaller) { + public static > SolverContext + of(EnvironmentMode environmentMode, SolverScope solverScope) { return new SolverContext<>(environmentMode, solverScope. getScoreDirector(), - solverScope.getProblemChangeDirector(), bestSolutionRecaller); + solverScope.getProblemChangeDirector()); } void release() { 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 627e1aba889..235ccc5b89c 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 @@ -56,8 +56,8 @@ public DefaultSolver(EnvironmentMode environmentMode, Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { - super(SolverContext.of(environmentMode, solverScope, bestSolutionRecaller), delegateScoreDirectorFactory, - bestSolutionRecaller, termination, phaseList); + super(SolverContext.of(environmentMode, solverScope), delegateScoreDirectorFactory, bestSolutionRecaller, termination, + phaseList); this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; this.solverScope = solverScope; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 6997f3982c5..e6c5ba9f862 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -2,7 +2,6 @@ import java.time.Clock; import java.util.ArrayList; -import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Objects; @@ -91,6 +90,7 @@ public final class DefaultSolverFactory implements SolverFactory delegateScoreDirectorFactory; private final ScoreDirectorFactory defaultScoreDirectorFactory; private final DomainAccessType domainAccessType; + private final List metricsRequiringConstraintMatchList; public DefaultSolverFactory(SolverConfig solverConfig) { this(solverConfig, DomainAccessType.AUTO); @@ -105,25 +105,20 @@ public DefaultSolverFactory(SolverConfig solverConfig, DomainAccessType domainAc this.solutionDescriptor = buildSolutionDescriptor(); var scoreDirectorFactoryConfig = Objects.requireNonNullElseGet(solverConfig.getScoreDirectorFactoryConfig(), ScoreDirectorFactoryConfig::new); - var hasMetricRequiringConstraintMatch = hasMetricRequiringConstraintMatch(solverConfig); + this.metricsRequiringConstraintMatchList = determineMetricsRequiringConstraintMatch(solverConfig); this.delegateScoreDirectorFactory = new DelegateScoreDirectorFactory<>( - Objects.requireNonNull(scoreDirectorFactoryConfig), hasMetricRequiringConstraintMatch); + Objects.requireNonNull(scoreDirectorFactoryConfig), !metricsRequiringConstraintMatchList.isEmpty()); // Caching score director factory as it potentially does expensive things this.defaultScoreDirectorFactory = this.delegateScoreDirectorFactory.buildScoreDirectorFactory(defaultEnvironmentMode, solutionDescriptor); } - private static boolean hasMetricRequiringConstraintMatch(SolverConfig solverConfig) { + private static List determineMetricsRequiringConstraintMatch(SolverConfig solverConfig) { var monitoringConfig = solverConfig.determineMetricConfig(); var solverMetricList = Objects.requireNonNull(monitoringConfig.getSolverMetricList()); - var metricsRequiringConstraintMatch = false; - if (!solverMetricList.isEmpty()) { - metricsRequiringConstraintMatch = !solverMetricList.stream() - .filter(SolverMetric::isMetricConstraintMatchBased) - .toList() - .isEmpty(); - } - return metricsRequiringConstraintMatch; + return solverMetricList.stream() + .filter(SolverMetric::isMetricConstraintMatchBased) + .toList(); } public Clock getClock() { @@ -148,22 +143,17 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var monitoringConfig = solverConfig.determineMetricConfig(); solverScope.setMonitoringTags(Tags.empty()); var solverMetricList = Objects.requireNonNull(monitoringConfig.getSolverMetricList()); - var metricsRequiringConstraintMatchSet = Collections. emptyList(); if (!solverMetricList.isEmpty()) { solverScope.setSolverMetricSet(EnumSet.copyOf(solverMetricList)); - metricsRequiringConstraintMatchSet = solverScope.getSolverMetricSet().stream() - .filter(SolverMetric::isMetricConstraintMatchBased) - .filter(solverScope::isMetricEnabled) - .toList(); } else { solverScope.setSolverMetricSet(EnumSet.noneOf(SolverMetric.class)); } var isStepAssertOrMore = defaultEnvironmentMode.isStepAssertOrMore(); - var constraintMatchEnabled = !metricsRequiringConstraintMatchSet.isEmpty() || isStepAssertOrMore; + var constraintMatchEnabled = !metricsRequiringConstraintMatchList.isEmpty() || isStepAssertOrMore; if (constraintMatchEnabled && !isStepAssertOrMore) { LOGGER.info( "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", - metricsRequiringConstraintMatchSet); + metricsRequiringConstraintMatchList); } var scoreDirector = delegateScoreDirectorFactory.createScoreDirector(getScoreDirectorFactory()); solverScope.setScoreDirector(scoreDirector); 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 44896f4426c..dc3cb70ca54 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 @@ -16,6 +16,7 @@ import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; import java.util.random.RandomGenerator; @@ -69,9 +70,12 @@ import ai.timefold.solver.core.impl.heuristic.move.AbstractSelectorBasedMove; import ai.timefold.solver.core.impl.heuristic.selector.move.factory.MoveIteratorFactory; import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.score.DummySimpleScoreEasyScoreCalculator; import ai.timefold.solver.core.impl.score.director.ScoreDirector; import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.util.Pair; import ai.timefold.solver.core.preview.api.move.builtin.Moves; import ai.timefold.solver.core.preview.api.neighborhood.Neighborhood; @@ -571,6 +575,46 @@ void solveWithProblemChange() throws InterruptedException { } } + @Test + void solvingEndedRestoresDefaultContext() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // LS (the last phase) overridden to a different EnvironmentMode than the default, forcing + // AbstractSolver.preparePhase() to swap in a non-default context for it. + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = (AbstractSolver) solverFactory.buildSolver(); + var problem = TestdataSolution.generateSolution(2, 2); + solver.solve(problem); + assertThat(solver.defaultSolverContext.scoreDirector().getWorkingSolution()).isNull(); + } + + @Test + void ensureScoreCalculationCountConsistent() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // LS (the last phase) overridden to a different EnvironmentMode than the default, forcing + // AbstractSolver.preparePhase() to swap to a non-default context for it, and solvingEnded() to + // restore the (already-populated, since CH ran on it first) default context afterward. + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = (AbstractSolver) solverFactory.buildSolver(); + var problem = TestdataSolution.generateSolution(2, 2); + + // Capture the score calculation count after the CH phase and + var calculationCountBeforeRestore = new AtomicLong(-1); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + public void solvingEnded(SolverScope solverScope) { + calculationCountBeforeRestore.set(solverScope.getScoreDirector().getCalculationCount()); + } + }); + solver.solve(problem); + + // After solvingEnded() restores defaultSolverContext, its calculation count must equal exactly + // the true running total captured above + assertThat(solver.defaultSolverContext.scoreDirector().getCalculationCount()) + .isEqualTo(calculationCountBeforeRestore.get()); + } + @Test void solveRepeatedlyBasicVariable(SoftAssertions softly) { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); @@ -2420,6 +2464,38 @@ void solveListVariableWithPhaseEnvironmentModeOverride() { assertThat(bestSolution).isNotNull(); } + @Test + void ensureListVariableStateIsReleased() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataListSolution.class, TestdataListEntity.class, + TestdataListValue.class); + var phaseConfigList = new ArrayList<>(solverConfig.getPhaseConfigList()); + phaseConfigList.add(new LocalSearchPhaseConfig().withTerminationConfig(new TerminationConfig().withStepCountLimit(10))); + solverConfig.setPhaseConfigList(phaseConfigList); + + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = (AbstractSolver) solverFactory.buildSolver(); + var problem = TestdataListSolution.generateUninitializedSolution(10, 4); + + var listVariableDescriptor = solver.defaultSolverContext.scoreDirector().getSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataListEntity.class) + .getListVariableDescriptor(); + + // Capture the SupplyManager's demand ref count right after each phase ends (CH, LS1, LS2 in order). + var countsAfterEachPhase = new ArrayList(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + countsAfterEachPhase.add(phaseScope.getScoreDirector().getSupplyManager() + .getActiveCount(listVariableDescriptor.getStateDemand())); + } + }); + solver.solve(problem); + // Three phases: CS, LS1 and LS2 + assertThat(countsAfterEachPhase).hasSize(3); + // The count of demanded list variable state must be equal for both LS phases + assertThat(countsAfterEachPhase.get(2)).isEqualTo(countsAfterEachPhase.get(1)); + } + @NullMarked public static class CorruptedIncrementalScoreCalculator implements AnalyzableIncrementalScoreCalculator { From 94a8cfb7460e3b6ec3a1f340e3a1653ca53dcdb5 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 13 Aug 2026 15:45:14 -0300 Subject: [PATCH 05/20] chore: more bug fixes --- .../ListVariableStateSupplyHolder.java | 45 ++++++++++++++++ .../list/ElementDestinationSelector.java | 26 ++++------ .../selector/list/RandomSubListSelector.java | 21 +++----- .../generic/list/ListChangeMoveSelector.java | 25 ++++----- .../generic/list/ListSwapMoveSelector.java | 28 ++++------ .../list/kopt/KOptListMoveSelector.java | 22 +++----- .../ruin/ListRuinRecreateMoveSelector.java | 24 +++------ .../ListVariableStateSupplyHolderTest.java | 52 +++++++++++++++++++ 8 files changed, 148 insertions(+), 95 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java new file mode 100644 index 00000000000..a7ee3c0bff1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java @@ -0,0 +1,45 @@ +package ai.timefold.solver.core.impl.domain.variable; + +import java.util.Objects; + +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; + +/** + * Demands a {@link ListVariableStateSupply} on {@link #phaseStarted(AbstractPhaseScope)} and releases it on + * {@link #phaseEnded(AbstractPhaseScope)}, re-demanding on every phase start because a phase may run under a + * different {@link ai.timefold.solver.core.config.solver.EnvironmentMode}, which swaps in a new score director + * (and thus a new {@link ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager}). + *

+ * Intended to be held as a field by selectors that need a {@link ListVariableStateSupply} across phase lifecycle + * events, delegating their own {@code phaseStarted}/{@code phaseEnded} overrides to this holder instead of each + * re-implementing the demand/cancel bookkeeping. + * + * @param the solution type, the class with the {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} + * annotation + */ +public final class ListVariableStateSupplyHolder { + + private final ListVariableDescriptor listVariableDescriptor; + private ListVariableStateSupply listVariableStateSupply; + + public ListVariableStateSupplyHolder(ListVariableDescriptor listVariableDescriptor) { + this.listVariableDescriptor = listVariableDescriptor; + } + + public void phaseStarted(AbstractPhaseScope phaseScope) { + listVariableStateSupply = phaseScope.getScoreDirector().getSupplyManager() + .demand(listVariableDescriptor.getStateDemand()); + } + + public void phaseEnded(AbstractPhaseScope phaseScope) { + phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); + listVariableStateSupply = null; + } + + @SuppressWarnings("unchecked") + public ListVariableStateSupply get() { + return (ListVariableStateSupply) Objects.requireNonNull(listVariableStateSupply, + "Impossible state: The listVariableStateSupply is not initialized yet."); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java index e400b256193..c55497912f0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java @@ -8,7 +8,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelector; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.ConcatenatingIterator; @@ -44,7 +44,7 @@ public class ElementDestinationSelector extends AbstractSelector listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector valueSelector, boolean randomSelection) { @@ -55,8 +55,9 @@ public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector replayingValueSelector, IterableValueSelector valueSelector, boolean randomSelection, boolean isExhaustiveSearch) { this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); this.entitySelector = entitySelector; - var selector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); + var selector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, listVariableStateSupplyHolder::get); this.replayingValueSelector = replayingValueSelector; this.valueSelector = listVariableDescriptor.allowsUnassignedValues() ? filterUnassignedValues(selector) : selector; this.randomSelection = randomSelection; @@ -65,11 +66,6 @@ public ElementDestinationSelector(EntitySelector entitySelector, phaseLifecycleSupport.addEventListener(this.valueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - private IterableValueSelector filterUnassignedValues( IterableValueSelector valueSelector) { /* @@ -89,7 +85,7 @@ private IterableValueSelector filterUnassignedValues( * and always add one option to unassign at the end, * we can keep the correct probabilities throughout. */ - return FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); + return FilteringValueSelector.ofAssigned(valueSelector, listVariableStateSupplyHolder::get); } @Override @@ -97,15 +93,13 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); // The phase may operate in a different environment mode, which uses a new score director. // We must ensure that the list variable state supply remains up to date. - var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); - listVariableStateSupply = null; + listVariableStateSupplyHolder.phaseEnded(phaseScope); } @Override @@ -127,9 +121,9 @@ public Iterator iterator() { // In case of list var which allows unassigned values, we need to exclude unassigned elements. var totalValueSize = valueSelector.getSize() - - (allowsUnassignedValues ? listVariableStateSupply.getUnassignedCount() : 0); + - (allowsUnassignedValues ? listVariableStateSupplyHolder.get().getUnassignedCount() : 0); var totalSize = Math.addExact(entitySelector.getSize(), totalValueSize); - return new ElementPositionRandomIterator<>(listVariableStateSupply, entitySelector, + return new ElementPositionRandomIterator<>(listVariableStateSupplyHolder.get(), entitySelector, replayingValueSelector != null ? replayingValueSelector.iterator() : null, valueSelector, workingRandom, totalSize, allowsUnassignedValues, allowsUnassignedValues && totalValueSize > 0); } else { @@ -149,7 +143,7 @@ public Iterator iterator() { // Value selector guarantees only unpinned values. var valueIterator = new MappingIterator<>(valueSelector.iterator(), v -> { - var pos = listVariableStateSupply.getElementPosition(v).ensureAssigned(); + var pos = listVariableStateSupplyHolder.get().getElementPosition(v).ensureAssigned(); return ElementPosition.of(pos.entity(), pos.index() + 1); }); if (listVariableDescriptor.allowsUnassignedValues()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java index d9a9d9c1d73..3208490bf47 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java @@ -3,9 +3,8 @@ import static ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ListChangeMoveSelector.filterPinnedListPlanningVariableValuesWithIndex; import java.util.Iterator; -import java.util.Objects; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelector; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator; @@ -22,15 +21,16 @@ public class RandomSubListSelector extends AbstractSelector listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public RandomSubListSelector( EntitySelector entitySelector, IterableValueSelector valueSelector, int minimumSubListSize, int maximumSubListSize) { this.entitySelector = entitySelector; - this.valueSelector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + this.valueSelector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, listVariableStateSupplyHolder::get); if (minimumSubListSize < 1) { throw new IllegalArgumentException("The minimumSubListSize (%d) must be greater than 0." .formatted(minimumSubListSize)); @@ -47,27 +47,20 @@ public RandomSubListSelector( phaseLifecycleSupport.addEventListener(this.valueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @Override public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, workingRandom); // The phase may run under a different environment mode, which swaps in a new score director // (and thus a new SupplyManager); re-demand so the supply doesn't go stale. - var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); - this.listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseEnded(phaseScope); triangleElementFactory = null; - listVariableStateSupply = null; } @Override @@ -147,7 +140,7 @@ protected SubList createUpcomingSelection() { // Using valueSelector instead of entitySelector is fairer // because entities with bigger list variables will be selected more often. var value = valueIterator.next(); - sourceEntity = listVariableStateSupply.getInverseSingleton(value); + sourceEntity = listVariableStateSupplyHolder.get().getInverseSingleton(value); if (sourceEntity == null) { // Ignore values which are unassigned. continue; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java index 6d00a428e9b..3ef0601edde 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java @@ -1,10 +1,10 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic.list; import java.util.Iterator; -import java.util.Objects; import java.util.function.Supplier; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.list.DestinationSelector; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; @@ -20,39 +20,32 @@ public class ListChangeMoveSelector extends GenericMoveSelector destinationSelector; private final boolean randomSelection; - private ListVariableStateSupply listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public ListChangeMoveSelector(IterableValueSelector sourceValueSelector, DestinationSelector destinationSelector, boolean randomSelection) { + var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); this.sourceValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(sourceValueSelector, this::getListVariableStateSupply); + filterPinnedListPlanningVariableValuesWithIndex(sourceValueSelector, listVariableStateSupplyHolder::get); this.destinationSelector = destinationSelector; this.randomSelection = randomSelection; phaseLifecycleSupport.addEventListener(this.sourceValueSelector); phaseLifecycleSupport.addEventListener(this.destinationSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @Override public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); // The phase may operate in a different environment mode, which uses a new score director. // We must ensure that the list variable state supply remains up to date. - var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); - var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); - this.listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); - listVariableStateSupply = null; + listVariableStateSupplyHolder.phaseEnded(phaseScope); } public static IterableValueSelector filterPinnedListPlanningVariableValuesWithIndex( @@ -87,12 +80,12 @@ public long getSize() { public Iterator> iterator() { if (randomSelection) { return new RandomListChangeIterator<>( - listVariableStateSupply, + listVariableStateSupplyHolder.get(), sourceValueSelector, destinationSelector); } else { return new OriginalListChangeIterator<>( - listVariableStateSupply, + listVariableStateSupplyHolder.get(), sourceValueSelector, destinationSelector); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java index 6b031324bb9..f6b66a388d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java @@ -3,9 +3,8 @@ import static ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ListChangeMoveSelector.filterPinnedListPlanningVariableValuesWithIndex; import java.util.Iterator; -import java.util.Objects; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; @@ -18,49 +17,42 @@ public class ListSwapMoveSelector extends GenericMoveSelector rightValueSelector; private final boolean randomSelection; - private ListVariableStateSupply listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public ListSwapMoveSelector(IterableValueSelector leftValueSelector, IterableValueSelector rightValueSelector, boolean randomSelection) { + var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); this.leftValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(leftValueSelector, this::getListVariableStateSupply); + filterPinnedListPlanningVariableValuesWithIndex(leftValueSelector, listVariableStateSupplyHolder::get); this.rightValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(rightValueSelector, this::getListVariableStateSupply); + filterPinnedListPlanningVariableValuesWithIndex(rightValueSelector, listVariableStateSupplyHolder::get); this.randomSelection = randomSelection; phaseLifecycleSupport.addEventListener(this.leftValueSelector); phaseLifecycleSupport.addEventListener(this.rightValueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @Override public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); // The phase may operate in a different environment mode, which uses a new score director. // We must ensure that the list variable state supply remains up to date. - var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); - var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); - listVariableStateSupply = null; + listVariableStateSupplyHolder.phaseEnded(phaseScope); } @Override public Iterator> iterator() { if (randomSelection) { - return new RandomListSwapIterator<>(listVariableStateSupply, leftValueSelector, rightValueSelector); + return new RandomListSwapIterator<>(listVariableStateSupplyHolder.get(), leftValueSelector, rightValueSelector); } else { - return new OriginalListSwapIterator<>(listVariableStateSupply, leftValueSelector, rightValueSelector); + return new OriginalListSwapIterator<>(listVariableStateSupplyHolder.get(), leftValueSelector, rightValueSelector); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java index a9508a6a6d5..002163338de 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java @@ -3,10 +3,10 @@ import static ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ListChangeMoveSelector.filterPinnedListPlanningVariableValuesWithIndex; import java.util.Iterator; -import java.util.Objects; import java.util.function.Supplier; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; @@ -26,14 +26,15 @@ final class KOptListMoveSelector extends GenericMoveSelector listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public KOptListMoveSelector(ListVariableDescriptor listVariableDescriptor, IterableValueSelector originSelector, IterableValueSelector valueSelector, int minK, int maxK, int[] pickedKDistribution) { this.listVariableDescriptor = listVariableDescriptor; - this.originSelector = createEffectiveValueSelector(originSelector, this::getListVariableStateSupply); - this.valueSelector = createEffectiveValueSelector(valueSelector, this::getListVariableStateSupply); + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + this.originSelector = createEffectiveValueSelector(originSelector, listVariableStateSupplyHolder::get); + this.valueSelector = createEffectiveValueSelector(valueSelector, listVariableStateSupplyHolder::get); this.minK = minK; this.maxK = maxK; this.pickedKDistribution = pickedKDistribution; @@ -50,25 +51,18 @@ private IterableValueSelector createEffectiveValueSelector( return FilteringValueSelector.ofAssigned(filteredValueSelector, listVariableStateSupplier); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @Override public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); // The phase may operate in a different environment mode, which uses a new score director. // We must ensure that the list variable state supply remains up to date. - var supplyManager = phaseScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); - listVariableStateSupply = null; + listVariableStateSupplyHolder.phaseEnded(phaseScope); } @Override @@ -95,7 +89,7 @@ public long getSize() { @Override public Iterator> iterator() { - return new KOptListMoveIterator<>(workingRandom, listVariableDescriptor, listVariableStateSupply, + return new KOptListMoveIterator<>(workingRandom, listVariableDescriptor, listVariableStateSupplyHolder.get(), originSelector, valueSelector, minK, maxK, pickedKDistribution); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java index 10c39d1099e..cf4f818e438 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java @@ -1,9 +1,8 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ruin; import java.util.Iterator; -import java.util.Objects; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.CountSupplier; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; @@ -18,21 +17,20 @@ final class ListRuinRecreateMoveSelector extends GenericMoveSelector { private final IterableValueSelector valueSelector; - private final ListVariableDescriptor listVariableDescriptor; private final RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder; private final CountSupplier minimumSelectedCountSupplier; private final CountSupplier maximumSelectedCountSupplier; private SolverScope solverScope; - private ListVariableStateSupply listVariableStateSupply; + private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public ListRuinRecreateMoveSelector(IterableValueSelector valueSelector, ListVariableDescriptor listVariableDescriptor, RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder, CountSupplier minimumSelectedCountSupplier, CountSupplier maximumSelectedCountSupplier) { super(); - this.valueSelector = FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); - this.listVariableDescriptor = listVariableDescriptor; + this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + this.valueSelector = FilteringValueSelector.ofAssigned(valueSelector, listVariableStateSupplyHolder::get); this.constructionHeuristicPhaseBuilder = constructionHeuristicPhaseBuilder; this.minimumSelectedCountSupplier = minimumSelectedCountSupplier; this.maximumSelectedCountSupplier = maximumSelectedCountSupplier; @@ -40,11 +38,6 @@ public ListRuinRecreateMoveSelector(IterableValueSelector valueSelect phaseLifecycleSupport.addEventListener(this.valueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @Override public long getSize() { var totalSize = 0L; @@ -69,24 +62,21 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { this.solverScope = phaseScope.getSolverScope(); // The phase may operate in a different environment mode, which uses a new score director. // We must ensure that the list variable state supply remains up to date. - this.listVariableStateSupply = phaseScope.getScoreDirector() - .getSupplyManager() - .demand(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override public void phaseEnded(AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); + listVariableStateSupplyHolder.phaseEnded(phaseScope); this.solverScope = null; - this.listVariableStateSupply = null; } @Override public Iterator> iterator() { var valueSelectorSize = valueSelector.getSize(); return new ListRuinRecreateMoveIterator<>(valueSelector, constructionHeuristicPhaseBuilder, - solverScope, listVariableStateSupply, + solverScope, listVariableStateSupplyHolder.get(), minimumSelectedCountSupplier.applyAsInt(valueSelectorSize), maximumSelectedCountSupplier.applyAsInt(valueSelectorSize), workingRandom); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java new file mode 100644 index 00000000000..ddb4f643d01 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java @@ -0,0 +1,52 @@ +package ai.timefold.solver.core.impl.domain.variable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; + +import org.junit.jupiter.api.Test; + +class ListVariableStateSupplyHolderTest { + + @SuppressWarnings("unchecked") + @Test + void demandsOnPhaseStartedAndCancelsOnPhaseEnded() { + ListVariableDescriptor listVariableDescriptor = mock(ListVariableDescriptor.class); + var stateDemand = new ListVariableStateDemand<>(listVariableDescriptor); + doReturn(stateDemand).when(listVariableDescriptor).getStateDemand(); + + ListVariableStateSupply listVariableStateSupply = + mock(ListVariableStateSupply.class); + SupplyManager supplyManager = mock(SupplyManager.class); + doReturn(listVariableStateSupply).when(supplyManager).demand(stateDemand); + + InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); + doReturn(supplyManager).when(scoreDirector).getSupplyManager(); + + AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + + var holder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + + // Not yet demanded: get() must fail fast rather than silently return null. + assertThatNullPointerException().isThrownBy(holder::get) + .withMessageContaining("not initialized yet"); + + holder.phaseStarted(phaseScope); + assertThat(holder.get()).isSameAs(listVariableStateSupply); + verify(supplyManager).demand(stateDemand); + + holder.phaseEnded(phaseScope); + verify(supplyManager).cancel(stateDemand); + assertThatNullPointerException().isThrownBy(holder::get) + .withMessageContaining("not initialized yet"); + } +} From 83bdd10bf5a66551681e615ba16d06bc8ab99e60 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 13 Aug 2026 17:18:05 -0300 Subject: [PATCH 06/20] chore: more bug fixes --- .../core/impl/solver/AbstractSolver.java | 5 +++ .../core/impl/solver/DefaultSolverTest.java | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 8a50aecf297..4a3a3598fc3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -168,6 +168,11 @@ public void solvingError(SolverScope solverScope, Exception exception for (Phase phase : phaseList) { phase.solvingError(solverScope, exception); } + if (currentContext != defaultSolverContext) { + // A phase may have failed while operating under a non-default environment mode, + // and we need to restore the default context + loadContext(currentContext, defaultSolverContext, solverScope); + } } public void phaseStarted(AbstractPhaseScope phaseScope) { 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 dc3cb70ca54..10eb745cf89 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 @@ -73,6 +73,7 @@ import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.score.DummySimpleScoreEasyScoreCalculator; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.ScoreDirector; import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -2080,6 +2081,41 @@ void failLocalSearchValueRangeAssertion() { "The value (bad value) from the planning variable (valueList) has been assigned to the entity (Generated Entity 0), but it is outside of the related value range [Generated Value 0-Generated Value 1]"); } + @Test + void solvingErrorRestoresDefaultContextWhenPhaseFails() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataListSolution.class, TestdataListEntity.class, + TestdataListValue.class); + var localSearchPhaseConfig = new LocalSearchPhaseConfig(); + localSearchPhaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // Force invalid move factory + localSearchPhaseConfig.setMoveSelectorConfig( + new MoveIteratorFactoryConfig().withMoveIteratorFactoryClass(InvalidMoveListFactory.class)); + solverConfig.setPhaseConfigList(List.of(new ConstructionHeuristicPhaseConfig(), localSearchPhaseConfig)); + + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = (AbstractSolver) solverFactory.buildSolver(); + var problem = TestdataListSolution.generateUninitializedSolution(2, 2); + + // Expected to be the director created for the LS phase + var swappedScoreDirector = new AtomicReference<>(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter<>() { + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + if (phaseScope.getPhaseIndex() == 1) { + swappedScoreDirector.set(phaseScope.getScoreDirector()); + } + } + }); + + assertThatCode(() -> solver.solve(problem)) + .hasMessageContaining("The value (bad value) from the planning variable (valueList)"); + + // The orphaned non-default director must have been closed + assertThat(swappedScoreDirector.get()).isNotNull(); + var closedWorkingSolution = ((InnerScoreDirector) swappedScoreDirector.get()).getWorkingSolution(); + assertThat(closedWorkingSolution).isNull(); + } + @Test void failCustomPhaseValueRangeAssertion() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataListSolution.class, TestdataListEntity.class, From d95663e0090b990217c59f0592cd7a5ed29a903c Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 13 Aug 2026 17:56:39 -0300 Subject: [PATCH 07/20] docs: environment per phase --- .../solver-diagnostics.adoc | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc index e570b96242b..d673c1b8f14 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc @@ -210,6 +210,37 @@ If your production environment doesn't care about reproducibility, use this mode Unlike all the other modes, this mode doesn't use any fixed <> unless one is provided. +[#environmentModePerPhase] +=== Using different environment modes per phase + +By default, every phase of the solver - such as xref:optimization-algorithms/construction-heuristics.adoc#constructionHeuristicsOverview[Construction Heuristic] and xref:optimization-algorithms/local-search.adoc#localSearchOverview[Local Search] - runs in the solver's environment mode. +If the solver's environment mode is not explicitly configured, that is the default `<>` mode. + +Each phase can override the solver's environment mode with a stricter mode of its own. +This is useful when you suspect that a bug is introduced during a specific phase: +instead of paying the performance cost of a stricter mode (such as `<>`) for the entire solver, +you enable it for only the phase under suspicion, while the other phases keep running at a faster mode. + +A phase's environment mode must be at least as strict as the solver's environment mode; it can never be less strict. +At least one phase must still use the solver's environment mode as-is. +If the solver's environment mode is `<>`, no phase can override it, +because every other mode is <> and therefore stricter. + +[source,java,options="nowrap"] +---- +SolverConfig solverConfig = new SolverConfig() + ... + .withEnvironmentMode(EnvironmentMode.PHASE_ASSERT) + .withPhases( + new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig() + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT)); +---- + +In this example, the solver's environment mode is `PHASE_ASSERT`. +The Construction Heuristic phase has no environment mode of its own, so it uses that default. +The Local Search phase overrides it and runs in the stricter `FULL_ASSERT` mode instead. + [#environmentModeBestPractices] === Best practices From 628949a8d87df1a83cd9e48b1fe2e8cbe31d7fb2 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 14 Aug 2026 09:16:13 -0300 Subject: [PATCH 08/20] chore: rebase --- .../core/impl/solver/AbstractSolver.java | 32 +++++++++---------- .../core/impl/solver/DefaultSolver.java | 8 ++--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 4a3a3598fc3..776bcd9fd84 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -76,9 +76,24 @@ protected AbstractSolver(SolverContext defaultSolverContext, this.currentContext = defaultSolverContext; } + public void solvingStarted(SolverScope solverScope) { + solverScope.setWorkingSolutionFromBestSolution(); + bestSolutionRecaller.solvingStarted(solverScope); + globalTermination.solvingStarted(solverScope); + phaseLifecycleSupport.fireSolvingStarted(solverScope); + // Using value range manager from the same score director as the working solution; this is a correct use. + var problemSizeStatistics = solverScope.getScoreDirector() + .getValueRangeManager() + .getProblemSizeStatistics(); + solverScope.setProblemSizeStatistics(problemSizeStatistics); + for (Phase phase : phaseList) { + phase.solvingStarted(solverScope); + } + } + protected void runPhases(SolverScope solverScope) { if (!solverScope.getSolutionDescriptor().hasMovableEntities(solverScope.getScoreDirector())) { - logger.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", + LOGGER.info("Skipped all phases ({}): out of {} planning entities, none are movable (non-pinned).", phaseList.size(), solverScope.getWorkingEntityCount()); return; } @@ -134,21 +149,6 @@ private void loadContext(SolverContext oldSolverContext, SolverCon currentContext = newSolverContext; } - public void solvingStarted(SolverScope solverScope) { - solverScope.setWorkingSolutionFromBestSolution(); - bestSolutionRecaller.solvingStarted(solverScope); - globalTermination.solvingStarted(solverScope); - phaseLifecycleSupport.fireSolvingStarted(solverScope); - // Using value range manager from the same score director as the working solution; this is a correct use. - var problemSizeStatistics = solverScope.getScoreDirector() - .getValueRangeManager() - .getProblemSizeStatistics(); - solverScope.setProblemSizeStatistics(problemSizeStatistics); - for (Phase phase : phaseList) { - phase.solvingStarted(solverScope); - } - } - public void solvingEnded(SolverScope solverScope) { for (Phase phase : phaseList) { phase.solvingEnded(solverScope); 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 235ccc5b89c..d7f4a81dc47 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 @@ -208,7 +208,7 @@ public void solvingStarted(SolverScope solverScope) { EventProducerId.solvingStarted()); LOGGER.info("Solving {}: time spent ({}), best score ({}), " - + "environment mode ({}), move thread count ({}), random ({}).", + + "default environment mode ({}), move thread count ({}), random ({}).", (startingSolverCount == 1 ? "started" : "restarted"), solverScope.calculateTimeMillisSpentUpToNow(), solverScope.getBestScore().raw(), @@ -314,7 +314,7 @@ public void solvingEnded(SolverScope solverScope) { } public void outerSolvingEnded(SolverScope solverScope) { - logger.info("Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), " + LOGGER.info("Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), " + "phase total ({}), environment mode ({}), move thread count ({}).", solverScope.getTimeMillisSpent(), solverScope.getBestScore().raw(), @@ -341,7 +341,7 @@ private boolean checkProblemChanges() { while (problemChange != null) { problemChange.doChange(solverScope.getWorkingSolution(), solverScope.getProblemChangeDirector()); solverScope.getScoreDirector().updateShadowVariables(); - logger.debug(" Real-time problem change applied; step index ({}).", stepIndex); + LOGGER.debug(" Real-time problem change applied; step index ({}).", stepIndex); stepIndex++; problemChange = problemChangeQueue.poll(); } @@ -353,7 +353,7 @@ private boolean checkProblemChanges() { basicPlumbingTermination.endProblemChangesProcessing(); bestSolutionRecaller.updateBestSolutionAndFireIfInitialized(solverScope, EventProducerId.problemChange()); - logger.info("Real-time problem fact changes done: step total ({}), new best score ({}).", + LOGGER.info("Real-time problem fact changes done: step total ({}), new best score ({}).", stepIndex, score); return true; } From 7724127110aede4fe761e2fb31b3b2684ac01dc2 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 18 Aug 2026 09:36:05 -0300 Subject: [PATCH 09/20] chore: address comments --- .../core/impl/score/director/AbstractScoreDirector.java | 4 ++-- .../solver/core/impl/score/director/InnerScoreDirector.java | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 756cf3d7345..20cfd85a34d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -446,7 +446,7 @@ protected void setCalculatedScore(Score_ score) { public InnerScoreDirector createChildThreadScoreDirector(ChildThreadType childThreadType) { // Most score directors don't need derived status; CS will override this. switch (childThreadType) { - case ChildThreadType.PART_THREAD -> { + case PART_THREAD -> { var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); @@ -455,7 +455,7 @@ public InnerScoreDirector createChildThreadScoreDirector(Chil childThreadScoreDirector.calculationCount = calculationCount; return childThreadScoreDirector; } - case ChildThreadType.MOVE_THREAD -> { + case MOVE_THREAD -> { var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index 0cc3df4f03a..1a318fd29af 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -287,7 +287,6 @@ default void incrementCalculationCount() { * @param workingScore never null * @param completedAction sometimes null, when assertion fails then the completedAction's {@link Object#toString()} * is included in the exception message - * @see ScoreDirectorFactory#assertScoreFromScratch */ void assertWorkingScoreFromScratch(InnerScore workingScore, Object completedAction); @@ -301,7 +300,6 @@ default void incrementCalculationCount() { * @param predictedScore never null * @param completedAction sometimes null, when assertion fails then the completedAction's {@link Object#toString()} * is included in the exception message - * @see ScoreDirectorFactory#assertScoreFromScratch */ void assertPredictedScoreFromScratch(InnerScore predictedScore, Object completedAction); From ed02db2c52a543c8f9d77a32ba46bceaf2f23eea Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 19 Aug 2026 16:18:33 -0300 Subject: [PATCH 10/20] chore: rebase --- .../DefaultLocalSearchPhaseFactory.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java index 8a0d869c4c9..8ee91baaa2c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java @@ -197,21 +197,21 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con } return buildAcceptor(acceptorConfig, configPolicy, environmentMode); } else { - var localSearchType_ = Objects.requireNonNullElse(localSearchType, LocalSearchType.LATE_ACCEPTANCE); - var acceptorConfig_ = new LocalSearchAcceptorConfig(); - if (neighborhoodsEnabled && localSearchType_ == LocalSearchType.VARIABLE_NEIGHBORHOOD_DESCENT) { + var updatedLocalSearchType = Objects.requireNonNullElse(localSearchType, LocalSearchType.LATE_ACCEPTANCE); + acceptorConfig = new LocalSearchAcceptorConfig(); + if (neighborhoodsEnabled && updatedLocalSearchType == LocalSearchType.VARIABLE_NEIGHBORHOOD_DESCENT) { // Maybe works, but never tested. throw new UnsupportedOperationException( "Variable Neighborhood descent is not yet supported with the Neighborhoods API."); } - var acceptorType = getAcceptorType(neighborhoodsEnabled, localSearchType_); - acceptorConfig_.setAcceptorTypeList(Collections.singletonList(acceptorType)); - return buildAcceptor(acceptorConfig_, configPolicy); + var acceptorType = getAcceptorType(neighborhoodsEnabled, updatedLocalSearchType); + acceptorConfig.setAcceptorTypeList(Collections.singletonList(acceptorType)); + return buildAcceptor(acceptorConfig, configPolicy, environmentMode); } } - private static @NonNull AcceptorType getAcceptorType(boolean neighborhoodsEnabled, LocalSearchType localSearchType_) { - var acceptorType = switch (localSearchType_) { + private static @NonNull AcceptorType getAcceptorType(boolean neighborhoodsEnabled, LocalSearchType localSearchType) { + var acceptorType = switch (localSearchType) { case HILL_CLIMBING, VARIABLE_NEIGHBORHOOD_DESCENT -> AcceptorType.HILL_CLIMBING; case TABU_SEARCH -> AcceptorType.ENTITY_TABU; case SIMULATED_ANNEALING -> AcceptorType.SIMULATED_ANNEALING; From 28b60303ef74409cf37412d9cb32f2cf79d6ba95 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 20 Aug 2026 10:48:45 -0300 Subject: [PATCH 11/20] chore: address comments --- .../DefaultConstructionHeuristicPhase.java | 8 +------- .../variable/ListVariableStateSupplyHolder.java | 14 +++++++++++--- .../DefaultExhaustiveSearchPhase.java | 8 +++++--- .../impl/localsearch/DefaultLocalSearchPhase.java | 8 +------- .../solver/core/impl/phase/AbstractPhase.java | 11 ++++++----- .../phase/AbstractPossiblyInitializingPhase.java | 6 +++--- .../core/impl/phase/custom/DefaultCustomPhase.java | 8 +------- 7 files changed, 28 insertions(+), 35 deletions(-) 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 b947342442d..62348f04e69 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 @@ -228,7 +228,7 @@ public void solvingError(SolverScope solverScope, Exception exception } public static class DefaultConstructionHeuristicPhaseBuilder - extends AbstractPossiblyInitializingPhaseBuilder { + extends AbstractPossiblyInitializingPhaseBuilder> { private final EntityPlacer entityPlacer; private final ConstructionHeuristicDecider decider; @@ -241,12 +241,6 @@ public DefaultConstructionHeuristicPhaseBuilder(int phaseIndex, boolean lastInit this.decider = decider; } - @Override - public DefaultConstructionHeuristicPhaseBuilder enableAssertions() { - super.enableAssertions(); - return this; - } - public EntityPlacer getEntityPlacer() { return entityPlacer; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java index a7ee3c0bff1..a81c972699a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java @@ -3,6 +3,7 @@ import java.util.Objects; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; /** @@ -21,6 +22,7 @@ public final class ListVariableStateSupplyHolder { private final ListVariableDescriptor listVariableDescriptor; + private SupplyManager supplyManager; private ListVariableStateSupply listVariableStateSupply; public ListVariableStateSupplyHolder(ListVariableDescriptor listVariableDescriptor) { @@ -28,17 +30,23 @@ public ListVariableStateSupplyHolder(ListVariableDescriptor listVaria } public void phaseStarted(AbstractPhaseScope phaseScope) { - listVariableStateSupply = phaseScope.getScoreDirector().getSupplyManager() - .demand(listVariableDescriptor.getStateDemand()); + this.supplyManager = phaseScope.getScoreDirector().getSupplyManager(); } public void phaseEnded(AbstractPhaseScope phaseScope) { - phaseScope.getScoreDirector().getSupplyManager().cancel(listVariableDescriptor.getStateDemand()); + if (listVariableStateSupply != null) { + supplyManager.cancel(listVariableDescriptor.getStateDemand()); + } + supplyManager = null; listVariableStateSupply = null; } @SuppressWarnings("unchecked") public ListVariableStateSupply get() { + if (listVariableStateSupply == null) { + // Lazy initilization of the list variable state + listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); + } return (ListVariableStateSupply) Objects.requireNonNull(listVariableStateSupply, "Impossible state: The listVariableStateSupply is not initialized yet."); } 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 945bf0707e6..3167d10d871 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 @@ -134,7 +134,7 @@ private void stepEnded(ExhaustiveSearchStepScope stepScope) { } } - public static class Builder extends AbstractPhaseBuilder { + public static class Builder extends AbstractPhaseBuilder> { private final Comparator> nodeComparator; private final AbstractExhaustiveSearchDecider> decider; @@ -150,12 +150,14 @@ public Builder(int phaseIndex, EnvironmentMode environmentMode, String logIndent this.decider = decider; } + @SuppressWarnings("unchecked") @Override - public Builder enableAssertions() { + public >> Builder_ + enableAssertions() { super.enableAssertions(); assertWorkingSolutionScoreFromScratch = environmentMode.isFullyAsserted(); assertExpectedWorkingSolutionScore = environmentMode.isIntrusivelyAsserted(); - return this; + return (Builder_) this; } @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 330c53e4b60..bf75cdd957c 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 @@ -254,7 +254,7 @@ public void solvingError(SolverScope solverScope, Exception exception decider.solvingError(solverScope, exception); } - public static class Builder extends AbstractPhaseBuilder { + public static class Builder extends AbstractPhaseBuilder> { private final LocalSearchDecider decider; @@ -264,12 +264,6 @@ public Builder(int phaseIndex, EnvironmentMode environmentMode, String logIndent this.decider = decider; } - @Override - public Builder enableAssertions() { - super.enableAssertions(); - return this; - } - @Override public DefaultLocalSearchPhase build() { return new DefaultLocalSearchPhase<>(this); 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 c99e39ed014..7e936a8d80a 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 @@ -48,7 +48,7 @@ public abstract class AbstractPhase implements Phase { /** Used for {@link #addPhaseLifecycleListener(PhaseLifecycleListener)}. */ protected PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); - protected AbstractPhase(AbstractPhaseBuilder builder) { + protected AbstractPhase(AbstractPhaseBuilder builder) { phaseIndex = builder.phaseIndex; environmentMode = builder.environmentMode; logIndentation = builder.logIndentation; @@ -260,7 +260,7 @@ but planning list variable (%s) has (%d) unexpected unassigned values. } } - public abstract static class AbstractPhaseBuilder { + public abstract static class AbstractPhaseBuilder> { private final int phaseIndex; protected final EnvironmentMode environmentMode; @@ -280,14 +280,15 @@ protected AbstractPhaseBuilder(int phaseIndex, EnvironmentMode environmentMode, this.phaseTermination = phaseTermination; } - public AbstractPhaseBuilder enableAssertions() { + @SuppressWarnings("unchecked") + public > Builder_ enableAssertions() { assertPhaseScoreFromScratch = environmentMode.isAsserted(); assertStepScoreFromScratch = environmentMode.isFullyAsserted(); assertExpectedStepScore = environmentMode.isIntrusivelyAsserted(); assertShadowVariablesAreNotStaleAfterStep = environmentMode.isIntrusivelyAsserted(); - return this; + return (Builder_) this; } - protected abstract AbstractPhase build(); + public abstract Phase_ build(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java index 3c482f962cc..c40e47f0c43 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPossiblyInitializingPhase.java @@ -14,7 +14,7 @@ public abstract class AbstractPossiblyInitializingPhase private final boolean lastInitializingPhase; - protected AbstractPossiblyInitializingPhase(AbstractPossiblyInitializingPhaseBuilder builder) { + protected AbstractPossiblyInitializingPhase(AbstractPossiblyInitializingPhaseBuilder builder) { super(builder); this.lastInitializingPhase = builder.isLastInitializingPhase(); } @@ -54,8 +54,8 @@ protected void ensureCorrectTermination(AbstractPhaseScope phaseScope } } - public abstract static class AbstractPossiblyInitializingPhaseBuilder - extends AbstractPhaseBuilder { + public abstract static class AbstractPossiblyInitializingPhaseBuilder> + extends AbstractPhaseBuilder { private final boolean lastInitializingPhase; 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 4f8093bb086..d82f615c6a9 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 @@ -122,7 +122,7 @@ public void phaseEnded(CustomPhaseScope phaseScope) { } public static final class DefaultCustomPhaseBuilder - extends AbstractPossiblyInitializingPhaseBuilder { + extends AbstractPossiblyInitializingPhaseBuilder> { private final List> customPhaseCommandList; @@ -133,12 +133,6 @@ public DefaultCustomPhaseBuilder(int phaseIndex, boolean lastInitializingPhase, this.customPhaseCommandList = List.copyOf(customPhaseCommandList); } - @Override - public DefaultCustomPhaseBuilder enableAssertions() { - super.enableAssertions(); - return this; - } - @Override public DefaultCustomPhase build() { return new DefaultCustomPhase<>(this); From ae19fde5150588747104a94d23b871337777fda1 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 20 Aug 2026 14:24:34 -0300 Subject: [PATCH 12/20] chore: allocate the list variable only once per score director --- .../ListVariableStateSupplyHolder.java | 38 +++++++++---------- .../variable/ShadowVariableSupport.java | 2 +- .../ListVariableExhaustiveSearchDecider.java | 6 +-- ...ctInverseEntityFilteringValueSelector.java | 3 +- .../score/director/AbstractScoreDirector.java | 18 ++++++--- .../ListVariableStateSupplyHolderTest.java | 11 ++---- 6 files changed, 39 insertions(+), 39 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java index a81c972699a..f2b5943911a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java @@ -2,27 +2,33 @@ import java.util.Objects; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; /** - * Demands a {@link ListVariableStateSupply} on {@link #phaseStarted(AbstractPhaseScope)} and releases it on - * {@link #phaseEnded(AbstractPhaseScope)}, re-demanding on every phase start because a phase may run under a - * different {@link ai.timefold.solver.core.config.solver.EnvironmentMode}, which swaps in a new score director - * (and thus a new {@link ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager}). + * Borrows the {@link ListVariableStateSupply} owned by the score director, + * re-reading it on every {@link #phaseStarted(AbstractPhaseScope)} + * because a phase may run under a different {@link EnvironmentMode}, + * which swaps in a new score director (and thus a new supply instance). *

- * Intended to be held as a field by selectors that need a {@link ListVariableStateSupply} across phase lifecycle - * events, delegating their own {@code phaseStarted}/{@code phaseEnded} overrides to this holder instead of each - * re-implementing the demand/cancel bookkeeping. + * This holder neither demands nor cancels the supply. + * The score director demands it once on construction and cancels it on close, + * so {@link #phaseEnded(AbstractPhaseScope)} only drops the borrowed reference. + * Sharing the score director's instance also guarantees selectors observe exactly the same list variable state the score + * calculation does. + *

+ * Intended to be held as a field by selectors that need a {@link ListVariableStateSupply} across phase lifecycle events, + * delegating their own {@code phaseStarted}/{@code phaseEnded} overrides to this holder instead of each re-implementing the + * bookkeeping. * - * @param the solution type, the class with the {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} + * @param the solution type, the class with the {@link PlanningSolution} * annotation */ public final class ListVariableStateSupplyHolder { private final ListVariableDescriptor listVariableDescriptor; - private SupplyManager supplyManager; private ListVariableStateSupply listVariableStateSupply; public ListVariableStateSupplyHolder(ListVariableDescriptor listVariableDescriptor) { @@ -30,23 +36,17 @@ public ListVariableStateSupplyHolder(ListVariableDescriptor listVaria } public void phaseStarted(AbstractPhaseScope phaseScope) { - this.supplyManager = phaseScope.getScoreDirector().getSupplyManager(); + // We reuse the state owned by the score director, rather than demanding a second supply of our own. + this.listVariableStateSupply = phaseScope.getScoreDirector().getListVariableStateSupply(listVariableDescriptor); } public void phaseEnded(AbstractPhaseScope phaseScope) { - if (listVariableStateSupply != null) { - supplyManager.cancel(listVariableDescriptor.getStateDemand()); - } - supplyManager = null; + // There's no need to release the state, as the score director will take care of it. listVariableStateSupply = null; } @SuppressWarnings("unchecked") public ListVariableStateSupply get() { - if (listVariableStateSupply == null) { - // Lazy initilization of the list variable state - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); - } return (ListVariableStateSupply) Objects.requireNonNull(listVariableStateSupply, "Impossible state: The listVariableStateSupply is not initialized yet."); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java index a959789f217..350b7dc481a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java @@ -110,7 +110,7 @@ public static ShadowVariableSupport create(InnerScoreDire public void linkShadowVariables() { if (listVariableDescriptor != null) { - listVariableChangeHandlerList.add(demand(listVariableDescriptor.getStateDemand())); + listVariableChangeHandlerList.add(scoreDirector.getListVariableStateSupply(listVariableDescriptor)); } scoreDirector.getSolutionDescriptor().getEntityDescriptors().stream() .map(EntityDescriptor::getDeclaredShadowVariableDescriptors) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/ListVariableExhaustiveSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/ListVariableExhaustiveSearchDecider.java index dd72861a14c..954638c5ad9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/ListVariableExhaustiveSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/decider/ListVariableExhaustiveSearchDecider.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.exhaustivesearch.decider; import java.util.Arrays; +import java.util.Objects; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; @@ -158,9 +159,8 @@ private static Move[] listAllMovesInReverseOrder(Exhausti @Override public void phaseStarted(ExhaustiveSearchPhaseScope phaseScope) { super.phaseStarted(phaseScope); - var listVariableDescriptor = phaseScope.getSolutionDescriptor().getListVariableDescriptor(); - this.listVariableState = - phaseScope.getSolverScope().getScoreDirector().getListVariableStateSupply(listVariableDescriptor); + var listVariableDescriptor = Objects.requireNonNull(phaseScope.getSolutionDescriptor().getListVariableDescriptor()); + this.listVariableState = phaseScope.getScoreDirector().getListVariableStateSupply(listVariableDescriptor); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/AbstractInverseEntityFilteringValueSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/AbstractInverseEntityFilteringValueSelector.java index a3537f49c23..cfb31a3d2d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/AbstractInverseEntityFilteringValueSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/AbstractInverseEntityFilteringValueSelector.java @@ -52,8 +52,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); ListVariableDescriptor variableDescriptor = (ListVariableDescriptor) childValueSelector.getVariableDescriptor(); - listVariableStateSupply = phaseScope.getScoreDirector().getSupplyManager() - .demand(variableDescriptor.getStateDemand()); + listVariableStateSupply = phaseScope.getScoreDirector().getListVariableStateSupply(variableDescriptor); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 20cfd85a34d..70744d2869f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -112,7 +112,19 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder(solutionDescriptor); + // We set the shadow variable support, + // which will be necessary for obtaining the change notifier this.shadowVariableSupport = ShadowVariableSupport.create(this); + // When using a list variable, + // we ensure that the listVariableStateSupply is initialized, + // as it will serve as the single source of truth for all other classes. + var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); + if (listVariableDescriptor == null) { + this.listVariableStateSupply = null; + } else { + this.listVariableStateSupply = getSupplyManager().demand(listVariableDescriptor.getStateDemand()); + } + // We can now initialize the shadow variables since all the necessary resources have been allocated this.shadowVariableSupport.linkShadowVariables(); // When true, a snapshot of the solution is created before, after and after the undo of a move. // In {@link EnvironmentMode#TRACKED_FULL_ASSERT}, the snapshots are compared when corruption is detected, @@ -121,12 +133,6 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder(getSolutionDescriptor(), getSupplyManager()) : null; this.valueRangeManager = new ValueRangeManager<>(solutionDescriptor); - var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); - if (listVariableDescriptor == null) { - this.listVariableStateSupply = null; - } else { - this.listVariableStateSupply = getSupplyManager().demand(listVariableDescriptor.getStateDemand()); - } setAllChangesWillBeUndoneBeforeStepEnds(false); // Make sure the notifier is correctly initialized. // Enable assertions this.isAssertClonedSolution = environmentMode.isFullyAsserted(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java index ddb4f643d01..fbf6c536690 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java @@ -2,12 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.testdomain.list.TestdataListSolution; @@ -18,18 +17,16 @@ class ListVariableStateSupplyHolderTest { @SuppressWarnings("unchecked") @Test - void demandsOnPhaseStartedAndCancelsOnPhaseEnded() { + void demandsOnPhaseStartedAndReleasesOnPhaseEnded() { ListVariableDescriptor listVariableDescriptor = mock(ListVariableDescriptor.class); var stateDemand = new ListVariableStateDemand<>(listVariableDescriptor); doReturn(stateDemand).when(listVariableDescriptor).getStateDemand(); ListVariableStateSupply listVariableStateSupply = mock(ListVariableStateSupply.class); - SupplyManager supplyManager = mock(SupplyManager.class); - doReturn(listVariableStateSupply).when(supplyManager).demand(stateDemand); InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); - doReturn(supplyManager).when(scoreDirector).getSupplyManager(); + doReturn(listVariableStateSupply).when(scoreDirector).getListVariableStateSupply(any(ListVariableDescriptor.class)); AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); doReturn(scoreDirector).when(phaseScope).getScoreDirector(); @@ -42,10 +39,8 @@ void demandsOnPhaseStartedAndCancelsOnPhaseEnded() { holder.phaseStarted(phaseScope); assertThat(holder.get()).isSameAs(listVariableStateSupply); - verify(supplyManager).demand(stateDemand); holder.phaseEnded(phaseScope); - verify(supplyManager).cancel(stateDemand); assertThatNullPointerException().isThrownBy(holder::get) .withMessageContaining("not initialized yet"); } From 96295be01efcd46327a7058fa64f510d3612efa3 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 21 Aug 2026 09:17:43 -0300 Subject: [PATCH 13/20] chore: remove ListVariableStateSupplyHolder --- .../ListVariableStateSupplyHolder.java | 53 ------------------- .../list/AbstractListMoveSelector.java | 43 +++++++++++++++ .../list/ElementDestinationSelector.java | 35 +++--------- .../selector/list/RandomSubListSelector.java | 24 ++++----- .../generic/list/GenericListMoveSelector.java | 42 +++++++++++++++ .../generic/list/ListChangeMoveSelector.java | 30 ++--------- .../generic/list/ListSwapMoveSelector.java | 32 +++-------- .../list/kopt/KOptListMoveSelector.java | 33 +++--------- .../ruin/ListRuinRecreateMoveSelector.java | 23 ++++---- .../ListVariableStateSupplyHolderTest.java | 47 ---------------- .../variable/ShadowVariableSupportTest.java | 7 +++ .../list/ElementDestinationSelectorTest.java | 4 +- .../list/kopt/KOptListMoveIteratorTest.java | 2 +- 13 files changed, 137 insertions(+), 238 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java delete mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java deleted file mode 100644 index f2b5943911a..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolder.java +++ /dev/null @@ -1,53 +0,0 @@ -package ai.timefold.solver.core.impl.domain.variable; - -import java.util.Objects; - -import ai.timefold.solver.core.api.domain.solution.PlanningSolution; -import ai.timefold.solver.core.config.solver.EnvironmentMode; -import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; - -/** - * Borrows the {@link ListVariableStateSupply} owned by the score director, - * re-reading it on every {@link #phaseStarted(AbstractPhaseScope)} - * because a phase may run under a different {@link EnvironmentMode}, - * which swaps in a new score director (and thus a new supply instance). - *

- * This holder neither demands nor cancels the supply. - * The score director demands it once on construction and cancels it on close, - * so {@link #phaseEnded(AbstractPhaseScope)} only drops the borrowed reference. - * Sharing the score director's instance also guarantees selectors observe exactly the same list variable state the score - * calculation does. - *

- * Intended to be held as a field by selectors that need a {@link ListVariableStateSupply} across phase lifecycle events, - * delegating their own {@code phaseStarted}/{@code phaseEnded} overrides to this holder instead of each re-implementing the - * bookkeeping. - * - * @param the solution type, the class with the {@link PlanningSolution} - * annotation - */ -public final class ListVariableStateSupplyHolder { - - private final ListVariableDescriptor listVariableDescriptor; - private ListVariableStateSupply listVariableStateSupply; - - public ListVariableStateSupplyHolder(ListVariableDescriptor listVariableDescriptor) { - this.listVariableDescriptor = listVariableDescriptor; - } - - public void phaseStarted(AbstractPhaseScope phaseScope) { - // We reuse the state owned by the score director, rather than demanding a second supply of our own. - this.listVariableStateSupply = phaseScope.getScoreDirector().getListVariableStateSupply(listVariableDescriptor); - } - - public void phaseEnded(AbstractPhaseScope phaseScope) { - // There's no need to release the state, as the score director will take care of it. - listVariableStateSupply = null; - } - - @SuppressWarnings("unchecked") - public ListVariableStateSupply get() { - return (ListVariableStateSupply) Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java new file mode 100644 index 00000000000..88b6929c784 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java @@ -0,0 +1,43 @@ +package ai.timefold.solver.core.impl.heuristic.selector.list; + +import java.util.Objects; + +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelector; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract sealed class AbstractListMoveSelector extends AbstractSelector + permits ElementDestinationSelector, RandomSubListSelector { + + protected final ListVariableDescriptor listVariableDescriptor; + @Nullable + protected ListVariableStateSupply listVariableStateSupply; + + protected AbstractListMoveSelector(ListVariableDescriptor listVariableDescriptor) { + this.listVariableDescriptor = listVariableDescriptor; + } + + protected ListVariableStateSupply getListVariableStateSupply() { + return Objects.requireNonNull(listVariableStateSupply, + "Impossible state: The listVariableStateSupply is not initialized yet."); + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // We reuse the state owned by the score director, rather than demanding a second supply of our own. + this.listVariableStateSupply = phaseScope.getScoreDirector().getListVariableStateSupply(listVariableDescriptor); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + // There's no need to release the state, as the score director will take care of it. + listVariableStateSupply = null; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java index c55497912f0..8f00fe056fc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelector.java @@ -8,14 +8,11 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelector; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.ConcatenatingIterator; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.util.MappingIterator; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; @@ -34,18 +31,15 @@ * * @param the solution type, the class with the {@link PlanningSolution} annotation */ -public class ElementDestinationSelector extends AbstractSelector +public final class ElementDestinationSelector extends AbstractListMoveSelector implements DestinationSelector { - private final ListVariableDescriptor listVariableDescriptor; private final EntitySelector entitySelector; private final IterableValueSelector replayingValueSelector; private final IterableValueSelector valueSelector; private final boolean randomSelection; private final boolean isExhaustiveSearch; - private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; - public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector valueSelector, boolean randomSelection) { this(entitySelector, null, valueSelector, randomSelection, false); @@ -54,10 +48,9 @@ public ElementDestinationSelector(EntitySelector entitySelector, Iter public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector replayingValueSelector, IterableValueSelector valueSelector, boolean randomSelection, boolean isExhaustiveSearch) { - this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + super((ListVariableDescriptor) valueSelector.getVariableDescriptor()); this.entitySelector = entitySelector; - var selector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, listVariableStateSupplyHolder::get); + var selector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); this.replayingValueSelector = replayingValueSelector; this.valueSelector = listVariableDescriptor.allowsUnassignedValues() ? filterUnassignedValues(selector) : selector; this.randomSelection = randomSelection; @@ -85,21 +78,7 @@ private IterableValueSelector filterUnassignedValues( * and always add one option to unassign at the end, * we can keep the correct probabilities throughout. */ - return FilteringValueSelector.ofAssigned(valueSelector, listVariableStateSupplyHolder::get); - } - - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - super.phaseStarted(phaseScope); - // The phase may operate in a different environment mode, which uses a new score director. - // We must ensure that the list variable state supply remains up to date. - listVariableStateSupplyHolder.phaseStarted(phaseScope); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); + return FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); } @Override @@ -121,9 +100,9 @@ public Iterator iterator() { // In case of list var which allows unassigned values, we need to exclude unassigned elements. var totalValueSize = valueSelector.getSize() - - (allowsUnassignedValues ? listVariableStateSupplyHolder.get().getUnassignedCount() : 0); + - (allowsUnassignedValues ? listVariableStateSupply.getUnassignedCount() : 0); var totalSize = Math.addExact(entitySelector.getSize(), totalValueSize); - return new ElementPositionRandomIterator<>(listVariableStateSupplyHolder.get(), entitySelector, + return new ElementPositionRandomIterator<>(listVariableStateSupply, entitySelector, replayingValueSelector != null ? replayingValueSelector.iterator() : null, valueSelector, workingRandom, totalSize, allowsUnassignedValues, allowsUnassignedValues && totalValueSize > 0); } else { @@ -143,7 +122,7 @@ public Iterator iterator() { // Value selector guarantees only unpinned values. var valueIterator = new MappingIterator<>(valueSelector.iterator(), v -> { - var pos = listVariableStateSupplyHolder.get().getElementPosition(v).ensureAssigned(); + var pos = listVariableStateSupply.getElementPosition(v).ensureAssigned(); return ElementPosition.of(pos.entity(), pos.index() + 1); }); if (listVariableDescriptor.allowsUnassignedValues()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java index 3208490bf47..b58d01682b5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java @@ -4,33 +4,31 @@ import java.util.Iterator; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelector; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; -public class RandomSubListSelector extends AbstractSelector implements SubListSelector { +import org.jspecify.annotations.NonNull; + +public final class RandomSubListSelector extends AbstractListMoveSelector + implements SubListSelector { private final EntitySelector entitySelector; private final IterableValueSelector valueSelector; - private final ListVariableDescriptor listVariableDescriptor; private final int minimumSubListSize; private final int maximumSubListSize; private TriangleElementFactory triangleElementFactory; - private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public RandomSubListSelector( EntitySelector entitySelector, IterableValueSelector valueSelector, int minimumSubListSize, int maximumSubListSize) { + super((ListVariableDescriptor) valueSelector.getVariableDescriptor()); this.entitySelector = entitySelector; - this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); - this.valueSelector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, listVariableStateSupplyHolder::get); + this.valueSelector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); if (minimumSubListSize < 1) { throw new IllegalArgumentException("The minimumSubListSize (%d) must be greater than 0." .formatted(minimumSubListSize)); @@ -48,18 +46,14 @@ public RandomSubListSelector( } @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { + public void phaseStarted(@NonNull AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, workingRandom); - // The phase may run under a different environment mode, which swaps in a new score director - // (and thus a new SupplyManager); re-demand so the supply doesn't go stale. - listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { + public void phaseEnded(@NonNull AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); triangleElementFactory = null; } @@ -140,7 +134,7 @@ protected SubList createUpcomingSelection() { // Using valueSelector instead of entitySelector is fairer // because entities with bigger list variables will be selected more often. var value = valueIterator.next(); - sourceEntity = listVariableStateSupplyHolder.get().getInverseSingleton(value); + sourceEntity = listVariableStateSupply.getInverseSingleton(value); if (sourceEntity == null) { // Ignore values which are unassigned. continue; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java new file mode 100644 index 00000000000..e3f99f752f1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java @@ -0,0 +1,42 @@ +package ai.timefold.solver.core.impl.heuristic.selector.move.generic.list; + +import java.util.Objects; + +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract class GenericListMoveSelector extends GenericMoveSelector { + + protected final ListVariableDescriptor listVariableDescriptor; + @Nullable + protected ListVariableStateSupply listVariableStateSupply; + + protected GenericListMoveSelector(ListVariableDescriptor listVariableDescriptor) { + this.listVariableDescriptor = listVariableDescriptor; + } + + protected ListVariableStateSupply getListVariableStateSupply() { + return Objects.requireNonNull(listVariableStateSupply, + "Impossible state: The listVariableStateSupply is not initialized yet."); + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // We reuse the state owned by the score director, rather than demanding a second supply of our own. + this.listVariableStateSupply = phaseScope.getScoreDirector().getListVariableStateSupply(listVariableDescriptor); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + // There's no need to release the state, as the score director will take care of it. + listVariableStateSupply = null; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java index 3ef0601edde..502c2c94ffc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelector.java @@ -4,50 +4,30 @@ import java.util.function.Supplier; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.list.DestinationSelector; -import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement; import ai.timefold.solver.core.preview.api.move.Move; -public class ListChangeMoveSelector extends GenericMoveSelector { +public final class ListChangeMoveSelector extends GenericListMoveSelector { private final IterableValueSelector sourceValueSelector; private final DestinationSelector destinationSelector; private final boolean randomSelection; - private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; - public ListChangeMoveSelector(IterableValueSelector sourceValueSelector, DestinationSelector destinationSelector, boolean randomSelection) { - var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + super((ListVariableDescriptor) sourceValueSelector.getVariableDescriptor()); this.sourceValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(sourceValueSelector, listVariableStateSupplyHolder::get); + filterPinnedListPlanningVariableValuesWithIndex(sourceValueSelector, this::getListVariableStateSupply); this.destinationSelector = destinationSelector; this.randomSelection = randomSelection; phaseLifecycleSupport.addEventListener(this.sourceValueSelector); phaseLifecycleSupport.addEventListener(this.destinationSelector); } - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - super.phaseStarted(phaseScope); - // The phase may operate in a different environment mode, which uses a new score director. - // We must ensure that the list variable state supply remains up to date. - listVariableStateSupplyHolder.phaseStarted(phaseScope); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); - } - public static IterableValueSelector filterPinnedListPlanningVariableValuesWithIndex( IterableValueSelector sourceValueSelector, Supplier> listVariableStateSupplier) { @@ -80,12 +60,12 @@ public long getSize() { public Iterator> iterator() { if (randomSelection) { return new RandomListChangeIterator<>( - listVariableStateSupplyHolder.get(), + listVariableStateSupply, sourceValueSelector, destinationSelector); } else { return new OriginalListChangeIterator<>( - listVariableStateSupplyHolder.get(), + listVariableStateSupply, sourceValueSelector, destinationSelector); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java index f6b66a388d6..ce138e2bc63 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListSwapMoveSelector.java @@ -4,55 +4,35 @@ import java.util.Iterator; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.preview.api.move.Move; -public class ListSwapMoveSelector extends GenericMoveSelector { +public final class ListSwapMoveSelector extends GenericListMoveSelector { private final IterableValueSelector leftValueSelector; private final IterableValueSelector rightValueSelector; private final boolean randomSelection; - private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; - public ListSwapMoveSelector(IterableValueSelector leftValueSelector, IterableValueSelector rightValueSelector, boolean randomSelection) { - var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); + super((ListVariableDescriptor) leftValueSelector.getVariableDescriptor()); this.leftValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(leftValueSelector, listVariableStateSupplyHolder::get); + filterPinnedListPlanningVariableValuesWithIndex(leftValueSelector, this::getListVariableStateSupply); this.rightValueSelector = - filterPinnedListPlanningVariableValuesWithIndex(rightValueSelector, listVariableStateSupplyHolder::get); + filterPinnedListPlanningVariableValuesWithIndex(rightValueSelector, this::getListVariableStateSupply); this.randomSelection = randomSelection; phaseLifecycleSupport.addEventListener(this.leftValueSelector); phaseLifecycleSupport.addEventListener(this.rightValueSelector); } - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - super.phaseStarted(phaseScope); - // The phase may operate in a different environment mode, which uses a new score director. - // We must ensure that the list variable state supply remains up to date. - listVariableStateSupplyHolder.phaseStarted(phaseScope); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); - } - @Override public Iterator> iterator() { if (randomSelection) { - return new RandomListSwapIterator<>(listVariableStateSupplyHolder.get(), leftValueSelector, rightValueSelector); + return new RandomListSwapIterator<>(listVariableStateSupply, leftValueSelector, rightValueSelector); } else { - return new OriginalListSwapIterator<>(listVariableStateSupplyHolder.get(), leftValueSelector, rightValueSelector); + return new OriginalListSwapIterator<>(listVariableStateSupply, leftValueSelector, rightValueSelector); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java index 002163338de..789e6b8f212 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveSelector.java @@ -6,18 +6,14 @@ import java.util.function.Supplier; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; +import ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.GenericListMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.util.MathUtils; import ai.timefold.solver.core.preview.api.move.Move; -final class KOptListMoveSelector extends GenericMoveSelector { - - private final ListVariableDescriptor listVariableDescriptor; +final class KOptListMoveSelector extends GenericListMoveSelector { private final IterableValueSelector originSelector; private final IterableValueSelector valueSelector; @@ -26,15 +22,12 @@ final class KOptListMoveSelector extends GenericMoveSelector listVariableStateSupplyHolder; - public KOptListMoveSelector(ListVariableDescriptor listVariableDescriptor, IterableValueSelector originSelector, IterableValueSelector valueSelector, int minK, int maxK, int[] pickedKDistribution) { - this.listVariableDescriptor = listVariableDescriptor; - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); - this.originSelector = createEffectiveValueSelector(originSelector, listVariableStateSupplyHolder::get); - this.valueSelector = createEffectiveValueSelector(valueSelector, listVariableStateSupplyHolder::get); + super(listVariableDescriptor); + this.originSelector = createEffectiveValueSelector(originSelector, this::getListVariableStateSupply); + this.valueSelector = createEffectiveValueSelector(valueSelector, this::getListVariableStateSupply); this.minK = minK; this.maxK = maxK; this.pickedKDistribution = pickedKDistribution; @@ -51,20 +44,6 @@ private IterableValueSelector createEffectiveValueSelector( return FilteringValueSelector.ofAssigned(filteredValueSelector, listVariableStateSupplier); } - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - super.phaseStarted(phaseScope); - // The phase may operate in a different environment mode, which uses a new score director. - // We must ensure that the list variable state supply remains up to date. - listVariableStateSupplyHolder.phaseStarted(phaseScope); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); - } - @Override public long getSize() { long total = 0; @@ -89,7 +68,7 @@ public long getSize() { @Override public Iterator> iterator() { - return new KOptListMoveIterator<>(workingRandom, listVariableDescriptor, listVariableStateSupplyHolder.get(), + return new KOptListMoveIterator<>(workingRandom, listVariableDescriptor, listVariableStateSupply, originSelector, valueSelector, minK, maxK, pickedKDistribution); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java index cf4f818e438..9fb39ea2b30 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelector.java @@ -2,11 +2,10 @@ import java.util.Iterator; -import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupplyHolder; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.CountSupplier; -import ai.timefold.solver.core.impl.heuristic.selector.move.generic.GenericMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhaseBuilder; +import ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.GenericListMoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueSelector; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; @@ -14,7 +13,9 @@ import ai.timefold.solver.core.impl.util.MathUtils; import ai.timefold.solver.core.preview.api.move.Move; -final class ListRuinRecreateMoveSelector extends GenericMoveSelector { +import org.jspecify.annotations.NonNull; + +final class ListRuinRecreateMoveSelector extends GenericListMoveSelector { private final IterableValueSelector valueSelector; private final RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder; @@ -22,15 +23,13 @@ final class ListRuinRecreateMoveSelector extends GenericMoveSelector< private final CountSupplier maximumSelectedCountSupplier; private SolverScope solverScope; - private final ListVariableStateSupplyHolder listVariableStateSupplyHolder; public ListRuinRecreateMoveSelector(IterableValueSelector valueSelector, ListVariableDescriptor listVariableDescriptor, RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder, CountSupplier minimumSelectedCountSupplier, CountSupplier maximumSelectedCountSupplier) { - super(); - this.listVariableStateSupplyHolder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); - this.valueSelector = FilteringValueSelector.ofAssigned(valueSelector, listVariableStateSupplyHolder::get); + super(listVariableDescriptor); + this.valueSelector = FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); this.constructionHeuristicPhaseBuilder = constructionHeuristicPhaseBuilder; this.minimumSelectedCountSupplier = minimumSelectedCountSupplier; this.maximumSelectedCountSupplier = maximumSelectedCountSupplier; @@ -57,18 +56,14 @@ public boolean isNeverEnding() { } @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { + public void phaseStarted(@NonNull AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); this.solverScope = phaseScope.getSolverScope(); - // The phase may operate in a different environment mode, which uses a new score director. - // We must ensure that the list variable state supply remains up to date. - listVariableStateSupplyHolder.phaseStarted(phaseScope); } @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { + public void phaseEnded(@NonNull AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); - listVariableStateSupplyHolder.phaseEnded(phaseScope); this.solverScope = null; } @@ -76,7 +71,7 @@ public void phaseEnded(AbstractPhaseScope phaseScope) { public Iterator> iterator() { var valueSelectorSize = valueSelector.getSize(); return new ListRuinRecreateMoveIterator<>(valueSelector, constructionHeuristicPhaseBuilder, - solverScope, listVariableStateSupplyHolder.get(), + solverScope, listVariableStateSupply, minimumSelectedCountSupplier.applyAsInt(valueSelectorSize), maximumSelectedCountSupplier.applyAsInt(valueSelectorSize), workingRandom); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java deleted file mode 100644 index fbf6c536690..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableStateSupplyHolderTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package ai.timefold.solver.core.impl.domain.variable; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNullPointerException; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; - -import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; -import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; -import ai.timefold.solver.core.testdomain.list.TestdataListSolution; - -import org.junit.jupiter.api.Test; - -class ListVariableStateSupplyHolderTest { - - @SuppressWarnings("unchecked") - @Test - void demandsOnPhaseStartedAndReleasesOnPhaseEnded() { - ListVariableDescriptor listVariableDescriptor = mock(ListVariableDescriptor.class); - var stateDemand = new ListVariableStateDemand<>(listVariableDescriptor); - doReturn(stateDemand).when(listVariableDescriptor).getStateDemand(); - - ListVariableStateSupply listVariableStateSupply = - mock(ListVariableStateSupply.class); - - InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); - doReturn(listVariableStateSupply).when(scoreDirector).getListVariableStateSupply(any(ListVariableDescriptor.class)); - - AbstractPhaseScope phaseScope = mock(AbstractPhaseScope.class); - doReturn(scoreDirector).when(phaseScope).getScoreDirector(); - - var holder = new ListVariableStateSupplyHolder<>(listVariableDescriptor); - - // Not yet demanded: get() must fail fast rather than silently return null. - assertThatNullPointerException().isThrownBy(holder::get) - .withMessageContaining("not initialized yet"); - - holder.phaseStarted(phaseScope); - assertThat(holder.get()).isSameAs(listVariableStateSupply); - - holder.phaseEnded(phaseScope); - assertThatNullPointerException().isThrownBy(holder::get) - .withMessageContaining("not initialized yet"); - } -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupportTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupportTest.java index 61f9f801547..eb23380d693 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupportTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupportTest.java @@ -23,6 +23,7 @@ import ai.timefold.solver.core.impl.domain.variable.declarative.GraphNode; import ai.timefold.solver.core.impl.domain.variable.declarative.TopologicalOrderGraph; import ai.timefold.solver.core.impl.domain.variable.declarative.VariableUpdaterInfo; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.NeighborhoodNotifier; import ai.timefold.solver.core.impl.score.director.ValueRangeManager; @@ -98,6 +99,8 @@ void shadowVariableListGraphEvents() { var neighborhoodNotifier = (NeighborhoodNotifier) Mockito.mock(NeighborhoodNotifier.class); when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor); when(scoreDirector.getNeighborhoodNotifier()).thenReturn(neighborhoodNotifier); + var listVariableStateSupply = mock(ListVariableStateSupply.class); + when(scoreDirector.getListVariableStateSupply(any(ListVariableDescriptor.class))).thenReturn(listVariableStateSupply); var valueRangeManager = new ValueRangeManager<>(solutionDescriptor); when(scoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); @@ -338,6 +341,10 @@ void listVariableChangeIsDispatchedEagerly() { var shadowVariableSupport = new ShadowVariableSupport<>(scoreDirector, DefaultTopologicalOrderGraph::new); + var listVariableStateSupply = + shadowVariableSupport.demand(solutionDescriptor.getListVariableDescriptor().getStateDemand()); + when(scoreDirector.getListVariableStateSupply(any(ListVariableDescriptor.class))).thenReturn(listVariableStateSupply); + shadowVariableSupport.linkShadowVariables(); shadowVariableSupport.resetWorkingSolution(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java index 835c18d067b..4da8ae4a12b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java @@ -504,7 +504,7 @@ void refreshReachableEntities() { scoreDirector.setWorkingSolution(solution); // Value selector - var listVariableDescriptor = TestdataListUnassignedPinnedEntityProvidingEntity.buildVariableDescriptorForValueList(); + var listVariableDescriptor = scoreDirector.getSolutionDescriptor().getListVariableDescriptor(); var iterableValueSelector = mockIterableValueSelector(listVariableDescriptor, v1, v2); var mimicRecorder = new ManualValueMimicRecorder<>(iterableValueSelector); var replayingValueSelector = new MimicReplayingValueSelector<>(mimicRecorder); @@ -559,7 +559,7 @@ void emptyIfThereAreNoEntities() { var entitySelector = mockEntitySelector(new TestdataListEntity[0]); var valueSelector = - mockIterableValueSelector(TestdataListEntity.buildVariableDescriptorForValueList(), v1, v2, v3); + mockIterableValueSelector(scoreDirector.getSolutionDescriptor().getListVariableDescriptor(), v1, v2, v3); var randomSelector = new ElementDestinationSelector<>(entitySelector, valueSelector, true); var solverScope = solvingStarted(randomSelector, scoreDirector); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveIteratorTest.java index fec36eb2fa3..b77022226c7 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/KOptListMoveIteratorTest.java @@ -25,7 +25,7 @@ import org.junit.jupiter.api.Test; -public class KOptListMoveIteratorTest { +class KOptListMoveIteratorTest { private static class KOptListMoveIteratorMockData { int minK; From 5059828b4ff43b2aa05d832810b640fcc862e2a1 Mon Sep 17 00:00:00 2001 From: Fred Date: Fri, 21 Aug 2026 13:37:02 -0300 Subject: [PATCH 14/20] chore: simplify factory logic --- .../TimefoldSolverEnterpriseService.java | 4 +- ...aultConstructionHeuristicPhaseFactory.java | 14 +- .../DefaultExhaustiveSearchPhaseFactory.java | 21 +- .../impl/heuristic/HeuristicConfigPolicy.java | 23 +- .../list/AbstractListMoveSelector.java | 3 +- ...eateConstructionHeuristicPhaseBuilder.java | 4 +- ...eateConstructionHeuristicPhaseFactory.java | 6 +- .../DefaultLocalSearchPhaseFactory.java | 64 ++--- .../decider/acceptor/AcceptorFactory.java | 268 +++++++++--------- .../DefaultPartitionedSearchPhaseFactory.java | 3 +- .../custom/DefaultCustomPhaseFactory.java | 4 +- ...ConstructionHeuristicPhaseBuilderTest.java | 3 + ...SelectorBasedListRuinRecreateMoveTest.java | 1 + .../decider/acceptor/AcceptorFactoryTest.java | 18 +- .../impl/neighborhood/NeighborhoodsTest.java | 2 +- 15 files changed, 220 insertions(+), 218 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 68f7a14840e..1434aaf5edf 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -196,8 +196,8 @@ LocalSearchDecider buildLocalSearch(int moveThreadCount, EnvironmentMode environmentMode, HeuristicConfigPolicy configPolicy); PartitionedSearchPhase buildPartitionedSearch(int phaseIndex, - PartitionedSearchPhaseConfig phaseConfig, EnvironmentMode environmentMode, - HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + PartitionedSearchPhaseConfig phaseConfig, HeuristicConfigPolicy solverConfigPolicy, + SolverTermination solverTermination, BiFunction, SolverTermination, PhaseTermination> phaseTerminationFunction); EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java index f81fd9e3648..624b5af5e25 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseFactory.java @@ -17,7 +17,6 @@ import ai.timefold.solver.core.config.heuristic.selector.move.composite.UnionMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; -import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase.DefaultConstructionHeuristicPhaseBuilder; @@ -53,7 +52,9 @@ public final DefaultConstructionHeuristicPhaseBuilder getBuilder(int constructionHeuristicType_.getDefaultEntitySorterManner()); var valueSorterManner = Objects.requireNonNullElse(phaseConfig.getValueSorterManner(), constructionHeuristicType_.getDefaultValueSorterManner()); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); var phaseConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(environmentMode) .withReinitializeVariableFilterEnabled(true) .withUnassignedValuesAllowed(true) .withEntitySorterManner(entitySorterManner) @@ -70,10 +71,9 @@ protected DefaultConstructionHeuristicPhaseBuilder createBuilder( HeuristicConfigPolicy phaseConfigPolicy, SolverTermination solverTermination, int phaseIndex, boolean lastInitializingPhase, EntityPlacer entityPlacer) { var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); - var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); - return new DefaultConstructionHeuristicPhaseBuilder<>(phaseIndex, lastInitializingPhase, environmentMode, - phaseConfigPolicy.getLogIndentation(), phaseTermination, entityPlacer, - buildDecider(phaseConfigPolicy, environmentMode, phaseTermination)) + return new DefaultConstructionHeuristicPhaseBuilder<>(phaseIndex, lastInitializingPhase, + phaseConfigPolicy.getEnvironmentMode(), phaseConfigPolicy.getLogIndentation(), phaseTermination, entityPlacer, + buildDecider(phaseConfigPolicy, phaseTermination)) .enableAssertions(); } @@ -160,14 +160,14 @@ public static EntityPlacerConfig buildListVariableQueuedValuePlacerConfig(Heuris } protected ConstructionHeuristicDecider buildDecider(HeuristicConfigPolicy configPolicy, - EnvironmentMode environmentMode, PhaseTermination termination) { + PhaseTermination termination) { var forager = buildForager(configPolicy); var moveThreadCount = configPolicy.getMoveThreadCount(); var decider = (moveThreadCount == null) ? new ConstructionHeuristicDecider<>(configPolicy.getLogIndentation(), termination, forager) : TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.MULTITHREADED_SOLVING) .buildConstructionHeuristic(termination, forager, configPolicy); - decider.enableAssertions(environmentMode); + decider.enableAssertions(configPolicy.getEnvironmentMode()); return decider; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java index d0269da2004..0c510b1e21e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java @@ -19,7 +19,6 @@ import ai.timefold.solver.core.config.heuristic.selector.move.generic.ChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; -import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; @@ -61,7 +60,9 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI var valueSorterManner = Objects.requireNonNullElse( phaseConfig.getValueSorterManner(), exhaustiveSearchType.getDefaultValueSorterManner()); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); var phaseConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(environmentMode) .withReinitializeVariableFilterEnabled(true) .withEntitySorterManner(entitySorterManner) .withValueSorterManner(valueSorterManner) @@ -70,20 +71,18 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); var scoreBounderEnabled = exhaustiveSearchType.isScoreBounderEnabled(); var nodeExplorationType = getNodeExplorationType(exhaustiveSearchType, phaseConfig); - var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); AbstractExhaustiveSearchDecider> decider; if (isMixedModel) { var basicVarEntitySelectorConfig = buildEntitySelectorConfig(phaseConfigPolicy, false); var basicVarEntitySelector = EntitySelectorFactory. create(basicVarEntitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - var basicVarDecider = - buildDecider(phaseConfigPolicy, basicVarEntitySelector, bestSolutionRecaller, environmentMode, - phaseTermination, scoreBounderEnabled, false); + var basicVarDecider = buildDecider(phaseConfigPolicy, basicVarEntitySelector, bestSolutionRecaller, + phaseTermination, scoreBounderEnabled, false); var listVarEntitySelectorConfig = buildEntitySelectorConfig(phaseConfigPolicy, true); var listVarEntitySelector = EntitySelectorFactory. create(listVarEntitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - var listVarDecider = buildDecider(phaseConfigPolicy, listVarEntitySelector, bestSolutionRecaller, environmentMode, - phaseTermination, scoreBounderEnabled, true); + var listVarDecider = buildDecider(phaseConfigPolicy, listVarEntitySelector, bestSolutionRecaller, phaseTermination, + scoreBounderEnabled, true); decider = new MixedVariableExhaustiveSearchDecider<>(basicVarDecider, listVarDecider); } else { var isListVariable = solverConfigPolicy.getSolutionDescriptor().getListVariableDescriptor() != null; @@ -91,7 +90,7 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI var entitySelector = EntitySelectorFactory. create(entitySelectorConfig) .buildEntitySelector(phaseConfigPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - decider = buildDecider(phaseConfigPolicy, entitySelector, bestSolutionRecaller, environmentMode, phaseTermination, + decider = buildDecider(phaseConfigPolicy, entitySelector, bestSolutionRecaller, phaseTermination, scoreBounderEnabled, isListVariable); } return new DefaultExhaustiveSearchPhase.Builder<>(phaseIndex, environmentMode, solverConfigPolicy.getLogIndentation(), @@ -159,8 +158,8 @@ protected EntityDescriptor deduceEntityDescriptor(SolutionDescriptor< private AbstractExhaustiveSearchDecider> buildDecider( HeuristicConfigPolicy configPolicy, EntitySelector sourceEntitySelector, - BestSolutionRecaller bestSolutionRecaller, EnvironmentMode environmentMode, - PhaseTermination termination, boolean scoreBounderEnabled, boolean isListVariable) { + BestSolutionRecaller bestSolutionRecaller, PhaseTermination termination, + boolean scoreBounderEnabled, boolean isListVariable) { var manualEntityMimicRecorder = new ManualEntityMimicRecorder<>(sourceEntitySelector); var entityClassName = sourceEntitySelector.getEntityDescriptor().getEntityClass().getName(); var mimicSelectorId = ConfigUtils.addRandomSuffix(entityClassName, configPolicy.getRandom().factoryUsage()); @@ -201,7 +200,7 @@ protected EntityDescriptor deduceEntityDescriptor(SolutionDescriptor< new MoveSelectorBasedMoveRepository<>(moveSelector), scoreBounderEnabled, scoreBounder); } - decider.enableAssertions(environmentMode); + decider.enableAssertions(configPolicy.getEnvironmentMode()); return decider; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java index 79f0d8ad4f2..e8459f9865e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java @@ -142,16 +142,31 @@ public Builder cloneBuilder() { } public HeuristicConfigPolicy copyConfigPolicy() { - return cloneBuilder() + return copyConfigPolicy(null); + } + + public HeuristicConfigPolicy copyConfigPolicy(EnvironmentMode environmentMode) { + var builder = cloneBuilder() .withEntitySorterManner(entitySorterManner) .withValueSorterManner(valueSorterManner) .withReinitializeVariableFilterEnabled(reinitializeVariableFilterEnabled) - .withUnassignedValuesAllowed(unassignedValuesAllowed) - .build(); + .withUnassignedValuesAllowed(unassignedValuesAllowed); + if (environmentMode != null) { + builder.withEnvironmentMode(environmentMode); + } + return builder.build(); } public HeuristicConfigPolicy copyPhaseConfigPolicy() { - return cloneBuilder().build(); + return copyPhaseConfigPolicy(null); + } + + public HeuristicConfigPolicy copyPhaseConfigPolicy(EnvironmentMode environmentMode) { + var builder = cloneBuilder(); + if (environmentMode != null) { + builder.withEnvironmentMode(environmentMode); + } + return builder.build(); } public HeuristicConfigPolicy copyConfigPolicyWithoutNearbySetting() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java index 88b6929c784..18880762efb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java @@ -11,8 +11,7 @@ import org.jspecify.annotations.Nullable; @NullMarked -public abstract sealed class AbstractListMoveSelector extends AbstractSelector - permits ElementDestinationSelector, RandomSubListSelector { +public abstract class AbstractListMoveSelector extends AbstractSelector { protected final ListVariableDescriptor listVariableDescriptor; @Nullable diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java index 51c9bb538cd..6d60b4c1d6b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java @@ -74,9 +74,7 @@ public static RuinRecreateConstructionHeuristicPhaseBuilder(configPolicy, constructionHeuristicPhaseFactory, phaseTermination, super.getEntityPlacer().copy(), - // The R&R decider uses the root solver environment mode by default - constructionHeuristicPhaseFactory.buildDecider(configPolicy, configPolicy.getEnvironmentMode(), - phaseTermination)); + constructionHeuristicPhaseFactory.buildDecider(configPolicy, phaseTermination)); } return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java index c96d0dbbc13..fe3d81a17f4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; -import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacer; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; @@ -21,15 +20,14 @@ protected RuinRecreateConstructionHeuristicPhaseBuilder createBuilder HeuristicConfigPolicy phaseConfigPolicy, SolverTermination solverTermination, int phaseIndex, boolean lastInitializingPhase, EntityPlacer entityPlacer) { var phaseTermination = PhaseTermination.bridge(new BasicPlumbingTermination(false)); - // The R&R decider uses the root solver environment mode by default return new RuinRecreateConstructionHeuristicPhaseBuilder<>(phaseConfigPolicy, this, phaseTermination, entityPlacer, - buildDecider(phaseConfigPolicy, phaseConfigPolicy.getEnvironmentMode(), phaseTermination)); + buildDecider(phaseConfigPolicy, phaseTermination)); } @Override protected RuinRecreateConstructionHeuristicDecider buildDecider(HeuristicConfigPolicy configPolicy, - EnvironmentMode environmentMode, PhaseTermination termination) { + PhaseTermination termination) { return new RuinRecreateConstructionHeuristicDecider<>(termination, buildForager(configPolicy)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java index 8ee91baaa2c..2dad9bf2cd5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java @@ -21,7 +21,6 @@ import ai.timefold.solver.core.config.localsearch.decider.acceptor.LocalSearchAcceptorConfig; import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchForagerConfig; import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchPickEarlyType; -import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; @@ -62,17 +61,17 @@ public DefaultLocalSearchPhaseFactory(LocalSearchPhaseConfig phaseConfig) { public LocalSearchPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(environmentMode); var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); - var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); - var decider = buildDecider(phaseConfigPolicy, environmentMode, phaseTermination); + var decider = buildDecider(phaseConfigPolicy, phaseTermination); return new DefaultLocalSearchPhase.Builder<>(phaseIndex, environmentMode, solverConfigPolicy.getLogIndentation(), phaseTermination, decider).enableAssertions().build(); } @SuppressWarnings({ "unchecked", "rawtypes" }) private LocalSearchDecider buildDecider(HeuristicConfigPolicy phaseConfigPolicy, - EnvironmentMode environmentMode, PhaseTermination phaseTermination) { + PhaseTermination phaseTermination) { var neighborhoodsEnabled = phaseConfigPolicy.isPreviewFeatureEnabled(PreviewFeature.NEIGHBORHOODS); var neighborhoodProviderClass = phaseConfig. getNeighborhoodProviderClass(); if (neighborhoodsEnabled) { @@ -98,34 +97,33 @@ The neighborhoodProviderClass (%s) can only be used if the %s preview feature is var moveSelectorConfig = phaseConfig.getMoveSelectorConfig(); if (moveSelectorConfig != null) { if (neighborhoodsEnabled) { - return buildMixedDecider(phaseConfigPolicy, environmentMode, phaseTermination, neighborhoodProviderClass); + return buildMixedDecider(phaseConfigPolicy, phaseTermination, neighborhoodProviderClass); } else { - return buildMoveSelectorBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination); + return buildMoveSelectorBasedDecider(phaseConfigPolicy, phaseTermination); } } else if (neighborhoodsEnabled) { - return buildNeighborhoodsBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination, - neighborhoodProviderClass); + return buildNeighborhoodsBasedDecider(phaseConfigPolicy, phaseTermination, neighborhoodProviderClass); } else { // The default branch; for now, it is move selectors. - return buildMoveSelectorBasedDecider(phaseConfigPolicy, environmentMode, phaseTermination); + return buildMoveSelectorBasedDecider(phaseConfigPolicy, phaseTermination); } } private LocalSearchDecider buildMoveSelectorBasedDecider(HeuristicConfigPolicy configPolicy, - EnvironmentMode environmentMode, PhaseTermination termination) { + PhaseTermination termination) { var moveRepository = new MoveSelectorBasedMoveRepository<>(buildMoveSelector(configPolicy, false)); - return buildDecider(moveRepository, configPolicy, environmentMode, termination); + return buildDecider(moveRepository, configPolicy, termination); } private LocalSearchDecider buildNeighborhoodsBasedDecider(HeuristicConfigPolicy configPolicy, - EnvironmentMode environmentMode, PhaseTermination termination, + PhaseTermination termination, Class> neighborhoodProviderClass) { - return buildDecider(buildNeighborhoodsBasedMoveRepository(configPolicy, environmentMode, neighborhoodProviderClass), - configPolicy, environmentMode, termination); + return buildDecider(buildNeighborhoodsBasedMoveRepository(configPolicy, neighborhoodProviderClass), configPolicy, + termination); } @SuppressWarnings("unchecked") private NeighborhoodsBasedMoveRepository buildNeighborhoodsBasedMoveRepository( - HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, + HeuristicConfigPolicy configPolicy, Class> neighborhoodProviderClass) { if (phaseConfig.getLocalSearchType() == LocalSearchType.VARIABLE_NEIGHBORHOOD_DESCENT) { throw new IllegalArgumentException( @@ -142,32 +140,29 @@ The localSearchType (%s) does not support the Neighborhoods API. "neighborhoodProviderClass", neighborhoodProviderClass); var solutionDescriptor = configPolicy.getSolutionDescriptor(); var neighborhoodBuilder = new DefaultNeighborhoodBuilder<>(solutionDescriptor.getMetaModel()); - var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, environmentMode); + var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, configPolicy.getEnvironmentMode()); return new NeighborhoodsBasedMoveRepository<>(moveStreamFactory, ((DefaultNeighborhood) neighborhoodProvider.defineNeighborhood(neighborhoodBuilder)) .getMoveProviderList()); } private LocalSearchDecider buildMixedDecider(HeuristicConfigPolicy configPolicy, - EnvironmentMode environmentMode, PhaseTermination termination, + PhaseTermination termination, Class> neighborhoodProviderClass) { var legacyMoveSelector = buildMoveSelector(configPolicy, neighborhoodProviderClass != null); if (legacyMoveSelector == null) { // There were no move selectors configured. - return buildNeighborhoodsBasedDecider(configPolicy, environmentMode, termination, neighborhoodProviderClass); + return buildNeighborhoodsBasedDecider(configPolicy, termination, neighborhoodProviderClass); } var neighborhoodsMoveSelector = - new NeighborhoodsMoveSelector<>( - buildNeighborhoodsBasedMoveRepository(configPolicy, environmentMode, neighborhoodProviderClass)); + new NeighborhoodsMoveSelector<>(buildNeighborhoodsBasedMoveRepository(configPolicy, neighborhoodProviderClass)); var moveSelector = new MixedMoveSelector<>(legacyMoveSelector, neighborhoodsMoveSelector); var moveRepository = new MoveSelectorBasedMoveRepository<>(moveSelector); - return buildDecider(moveRepository, configPolicy, environmentMode, termination); + return buildDecider(moveRepository, configPolicy, termination); } private LocalSearchDecider buildDecider(MoveRepository moveRepository, - HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, - PhaseTermination termination) { - var acceptor = buildAcceptor(configPolicy, environmentMode, - moveRepository instanceof NeighborhoodsBasedMoveRepository); + HeuristicConfigPolicy configPolicy, PhaseTermination termination) { + var acceptor = buildAcceptor(configPolicy, moveRepository instanceof NeighborhoodsBasedMoveRepository); var forager = buildForager(); if (moveRepository.isNeverEnding() && !forager.supportsNeverEndingMoveSelector()) { throw new IllegalStateException(""" @@ -179,14 +174,13 @@ The move repository (%s) is neverEnding (%s), but the forager (%s) does not supp var decider = moveThreadCount == null ? new LocalSearchDecider<>(configPolicy.getLogIndentation(), termination, moveRepository, acceptor, forager) : TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.MULTITHREADED_SOLVING) - .buildLocalSearch(moveThreadCount, termination, moveRepository, acceptor, forager, environmentMode, - configPolicy); - decider.enableAssertions(environmentMode); + .buildLocalSearch(moveThreadCount, termination, moveRepository, acceptor, forager, + configPolicy.getEnvironmentMode(), configPolicy); + decider.enableAssertions(configPolicy.getEnvironmentMode()); return decider; } - protected Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode, - boolean neighborhoodsEnabled) { + protected Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, boolean neighborhoodsEnabled) { var acceptorConfig = phaseConfig.getAcceptorConfig(); var localSearchType = phaseConfig.getLocalSearchType(); if (acceptorConfig != null) { @@ -195,7 +189,7 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con "The localSearchType (%s) must not be configured if the acceptorConfig (%s) is explicitly configured." .formatted(localSearchType, acceptorConfig)); } - return buildAcceptor(acceptorConfig, configPolicy, environmentMode); + return buildAcceptor(acceptorConfig, configPolicy); } else { var updatedLocalSearchType = Objects.requireNonNullElse(localSearchType, LocalSearchType.LATE_ACCEPTANCE); acceptorConfig = new LocalSearchAcceptorConfig(); @@ -206,7 +200,7 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con } var acceptorType = getAcceptorType(neighborhoodsEnabled, updatedLocalSearchType); acceptorConfig.setAcceptorTypeList(Collections.singletonList(acceptorType)); - return buildAcceptor(acceptorConfig, configPolicy, environmentMode); + return buildAcceptor(acceptorConfig, configPolicy); } } @@ -226,8 +220,8 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con } private Acceptor buildAcceptor(LocalSearchAcceptorConfig acceptorConfig, - HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode) { - return AcceptorFactory. create(acceptorConfig).buildAcceptor(configPolicy, environmentMode); + HeuristicConfigPolicy configPolicy) { + return AcceptorFactory. create(acceptorConfig).buildAcceptor(configPolicy); } protected LocalSearchForager buildForager() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java index b5e159bdb4b..1a58c55f94b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactory.java @@ -2,8 +2,6 @@ import java.util.List; import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.Stream; import ai.timefold.solver.core.config.localsearch.decider.acceptor.AcceptorType; @@ -40,20 +38,20 @@ public AcceptorFactory(LocalSearchAcceptorConfig acceptorConfig) { this.acceptorConfig = acceptorConfig; } - public Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy, EnvironmentMode environmentMode) { + public Acceptor buildAcceptor(HeuristicConfigPolicy configPolicy) { List> acceptorList = Stream.of( buildHillClimbingAcceptor(), buildStepCountingHillClimbingAcceptor(), - buildEntityTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), - buildValueTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), - buildMoveTabuAcceptor(environmentMode, configPolicy.getLogIndentation()), + buildEntityTabuAcceptor(configPolicy), + buildValueTabuAcceptor(configPolicy), + buildMoveTabuAcceptor(configPolicy), buildSimulatedAnnealingAcceptor(configPolicy), buildLateAcceptanceAcceptor(), buildDiversifiedLateAcceptanceAcceptor(configPolicy), buildGreatDelugeAcceptor(configPolicy)) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toList()); + .filter(Objects::nonNull) + .map(a -> (Acceptor) a) + .toList(); if (acceptorList.size() == 1) { return acceptorList.getFirst(); @@ -67,11 +65,11 @@ The acceptor does not specify any acceptorType (%s) or other acceptor property. } } - private Optional> buildHillClimbingAcceptor() { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.HILL_CLIMBING)) { - return Optional.of(new HillClimbingAcceptor<>()); + private HillClimbingAcceptor buildHillClimbingAcceptor() { + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.HILL_CLIMBING)) { + return null; } - return Optional.empty(); + return new HillClimbingAcceptor<>(); } private boolean acceptorTypeListsContainsAcceptorType(AcceptorType acceptorType) { @@ -79,75 +77,71 @@ private boolean acceptorTypeListsContainsAcceptorType(AcceptorType acceptorType) return acceptorTypeList != null && acceptorTypeList.contains(acceptorType); } - private Optional> buildStepCountingHillClimbingAcceptor() { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.STEP_COUNTING_HILL_CLIMBING) - || acceptorConfig.getStepCountingHillClimbingSize() != null) { - int stepCountingHillClimbingSize_ = - Objects.requireNonNullElse(acceptorConfig.getStepCountingHillClimbingSize(), 400); - var stepCountingHillClimbingType_ = - Objects.requireNonNullElse(acceptorConfig.getStepCountingHillClimbingType(), - StepCountingHillClimbingType.STEP); - var acceptor = new StepCountingHillClimbingAcceptor( - stepCountingHillClimbingSize_, stepCountingHillClimbingType_); - return Optional.of(acceptor); - } - return Optional.empty(); + private StepCountingHillClimbingAcceptor buildStepCountingHillClimbingAcceptor() { + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.STEP_COUNTING_HILL_CLIMBING) + && acceptorConfig.getStepCountingHillClimbingSize() == null) { + return null; + } + int stepCountingHillClimbingSize_ = + Objects.requireNonNullElse(acceptorConfig.getStepCountingHillClimbingSize(), 400); + var stepCountingHillClimbingType_ = + Objects.requireNonNullElse(acceptorConfig.getStepCountingHillClimbingType(), + StepCountingHillClimbingType.STEP); + return new StepCountingHillClimbingAcceptor( + stepCountingHillClimbingSize_, stepCountingHillClimbingType_); } - private Optional> buildEntityTabuAcceptor(EnvironmentMode environmentMode, - String logIndentation) { + private EntityTabuAcceptor buildEntityTabuAcceptor(HeuristicConfigPolicy configPolicy) { var entityTabuSize = acceptorConfig.getEntityTabuSize(); var entityTabuRatio = acceptorConfig.getEntityTabuRatio(); var fadingEntityTabuSize = acceptorConfig.getFadingEntityTabuSize(); var fadingEntityTabuRatio = acceptorConfig.getFadingEntityTabuRatio(); - if (acceptorTypeListsContainsAcceptorType(AcceptorType.ENTITY_TABU) - || entityTabuSize != null || entityTabuRatio != null - || fadingEntityTabuSize != null || fadingEntityTabuRatio != null) { - var acceptor = new EntityTabuAcceptor(logIndentation); - if (entityTabuSize != null) { - if (entityTabuRatio != null) { - throw new IllegalArgumentException( - "The acceptor cannot have both entityTabuSize (%d) and entityTabuRatio (%f)." - .formatted(entityTabuSize, entityTabuRatio)); - } - acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(entityTabuSize)); - } else if (entityTabuRatio != null) { - acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(entityTabuRatio)); - } else if (fadingEntityTabuSize == null && fadingEntityTabuRatio == null) { - acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(0.1)); + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.ENTITY_TABU) && entityTabuSize == null + && entityTabuRatio == null && fadingEntityTabuSize == null && fadingEntityTabuRatio == null) { + return null; + } + var acceptor = new EntityTabuAcceptor(configPolicy.getLogIndentation()); + if (entityTabuSize != null) { + if (entityTabuRatio != null) { + throw new IllegalArgumentException( + "The acceptor cannot have both entityTabuSize (%d) and entityTabuRatio (%f)." + .formatted(entityTabuSize, entityTabuRatio)); } - if (fadingEntityTabuSize != null) { - if (fadingEntityTabuRatio != null) { - throw new IllegalArgumentException( - "The acceptor cannot have both fadingEntityTabuSize (%d) and fadingEntityTabuRatio (%f)." - .formatted(fadingEntityTabuSize, fadingEntityTabuRatio)); - } - acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingEntityTabuSize)); - } else if (fadingEntityTabuRatio != null) { - acceptor.setFadingTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(fadingEntityTabuRatio)); + acceptor.setTabuSizeStrategy(new FixedTabuSizeStrategy<>(entityTabuSize)); + } else if (entityTabuRatio != null) { + acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(entityTabuRatio)); + } else if (fadingEntityTabuSize == null && fadingEntityTabuRatio == null) { + acceptor.setTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(0.1)); + } + if (fadingEntityTabuSize != null) { + if (fadingEntityTabuRatio != null) { + throw new IllegalArgumentException( + "The acceptor cannot have both fadingEntityTabuSize (%d) and fadingEntityTabuRatio (%f)." + .formatted(fadingEntityTabuSize, fadingEntityTabuRatio)); } - acceptor.enableAssertions(environmentMode); - return Optional.of(acceptor); + acceptor.setFadingTabuSizeStrategy(new FixedTabuSizeStrategy<>(fadingEntityTabuSize)); + } else if (fadingEntityTabuRatio != null) { + acceptor.setFadingTabuSizeStrategy(new EntityRatioTabuSizeStrategy<>(fadingEntityTabuRatio)); } - return Optional.empty(); + acceptor.enableAssertions(configPolicy.getEnvironmentMode()); + return acceptor; } - private Optional> buildValueTabuAcceptor(EnvironmentMode environmentMode, - String logIndentation) { + private ValueTabuAcceptor buildValueTabuAcceptor(HeuristicConfigPolicy configPolicy) { var valueTabuSize = acceptorConfig.getValueTabuSize(); var fadingValueTabuSize = acceptorConfig.getFadingValueTabuSize(); - if (acceptorTypeListsContainsAcceptorType(AcceptorType.VALUE_TABU) - || valueTabuSize != null || fadingValueTabuSize != null) { - if (valueTabuSize == null && fadingValueTabuSize == null) { - throw new IllegalArgumentException( - "The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured." - .formatted(AcceptorType.VALUE_TABU)); - } - var acceptor = new ValueTabuAcceptor(logIndentation); - configureFixedSizeTabuAcceptor(acceptor, environmentMode, valueTabuSize, fadingValueTabuSize); - return Optional.of(acceptor); + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.VALUE_TABU) && valueTabuSize == null + && fadingValueTabuSize == null) { + return null; } - return Optional.empty(); + if (valueTabuSize == null && fadingValueTabuSize == null) { + throw new IllegalArgumentException( + "The acceptorType (%s) requires either valueTabuSize or fadingValueTabuSize to be configured." + .formatted(AcceptorType.VALUE_TABU)); + } + var acceptor = new ValueTabuAcceptor(configPolicy.getLogIndentation()); + configureFixedSizeTabuAcceptor(acceptor, configPolicy.getEnvironmentMode(), valueTabuSize, fadingValueTabuSize); + return acceptor; } private static void configureFixedSizeTabuAcceptor(AbstractTabuAcceptor acceptor, @@ -161,92 +155,92 @@ private static void configureFixedSizeTabuAcceptor(AbstractTabuAccep acceptor.enableAssertions(environmentMode); } - private Optional> buildMoveTabuAcceptor(EnvironmentMode environmentMode, - String logIndentation) { + private MoveTabuAcceptor buildMoveTabuAcceptor(HeuristicConfigPolicy configPolicy) { var moveTabuSize = acceptorConfig.getMoveTabuSize(); var fadingMoveTabuSize = acceptorConfig.getFadingMoveTabuSize(); - if (acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU) - || moveTabuSize != null || fadingMoveTabuSize != null) { - if (moveTabuSize == null && fadingMoveTabuSize == null) { - throw new IllegalArgumentException( - "The acceptorType (%s) requires either moveTabuSize or fadingMoveTabuSize to be configured." - .formatted(AcceptorType.MOVE_TABU)); - } - var acceptor = new MoveTabuAcceptor(logIndentation); - configureFixedSizeTabuAcceptor(acceptor, environmentMode, moveTabuSize, fadingMoveTabuSize); - return Optional.of(acceptor); + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.MOVE_TABU) && moveTabuSize == null + && fadingMoveTabuSize == null) { + return null; + } + if (moveTabuSize == null && fadingMoveTabuSize == null) { + throw new IllegalArgumentException( + "The acceptorType (%s) requires either moveTabuSize or fadingMoveTabuSize to be configured." + .formatted(AcceptorType.MOVE_TABU)); } - return Optional.empty(); + var acceptor = new MoveTabuAcceptor(configPolicy.getLogIndentation()); + configureFixedSizeTabuAcceptor(acceptor, configPolicy.getEnvironmentMode(), moveTabuSize, fadingMoveTabuSize); + return acceptor; } - private Optional> + private SimulatedAnnealingAcceptor buildSimulatedAnnealingAcceptor(HeuristicConfigPolicy configPolicy) { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.SIMULATED_ANNEALING) - || acceptorConfig.getSimulatedAnnealingStartingTemperature() != null) { - var acceptor = new SimulatedAnnealingAcceptor(); - if (acceptorConfig.getSimulatedAnnealingStartingTemperature() == null) { - // TODO Support SA without a parameter - throw new IllegalArgumentException( - "The acceptorType (%s) requires non-null acceptorConfig.getSimulatedAnnealingStartingTemperature()." - .formatted(AcceptorType.SIMULATED_ANNEALING)); - } - acceptor.setStartingTemperature( - configPolicy.getScoreDefinition().parseScore(acceptorConfig.getSimulatedAnnealingStartingTemperature())); - return Optional.of(acceptor); + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.SIMULATED_ANNEALING) + && acceptorConfig.getSimulatedAnnealingStartingTemperature() == null) { + return null; } - return Optional.empty(); + var acceptor = new SimulatedAnnealingAcceptor(); + if (acceptorConfig.getSimulatedAnnealingStartingTemperature() == null) { + // TODO Support SA without a parameter + throw new IllegalArgumentException( + "The acceptorType (%s) requires non-null acceptorConfig.getSimulatedAnnealingStartingTemperature()." + .formatted(AcceptorType.SIMULATED_ANNEALING)); + } + acceptor.setStartingTemperature( + configPolicy.getScoreDefinition().parseScore(acceptorConfig.getSimulatedAnnealingStartingTemperature())); + return acceptor; } - private Optional> buildLateAcceptanceAcceptor() { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.LATE_ACCEPTANCE) - || (!acceptorTypeListsContainsAcceptorType(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE) - && acceptorConfig.getLateAcceptanceSize() != null)) { - var acceptor = new LateAcceptanceAcceptor(); - acceptor.setLateAcceptanceSize(Objects.requireNonNullElse(acceptorConfig.getLateAcceptanceSize(), 400)); - return Optional.of(acceptor); + private LateAcceptanceAcceptor buildLateAcceptanceAcceptor() { + var hasLA = acceptorTypeListsContainsAcceptorType(AcceptorType.LATE_ACCEPTANCE); + var hasDLAS = acceptorTypeListsContainsAcceptorType(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE); + var hasSetting = acceptorConfig.getLateAcceptanceSize() != null; + if ((!hasLA && !hasSetting) || (hasSetting && !hasLA && hasDLAS)) { + return null; } - return Optional.empty(); + var acceptor = new LateAcceptanceAcceptor(); + acceptor.setLateAcceptanceSize(Objects.requireNonNullElse(acceptorConfig.getLateAcceptanceSize(), 400)); + return acceptor; } - private Optional> + private DiversifiedLateAcceptanceAcceptor buildDiversifiedLateAcceptanceAcceptor(HeuristicConfigPolicy configPolicy) { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)) { - configPolicy.ensurePreviewFeature(PreviewFeature.DIVERSIFIED_LATE_ACCEPTANCE); - var acceptor = new DiversifiedLateAcceptanceAcceptor(); - acceptor.setLateAcceptanceSize(Objects.requireNonNullElse(acceptorConfig.getLateAcceptanceSize(), 5)); - return Optional.of(acceptor); + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)) { + return null; } - return Optional.empty(); + configPolicy.ensurePreviewFeature(PreviewFeature.DIVERSIFIED_LATE_ACCEPTANCE); + var acceptor = new DiversifiedLateAcceptanceAcceptor(); + acceptor.setLateAcceptanceSize(Objects.requireNonNullElse(acceptorConfig.getLateAcceptanceSize(), 5)); + return acceptor; } - private Optional> buildGreatDelugeAcceptor(HeuristicConfigPolicy configPolicy) { - if (acceptorTypeListsContainsAcceptorType(AcceptorType.GREAT_DELUGE) - || acceptorConfig.getGreatDelugeWaterLevelIncrementScore() != null - || acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { - var acceptor = new GreatDelugeAcceptor(); - if (acceptorConfig.getGreatDelugeWaterLevelIncrementScore() != null) { - if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { - throw new IllegalArgumentException(""" - The acceptor cannot have both acceptorConfig.getGreatDelugeWaterLevelIncrementScore() (%s) \ - and acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s).""" - .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementScore(), - acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); - } - acceptor.setWaterLevelIncrementScore( - configPolicy.getScoreDefinition().parseScore(acceptorConfig.getGreatDelugeWaterLevelIncrementScore())); - } else if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { - if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() <= 0.0) { - throw new IllegalArgumentException(""" - The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s) must be positive \ - because the water level should increase.""" - .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); - } - acceptor.setWaterLevelIncrementRatio(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()); - } else { - acceptor.setWaterLevelIncrementRatio(DEFAULT_WATER_LEVEL_INCREMENT_RATIO); + private GreatDelugeAcceptor buildGreatDelugeAcceptor(HeuristicConfigPolicy configPolicy) { + if (!acceptorTypeListsContainsAcceptorType(AcceptorType.GREAT_DELUGE) + && acceptorConfig.getGreatDelugeWaterLevelIncrementScore() == null + && acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() == null) { + return null; + } + var acceptor = new GreatDelugeAcceptor(); + if (acceptorConfig.getGreatDelugeWaterLevelIncrementScore() != null) { + if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { + throw new IllegalArgumentException(""" + The acceptor cannot have both acceptorConfig.getGreatDelugeWaterLevelIncrementScore() (%s) \ + and acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s).""" + .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementScore(), + acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); } - return Optional.of(acceptor); + acceptor.setWaterLevelIncrementScore( + configPolicy.getScoreDefinition().parseScore(acceptorConfig.getGreatDelugeWaterLevelIncrementScore())); + } else if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() != null) { + if (acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() <= 0.0) { + throw new IllegalArgumentException(""" + The acceptorConfig.getGreatDelugeWaterLevelIncrementRatio() (%s) must be positive \ + because the water level should increase.""" + .formatted(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio())); + } + acceptor.setWaterLevelIncrementRatio(acceptorConfig.getGreatDelugeWaterLevelIncrementRatio()); + } else { + acceptor.setWaterLevelIncrementRatio(DEFAULT_WATER_LEVEL_INCREMENT_RATIO); } - return Optional.empty(); + return acceptor; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java index ac628e39a66..7251546a414 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java @@ -19,8 +19,9 @@ public PartitionedSearchPhase buildPhase(int phaseIndex, boolean last HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { var environmentMode = resolveEnvironmentMode(solverConfigPolicy); + var solverConfigPolicyUpdated = solverConfigPolicy.copyConfigPolicy(environmentMode); return TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.PARTITIONED_SEARCH) - .buildPartitionedSearch(phaseIndex, phaseConfig, environmentMode, solverConfigPolicy, solverTermination, + .buildPartitionedSearch(phaseIndex, phaseConfig, solverConfigPolicyUpdated, solverTermination, this::buildPhaseTermination); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java index 2519c722438..ee639b24c6d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhaseFactory.java @@ -21,10 +21,10 @@ public DefaultCustomPhaseFactory(CustomPhaseConfig phaseConfig) { public CustomPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(environmentMode); var customPhaseCommandClassList = phaseConfig.getCustomPhaseCommandClassList(); var customPhaseCommandList = phaseConfig.getCustomPhaseCommandList(); - var environmentMode = resolveEnvironmentMode(phaseConfigPolicy); if (ConfigUtils.isEmptyCollection(customPhaseCommandClassList) && ConfigUtils.isEmptyCollection(customPhaseCommandList)) { throw new IllegalArgumentException( diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java index c610b392c25..8ec45c11615 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; @@ -18,6 +19,7 @@ class RuinRecreateConstructionHeuristicPhaseBuilderTest { @Test void buildSingleThreaded() { var solverConfigPolicy = new HeuristicConfigPolicy.Builder() + .withEnvironmentMode(EnvironmentMode.PHASE_ASSERT) .withSolutionDescriptor(TestdataSolution.buildSolutionDescriptor()) .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY })) @@ -31,6 +33,7 @@ void buildSingleThreaded() { @Test void buildMultiThreaded() { var solverConfigPolicy = new HeuristicConfigPolicy.Builder() + .withEnvironmentMode(EnvironmentMode.PHASE_ASSERT) .withSolutionDescriptor(TestdataSolution.buildSolutionDescriptor()) .withMoveThreadCount(2) .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListRuinRecreateMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListRuinRecreateMoveTest.java index 48b24f0067f..f0c986b8fe2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListRuinRecreateMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListRuinRecreateMoveTest.java @@ -160,6 +160,7 @@ void executeAndUndoNewDestinationEntityWithPinnedPrefix() { scoreDirector.setWorkingSolution(solution); var solverConfigPolicy = new HeuristicConfigPolicy.Builder() + .withEnvironmentMode(EnvironmentMode.PHASE_ASSERT) .withSolutionDescriptor(solutionDescriptor) .withInitializingScoreTrend(InitializingScoreTrend.buildUniformTrend(InitializingScoreTrendLevel.ANY, 1)) .build(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java index 2625aeb1902..fcfd520054c 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AcceptorFactoryTest.java @@ -55,7 +55,7 @@ void buildCompositeAcceptor() { AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); Acceptor acceptor = - acceptorFactory.buildAcceptor(heuristicConfigPolicy, heuristicConfigPolicy.getEnvironmentMode()); + acceptorFactory.buildAcceptor(heuristicConfigPolicy); assertThat(acceptor).isExactlyInstanceOf(CompositeAcceptor.class); CompositeAcceptor compositeAcceptor = (CompositeAcceptor) acceptor; assertThat(compositeAcceptor.acceptorList) @@ -70,7 +70,7 @@ void noAcceptorConfigured_throwsException() { AcceptorFactory acceptorFactory = AcceptorFactory.create(new LocalSearchAcceptorConfig()); assertThatIllegalArgumentException() .isThrownBy( - () -> acceptorFactory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)) + () -> acceptorFactory.buildAcceptor(mock(HeuristicConfigPolicy.class))) .withMessageContaining("The acceptor does not specify any acceptorType"); } @@ -80,13 +80,13 @@ void lateAcceptanceAcceptor() { .withAcceptorTypeList(List.of(AcceptorType.LATE_ACCEPTANCE)); HeuristicConfigPolicy heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); + var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); assertThat(acceptor).isExactlyInstanceOf(LateAcceptanceAcceptor.class); localSearchAcceptorConfig = new LocalSearchAcceptorConfig() .withLateAcceptanceSize(10); acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); + acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); assertThat(acceptor).isExactlyInstanceOf(LateAcceptanceAcceptor.class); } @@ -96,14 +96,14 @@ void diversifiedLateAcceptanceAcceptor() { .withAcceptorTypeList(List.of(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)); HeuristicConfigPolicy heuristicConfigPolicy = mock(HeuristicConfigPolicy.class); AcceptorFactory acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); + var acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); assertThat(acceptor).isExactlyInstanceOf(DiversifiedLateAcceptanceAcceptor.class); localSearchAcceptorConfig = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.DIVERSIFIED_LATE_ACCEPTANCE)) .withLateAcceptanceSize(10); acceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); - acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT); + acceptor = acceptorFactory.buildAcceptor(heuristicConfigPolicy); assertThat(acceptor).isExactlyInstanceOf(DiversifiedLateAcceptanceAcceptor.class); doThrow(new IllegalStateException()).when(heuristicConfigPolicy).ensurePreviewFeature(any()); @@ -112,7 +112,7 @@ void diversifiedLateAcceptanceAcceptor() { .withLateAcceptanceSize(10); AcceptorFactory badAcceptorFactory = AcceptorFactory.create(localSearchAcceptorConfig); assertThatIllegalStateException() - .isThrownBy(() -> badAcceptorFactory.buildAcceptor(heuristicConfigPolicy, EnvironmentMode.PHASE_ASSERT)); + .isThrownBy(() -> badAcceptorFactory.buildAcceptor(heuristicConfigPolicy)); } @Test @@ -121,7 +121,7 @@ void valueTabuWithoutSizes_throwsException() { .withAcceptorTypeList(List.of(AcceptorType.VALUE_TABU)); var factory = AcceptorFactory.create(config); assertThatIllegalArgumentException() - .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)); + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); } @Test @@ -130,6 +130,6 @@ void moveTabuWithoutSizes_throwsException() { .withAcceptorTypeList(List.of(AcceptorType.MOVE_TABU)); var factory = AcceptorFactory.create(config); assertThatIllegalArgumentException() - .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class), EnvironmentMode.PHASE_ASSERT)); + .isThrownBy(() -> factory.buildAcceptor(mock(HeuristicConfigPolicy.class))); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java index 97c829327a0..f76ac8c7150 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java @@ -69,7 +69,7 @@ void changeMoveBasedLocalSearch() { List.of(new ChangeMoveProvider<>(variableMetaModel))); var acceptor = AcceptorFactory. create(new LocalSearchAcceptorConfig().withLateAcceptanceSize(400)) - .buildAcceptor(heuristicConfigPolicy, heuristicConfigPolicy.getEnvironmentMode()); + .buildAcceptor(heuristicConfigPolicy); var forager = LocalSearchForagerFactory . create(new LocalSearchForagerConfig().withAcceptedCountLimit(1)).buildForager(); var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager); From 72ee4995ab43df68bc3edfbad2a2c3ab1508202b Mon Sep 17 00:00:00 2001 From: Fred Date: Mon, 24 Aug 2026 21:08:28 -0300 Subject: [PATCH 15/20] chore: improve score director factory logic --- .../variable/ShadowVariableUpdateHelper.java | 14 +- .../impl/move/MoveTesterScoreDirector.java | 8 +- .../move/MoveTesterScoreDirectorFactory.java | 10 +- .../score/director/AbstractScoreDirector.java | 24 +- .../AbstractScoreDirectorFactory.java | 33 +- .../DelegateScoreDirectorFactory.java | 220 +++++++++--- .../score/director/ScoreDirectorFactory.java | 80 ++++- .../director/easy/EasyScoreDirector.java | 6 +- .../easy/EasyScoreDirectorFactory.java | 21 +- .../incremental/IncrementalScoreDirector.java | 7 +- .../IncrementalScoreDirectorFactory.java | 20 +- .../BavetConstraintStreamScoreDirector.java | 11 +- ...tConstraintStreamScoreDirectorFactory.java | 32 +- ...tConstraintStreamScoreDirectorFactory.java | 7 +- .../core/impl/solver/AbstractSolver.java | 22 +- .../core/impl/solver/DefaultSolver.java | 14 +- .../impl/solver/DefaultSolverFactory.java | 108 +++--- .../core/impl/move/MoveDirectorTest.java | 36 +- .../DelegateScoreDirectorFactoryTest.java | 331 ++++++++++++++---- .../easy/EasyScoreDirectorSemanticsTest.java | 23 +- ...IncrementalScoreDirectorSemanticsTest.java | 16 +- .../IncrementalScoreDirectorTest.java | 67 ++-- ...treamsBavetScoreDirectorSemanticsTest.java | 16 +- 23 files changed, 746 insertions(+), 380 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java index b437c8b5e2d..1682c5caae6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java @@ -273,8 +273,15 @@ private List> fetchBasicDescriptors(EntityDes private static class InternalScoreDirectorFactory> extends AbstractScoreDirectorFactory> { - public InternalScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - super(solutionDescriptor, environmentMode); + public InternalScoreDirectorFactory(SolutionDescriptor solutionDescriptor, + EnvironmentMode globalEnvironmentMode) { + super(solutionDescriptor, globalEnvironmentMode); + } + + @Override + public AbstractScoreDirector.AbstractScoreDirectorBuilder + createScoreDirectorBuilder(EnvironmentMode environmentMode) { + throw new UnsupportedOperationException(); } @Override @@ -319,7 +326,8 @@ public static final class Builder> public Builder(SolutionDescriptor solutionDescriptor) { // We use PHASE_ASSERT by default - super(new InternalScoreDirectorFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT)); + super(new InternalScoreDirectorFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT), + EnvironmentMode.PHASE_ASSERT); withConstraintMatchPolicy(DISABLED); withLookUpEnabled(false); withExpectShadowVariablesInCorrectState(false); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java index 675f60b01c9..74b660a2490 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; import ai.timefold.solver.core.impl.score.director.InnerScore; @@ -44,8 +45,9 @@ public boolean requiresFlushing() { static final class Builder> extends AbstractScoreDirectorBuilder, Builder> { - public Builder(MoveTesterScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); + public Builder(MoveTesterScoreDirectorFactory scoreDirectorFactory, + EnvironmentMode environmentMode) { + super(scoreDirectorFactory, environmentMode); } @Override @@ -59,4 +61,4 @@ public MoveTesterScoreDirector buildDerived() { } } -} \ No newline at end of file +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java index 7b392c7bceb..a0c901d438a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirectorFactory.java @@ -12,13 +12,15 @@ final class MoveTesterScoreDirectorFactory> extends AbstractScoreDirectorFactory> { - public MoveTesterScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - super(solutionDescriptor, environmentMode); + public MoveTesterScoreDirectorFactory(SolutionDescriptor solutionDescriptor, + EnvironmentMode globalEnvironmentMode) { + super(solutionDescriptor, globalEnvironmentMode); } @Override - public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder() { - return new MoveTesterScoreDirector.Builder<>(this); + public AbstractScoreDirector.AbstractScoreDirectorBuilder + createScoreDirectorBuilder(EnvironmentMode environmentMode) { + return new MoveTesterScoreDirector.Builder<>(this, environmentMode); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 70744d2869f..303e82b197e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -102,13 +102,15 @@ public abstract class AbstractScoreDirector moveRepository; protected AbstractScoreDirector(AbstractScoreDirectorBuilder builder) { - this.environmentMode = builder.scoreDirectorFactory.getEnvironmentMode(); + this.environmentMode = builder.environmentMode; this.scoreDirectorFactory = builder.scoreDirectorFactory; // Needs early init, as supplies will need the instance to exist. this.neighborhoodsElementUpdateNotifier = new NeighborhoodNotifier<>(); var solutionDescriptor = this.scoreDirectorFactory.getSolutionDescriptor(); this.lookUpEnabled = builder.lookUpEnabled; - this.lookUpManager = lookUpEnabled ? new LookupManager(solutionDescriptor.getLookUpStrategyResolver()) : null; + this.lookUpManager = + lookUpEnabled ? new LookupManager(Objects.requireNonNull(solutionDescriptor.getLookUpStrategyResolver())) + : null; this.constraintMatchPolicy = builder.constraintMatchPolicy; this.expectShadowVariablesInCorrectState = builder.expectShadowVariablesInCorrectState; this.variableDescriptorCache = new VariableDescriptorCache<>(solutionDescriptor); @@ -126,7 +128,9 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder createChildThreadScoreDirector(Chil childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); return childThreadScoreDirector; } - default -> throw new IllegalStateException("The childThreadType (" + childThreadType + ") is not implemented."); + default -> + throw new IllegalStateException("The childThreadType (%s) is not implemented.".formatted(childThreadType)); } } @@ -686,7 +691,6 @@ public void afterProblemFactRemoved(Object problemFact) { * it would be equal to the score of that parameter. * * @param solution never null - * @see InnerScoreDirector#assertWorkingScoreFromScratch(InnerScore, Object) */ @Override public void assertScoreFromScratch(Solution_ solution) { @@ -761,7 +765,7 @@ private void assertScoreFromScratch(InnerScore innerScore, Object comple // Most score directors don't need derived status; CS will override this. try (var uncorruptedScoreDirector = assertionScoreDirectorFactory.createScoreDirectorBuilder() .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).buildDerived()) { - uncorruptedScoreDirector.setWorkingSolution(workingSolution); + uncorruptedScoreDirector.setWorkingSolution(Objects.requireNonNull(workingSolution)); var uncorruptedInnerScore = uncorruptedScoreDirector.calculateScore(); if (!innerScore.equals(uncorruptedInnerScore)) { var corruptionAnalyzer = new CorruptionAnalyzer<>(this); @@ -954,13 +958,15 @@ public String toString() { public abstract static class AbstractScoreDirectorBuilder, Factory_ extends AbstractScoreDirectorFactory, Builder_ extends AbstractScoreDirectorBuilder> { protected final Factory_ scoreDirectorFactory; + protected final EnvironmentMode environmentMode; protected ConstraintMatchPolicy constraintMatchPolicy = ConstraintMatchPolicy.DISABLED; protected boolean lookUpEnabled = false; protected boolean expectShadowVariablesInCorrectState = true; - protected AbstractScoreDirectorBuilder(Factory_ scoreDirectorFactory) { + protected AbstractScoreDirectorBuilder(Factory_ scoreDirectorFactory, EnvironmentMode environmentMode) { this.scoreDirectorFactory = Objects.requireNonNull(scoreDirectorFactory); + this.environmentMode = environmentMode; } @SuppressWarnings("unchecked") @@ -981,7 +987,7 @@ public Builder_ withExpectShadowVariablesInCorrectState(boolean expectShadowVari return (Builder_) this; } - public abstract AbstractScoreDirector build(); + public abstract > Director_ build(); /** * Optionally makes the score director a derived one; most score directors do not require this. @@ -991,7 +997,7 @@ public Builder_ withExpectShadowVariablesInCorrectState(boolean expectShadowVari * * @return this */ - public AbstractScoreDirector buildDerived() { + public > Director_ buildDerived() { return build(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java index 971ccef092b..e7d87c533c4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java @@ -12,6 +12,8 @@ import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,28 +24,26 @@ * @param the score type to go with the solution * @see ScoreDirectorFactory */ +@NullMarked public abstract class AbstractScoreDirectorFactory, Factory_ extends AbstractScoreDirectorFactory> implements ScoreDirectorFactory { protected final transient Logger logger = LoggerFactory.getLogger(getClass()); protected final SolutionDescriptor solutionDescriptor; - protected final EnvironmentMode environmentMode; + protected final EnvironmentMode globalEnvironmentMode; + @Nullable protected final ListVariableDescriptor listVariableDescriptor; - + @Nullable protected InitializingScoreTrend initializingScoreTrend; - + @Nullable protected ScoreDirectorFactory assertionScoreDirectorFactory = null; - protected AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - this.solutionDescriptor = solutionDescriptor; - this.environmentMode = Objects.requireNonNull(environmentMode); + protected AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, + EnvironmentMode globalEnvironmentMode) { + this.solutionDescriptor = Objects.requireNonNull(solutionDescriptor); this.listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); - } - - @Override - public EnvironmentMode getEnvironmentMode() { - return environmentMode; + this.globalEnvironmentMode = globalEnvironmentMode; } @Override @@ -57,15 +57,22 @@ public ScoreDefinition getScoreDefinition() { } @Override - public InitializingScoreTrend getInitializingScoreTrend() { + public @Nullable InitializingScoreTrend getInitializingScoreTrend() { return initializingScoreTrend; } + @Override + public , Builder_ extends AbstractScoreDirector.AbstractScoreDirectorBuilder> + AbstractScoreDirector.AbstractScoreDirectorBuilder + createScoreDirectorBuilder() { + return createScoreDirectorBuilder(globalEnvironmentMode); + } + public void setInitializingScoreTrend(InitializingScoreTrend initializingScoreTrend) { this.initializingScoreTrend = initializingScoreTrend; } - public ScoreDirectorFactory getAssertionScoreDirectorFactory() { + public @Nullable ScoreDirectorFactory getAssertionScoreDirectorFactory() { return assertionScoreDirectorFactory; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java index 0870b7d1aa6..409a8fd4e32 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java @@ -1,96 +1,222 @@ package ai.timefold.solver.core.impl.score.director; import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.stream.ConstraintMetaModel; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +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.constraint.ConstraintMatchPolicy; +import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; +import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector.AbstractScoreDirectorBuilder; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * The delegate score factory creates a {@link ScoreDirectorFactory} based on the specified environment mode. - * This functionality enables the creation of different score director factories using the delegate. - * It is necessary because the solver phases may operate under various environment modes, - * requiring the creation of different factories. + * The single entry point for turning a {@link ScoreDirectorFactoryConfig} into score directors. + * It decides which score calculation implementation the configuration selected + * ({@link EasyScoreDirectorFactory}, {@link IncrementalScoreDirectorFactory} + * or {@link BavetConstraintStreamScoreDirectorFactory}) + * and delegates to a factory of that type, hiding the choice from its callers. + *

+ * Building a delegate is potentially expensive, + * therefore exactly one is built eagerly for the default environment mode + * and then reused for every score director requested for that mode. + *

+ * Since a solver phase may override the solver's environment mode, + * {@link #createScoreDirectorBuilder(EnvironmentMode)} may be called with a different mode than the default one. + * Most delegates only pass the environment mode on to the score director they build, + * so they can serve any mode and are reused as they are. + * The exception is {@link BavetConstraintStreamScoreDirectorFactory}, + * which builds its constraint network from the environment mode up front; + * for it, a throwaway delegate is built for the requested mode instead. + *

+ * On top of picking the delegate, this factory applies the parts of the configuration + * which are shared by all implementations: + * the {@link InitializingScoreTrend}, + * the optional assertion score director factory, + * and the {@link ConstraintMatchPolicy} implied by the environment mode and the enabled metrics. + * + * @param the solution type, the class with the {@link PlanningSolution} + * annotation + * @param the score type to go with the solution */ @NullMarked -public class DelegateScoreDirectorFactory> { +public class DelegateScoreDirectorFactory> + implements ScoreDirectorFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(DelegateScoreDirectorFactory.class); private final ScoreDirectorFactoryConfig config; - private final boolean hasMetricRequiringConstraintMatch; + private final SolutionDescriptor solutionDescriptor; + private final EnvironmentMode globalEnvironmentMode; + private final ScoreDirectorFactory scoreDirectorFactory; + private final List metricsRequiringConstraintMatchList; + private final boolean requireNewFactoryOnDifferentEnvironment; - public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config) { - this(config, false); + /** + * The constructor used by the solver, + * as only a full {@link SolverConfig} tells which metrics require constraint matching. + * + * @param environmentMode the default environment mode, typically the solver's + */ + public DelegateScoreDirectorFactory(SolverConfig solverConfig, SolutionDescriptor solutionDescriptor, + EnvironmentMode environmentMode) { + this(Objects.requireNonNullElseGet(solverConfig.getScoreDirectorFactoryConfig(), ScoreDirectorFactoryConfig::new), + solutionDescriptor, environmentMode, determineMetricsRequiringConstraintMatch(solverConfig)); } - public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, boolean hasMetricRequiringConstraintMatch) { - this.config = config; - this.hasMetricRequiringConstraintMatch = hasMetricRequiringConstraintMatch; - assertCorrectDirectorFactory(config); + /** + * As defined by the constructor which also takes a metric list, + * with no metrics requiring constraint matching. + */ + public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionDescriptor solutionDescriptor, + EnvironmentMode environmentMode) { + this(config, solutionDescriptor, environmentMode, Collections.emptyList()); } /** - * Build a score director factory according to the given environment mode. - * - * @param environmentMode the environment mode + * @param config the score factory configuration * @param solutionDescriptor the solution descriptor - * @return a new instance of the score director factory compatible with the environment mode and solver configuration. + * @param environmentMode the default environment mode; + * the delegate is built eagerly for this mode, as building it is potentially expensive + * @param metricsRequiringConstraintMatchList the enabled metrics which can only be computed + * when the score directors track constraint matches */ - public ScoreDirectorFactory buildScoreDirectorFactory(EnvironmentMode environmentMode, - SolutionDescriptor solutionDescriptor) { - var scoreDirectorFactory = decideMultipleScoreDirectorFactories(solutionDescriptor, environmentMode); - var assertionScoreDirectorFactory = config.getAssertionScoreDirectorFactory(); - if (assertionScoreDirectorFactory != null) { - if (assertionScoreDirectorFactory.getAssertionScoreDirectorFactory() != null) { + public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionDescriptor solutionDescriptor, + EnvironmentMode environmentMode, List metricsRequiringConstraintMatchList) { + this.config = config; + assertCorrectDirectorFactory(Objects.requireNonNull(config)); + this.solutionDescriptor = solutionDescriptor; + this.globalEnvironmentMode = environmentMode; + this.metricsRequiringConstraintMatchList = metricsRequiringConstraintMatchList; + this.scoreDirectorFactory = internalBuildScoreDirectorFactory(solutionDescriptor, environmentMode); + // Constraint Stream factory requires a new factory if the environment changes + this.requireNewFactoryOnDifferentEnvironment = config.getConstraintProviderClass() != null; + } + + @Override + public SolutionDescriptor getSolutionDescriptor() { + return solutionDescriptor; + } + + @Override + public ScoreDefinition getScoreDefinition() { + return scoreDirectorFactory.getScoreDefinition(); + } + + @Override + public @Nullable InitializingScoreTrend getInitializingScoreTrend() { + return scoreDirectorFactory.getInitializingScoreTrend(); + } + + /** + * Exposes the factory built for the default environment mode, + * for the benefit of the code which needs the concrete implementation rather than this wrapper, + * such as the Quarkus and Spring integrations building a + * {@link ConstraintMetaModel}. + * Prefer this factory itself wherever the environment mode may still vary. + */ + public ScoreDirectorFactory getDelegate() { + return scoreDirectorFactory; + } + + @Override + public , Builder_ extends AbstractScoreDirectorBuilder> + AbstractScoreDirectorBuilder createScoreDirectorBuilder() { + return createScoreDirectorBuilder(globalEnvironmentMode); + } + + @Override + public , Builder_ extends AbstractScoreDirectorBuilder> + AbstractScoreDirectorBuilder + createScoreDirectorBuilder(EnvironmentMode environmentMode) { + if (environmentMode != globalEnvironmentMode && requireNewFactoryOnDifferentEnvironment) { + // The BavetConstraintStreamScoreDirectorFactory creates a BavetConstraintFactory based on the environment + // and requires a new factory to generate a new score director with a different environment. + var newFactory = internalBuildScoreDirectorFactory(solutionDescriptor, environmentMode); + return newFactory.createScoreDirectorBuilder(); + } else { + return scoreDirectorFactory.createScoreDirectorBuilder(environmentMode); + } + } + + private ScoreDirectorFactory internalBuildScoreDirectorFactory( + SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { + var factory = decideMultipleScoreDirectorFactories(solutionDescriptor, environmentMode); + var assertionScoreDirectorFactoryConfig = config.getAssertionScoreDirectorFactory(); + if (assertionScoreDirectorFactoryConfig != null) { + if (assertionScoreDirectorFactoryConfig.getAssertionScoreDirectorFactory() != null) { throw new IllegalArgumentException( "A assertionScoreDirectorFactory (%s) cannot have a non-null assertionScoreDirectorFactory (%s)." - .formatted(assertionScoreDirectorFactory, - assertionScoreDirectorFactory.getAssertionScoreDirectorFactory())); + .formatted(assertionScoreDirectorFactoryConfig, + assertionScoreDirectorFactoryConfig.getAssertionScoreDirectorFactory())); } if (environmentMode.compareTo(EnvironmentMode.STEP_ASSERT) > 0) { throw new IllegalArgumentException( "A non-null assertionScoreDirectorFactory (%s) requires an environmentMode (%s) of %s or lower." - .formatted(assertionScoreDirectorFactory, environmentMode, EnvironmentMode.STEP_ASSERT)); + .formatted(assertionScoreDirectorFactoryConfig, environmentMode, EnvironmentMode.STEP_ASSERT)); } - var assertionScoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(assertionScoreDirectorFactory); - scoreDirectorFactory.setAssertionScoreDirectorFactory(assertionScoreDirectorFactoryFactory - .buildScoreDirectorFactory(EnvironmentMode.NON_REPRODUCIBLE, solutionDescriptor)); + var assertScoreDirectorFactory = + new DelegateScoreDirectorFactory(assertionScoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.NON_REPRODUCIBLE, Collections.emptyList()); + // We use the delegate to prevent issues in areas where non-delegate factories are expected + factory.setAssertionScoreDirectorFactory(assertScoreDirectorFactory.getDelegate()); } - scoreDirectorFactory.setInitializingScoreTrend(InitializingScoreTrend.parseTrend( - config.getInitializingScoreTrend() == null ? InitializingScoreTrendLevel.ANY.name() - : config.getInitializingScoreTrend(), - solutionDescriptor.getScoreDefinition().getLevelsSize())); - return scoreDirectorFactory; + factory.setInitializingScoreTrend(decideInitializingScoreTrend(config, solutionDescriptor)); + return factory; + } + + private static List determineMetricsRequiringConstraintMatch(SolverConfig solverConfig) { + var monitoringConfig = solverConfig.determineMetricConfig(); + var solverMetricList = Objects.requireNonNull(monitoringConfig.getSolverMetricList()); + return solverMetricList.stream() + .filter(SolverMetric::isMetricConstraintMatchBased) + .toList(); + } + + private static InitializingScoreTrend decideInitializingScoreTrend(ScoreDirectorFactoryConfig config, + SolutionDescriptor solutionDescriptor) { + var initializingScoreTrend = config.getInitializingScoreTrend() == null ? InitializingScoreTrendLevel.ANY.name() + : config.getInitializingScoreTrend(); + return InitializingScoreTrend.parseTrend(initializingScoreTrend, + solutionDescriptor.getScoreDefinition().getLevelsSize()); } /** - * Creates a new instance of the score director. - * - * @param scoreScoreDirectorFactory the factory to be used to create the score director instance. + * Unlike the default implementation, + * this also enables constraint matching when a metric requires it, + * logging that fact as it costs performance. */ - public InnerScoreDirector - createScoreDirector(ScoreDirectorFactory scoreScoreDirectorFactory) { - var isConstraintMatchEnabled = - hasMetricRequiringConstraintMatch || scoreScoreDirectorFactory.getEnvironmentMode().isStepAssertOrMore(); - return scoreScoreDirectorFactory.createScoreDirectorBuilder() - .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. - .withConstraintMatchPolicy( - isConstraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) - .build(); + @Override + public ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { + var isStepAssertOrMore = environmentMode.isStepAssertOrMore(); + var constraintMatchEnabled = !metricsRequiringConstraintMatchList.isEmpty() || isStepAssertOrMore; + if (constraintMatchEnabled && !isStepAssertOrMore) { + LOGGER.info( + "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", + metricsRequiringConstraintMatchList); + } + return constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED; } private AbstractScoreDirectorFactory decideMultipleScoreDirectorFactories( SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - // At this point, we are guaranteed to have at most one score director factory selected. + // At this point, we're guaranteed to have at most one score director factory selected. if (config.getEasyScoreCalculatorClass() != null) { return EasyScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); } else if (config.getIncrementalScoreCalculatorClass() != null) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java index c1195d4b90e..0bb5cd8d06c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java @@ -2,46 +2,106 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector.AbstractScoreDirectorBuilder; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + /** + * Builds {@link AbstractScoreDirector} instances, which in turn calculate the {@link Score} of a solution. + *

+ * Building the factory is potentially expensive, + * as it eagerly does whatever the score calculation implementation needs to be set up, + * such as building the entire constraint network of a {@link ConstraintProvider}. + * Building a score director out of an existing factory is comparatively cheap. + * Therefore, a factory is built once and shared, + * while every consumer that needs to calculate a score gets its own score director. + *

+ * Every factory is bound to a default {@link EnvironmentMode}, + * the one it was built for. + * {@link #createScoreDirectorBuilder()} builds for that mode, + * whereas {@link #createScoreDirectorBuilder(EnvironmentMode)} builds for the requested mode instead; + * the latter exists because a solver phase may override the solver's environment mode. + * Implementations must guarantee that the returned builder produces a score director + * that actually runs in the requested mode, + * even if that means the implementation cannot reuse the state it built for its default mode. + * {@link DelegateScoreDirectorFactory} is the implementation which handles that situation, + * and therefore the one solver components are expected to hold on to. + * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the score type to go with the solution */ +@NullMarked public interface ScoreDirectorFactory> { - /** - * @return never null - */ SolutionDescriptor getSolutionDescriptor(); + ScoreDefinition getScoreDefinition(); + /** - * @return never null + * Prepares a score director which runs in the given environment mode, + * regardless of the mode this factory was built for. + * + * @param environmentMode the environment mode the resulting score director must run in */ - ScoreDefinition getScoreDefinition(); + , Builder_ extends AbstractScoreDirectorBuilder> + AbstractScoreDirectorBuilder + createScoreDirectorBuilder(EnvironmentMode environmentMode); + /** + * As defined by {@link #createScoreDirectorBuilder(EnvironmentMode)}, + * using the environment mode this factory was built for. + */ , Builder_ extends AbstractScoreDirectorBuilder> AbstractScoreDirectorBuilder createScoreDirectorBuilder(); - default > - AbstractScoreDirector buildScoreDirector() { + /** + * Builds a score director for the given environment mode, + * with all builder options left at their defaults. + * Use {@link #createScoreDirectorBuilder(EnvironmentMode)} to customize them. + */ + default , Director_ extends AbstractScoreDirector> + Director_ buildScoreDirector(EnvironmentMode environmentMode) { + AbstractScoreDirectorBuilder builder = createScoreDirectorBuilder(environmentMode); + return builder.build(); + } + + /** + * As defined by {@link #buildScoreDirector(EnvironmentMode)}, + * using the environment mode this factory was built for. + */ + default , Director_ extends AbstractScoreDirector> + Director_ buildScoreDirector() { AbstractScoreDirectorBuilder builder = createScoreDirectorBuilder(); return builder.build(); } /** - * @return never null + * Decides whether the score directors built for the given environment mode need to track constraint matches, + * which carries a performance penalty. + * The assertions of {@link EnvironmentMode#STEP_ASSERT} and stricter require it; + * implementations may enable it for other reasons as well, + * such as constraint match based metrics being enabled. + * + * @param environmentMode the environment mode the score director will run in */ - EnvironmentMode getEnvironmentMode(); + default ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { + return environmentMode.isStepAssertOrMore() ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED; + } /** - * @return never null + * @return null if the factory was not built from a {@link ScoreDirectorFactoryConfig}, + * as is often the case in tests; solver-built factories always have a trend */ + @Nullable InitializingScoreTrend getInitializingScoreTrend(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java index fe665a29ef1..ab9d4b89923 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatch; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; @@ -80,8 +81,8 @@ public static final class Builder> private @Nullable EasyScoreCalculator easyScoreCalculator; - public Builder(EasyScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); + public Builder(EasyScoreDirectorFactory scoreDirectorFactory, EnvironmentMode environmentMode) { + super(scoreDirectorFactory, environmentMode); } @Override @@ -95,6 +96,7 @@ public Builder withEasyScoreCalculator(EasyScoreCalculator build() { return new EasyScoreDirector<>(this); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorFactory.java index e95953a721e..410e0576df6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorFactory.java @@ -7,10 +7,11 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; +import org.jspecify.annotations.NonNull; + /** * Easy implementation of {@link ScoreDirectorFactory}. * @@ -24,7 +25,7 @@ public final class EasyScoreDirectorFactory> EasyScoreDirectorFactory buildScoreDirectorFactory(SolutionDescriptor solutionDescriptor, ScoreDirectorFactoryConfig config, - EnvironmentMode environmentMode) { + EnvironmentMode globalEnvironmentMode) { var easyScoreCalculatorClass = config.getEasyScoreCalculatorClass(); if (easyScoreCalculatorClass == null || !EasyScoreCalculator.class.isAssignableFrom(easyScoreCalculatorClass)) { throw new IllegalArgumentException( @@ -35,26 +36,20 @@ public final class EasyScoreDirectorFactory(solutionDescriptor, easyScoreCalculator, environmentMode); + return new EasyScoreDirectorFactory<>(solutionDescriptor, easyScoreCalculator, globalEnvironmentMode); } private final EasyScoreCalculator easyScoreCalculator; public EasyScoreDirectorFactory(SolutionDescriptor solutionDescriptor, - EasyScoreCalculator easyScoreCalculator, EnvironmentMode environmentMode) { - super(solutionDescriptor, environmentMode); + EasyScoreCalculator easyScoreCalculator, EnvironmentMode globalEnvironmentMode) { + super(solutionDescriptor, globalEnvironmentMode); this.easyScoreCalculator = easyScoreCalculator; } @Override - public EasyScoreDirector.Builder createScoreDirectorBuilder() { - return new EasyScoreDirector.Builder<>(this) + public EasyScoreDirector.Builder createScoreDirectorBuilder(@NonNull EnvironmentMode environmentMode) { + return new EasyScoreDirector.Builder<>(this, environmentMode) .withEasyScoreCalculator(easyScoreCalculator); } - - @Override - public AbstractScoreDirector buildScoreDirector() { - return this.createScoreDirectorBuilder().build(); - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java index fb28c556720..23d21047511 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -13,6 +13,7 @@ import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; import ai.timefold.solver.core.api.score.stream.ConstraintJustification; import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; @@ -264,8 +265,9 @@ public static final class Builder> private @Nullable IncrementalScoreCalculator incrementalScoreCalculator; - public Builder(IncrementalScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); + public Builder(IncrementalScoreDirectorFactory scoreDirectorFactory, + EnvironmentMode environmentMode) { + super(scoreDirectorFactory, environmentMode); } public Builder @@ -279,6 +281,7 @@ public Builder withConstraintMatchPolicy(ConstraintMatchPolic return super.withConstraintMatchPolicy(determineCorrectPolicy(constraintMatchPolicy, incrementalScoreCalculator)); } + @SuppressWarnings("unchecked") @Override public IncrementalScoreDirector build() { return new IncrementalScoreDirector<>(this); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java index 76f52d77e73..ad504c172e8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java @@ -12,6 +12,8 @@ import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; +import org.jspecify.annotations.NonNull; + /** * Incremental implementation of {@link ScoreDirectorFactory}. * @@ -25,7 +27,7 @@ public final class IncrementalScoreDirectorFactory> IncrementalScoreDirectorFactory buildScoreDirectorFactory(SolutionDescriptor solutionDescriptor, ScoreDirectorFactoryConfig config, - EnvironmentMode environmentMode) { + EnvironmentMode globalEnvironmentMode) { if (!IncrementalScoreCalculator.class.isAssignableFrom(config.getIncrementalScoreCalculatorClass())) { throw new IllegalArgumentException("The incrementalScoreCalculatorClass (%s) does not implement %s." .formatted(config.getIncrementalScoreCalculatorClass(), IncrementalScoreCalculator.class.getSimpleName())); @@ -36,27 +38,23 @@ public final class IncrementalScoreDirectorFactory> incrementalScoreCalculatorSupplier; public IncrementalScoreDirectorFactory(SolutionDescriptor solutionDescriptor, Supplier> incrementalScoreCalculatorSupplier, - EnvironmentMode environmentMode) { - super(solutionDescriptor, environmentMode); + EnvironmentMode globalEnvironmentMode) { + super(solutionDescriptor, globalEnvironmentMode); this.incrementalScoreCalculatorSupplier = incrementalScoreCalculatorSupplier; } @Override - public IncrementalScoreDirector.Builder createScoreDirectorBuilder() { - return new IncrementalScoreDirector.Builder<>(this) + public IncrementalScoreDirector.Builder + createScoreDirectorBuilder(@NonNull EnvironmentMode environmentMode) { + return new IncrementalScoreDirector.Builder<>(this, environmentMode) .withIncrementalScoreCalculator(incrementalScoreCalculatorSupplier.get()); } - @Override - public IncrementalScoreDirector buildScoreDirector() { - return createScoreDirectorBuilder().build(); - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java index 38454c7d296..32a983f3e1b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.declarative.ConsistencyTracker; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; @@ -215,18 +216,20 @@ public static final class Builder> extends AbstractScoreDirectorBuilder, Builder> { - public Builder(BavetConstraintStreamScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); + public Builder(BavetConstraintStreamScoreDirectorFactory scoreDirectorFactory, + EnvironmentMode environmentMode) { + super(scoreDirectorFactory, environmentMode); } + @SuppressWarnings("unchecked") @Override public BavetConstraintStreamScoreDirector build() { return new BavetConstraintStreamScoreDirector<>(this, false); } + @SuppressWarnings("unchecked") @Override - public AbstractScoreDirector> - buildDerived() { + public BavetConstraintStreamScoreDirector buildDerived() { return new BavetConstraintStreamScoreDirector<>(this, true); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java index f596146042e..2bcdd9b9e03 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java @@ -13,20 +13,24 @@ import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.declarative.ConsistencyTracker; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; -import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirector.Builder; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintFactory; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintSession; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintSessionFactory; import ai.timefold.solver.core.impl.score.stream.common.AbstractConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.score.stream.common.inliner.AbstractScoreInliner; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked public final class BavetConstraintStreamScoreDirectorFactory> extends AbstractConstraintStreamScoreDirectorFactory> { public static > BavetConstraintStreamScoreDirectorFactory buildScoreDirectorFactory(SolutionDescriptor solutionDescriptor, ScoreDirectorFactoryConfig config, - EnvironmentMode environmentMode) { + EnvironmentMode globalEnvironmentMode) { var providedConstraintProviderClass = config.getConstraintProviderClass(); if (providedConstraintProviderClass == null || !ConstraintProvider.class.isAssignableFrom(providedConstraintProviderClass)) { @@ -40,7 +44,7 @@ public final class BavetConstraintStreamScoreDirectorFactory(solutionDescriptor, constraintProvider, environmentMode, + return new BavetConstraintStreamScoreDirectorFactory<>(solutionDescriptor, constraintProvider, globalEnvironmentMode, profilingEnabled); } @@ -63,20 +67,20 @@ private static Class getConstraintProviderClass(Sc private final ConstraintMetaModel constraintMetaModel; public BavetConstraintStreamScoreDirectorFactory(SolutionDescriptor solutionDescriptor, - ConstraintProvider constraintProvider, EnvironmentMode environmentMode) { - this(solutionDescriptor, constraintProvider, environmentMode, false); + ConstraintProvider constraintProvider, EnvironmentMode globalEnvironmentMode) { + this(solutionDescriptor, constraintProvider, globalEnvironmentMode, false); } public BavetConstraintStreamScoreDirectorFactory(SolutionDescriptor solutionDescriptor, - ConstraintProvider constraintProvider, EnvironmentMode environmentMode, boolean profilingEnabled) { - super(solutionDescriptor, environmentMode); - var constraintFactory = new BavetConstraintFactory<>(solutionDescriptor, environmentMode); + ConstraintProvider constraintProvider, EnvironmentMode globalEnvironmentMode, boolean profilingEnabled) { + super(solutionDescriptor, globalEnvironmentMode); + var constraintFactory = new BavetConstraintFactory<>(solutionDescriptor, globalEnvironmentMode); constraintMetaModel = DefaultConstraintMetaModel.of(constraintFactory.buildConstraints(constraintProvider)); constraintSessionFactory = new BavetConstraintSessionFactory<>(solutionDescriptor, constraintMetaModel, profilingEnabled); } - public BavetConstraintSession newSession(Solution_ workingSolution, + public BavetConstraintSession newSession(@Nullable Solution_ workingSolution, ConsistencyTracker consistencyTracker, ConstraintMatchPolicy constraintMatchPolicy, boolean scoreDirectorDerived) { return constraintSessionFactory.buildSession(workingSolution, consistencyTracker, constraintMatchPolicy, @@ -99,13 +103,7 @@ public ConstraintMetaModel getConstraintMetaModel() { } @Override - public BavetConstraintStreamScoreDirector.Builder createScoreDirectorBuilder() { - return new BavetConstraintStreamScoreDirector.Builder<>(this); + public Builder createScoreDirectorBuilder(EnvironmentMode environmentMode) { + return new Builder<>(this, environmentMode); } - - @Override - public AbstractScoreDirector buildScoreDirector() { - return createScoreDirectorBuilder().build(); - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/common/AbstractConstraintStreamScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/common/AbstractConstraintStreamScoreDirectorFactory.java index bc5564752f9..3bfb0e38b3a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/common/AbstractConstraintStreamScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/common/AbstractConstraintStreamScoreDirectorFactory.java @@ -9,6 +9,8 @@ import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; import ai.timefold.solver.core.impl.score.stream.common.inliner.AbstractScoreInliner; +import org.jspecify.annotations.NullMarked; + /** * FP streams implementation of {@link ScoreDirectorFactory}. * @@ -16,12 +18,13 @@ * @param the score type to go with the solution * @see ScoreDirectorFactory */ +@NullMarked public abstract class AbstractConstraintStreamScoreDirectorFactory, Factory_ extends AbstractConstraintStreamScoreDirectorFactory> extends AbstractScoreDirectorFactory { protected AbstractConstraintStreamScoreDirectorFactory(SolutionDescriptor solutionDescriptor, - EnvironmentMode environmentMode) { - super(solutionDescriptor, environmentMode); + EnvironmentMode globalEnvironmentMode) { + super(solutionDescriptor, globalEnvironmentMode); } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 776bcd9fd84..0a1b42a30ad 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -15,7 +15,6 @@ import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleSupport; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; -import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; 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.DefaultProblemChangeDirector; @@ -46,7 +45,7 @@ public abstract class AbstractSolver implements Solver { protected final transient Logger LOGGER = LoggerFactory.getLogger(getClass()); protected final SolverContext defaultSolverContext; - private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; + private final ScoreDirectorFactory scoreDirectorFactory; private final SolverEventSupport solverEventSupport = new SolverEventSupport<>(this); private final PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); @@ -64,10 +63,10 @@ public abstract class AbstractSolver implements Solver { // ************************************************************************ protected AbstractSolver(SolverContext defaultSolverContext, - DelegateScoreDirectorFactory delegateScoreDirectorFactory, - BestSolutionRecaller bestSolutionRecaller, UniversalTermination globalTermination, - List> phaseList) { - this.delegateScoreDirectorFactory = delegateScoreDirectorFactory; + ScoreDirectorFactory scoreDirectorFactory, + BestSolutionRecaller bestSolutionRecaller, + UniversalTermination globalTermination, List> phaseList) { + this.scoreDirectorFactory = scoreDirectorFactory; this.bestSolutionRecaller = bestSolutionRecaller; this.globalTermination = globalTermination; bestSolutionRecaller.setSolverEventSupport(solverEventSupport); @@ -125,9 +124,10 @@ private void preparePhase(SolverScope solverScope, Phase phase) { // Since the current logic does not cache any solver context other than the default, // we need to create a new solver context // because the required environment mode differs from both the current and the default modes. - ScoreDirectorFactory newScoreDirectorFactory = delegateScoreDirectorFactory - .buildScoreDirectorFactory(phase.getEnvironmentMode(), solverScope.getSolutionDescriptor()); - var newScoreDirector = delegateScoreDirectorFactory.createScoreDirector(newScoreDirectorFactory); + var newScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(phase.getEnvironmentMode()) + .withLookUpEnabled(true) + .withConstraintMatchPolicy(scoreDirectorFactory.decideConstraintMatchPolicy(phase.getEnvironmentMode())) + .build(); var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, new DefaultProblemChangeDirector<>(newScoreDirector)); loadContext(currentContext, newSolverContext, solverScope); @@ -248,8 +248,8 @@ public BestSolutionRecaller getBestSolutionRecaller() { } @SuppressWarnings("unchecked") - public > DelegateScoreDirectorFactory getDelegateScoreDirectorFactory() { - return (DelegateScoreDirectorFactory) delegateScoreDirectorFactory; + public > ScoreDirectorFactory getScoreDirectorFactory() { + return (ScoreDirectorFactory) scoreDirectorFactory; } public List> getPhaseList() { 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 d7f4a81dc47..f05e88b2775 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 @@ -9,7 +9,6 @@ import ai.timefold.solver.core.api.domain.common.PlanningId; 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.change.ProblemChange; import ai.timefold.solver.core.api.solver.event.EventProducerId; @@ -17,7 +16,6 @@ import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.phase.Phase; -import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; 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.random.RandomSource; @@ -51,12 +49,11 @@ public class DefaultSolver extends AbstractSolver { // Constructors and simple getters/setters // ************************************************************************ - public DefaultSolver(EnvironmentMode environmentMode, - DelegateScoreDirectorFactory delegateScoreDirectorFactory, + public DefaultSolver(EnvironmentMode globalEnvironmentMode, ScoreDirectorFactory scoreDirectorFactory, Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { - super(SolverContext.of(environmentMode, solverScope), delegateScoreDirectorFactory, bestSolutionRecaller, termination, + super(SolverContext.of(globalEnvironmentMode, solverScope), scoreDirectorFactory, bestSolutionRecaller, termination, phaseList); this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; @@ -69,13 +66,6 @@ public RandomSource getRandomSource() { return randomFactory.get(); } - @SuppressWarnings({ "unchecked", "resource" }) - public > ScoreDirectorFactory getScoreDirectorFactory() { - InnerScoreDirector scoreDirector = - (InnerScoreDirector) defaultSolverContext.scoreDirector(); - return scoreDirector.getScoreDirectorFactory(); - } - public SolverScope getSolverScope() { return solverScope; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index e6c5ba9f862..2eeae6bd281 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -19,7 +19,6 @@ import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; -import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.config.solver.SolverConfig; @@ -55,25 +54,31 @@ import io.micrometer.core.instrument.Tags; /** - * The default solver factory must maintain a default score director factory, - * as some solver components depend on this factory, - * including {@link SolverManager} and {@code TimefoldSolverBeanFactory}. + * Builds {@link DefaultSolver} instances out of a {@link SolverConfig}, + * and owns the state which is expensive to build and therefore shared by every solver it builds: + * the {@link SolutionDescriptor} and a single {@link DelegateScoreDirectorFactory}. *

- * The proposed approach establishes that the configuration defines a root environment mode, - * which is used to create the default score director factory. - * Since the phases can override the environment, - * the delegate factory will enable the creation of separate factories - * while maintaining a default one that is used for all other components. + * The solver config has one environment mode, the default one, + * and each of its phases may override it with a stricter one. + * The score director factory is built once for the default environment mode; + * a phase which overrides the mode asks the delegate for a score director in its own mode instead, + * which the delegate serves without this factory having to keep one instance per mode. *

- * The necessity for a default environment mode can be illustrated by the following use case. - * Imagine a configuration that includes multiple phases, each with a different environment mode. - * If a Quarkus application needs to inject a {@link ConstraintMetaModel} - * instance, this instance depends on the score director factory, - * which in turn relies on the environment mode. - * If multiple phase environments exist, - * selecting one of these environments is not possible - * as this injection point is decoupled from the solving life cycle. - * + * That is also why a default environment mode has to exist at all, + * even for a config whose phases all override it. + * Some components depend on the score director factory + * while being decoupled from the solving life cycle, + * and therefore have no phase whose environment mode they could adopt; + * {@link SolverManager} and the integrations + * ({@code TimefoldSolverBeanFactory} injecting a {@link ConstraintMetaModel}, for instance) + * are such components. + * They all get the default environment mode. + *

+ * {@link #assertEnvironmentModeConfiguration(SolverConfig)} guards the invariants this relies on: + * no phase may be less strict than the default mode, + * at least one phase must actually use the default mode, + * and a non-reproducible default mode forces every phase to be non-reproducible as well. + * * @param the solution type, the class with the {@link PlanningSolution} annotation * @see SolverFactory */ @@ -86,11 +91,9 @@ public final class DefaultSolverFactory implements SolverFactory solutionDescriptor; - private final EnvironmentMode defaultEnvironmentMode; + private final EnvironmentMode globalEnvironmentMode; private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; - private final ScoreDirectorFactory defaultScoreDirectorFactory; private final DomainAccessType domainAccessType; - private final List metricsRequiringConstraintMatchList; public DefaultSolverFactory(SolverConfig solverConfig) { this(solverConfig, DomainAccessType.AUTO); @@ -101,24 +104,11 @@ public DefaultSolverFactory(SolverConfig solverConfig, DomainAccessType domainAc this.clock = Objects.requireNonNullElse(solverConfig.getClock(), Clock.systemDefaultZone()); this.solverConfig = Objects.requireNonNull(solverConfig, "The solverConfig (%s) cannot be null.".formatted(solverConfig)); - this.defaultEnvironmentMode = assertEnvironmentModeConfiguration(solverConfig); + this.globalEnvironmentMode = assertEnvironmentModeConfiguration(solverConfig); this.solutionDescriptor = buildSolutionDescriptor(); - var scoreDirectorFactoryConfig = - Objects.requireNonNullElseGet(solverConfig.getScoreDirectorFactoryConfig(), ScoreDirectorFactoryConfig::new); - this.metricsRequiringConstraintMatchList = determineMetricsRequiringConstraintMatch(solverConfig); - this.delegateScoreDirectorFactory = new DelegateScoreDirectorFactory<>( - Objects.requireNonNull(scoreDirectorFactoryConfig), !metricsRequiringConstraintMatchList.isEmpty()); - // Caching score director factory as it potentially does expensive things - this.defaultScoreDirectorFactory = - this.delegateScoreDirectorFactory.buildScoreDirectorFactory(defaultEnvironmentMode, solutionDescriptor); - } - - private static List determineMetricsRequiringConstraintMatch(SolverConfig solverConfig) { - var monitoringConfig = solverConfig.determineMetricConfig(); - var solverMetricList = Objects.requireNonNull(monitoringConfig.getSolverMetricList()); - return solverMetricList.stream() - .filter(SolverMetric::isMetricConstraintMatchBased) - .toList(); + // Caching score director factory for the default environment mode as it potentially does expensive things + this.delegateScoreDirectorFactory = + new DelegateScoreDirectorFactory<>(solverConfig, solutionDescriptor, globalEnvironmentMode); } public Clock getClock() { @@ -129,9 +119,15 @@ public SolutionDescriptor getSolutionDescriptor() { return solutionDescriptor; } + /** + * @return the factory built for the default environment mode; + * the delegate and not its {@link DelegateScoreDirectorFactory} wrapper, + * as callers outside the solving life cycle expect the concrete implementation, + * such as {@code BeanUtil#buildConstraintMetaModel} which needs a constraint stream factory + */ @SuppressWarnings("unchecked") public > ScoreDirectorFactory getScoreDirectorFactory() { - return (ScoreDirectorFactory) defaultScoreDirectorFactory; + return (ScoreDirectorFactory) delegateScoreDirectorFactory.getDelegate(); } @Override @@ -148,20 +144,16 @@ public Solver buildSolver(SolverConfigOverride configOverride) { } else { solverScope.setSolverMetricSet(EnumSet.noneOf(SolverMetric.class)); } - var isStepAssertOrMore = defaultEnvironmentMode.isStepAssertOrMore(); - var constraintMatchEnabled = !metricsRequiringConstraintMatchList.isEmpty() || isStepAssertOrMore; - if (constraintMatchEnabled && !isStepAssertOrMore) { - LOGGER.info( - "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", - metricsRequiringConstraintMatchList); - } - var scoreDirector = delegateScoreDirectorFactory.createScoreDirector(getScoreDirectorFactory()); + var scoreDirector = delegateScoreDirectorFactory.createScoreDirectorBuilder(globalEnvironmentMode) + .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. + .withConstraintMatchPolicy(delegateScoreDirectorFactory.decideConstraintMatchPolicy(globalEnvironmentMode)) + .build(); solverScope.setScoreDirector(scoreDirector); solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(scoreDirector)); var moveThreadCount = resolveMoveThreadCount(true); var bestSolutionRecaller = - BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(defaultEnvironmentMode); - var randomFactory = buildRandomSupplier(defaultEnvironmentMode); + BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(globalEnvironmentMode); + var randomFactory = buildRandomSupplier(globalEnvironmentMode); var previewFeaturesEnabled = solverConfig.getEnablePreviewFeatureSet(); var scoreDirectorFactoryConfig = solverConfig.getScoreDirectorFactoryConfig(); @@ -176,13 +168,13 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var configPolicy = new HeuristicConfigPolicy.Builder() .withPreviewFeatureSet(previewFeaturesEnabled) - .withEnvironmentMode(defaultEnvironmentMode) + .withEnvironmentMode(globalEnvironmentMode) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(solverConfig.getMoveThreadBufferSize()) .withThreadFactoryClass(solverConfig.getThreadFactoryClass()) .withNearbyDistanceMeterClass(solverConfig.getNearbyDistanceMeterClass()) .withRandom(randomFactory.get()) - .withInitializingScoreTrend(defaultScoreDirectorFactory.getInitializingScoreTrend()) + .withInitializingScoreTrend(delegateScoreDirectorFactory.getInitializingScoreTrend()) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(ClassInstanceCache.create()) .build(); @@ -190,7 +182,7 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var termination = buildTermination(basicPlumbingTermination, configPolicy, configOverride); var phaseList = buildPhaseList(configPolicy, bestSolutionRecaller, termination); - return new DefaultSolver<>(defaultEnvironmentMode, delegateScoreDirectorFactory, randomFactory, bestSolutionRecaller, + return new DefaultSolver<>(globalEnvironmentMode, delegateScoreDirectorFactory, randomFactory, bestSolutionRecaller, basicPlumbingTermination, (UniversalTermination) termination, phaseList, solverScope, moveThreadCount == null ? SolverConfig.MOVE_THREAD_COUNT_NONE : Integer.toString(moveThreadCount)); } @@ -307,9 +299,8 @@ private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig s // If the default environment is non-reproducible, // then all phase environment modes must also be non-reproducible throw new IllegalStateException( - "The default environment mode is (%s), and all phase environments [%s] must also be non-reproducible." - .formatted(defaultEnvironmentMode.name(), - String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()))); + "Phase-level environmentMode override is only possible when global environmentMode is reproducible, but was %s." + .formatted(defaultEnvironmentMode.name())); } // If none of the phase environments use the default environment, we fail fast. var checkDefaultEnvironment = phaseEnvironmentList.isEmpty(); @@ -321,9 +312,8 @@ private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig s } if (!checkDefaultEnvironment) { throw new IllegalStateException(""" - The default environment mode (%s) is not used in any of the defined phases environment modes [%s]. - Maybe adjust the solver config's default environment mode. - Maybe adjust at least one of the phase environment modes to match the default environment mode (%s)""" + The global environment mode is %s, but none of the phase environment modes are using it [%s]. + Maybe adjust at least one of the phase environment modes to match the global environmentMode (%s)""" .formatted( defaultEnvironmentMode.name(), String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()), @@ -338,7 +328,7 @@ Maybe adjust at least one of the phase environment modes to match the default en if (!invalidPhaseEnvironmentList.isEmpty()) { // The phase environments must have an assertion level greater than or equal to the default environment level throw new IllegalStateException( - "The phase environments must have an assertion level higher than or equal to the default environment level (%s). The following phase environment modes are not valid: [%s]." + "The phase environments must have an assertion level higher than or equal to the global environment level (%s). The following phase environment modes are not valid: [%s]." .formatted(defaultEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); } return defaultEnvironmentMode; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 3d4df58304f..c4386ee8ea9 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -147,7 +147,7 @@ void assignValueAndSetInMiddle() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -193,7 +193,7 @@ void assignValueAndSetAtStart() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -243,7 +243,7 @@ void assignValueAndSetAtEnd() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -283,7 +283,7 @@ void assignValueAndSetOnEmptyList() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -315,7 +315,7 @@ void assignValueAndAddToEmptyList() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -357,7 +357,7 @@ void assignValueAndAddAtStart() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -408,7 +408,7 @@ void assignValueAndAddInMiddle() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -460,7 +460,7 @@ void assignValueAndAddAtEnd() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -505,7 +505,7 @@ void assignValueAndAddFailsWhenValueAlreadyAssigned() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -535,7 +535,7 @@ void assignValuesAndAddToEmptyList() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -575,7 +575,7 @@ void assignValuesAndAddAtStart() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -621,7 +621,7 @@ void assignValuesAndAddInMiddle() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -669,7 +669,7 @@ void assignValuesAndAddAtEnd() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -709,7 +709,7 @@ void assignValuesAndAddFailsWhenValueAlreadyAssigned() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -740,7 +740,7 @@ void assignValueAndSetFailsWhenValueAlreadyAssigned() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -1672,7 +1672,7 @@ void twoUnassignsInARow() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataListEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -1708,7 +1708,7 @@ void twoChangesInARow() { constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataMixedEntity.class) .penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); @@ -1894,7 +1894,7 @@ private BavetConstraintStreamScoreDirector buildS .penalize(SimpleScore.ONE) .asConstraint("Bad value") }, EnvironmentMode.FULL_ASSERT); - var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); + var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f, EnvironmentMode.FULL_ASSERT).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); return scoreDirector; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java index ce72a964391..d4c979b264a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java @@ -4,18 +4,22 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import java.util.HashMap; +import java.util.List; import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; -import ai.timefold.solver.core.api.score.stream.Constraint; -import ai.timefold.solver.core.api.score.stream.ConstraintFactory; -import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.monitoring.MonitoringConfig; +import ai.timefold.solver.core.config.solver.monitoring.SolverMetric; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirector; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; import ai.timefold.solver.core.testconstraint.DummyConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -25,108 +29,213 @@ class DelegateScoreDirectorFactoryTest { - @Test - void multipleScoreCalculations_throwsException() { - var config = new ScoreDirectorFactoryConfig() - .withConstraintProviderClass(TestdataConstraintProvider.class) + private static DelegateScoreDirectorFactory buildTestdataScoreDirectorFactory( + ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { + return new DelegateScoreDirectorFactory<>(config, TestdataSolution.buildSolutionDescriptor(), environmentMode); + } + + private static DelegateScoreDirectorFactory buildTestdataScoreDirectorFactory( + ScoreDirectorFactoryConfig config) { + return buildTestdataScoreDirectorFactory(config, EnvironmentMode.PHASE_ASSERT); + } + + private static ScoreDirectorFactoryConfig easyConfig() { + return new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestCustomPropertiesEasyScoreCalculator.class); - assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> buildTestdataScoreDirectoryFactory(config)) - .withMessageContaining("scoreDirectorFactory") - .withMessageContaining("together"); } - private ScoreDirectorFactory - buildTestdataScoreDirectoryFactory(ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { - return new DelegateScoreDirectorFactory(config) - .buildScoreDirectorFactory(environmentMode, TestdataSolution.buildSolutionDescriptor()); + private static ScoreDirectorFactoryConfig incrementalConfig() { + return new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); } - private ScoreDirectorFactory - buildTestdataScoreDirectoryFactory(ScoreDirectorFactoryConfig config) { - return buildTestdataScoreDirectoryFactory(config, EnvironmentMode.PHASE_ASSERT); + private static ScoreDirectorFactoryConfig constraintStreamConfig() { + return new ScoreDirectorFactoryConfig() + .withConstraintProviderClass(DummyConstraintProvider.class); } + // ************************************************************************ + // Picking the delegate + // ************************************************************************ + @Test - void constraintMatchEnabledPerPhaseEnvironmentMode() { - var config = new ScoreDirectorFactoryConfig().withConstraintProviderClass(DummyConstraintProvider.class); - var delegateScoreDirectorFactory = new DelegateScoreDirectorFactory(config, false); - var phaseScoreDirectorFactory = delegateScoreDirectorFactory.buildScoreDirectorFactory(EnvironmentMode.FULL_ASSERT, - TestdataSolution.buildSolutionDescriptor()); - try (var scoreDirector = delegateScoreDirectorFactory.createScoreDirector(phaseScoreDirectorFactory)) { - assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.ENABLED); + void easyScoreCalculatorDelegate() { + assertThat(buildTestdataScoreDirectorFactory(easyConfig()).getDelegate()) + .isExactlyInstanceOf(EasyScoreDirectorFactory.class); + } + + @Test + void incrementalScoreCalculatorDelegate() { + assertThat(buildTestdataScoreDirectorFactory(incrementalConfig()).getDelegate()) + .isExactlyInstanceOf(IncrementalScoreDirectorFactory.class); + } + + @Test + void constraintStreamsDelegate() { + assertThat(buildTestdataScoreDirectorFactory(constraintStreamConfig()).getDelegate()) + .isExactlyInstanceOf(BavetConstraintStreamScoreDirectorFactory.class); + } + + @Test + void delegateIsSharedWithTheScoreDirectorsItBuilds() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(incrementalConfig()); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector()) { + assertThat(scoreDirector.getScoreDirectorFactory()).isSameAs(scoreDirectorFactory.getDelegate()); } + assertThat(scoreDirectorFactory.getSolutionDescriptor()) + .isSameAs(scoreDirectorFactory.getDelegate().getSolutionDescriptor()); + assertThat(scoreDirectorFactory.getScoreDefinition()) + .isSameAs(scoreDirectorFactory.getDelegate().getScoreDefinition()); } @Test - void constraintStreamsBavet() { - var config = new ScoreDirectorFactoryConfig() - .withConstraintProviderClass(TestdataConstraintProvider.class); - var scoreDirectorFactory = - BavetConstraintStreamScoreDirectorFactory.buildScoreDirectorFactory(TestdataSolution.buildSolutionDescriptor(), - config, EnvironmentMode.PHASE_ASSERT); - assertThat(scoreDirectorFactory).isInstanceOf(BavetConstraintStreamScoreDirectorFactory.class); + void noScoreCalculation_throwsException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(new ScoreDirectorFactoryConfig())) + .withMessageContaining("lacks configuration"); } - public static class TestCustomPropertiesEasyScoreCalculator - implements EasyScoreCalculator { + @Test + void solverConfigWithoutScoreDirectorFactory_throwsException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new DelegateScoreDirectorFactory(new SolverConfig(), + TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT)) + .withMessageContaining("lacks configuration"); + } - private String stringProperty; - private int intProperty; + @Test + void multipleScoreCalculations_throwsException() { + var config = constraintStreamConfig() + .withEasyScoreCalculatorClass(TestCustomPropertiesEasyScoreCalculator.class); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(config)) + .withMessageContaining("scoreDirectorFactory") + .withMessageContaining("together"); + } - public String getStringProperty() { - return stringProperty; + @Test + void incrementalMultipleScoreCalculations_throwsException() { + var config = constraintStreamConfig() + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(config)) + .withMessageContaining("scoreDirectorFactory") + .withMessageContaining("together"); + } + + // ************************************************************************ + // Environment modes + // ************************************************************************ + + @Test + void globalEnvironmentModeIsUsedWhenNoneRequested() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(incrementalConfig(), EnvironmentMode.FULL_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector()) { + // The environment mode is not exposed by the score director; the test lives in the same package to read it. + assertThat(scoreDirector.environmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); } + } - @SuppressWarnings("unused") - public void setStringProperty(String stringProperty) { - this.stringProperty = stringProperty; + @Test + void otherEnvironmentModeReusesDelegate() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(incrementalConfig(), EnvironmentMode.PHASE_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + assertThat(scoreDirector.environmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); + // Only the constraint stream factory depends on the environment mode; the others are reused as they are. + assertThat(scoreDirector.getScoreDirectorFactory()).isSameAs(scoreDirectorFactory.getDelegate()); } + } - public int getIntProperty() { - return intProperty; + @Test + void otherEnvironmentModeRebuildsConstraintStreamDelegate() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + assertThat(scoreDirector.environmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); + // The constraint network is built from the environment mode, so the cached delegate cannot be reused. + assertThat(scoreDirector.getScoreDirectorFactory()) + .isExactlyInstanceOf(BavetConstraintStreamScoreDirectorFactory.class) + .isNotSameAs(scoreDirectorFactory.getDelegate()); } + } - @SuppressWarnings("unused") - public void setIntProperty(int intProperty) { - this.intProperty = intProperty; + @Test + void globalEnvironmentModeReusesConstraintStreamDelegate() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.PHASE_ASSERT)) { + assertThat(scoreDirector.getScoreDirectorFactory()).isSameAs(scoreDirectorFactory.getDelegate()); } + } - @Override - public @NonNull SimpleScore calculateScore(@NonNull TestdataSolution testdataSolution) { - return SimpleScore.ZERO; + // ************************************************************************ + // Constraint match policy + // ************************************************************************ + + @Test + void constraintMatchDisabledUnlessRequested() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.FULL_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector()) { + // The environment mode alone does not enable constraint matching; + // the caller has to apply decideConstraintMatchPolicy() to the builder. + assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.DISABLED); } } - public static class TestdataConstraintProvider implements ConstraintProvider { - @Override - public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { - return new Constraint[0]; + @Test + void constraintMatchEnabledPerPhaseEnvironmentMode() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); + assertThat(scoreDirectorFactory.decideConstraintMatchPolicy(EnvironmentMode.PHASE_ASSERT)) + .isEqualTo(ConstraintMatchPolicy.DISABLED); + assertThat(scoreDirectorFactory.decideConstraintMatchPolicy(EnvironmentMode.FULL_ASSERT)) + .isEqualTo(ConstraintMatchPolicy.ENABLED); + + var phaseEnvironmentMode = EnvironmentMode.FULL_ASSERT; + try (var scoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(phaseEnvironmentMode) + .withConstraintMatchPolicy(scoreDirectorFactory.decideConstraintMatchPolicy(phaseEnvironmentMode)) + .build()) { + assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.ENABLED); } } @Test - void incrementalMultipleScoreCalculations_throwsException() { - var config = new ScoreDirectorFactoryConfig() - .withConstraintProviderClass(ai.timefold.solver.core.testdomain.TestdataConstraintProvider.class) - .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); - assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> buildTestdataScoreDirectoryFactory(config)) - .withMessageContaining("scoreDirectorFactory") - .withMessageContaining("together"); + void constraintMatchEnabledByMetric() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(SolverMetric.CONSTRAINT_MATCH_TOTAL_BEST_SCORE); + assertThat(scoreDirectorFactory.decideConstraintMatchPolicy(EnvironmentMode.NO_ASSERT)) + .isEqualTo(ConstraintMatchPolicy.ENABLED); + } + + @Test + void constraintMatchNotEnabledByOtherMetric() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(SolverMetric.BEST_SCORE); + assertThat(scoreDirectorFactory.decideConstraintMatchPolicy(EnvironmentMode.NO_ASSERT)) + .isEqualTo(ConstraintMatchPolicy.DISABLED); + } + + private static DelegateScoreDirectorFactory + buildTestdataScoreDirectorFactory(SolverMetric solverMetric) { + var solverConfig = new SolverConfig() + .withScoreDirectorFactory(constraintStreamConfig()) + .withMonitoringConfig(new MonitoringConfig().withSolverMetricList(List.of(solverMetric))); + return new DelegateScoreDirectorFactory<>(solverConfig, TestdataSolution.buildSolutionDescriptor(), + EnvironmentMode.NO_ASSERT); } + // ************************************************************************ + // Configuration shared by all delegates + // ************************************************************************ + @Test void incrementalScoreCalculatorWithCustomProperties() { - var config = new ScoreDirectorFactoryConfig(); - config.setIncrementalScoreCalculatorClass( - TestCustomPropertiesIncrementalScoreCalculator.class); + var config = incrementalConfig(); var customProperties = new HashMap(); customProperties.put("stringProperty", "string 1"); customProperties.put("intProperty", "7"); config.setIncrementalScoreCalculatorCustomProperties(customProperties); var scoreDirectorFactory = - (IncrementalScoreDirectorFactory) buildTestdataScoreDirectoryFactory(config); - try (var scoreDirector = scoreDirectorFactory.buildScoreDirector()) { + (IncrementalScoreDirectorFactory) buildTestdataScoreDirectorFactory(config) + .getDelegate(); + try (IncrementalScoreDirector scoreDirector = + scoreDirectorFactory.buildScoreDirector()) { var scoreCalculator = (TestCustomPropertiesIncrementalScoreCalculator) scoreDirector.getIncrementalScoreCalculator(); assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); @@ -135,26 +244,94 @@ void incrementalScoreCalculatorWithCustomProperties() { } @Test - void buildWithAssertionScoreDirectorFactory() { - var assertionScoreDirectorConfig = new ScoreDirectorFactoryConfig() - .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); - var config = new ScoreDirectorFactoryConfig() - .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class) - .withAssertionScoreDirectorFactory(assertionScoreDirectorConfig); + void initializingScoreTrendFromConfig() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(incrementalConfig() + .withInitializingScoreTrend("ONLY_DOWN")); + assertThat(scoreDirectorFactory.getInitializingScoreTrend()) + .isEqualTo(InitializingScoreTrend.parseTrend("ONLY_DOWN", 1)); + // The trend is set on the delegate, which is where the solver reads it from. + assertThat(scoreDirectorFactory.getDelegate().getInitializingScoreTrend()) + .isSameAs(scoreDirectorFactory.getInitializingScoreTrend()); + } - var scoreDirectorFactory = - (AbstractScoreDirectorFactory) buildTestdataScoreDirectoryFactory(config, - EnvironmentMode.STEP_ASSERT); + @Test + void initializingScoreTrendDefaultsToAny() { + assertThat(buildTestdataScoreDirectorFactory(incrementalConfig()).getInitializingScoreTrend()) + .isEqualTo(InitializingScoreTrend.parseTrend("ANY", 1)); + } - var assertionScoreDirectorFactory = - (IncrementalScoreDirectorFactory) scoreDirectorFactory - .getAssertionScoreDirectorFactory(); - try (var assertionScoreDirector = assertionScoreDirectorFactory.buildScoreDirector()) { + @Test + void buildWithAssertionScoreDirectorFactory() { + var config = incrementalConfig() + .withAssertionScoreDirectorFactory(incrementalConfig()); + + var scoreDirectorFactory = (AbstractScoreDirectorFactory) buildTestdataScoreDirectorFactory( + config, EnvironmentMode.STEP_ASSERT).getDelegate(); + + var assertionScoreDirectorFactory = scoreDirectorFactory.getAssertionScoreDirectorFactory(); + // The assertion factory is the delegate of its own DelegateScoreDirectorFactory, + // as the code reading it expects a concrete factory. + assertThat(assertionScoreDirectorFactory).isExactlyInstanceOf(IncrementalScoreDirectorFactory.class); + try (IncrementalScoreDirector assertionScoreDirector = + ((IncrementalScoreDirectorFactory) assertionScoreDirectorFactory) + .buildScoreDirector()) { + // The assertion score director always runs in NON_REPRODUCIBLE, regardless of the requested mode. + assertThat(assertionScoreDirector.environmentMode).isEqualTo(EnvironmentMode.NON_REPRODUCIBLE); var assertionScoreCalculator = assertionScoreDirector.getIncrementalScoreCalculator(); assertThat(assertionScoreCalculator).isExactlyInstanceOf(TestCustomPropertiesIncrementalScoreCalculator.class); } } + @Test + void nestedAssertionScoreDirectorFactory_throwsException() { + var config = incrementalConfig() + .withAssertionScoreDirectorFactory(incrementalConfig() + .withAssertionScoreDirectorFactory(incrementalConfig())); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(config, EnvironmentMode.STEP_ASSERT)) + .withMessageContaining("cannot have a non-null assertionScoreDirectorFactory"); + } + + @Test + void assertionScoreDirectorFactoryInLenientEnvironmentMode_throwsException() { + var config = incrementalConfig() + .withAssertionScoreDirectorFactory(incrementalConfig()); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(config, EnvironmentMode.PHASE_ASSERT)) + .withMessageContaining("requires an environmentMode") + .withMessageContaining(EnvironmentMode.STEP_ASSERT.name()); + } + + public static class TestCustomPropertiesEasyScoreCalculator + implements EasyScoreCalculator { + + private String stringProperty; + private int intProperty; + + public String getStringProperty() { + return stringProperty; + } + + @SuppressWarnings("unused") + public void setStringProperty(String stringProperty) { + this.stringProperty = stringProperty; + } + + public int getIntProperty() { + return intProperty; + } + + @SuppressWarnings("unused") + public void setIntProperty(int intProperty) { + this.intProperty = intProperty; + } + + @Override + public @NonNull SimpleScore calculateScore(@NonNull TestdataSolution testdataSolution) { + return SimpleScore.ZERO; + } + } + @NullMarked public static class TestCustomPropertiesIncrementalScoreCalculator implements IncrementalScoreCalculator { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java index eb6399abf6f..61d51c776bd 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java @@ -31,10 +31,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataConstraintWeightOverridesEasyScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -43,9 +41,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedListEasyScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -54,9 +51,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedWithIndexListEasyScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Test @@ -69,8 +65,8 @@ void easyScoreCalculatorWithCustomProperties() { config.setEasyScoreCalculatorCustomProperties(customProperties); var testdataSolutionScoreDirectorFactory = buildTestdataScoreDirectoryFactory(config); - try (var scoreDirector = - (EasyScoreDirector) testdataSolutionScoreDirectorFactory.buildScoreDirector()) { + try (EasyScoreDirector scoreDirector = + testdataSolutionScoreDirectorFactory.buildScoreDirector()) { var scoreCalculator = (TestCustomPropertiesEasyScoreCalculator) scoreDirector.getEasyScoreCalculator(); assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); assertThat(scoreCalculator.getIntProperty()).isEqualTo(7); @@ -79,8 +75,7 @@ void easyScoreCalculatorWithCustomProperties() { private ScoreDirectorFactory buildTestdataScoreDirectoryFactory( ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { - return new DelegateScoreDirectorFactory(config) - .buildScoreDirectorFactory(environmentMode, TestdataSolution.buildSolutionDescriptor()); + return new DelegateScoreDirectorFactory<>(config, TestdataSolution.buildSolutionDescriptor(), environmentMode); } private ScoreDirectorFactory diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java index b773d5c899f..df1acde04ce 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java @@ -33,10 +33,8 @@ final class IncrementalScoreDirectorSemanticsTest extends AbstractScoreDirectorS SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataConstraintWeightOverridesIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -44,9 +42,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedListIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -55,9 +52,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedWithIndexListIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @NullMarked diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java index cd195be2f63..9ba6f4a7d5a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java @@ -29,8 +29,9 @@ class IncrementalScoreDirectorTest { @Test void illegalStateExceptionThrownWhenConstraintMatchNotEnabled() { - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { scoreDirector.setWorkingSolution(new Object()); assertThatIllegalStateException().isThrownBy(scoreDirector::getConstraintMatchTotalMap) .withMessageContaining(ConstraintMatchPolicy.DISABLED.name()); @@ -39,9 +40,10 @@ void illegalStateExceptionThrownWhenConstraintMatchNotEnabled() { @Test void constraintMatchTotalsNeverNull() { - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(true)) - .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(true)) + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(scoreDirector.getConstraintMatchTotalMap()).isNotNull(); } @@ -49,9 +51,10 @@ void constraintMatchTotalsNeverNull() { @Test void constraintMatchIsNotEnabledWhenScoreCalculatorNotConstraintMatchAware() { - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)) - .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)) + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.DISABLED); } } @@ -62,7 +65,6 @@ private IncrementalScoreDirectorFactory mockIncrementalScor when(factory.getScoreDefinition()).thenReturn(new SimpleScoreDefinition()); SolutionDescriptor solutionDescriptor = mock(SolutionDescriptor.class); when(factory.getSolutionDescriptor()).thenReturn(solutionDescriptor); - when(factory.getEnvironmentMode()).thenReturn(EnvironmentMode.PHASE_ASSERT); return factory; } @@ -80,8 +82,9 @@ class Justifications { @Test void registerConstraintMatchThrowsWhenConstraintMatchingDisabled() { - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { scoreDirector.setWorkingSolution(new Object()); assertThatIllegalStateException() .isThrownBy(() -> scoreDirector.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-1), @@ -93,9 +96,10 @@ void registerConstraintMatchThrowsWhenConstraintMatchingDisabled() { @Test void registerConstraintMatchUpdatesTotalScoreAndMap() { var calculator = new RegistryCapturingCalculator(SimpleScore.of(-5)); - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-5)); @@ -140,9 +144,10 @@ public SimpleScore calculateScore() { } }; - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-3)); @@ -186,9 +191,10 @@ public SimpleScore calculateScore() { } }; - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); registration[0].cancel(); assertThatIllegalStateException().isThrownBy(registration[0]::cancel) @@ -233,9 +239,10 @@ public SimpleScore calculateScore() { } }; - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-10)); @@ -286,9 +293,10 @@ public SimpleScore calculateScore() { } }; - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-7)); assertThat(scoreDirector.getConstraintMatchTotalMap()).containsKey(CONSTRAINT_A); @@ -335,9 +343,10 @@ public SimpleScore calculateScore() { } }; - try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) - .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) - .build()) { + try (var scoreDirector = + new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory(), EnvironmentMode.PHASE_ASSERT) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { scoreDirector.setWorkingSolution(new Object()); assertThat(registration[0].constraintRef()).isEqualTo(CONSTRAINT_A); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java index d577c4682d5..cedfa15001c 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java @@ -22,10 +22,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataConstraintWeightOverridesConstraintProvider.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -34,9 +32,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedListConstraintProvider.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } @Override @@ -45,9 +42,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedWithIndexListConstraintProvider.class); - var scoreDirectorFactoryFactory = - new DelegateScoreDirectorFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, + TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); } } From 992c2499dd5444cd71f090081c4b00e02f355b83 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 25 Aug 2026 10:19:32 -0300 Subject: [PATCH 16/20] chore: address comments --- .../score/director/AbstractScoreDirector.java | 15 ++--- .../AbstractScoreDirectorFactory.java | 4 +- .../DelegateScoreDirectorFactory.java | 41 ++++++++----- .../score/director/ScoreDirectorFactory.java | 20 ++----- .../director/easy/EasyScoreDirector.java | 1 - .../incremental/IncrementalScoreDirector.java | 1 - .../BavetConstraintStreamScoreDirector.java | 2 - .../AbstractScoreDirectorSemanticsTest.java | 12 ++++ .../DelegateScoreDirectorFactoryTest.java | 59 +++++++++++++++++-- .../easy/EasyScoreDirectorSemanticsTest.java | 16 ++--- ...IncrementalScoreDirectorSemanticsTest.java | 12 ++-- ...treamsBavetScoreDirectorSemanticsTest.java | 12 ++-- .../impl/solver/DefaultSolverFactoryTest.java | 6 +- 13 files changed, 129 insertions(+), 72 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 303e82b197e..7d305f8c252 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -458,7 +458,7 @@ public InnerScoreDirector createChildThreadScoreDirector(Chil switch (childThreadType) { case PART_THREAD -> { var childThreadScoreDirector = - scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) + scoreDirectorFactory.createScoreDirectorBuilder(environmentMode).withLookUpEnabled(lookUpEnabled) .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); // ScoreCalculationCountTermination takes into account previous phases // but the calculationCount of partitions is maxed, not summed. @@ -466,8 +466,9 @@ public InnerScoreDirector createChildThreadScoreDirector(Chil return childThreadScoreDirector; } case MOVE_THREAD -> { - var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) - .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + var childThreadScoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder(environmentMode).withLookUpEnabled(true) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); return childThreadScoreDirector; } @@ -697,7 +698,7 @@ public void assertScoreFromScratch(Solution_ solution) { // Get the score before uncorruptedScoreDirector.calculateScore() modifies it var score = getSolutionDescriptor(). getScore(solution); // Most score directors don't need derived status; CS will override this. - try (var uncorruptedScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() + try (var uncorruptedScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(environmentMode) .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) .buildDerived()) { uncorruptedScoreDirector.setWorkingSolution(solution); @@ -763,7 +764,7 @@ private void assertScoreFromScratch(InnerScore innerScore, Object comple assertionScoreDirectorFactory = scoreDirectorFactory; } // Most score directors don't need derived status; CS will override this. - try (var uncorruptedScoreDirector = assertionScoreDirectorFactory.createScoreDirectorBuilder() + try (var uncorruptedScoreDirector = assertionScoreDirectorFactory.createScoreDirectorBuilder(environmentMode) .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).buildDerived()) { uncorruptedScoreDirector.setWorkingSolution(Objects.requireNonNull(workingSolution)); var uncorruptedInnerScore = uncorruptedScoreDirector.calculateScore(); @@ -987,7 +988,7 @@ public Builder_ withExpectShadowVariablesInCorrectState(boolean expectShadowVari return (Builder_) this; } - public abstract > Director_ build(); + public abstract AbstractScoreDirector build(); /** * Optionally makes the score director a derived one; most score directors do not require this. @@ -997,7 +998,7 @@ public Builder_ withExpectShadowVariablesInCorrectState(boolean expectShadowVari * * @return this */ - public > Director_ buildDerived() { + public AbstractScoreDirector buildDerived() { return build(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java index e7d87c533c4..4b8966ee6e8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorFactory.java @@ -62,9 +62,7 @@ public ScoreDefinition getScoreDefinition() { } @Override - public , Builder_ extends AbstractScoreDirector.AbstractScoreDirectorBuilder> - AbstractScoreDirector.AbstractScoreDirectorBuilder - createScoreDirectorBuilder() { + public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder() { return createScoreDirectorBuilder(globalEnvironmentMode); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java index 409a8fd4e32..29cced5e24d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.score.Score; @@ -44,7 +46,8 @@ * so they can serve any mode and are reused as they are. * The exception is {@link BavetConstraintStreamScoreDirectorFactory}, * which builds its constraint network from the environment mode up front; - * for it, a throwaway delegate is built for the requested mode instead. + * for it, a separate delegate is built for the requested mode, + * then cached and shared like the default one, as building it is expensive. *

* On top of picking the delegate, this factory applies the parts of the configuration * which are shared by all implementations: @@ -67,6 +70,12 @@ public class DelegateScoreDirectorFactory scoreDirectorFactory; private final List metricsRequiringConstraintMatchList; private final boolean requireNewFactoryOnDifferentEnvironment; + /** + * Only holds modes other than the global one; + * the global mode's factory is {@link #scoreDirectorFactory}. + */ + private final Map> environmentModeToFactoryMap = + new ConcurrentHashMap<>(); /** * The constructor used by the solver, @@ -107,6 +116,11 @@ public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionD this.scoreDirectorFactory = internalBuildScoreDirectorFactory(solutionDescriptor, environmentMode); // Constraint Stream factory requires a new factory if the environment changes this.requireNewFactoryOnDifferentEnvironment = config.getConstraintProviderClass() != null; + if (!metricsRequiringConstraintMatchList.isEmpty() && !environmentMode.isStepAssertOrMore()) { + LOGGER.info( + "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", + metricsRequiringConstraintMatchList); + } } @Override @@ -136,20 +150,20 @@ public ScoreDirectorFactory getDelegate() { } @Override - public , Builder_ extends AbstractScoreDirectorBuilder> - AbstractScoreDirectorBuilder createScoreDirectorBuilder() { + public AbstractScoreDirectorBuilder createScoreDirectorBuilder() { return createScoreDirectorBuilder(globalEnvironmentMode); } @Override - public , Builder_ extends AbstractScoreDirectorBuilder> - AbstractScoreDirectorBuilder - createScoreDirectorBuilder(EnvironmentMode environmentMode) { + public AbstractScoreDirectorBuilder createScoreDirectorBuilder(EnvironmentMode environmentMode) { if (environmentMode != globalEnvironmentMode && requireNewFactoryOnDifferentEnvironment) { // The BavetConstraintStreamScoreDirectorFactory creates a BavetConstraintFactory based on the environment // and requires a new factory to generate a new score director with a different environment. - var newFactory = internalBuildScoreDirectorFactory(solutionDescriptor, environmentMode); - return newFactory.createScoreDirectorBuilder(); + // Building one is expensive, so each mode gets its factory built at most once and then shared, + // exactly as the global mode's factory is shared. + var factory = environmentModeToFactoryMap.computeIfAbsent(environmentMode, + mode -> internalBuildScoreDirectorFactory(solutionDescriptor, mode)); + return factory.createScoreDirectorBuilder(); } else { return scoreDirectorFactory.createScoreDirectorBuilder(environmentMode); } @@ -204,14 +218,9 @@ private static InitializingScoreTrend decideInitializingScoreTrend(S */ @Override public ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { - var isStepAssertOrMore = environmentMode.isStepAssertOrMore(); - var constraintMatchEnabled = !metricsRequiringConstraintMatchList.isEmpty() || isStepAssertOrMore; - if (constraintMatchEnabled && !isStepAssertOrMore) { - LOGGER.info( - "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", - metricsRequiringConstraintMatchList); - } - return constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED; + return !metricsRequiringConstraintMatchList.isEmpty() || environmentMode.isStepAssertOrMore() + ? ConstraintMatchPolicy.ENABLED + : ConstraintMatchPolicy.DISABLED; } private AbstractScoreDirectorFactory decideMultipleScoreDirectorFactories( diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java index 0bb5cd8d06c..c0519f27d05 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactory.java @@ -51,37 +51,29 @@ public interface ScoreDirectorFactory> { * * @param environmentMode the environment mode the resulting score director must run in */ - , Builder_ extends AbstractScoreDirectorBuilder> - AbstractScoreDirectorBuilder - createScoreDirectorBuilder(EnvironmentMode environmentMode); + AbstractScoreDirectorBuilder createScoreDirectorBuilder(EnvironmentMode environmentMode); /** * As defined by {@link #createScoreDirectorBuilder(EnvironmentMode)}, * using the environment mode this factory was built for. */ - , Builder_ extends AbstractScoreDirectorBuilder> - AbstractScoreDirectorBuilder - createScoreDirectorBuilder(); + AbstractScoreDirectorBuilder createScoreDirectorBuilder(); /** * Builds a score director for the given environment mode, * with all builder options left at their defaults. * Use {@link #createScoreDirectorBuilder(EnvironmentMode)} to customize them. */ - default , Director_ extends AbstractScoreDirector> - Director_ buildScoreDirector(EnvironmentMode environmentMode) { - AbstractScoreDirectorBuilder builder = createScoreDirectorBuilder(environmentMode); - return builder.build(); + default AbstractScoreDirector buildScoreDirector(EnvironmentMode environmentMode) { + return createScoreDirectorBuilder(environmentMode).build(); } /** * As defined by {@link #buildScoreDirector(EnvironmentMode)}, * using the environment mode this factory was built for. */ - default , Director_ extends AbstractScoreDirector> - Director_ buildScoreDirector() { - AbstractScoreDirectorBuilder builder = createScoreDirectorBuilder(); - return builder.build(); + default AbstractScoreDirector buildScoreDirector() { + return createScoreDirectorBuilder().build(); } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java index ab9d4b89923..e92a114a56b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java @@ -96,7 +96,6 @@ public Builder withEasyScoreCalculator(EasyScoreCalculator build() { return new EasyScoreDirector<>(this); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java index 23d21047511..5feefcfe54f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -281,7 +281,6 @@ public Builder withConstraintMatchPolicy(ConstraintMatchPolic return super.withConstraintMatchPolicy(determineCorrectPolicy(constraintMatchPolicy, incrementalScoreCalculator)); } - @SuppressWarnings("unchecked") @Override public IncrementalScoreDirector build() { return new IncrementalScoreDirector<>(this); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java index 32a983f3e1b..5cc42d3e1d1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java @@ -221,13 +221,11 @@ public Builder(BavetConstraintStreamScoreDirectorFactory scor super(scoreDirectorFactory, environmentMode); } - @SuppressWarnings("unchecked") @Override public BavetConstraintStreamScoreDirector build() { return new BavetConstraintStreamScoreDirector<>(this, false); } - @SuppressWarnings("unchecked") @Override public BavetConstraintStreamScoreDirector buildDerived() { return new BavetConstraintStreamScoreDirector<>(this, true); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorSemanticsTest.java index 68ceff4c2e8..e348a5223a6 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirectorSemanticsTest.java @@ -37,6 +37,18 @@ public abstract class AbstractScoreDirectorSemanticsTest { buildScoreDirectorFactoryWithListVariablePinIndex( SolutionDescriptor solutionDescriptor); + @Test + void scoreDirectorFactoriesUseTheGivenSolutionDescriptor() { + // Building a SolutionDescriptor is expensive, and the caller's instance is the one the tests work with, + // so an implementation must use the one it is given rather than building its own. + assertThat(buildScoreDirectorFactoryWithConstraintConfiguration(constraintConfigurationSolutionDescriptor) + .getSolutionDescriptor()).isSameAs(constraintConfigurationSolutionDescriptor); + assertThat(buildScoreDirectorFactoryWithListVariableEntityPin(pinnedListSolutionDescriptor) + .getSolutionDescriptor()).isSameAs(pinnedListSolutionDescriptor); + assertThat(buildScoreDirectorFactoryWithListVariablePinIndex(pinnedWithIndexListSolutionDescriptor) + .getSolutionDescriptor()).isSameAs(pinnedWithIndexListSolutionDescriptor); + } + @Test void independentScoreDirectors() { var scoreDirectorFactory = diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java index d4c979b264a..0a26752ae3b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java @@ -20,12 +20,15 @@ import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; import ai.timefold.solver.core.testconstraint.DummyConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; class DelegateScoreDirectorFactoryTest { @@ -158,6 +161,23 @@ void otherEnvironmentModeRebuildsConstraintStreamDelegate() { } } + @Test + void constraintStreamDelegateForOtherEnvironmentModeIsBuiltOnceAndShared() { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); + // Building a constraint stream factory rebuilds the whole constraint network, so it must not happen per request. + try (var first = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT); + var second = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + assertThat(second.getScoreDirectorFactory()).isSameAs(first.getScoreDirectorFactory()); + } + // A different mode still gets its own factory. + try (var stepAssert = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.STEP_ASSERT); + var fullAssert = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + assertThat(stepAssert.getScoreDirectorFactory()) + .isNotSameAs(fullAssert.getScoreDirectorFactory()) + .isNotSameAs(scoreDirectorFactory.getDelegate()); + } + } + @Test void globalEnvironmentModeReusesConstraintStreamDelegate() { var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); @@ -166,6 +186,34 @@ void globalEnvironmentModeReusesConstraintStreamDelegate() { } } + @ParameterizedTest + @EnumSource(ChildThreadType.class) + void childThreadScoreDirectorKeepsTheRequestedEnvironmentMode(ChildThreadType childThreadType) { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(incrementalConfig(), EnvironmentMode.PHASE_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + scoreDirector.setWorkingSolution(TestdataSolution.generateSolution()); + // The child thread runs the phase's assertions, not the solver's laxer default; + // the factory's own mode is still PHASE_ASSERT, as the delegate was reused. + try (var childScoreDirector = (AbstractScoreDirector) scoreDirector + .createChildThreadScoreDirector(childThreadType)) { + assertThat(childScoreDirector.environmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + } + } + + @ParameterizedTest + @EnumSource(ChildThreadType.class) + void constraintStreamChildThreadScoreDirectorKeepsTheRequestedEnvironmentMode(ChildThreadType childThreadType) { + var scoreDirectorFactory = buildTestdataScoreDirectorFactory(constraintStreamConfig(), EnvironmentMode.PHASE_ASSERT); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.FULL_ASSERT)) { + scoreDirector.setWorkingSolution(TestdataSolution.generateSolution()); + try (var childScoreDirector = (AbstractScoreDirector) scoreDirector + .createChildThreadScoreDirector(childThreadType)) { + assertThat(childScoreDirector.environmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + } + } + // ************************************************************************ // Constraint match policy // ************************************************************************ @@ -234,8 +282,7 @@ void incrementalScoreCalculatorWithCustomProperties() { var scoreDirectorFactory = (IncrementalScoreDirectorFactory) buildTestdataScoreDirectorFactory(config) .getDelegate(); - try (IncrementalScoreDirector scoreDirector = - scoreDirectorFactory.buildScoreDirector()) { + try (var scoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(EnvironmentMode.PHASE_ASSERT).build()) { var scoreCalculator = (TestCustomPropertiesIncrementalScoreCalculator) scoreDirector.getIncrementalScoreCalculator(); assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); @@ -272,9 +319,11 @@ void buildWithAssertionScoreDirectorFactory() { // The assertion factory is the delegate of its own DelegateScoreDirectorFactory, // as the code reading it expects a concrete factory. assertThat(assertionScoreDirectorFactory).isExactlyInstanceOf(IncrementalScoreDirectorFactory.class); - try (IncrementalScoreDirector assertionScoreDirector = - ((IncrementalScoreDirectorFactory) assertionScoreDirectorFactory) - .buildScoreDirector()) { + var incrementalAssertionFactory = + (IncrementalScoreDirectorFactory) assertionScoreDirectorFactory; + // Built through the factory default, as the point is which mode the factory itself carries. + try (var assertionScoreDirector = + (IncrementalScoreDirector) incrementalAssertionFactory.buildScoreDirector()) { // The assertion score director always runs in NON_REPRODUCIBLE, regardless of the requested mode. assertThat(assertionScoreDirector.environmentMode).isEqualTo(EnvironmentMode.NON_REPRODUCIBLE); var assertionScoreCalculator = assertionScoreDirector.getIncrementalScoreCalculator(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java index 61d51c776bd..2f10940c7ad 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirectorSemanticsTest.java @@ -31,8 +31,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataConstraintWeightOverridesEasyScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -41,8 +41,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedListEasyScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -51,8 +51,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataPinnedWithIndexListEasyScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Test @@ -65,8 +65,8 @@ void easyScoreCalculatorWithCustomProperties() { config.setEasyScoreCalculatorCustomProperties(customProperties); var testdataSolutionScoreDirectorFactory = buildTestdataScoreDirectoryFactory(config); - try (EasyScoreDirector scoreDirector = - testdataSolutionScoreDirectorFactory.buildScoreDirector()) { + try (var scoreDirector = (EasyScoreDirector) testdataSolutionScoreDirectorFactory + .buildScoreDirector()) { var scoreCalculator = (TestCustomPropertiesEasyScoreCalculator) scoreDirector.getEasyScoreCalculator(); assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); assertThat(scoreCalculator.getIntProperty()).isEqualTo(7); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java index df1acde04ce..8b902b8727e 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java @@ -33,8 +33,8 @@ final class IncrementalScoreDirectorSemanticsTest extends AbstractScoreDirectorS SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataConstraintWeightOverridesIncrementalScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -42,8 +42,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedListIncrementalScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -52,8 +52,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedWithIndexListIncrementalScoreCalculator.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @NullMarked diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java index cedfa15001c..79a36e62856 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/stream/ConstraintStreamsBavetScoreDirectorSemanticsTest.java @@ -22,8 +22,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataConstraintWeightOverridesConstraintProvider.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataConstraintWeightOverridesSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -32,8 +32,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedListConstraintProvider.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -42,8 +42,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataPinnedWithIndexListConstraintProvider.class); - return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, - TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java index c8afc141ea2..58373941eb7 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java @@ -194,7 +194,7 @@ void assertEnvironmentWithNonReproducibleAndMismatchingPhase() { solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NO_ASSERT); assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) - .hasMessageContaining("must also be non-reproducible"); + .hasMessageContaining("is only possible when global environmentMode is reproducible"); } @Test @@ -204,7 +204,7 @@ void assertEnvironmentModeWithDefaultNotUsedByAnyPhase() { solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NON_INTRUSIVE_FULL_ASSERT); assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) - .hasMessageContaining("is not used in any of the defined phases environment modes"); + .hasMessageContaining("but none of the phase environment modes are using it"); } @Test @@ -215,7 +215,7 @@ void assertEnvironmentModeWithPhaseLessStrictThanDefault() { solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NO_ASSERT); assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) .hasMessageContaining( - "must have an assertion level higher than or equal to the default environment level"); + "must have an assertion level higher than or equal to the global environment level"); } } From 11cdceb1594280dba7c73828696f3d2b2480f115 Mon Sep 17 00:00:00 2001 From: Fred Date: Tue, 25 Aug 2026 15:46:13 -0300 Subject: [PATCH 17/20] chore: improve solver state management --- .../variable/ShadowVariableUpdateHelper.java | 9 +- .../score/director/AbstractScoreDirector.java | 5 + .../DelegateScoreDirectorFactory.java | 7 +- .../score/director/InnerScoreDirector.java | 10 + .../core/impl/solver/AbstractSolver.java | 88 ++------- .../core/impl/solver/DefaultSolver.java | 7 +- .../impl/solver/DefaultSolverFactory.java | 52 ++--- .../impl/solver/SolverContextManager.java | 184 ++++++++++++++++++ .../core/impl/solver/DefaultSolverTest.java | 116 +++++++++-- .../impl/solver/SolverContextManagerTest.java | 177 +++++++++++++++++ .../TimefoldProcessorFailedSolveTest.java | 85 ++++++++ ...tdataQuarkusFailingConstraintProvider.java | 35 ++++ 12 files changed, 641 insertions(+), 134 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java create mode 100644 quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorFailedSolveTest.java create mode 100644 quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/failing/TestdataQuarkusFailingConstraintProvider.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java index 1682c5caae6..4e724f81446 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java @@ -278,16 +278,15 @@ public InternalScoreDirectorFactory(SolutionDescriptor solutionDescri super(solutionDescriptor, globalEnvironmentMode); } + /** + * Score directors are built directly through {@link InternalScoreDirector.Builder}, + * never through this factory; the inherited no-arg variant funnels into this one. + */ @Override public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder(EnvironmentMode environmentMode) { throw new UnsupportedOperationException(); } - - @Override - public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder() { - throw new UnsupportedOperationException(); - } } @NullMarked diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 7d305f8c252..68b391e2ecf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -168,6 +168,11 @@ public VariableDescriptorCache getVariableDescriptorCache() { return variableDescriptorCache; } + @Override + public EnvironmentMode getEnvironmentMode() { + return environmentMode; + } + @Override @SuppressWarnings("unchecked") public ListVariableStateSupply diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java index 29cced5e24d..e5b589be22e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java @@ -108,8 +108,8 @@ public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionD */ public DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode, List metricsRequiringConstraintMatchList) { - this.config = config; - assertCorrectDirectorFactory(Objects.requireNonNull(config)); + this.config = Objects.requireNonNull(config); + assertCorrectDirectorFactory(config); this.solutionDescriptor = solutionDescriptor; this.globalEnvironmentMode = environmentMode; this.metricsRequiringConstraintMatchList = metricsRequiringConstraintMatchList; @@ -213,8 +213,7 @@ private static InitializingScoreTrend decideInitializingScoreTrend(S /** * Unlike the default implementation, - * this also enables constraint matching when a metric requires it, - * logging that fact as it costs performance. + * this also enables constraint matching when a metric requires it. */ @Override public ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index 1a318fd29af..4d65531f15e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.api.score.stream.Constraint; import ai.timefold.solver.core.api.score.stream.ConstraintRef; import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; @@ -183,6 +184,15 @@ default InnerScore executeTemporaryMove(Move move, boolean as */ ScoreDefinition getScoreDefinition(); + /** + * The environment mode this score director was built for, + * which decides which assertions it runs. + * It is not necessarily the solver's global environment mode: + * a phase may override it, + * in which case that phase's score director reports the phase's mode. + */ + EnvironmentMode getEnvironmentMode(); + /** * Returns a planning clone of the solution, * which is not a shallow clone nor a deep clone nor a partition clone. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 0a1b42a30ad..efebd0df340 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -15,9 +15,7 @@ import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleSupport; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; -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.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; import ai.timefold.solver.core.impl.solver.random.DefaultRandomSource; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; @@ -44,7 +42,7 @@ public abstract class AbstractSolver implements Solver { protected final transient Logger LOGGER = LoggerFactory.getLogger(getClass()); - protected final SolverContext defaultSolverContext; + protected final EnvironmentMode globalEnvironmentMode; private final ScoreDirectorFactory scoreDirectorFactory; private final SolverEventSupport solverEventSupport = new SolverEventSupport<>(this); private final PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); @@ -56,23 +54,23 @@ public abstract class AbstractSolver implements Solver { protected final List> phaseList; private RandomGenerator.@Nullable SplittableGenerator savedRandom; - private SolverContext currentContext; + + private final SolverContextManager solverContextManager; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - protected AbstractSolver(SolverContext defaultSolverContext, - ScoreDirectorFactory scoreDirectorFactory, - BestSolutionRecaller bestSolutionRecaller, - UniversalTermination globalTermination, List> phaseList) { + protected AbstractSolver(EnvironmentMode globalEnvironmentMode, ScoreDirectorFactory scoreDirectorFactory, + BestSolutionRecaller bestSolutionRecaller, UniversalTermination globalTermination, + List> phaseList) { + this.globalEnvironmentMode = globalEnvironmentMode; this.scoreDirectorFactory = scoreDirectorFactory; this.bestSolutionRecaller = bestSolutionRecaller; this.globalTermination = globalTermination; bestSolutionRecaller.setSolverEventSupport(solverEventSupport); this.phaseList = List.copyOf(phaseList); - this.defaultSolverContext = defaultSolverContext; - this.currentContext = defaultSolverContext; + this.solverContextManager = new SolverContextManager<>(scoreDirectorFactory, bestSolutionRecaller, phaseList); } public void solvingStarted(SolverScope solverScope) { @@ -88,6 +86,7 @@ public void solvingStarted(SolverScope solverScope) { for (Phase phase : phaseList) { phase.solvingStarted(solverScope); } + solverContextManager.solvingStarted(solverScope); } protected void runPhases(SolverScope solverScope) { @@ -99,7 +98,6 @@ protected void runPhases(SolverScope solverScope) { Iterator> it = phaseList.iterator(); while (!globalTermination.isSolverTerminated(solverScope) && it.hasNext()) { Phase phase = it.next(); - preparePhase(solverScope, phase); phase.solve(solverScope); // If there is a next phase, it starts from the best solution, which might differ from the working solution. // If there isn't, no need to planning clone the best solution to the working solution. @@ -109,46 +107,6 @@ protected void runPhases(SolverScope solverScope) { } } - @SuppressWarnings({ "rawtypes", "unchecked" }) - private void preparePhase(SolverScope solverScope, Phase phase) { - // The environment modes match, and there is no need for any changes. - if (phase.getEnvironmentMode() == currentContext.environmentMode()) { - return; - } - // The phase environment mode matches default, so we will restore it. - if (phase.getEnvironmentMode() == defaultSolverContext.environmentMode()) { - // Update and load the default context - loadContext(currentContext, defaultSolverContext, solverScope); - return; - } - // Since the current logic does not cache any solver context other than the default, - // we need to create a new solver context - // because the required environment mode differs from both the current and the default modes. - var newScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(phase.getEnvironmentMode()) - .withLookUpEnabled(true) - .withConstraintMatchPolicy(scoreDirectorFactory.decideConstraintMatchPolicy(phase.getEnvironmentMode())) - .build(); - var newSolverContext = new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, - new DefaultProblemChangeDirector<>(newScoreDirector)); - loadContext(currentContext, newSolverContext, solverScope); - } - - private void loadContext(SolverContext oldSolverContext, SolverContext newSolverContext, - SolverScope solverScope) { - solverScope.setScoreDirector(newSolverContext.scoreDirector()); - solverScope.setProblemChangeDirector(newSolverContext.problemChangeDirector()); - // We will use the same working solution set from the previous phase, as it has already been cloned - newSolverContext.scoreDirector().setWorkingSolution(oldSolverContext.scoreDirector().getWorkingSolution()); - bestSolutionRecaller.enableAssertions(newSolverContext.environmentMode()); - // Ensure that the score calculation count is consistent for the new director - newSolverContext.scoreDirector().resetCalculationCount(); - newSolverContext.scoreDirector().incrementCalculationCount(oldSolverContext.scoreDirector().getCalculationCount()); - if (oldSolverContext != defaultSolverContext) { - oldSolverContext.release(); - } - currentContext = newSolverContext; - } - public void solvingEnded(SolverScope solverScope) { for (Phase phase : phaseList) { phase.solvingEnded(solverScope); @@ -156,26 +114,19 @@ public void solvingEnded(SolverScope solverScope) { bestSolutionRecaller.solvingEnded(solverScope); globalTermination.solvingEnded(solverScope); phaseLifecycleSupport.fireSolvingEnded(solverScope); - if (currentContext != defaultSolverContext) { - // Restore the default context - // so solverScope operate on the original score director - loadContext(currentContext, defaultSolverContext, solverScope); - } } public void solvingError(SolverScope solverScope, Exception exception) { + // Notify first, so listeners still observe the score director in the state the failure left it in. phaseLifecycleSupport.fireSolvingError(solverScope, exception); for (Phase phase : phaseList) { phase.solvingError(solverScope, exception); } - if (currentContext != defaultSolverContext) { - // A phase may have failed while operating under a non-default environment mode, - // and we need to restore the default context - loadContext(currentContext, defaultSolverContext, solverScope); - } + solverContextManager.solvingError(solverScope, exception); } public void phaseStarted(AbstractPhaseScope phaseScope) { + solverContextManager.phaseStarted(phaseScope); bestSolutionRecaller.phaseStarted(phaseScope); phaseLifecycleSupport.firePhaseStarted(phaseScope); globalTermination.phaseStarted(phaseScope); @@ -255,19 +206,4 @@ public > ScoreDirectorFactory ge public List> getPhaseList() { return phaseList; } - - public record SolverContext>(EnvironmentMode environmentMode, - InnerScoreDirector scoreDirector, - DefaultProblemChangeDirector problemChangeDirector) { - - public static > SolverContext - of(EnvironmentMode environmentMode, SolverScope solverScope) { - return new SolverContext<>(environmentMode, solverScope. getScoreDirector(), - solverScope.getProblemChangeDirector()); - } - - void release() { - scoreDirector.close(); - } - } } 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 f05e88b2775..8679052f669 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 @@ -53,8 +53,7 @@ public DefaultSolver(EnvironmentMode globalEnvironmentMode, ScoreDirectorFactory Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { - super(SolverContext.of(globalEnvironmentMode, solverScope), scoreDirectorFactory, bestSolutionRecaller, termination, - phaseList); + super(globalEnvironmentMode, scoreDirectorFactory, bestSolutionRecaller, termination, phaseList); this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; this.solverScope = solverScope; @@ -202,7 +201,7 @@ public void solvingStarted(SolverScope solverScope) { (startingSolverCount == 1 ? "started" : "restarted"), solverScope.calculateTimeMillisSpentUpToNow(), solverScope.getBestScore().raw(), - defaultSolverContext.environmentMode().name(), + globalEnvironmentMode.name(), moveThreadCountDescription, randomFactory); if (LOGGER.isInfoEnabled()) { // Formatting is expensive here. @@ -310,7 +309,7 @@ public void outerSolvingEnded(SolverScope solverScope) { solverScope.getBestScore().raw(), solverScope.getMoveEvaluationSpeed(), phaseList.size(), - defaultSolverContext.environmentMode().name(), + globalEnvironmentMode.name(), moveThreadCountDescription); // Must be kept open for doProblemFactChange solverScope.getScoreDirector().close(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 2eeae6bd281..2995c7b7a72 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -58,13 +58,13 @@ * and owns the state which is expensive to build and therefore shared by every solver it builds: * the {@link SolutionDescriptor} and a single {@link DelegateScoreDirectorFactory}. *

- * The solver config has one environment mode, the default one, + * The solver config has one environment mode, the global one, * and each of its phases may override it with a stricter one. - * The score director factory is built once for the default environment mode; + * The score director factory is built once for the global environment mode; * a phase which overrides the mode asks the delegate for a score director in its own mode instead, * which the delegate serves without this factory having to keep one instance per mode. *

- * That is also why a default environment mode has to exist at all, + * That is also why a global environment mode has to exist at all, * even for a config whose phases all override it. * Some components depend on the score director factory * while being decoupled from the solving life cycle, @@ -72,12 +72,12 @@ * {@link SolverManager} and the integrations * ({@code TimefoldSolverBeanFactory} injecting a {@link ConstraintMetaModel}, for instance) * are such components. - * They all get the default environment mode. + * They all get the global environment mode. *

* {@link #assertEnvironmentModeConfiguration(SolverConfig)} guards the invariants this relies on: - * no phase may be less strict than the default mode, - * at least one phase must actually use the default mode, - * and a non-reproducible default mode forces every phase to be non-reproducible as well. + * no phase may be less strict than the global mode, + * at least one phase must actually use the global mode, + * and a non-reproducible global mode admits no phase-level override at all. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @see SolverFactory @@ -284,54 +284,54 @@ public void ensurePreviewFeature(PreviewFeature previewFeature) { } private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig solverConfig) { - var defaultEnvironmentMode = solverConfig.determineEnvironmentMode(); + var globalEnvironmentMode = solverConfig.determineEnvironmentMode(); var phaseConfigList = solverConfig.getPhaseConfigList(); if (ConfigUtils.isEmptyCollection(phaseConfigList)) { - return defaultEnvironmentMode; + return globalEnvironmentMode; } var phaseEnvironmentList = phaseConfigList.stream() .map(phaseConfig -> Objects.requireNonNullElse(phaseConfig.getEnvironmentMode(), - defaultEnvironmentMode)) + globalEnvironmentMode)) .toList(); - if (defaultEnvironmentMode == EnvironmentMode.NON_REPRODUCIBLE - && phaseEnvironmentList.stream().anyMatch(environmentMode -> environmentMode != defaultEnvironmentMode)) { - // If the default environment is non-reproducible, - // then all phase environment modes must also be non-reproducible + if (globalEnvironmentMode == EnvironmentMode.NON_REPRODUCIBLE + && phaseEnvironmentList.stream().anyMatch(environmentMode -> environmentMode != globalEnvironmentMode)) { + // A non-reproducible global environment mode cannot be overridden per phase, + // as a phase-level override would have nothing reproducible to be an override of. throw new IllegalStateException( "Phase-level environmentMode override is only possible when global environmentMode is reproducible, but was %s." - .formatted(defaultEnvironmentMode.name())); + .formatted(globalEnvironmentMode.name())); } - // If none of the phase environments use the default environment, we fail fast. - var checkDefaultEnvironment = phaseEnvironmentList.isEmpty(); + // If none of the phase environments use the global environment, we fail fast. + var checkGlobalEnvironment = phaseEnvironmentList.isEmpty(); for (var phaseEnvironment : phaseEnvironmentList) { - if (phaseEnvironment == defaultEnvironmentMode) { - checkDefaultEnvironment = true; + if (phaseEnvironment == globalEnvironmentMode) { + checkGlobalEnvironment = true; break; } } - if (!checkDefaultEnvironment) { + if (!checkGlobalEnvironment) { throw new IllegalStateException(""" The global environment mode is %s, but none of the phase environment modes are using it [%s]. Maybe adjust at least one of the phase environment modes to match the global environmentMode (%s)""" .formatted( - defaultEnvironmentMode.name(), + globalEnvironmentMode.name(), String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()), - defaultEnvironmentMode.name())); + globalEnvironmentMode.name())); } var invalidPhaseEnvironmentList = new ArrayList(phaseConfigList.size()); for (var phaseEnvironment : phaseEnvironmentList) { - if (phaseEnvironment.ordinal() > defaultEnvironmentMode.ordinal()) { + if (phaseEnvironment.ordinal() > globalEnvironmentMode.ordinal()) { invalidPhaseEnvironmentList.add(phaseEnvironment.name()); } } if (!invalidPhaseEnvironmentList.isEmpty()) { - // The phase environments must have an assertion level greater than or equal to the default environment level + // The phase environments must have an assertion level greater than or equal to the global environment level throw new IllegalStateException( "The phase environments must have an assertion level higher than or equal to the global environment level (%s). The following phase environment modes are not valid: [%s]." - .formatted(defaultEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); + .formatted(globalEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); } - return defaultEnvironmentMode; + return globalEnvironmentMode; } // Required for testability as final classes cannot be mocked. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java new file mode 100644 index 00000000000..d10789ee833 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java @@ -0,0 +1,184 @@ +package ai.timefold.solver.core.impl.solver; + +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.SolverManager; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; +import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Owns the {@link InnerScoreDirector} the solver is currently working with, + * and swaps it whenever a {@link Phase} requires an {@link EnvironmentMode} other than the one in use. + *

+ * A score director is built for exactly one environment mode, + * because the mode decides which assertions it runs and how much bookkeeping it keeps. + * The solver config has a global mode and each phase may override it with a stricter one, + * so a solve may need more than one score director. + * Rather than every caller reasoning about that, all of it lives here: + * {@link #phaseStarted(AbstractPhaseScope)} compares the phase's mode to the current one, + * and only when they differ does it build a replacement, hand the working solution over, + * and close the one being replaced. + *

+ * Score directors are not cached; a phase that switches back to a mode used earlier gets a fresh instance. + * Caching happens one level down, in the {@link ScoreDirectorFactory}, + * which keeps at most one factory per environment mode — that is the expensive part to build, + * whereas a score director on top of an existing factory is cheap. + * A consequence is that the solver ends holding the *last* phase's score director, + * not the one it started with. + *

+ * Everything a swap has to keep continuous lives in {@link #loadContext}: + * the working solution, the score calculation count, the {@link SolverScope}'s view of both directors, + * and the assertion level of the {@link BestSolutionRecaller}. + *

+ * Ownership of closing. This class closes only the score directors it replaces. + * The last one standing is closed by the solver + * ({@code DefaultSolver.outerSolvingEnded}) on the normal path. + * On the failure path {@code outerSolvingEnded} never runs, + * so {@link #solvingError(SolverScope, Exception)} closes it instead. + *

+ * Not thread-safe, and does not need to be: one instance belongs to one solver, + * and every life-cycle method is called on that solver's own thread. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the score type to go with the solution + */ +@NullMarked +public class SolverContextManager> { + + private final ScoreDirectorFactory scoreDirectorFactory; + private final BestSolutionRecaller bestSolutionRecaller; + private final List> phaseList; + + @Nullable + private SolverContext currentContext; + + public SolverContextManager(ScoreDirectorFactory scoreDirectorFactory, + BestSolutionRecaller bestSolutionRecaller, List> phaseList) { + this.scoreDirectorFactory = scoreDirectorFactory; + this.bestSolutionRecaller = bestSolutionRecaller; + this.phaseList = phaseList; + } + + // ************************************************************************ + // Life-cycle methods + // ************************************************************************ + + /** + * Adopts the score director the solver was built with as the starting context. + * Must run before any phase starts. + */ + public void solvingStarted(SolverScope solverScope) { + this.currentContext = SolverContext.of(solverScope); + } + + /** + * Swaps in a score director for the phase's {@link EnvironmentMode} if it differs from the one in use, + * and closes the one being replaced. + * Does nothing when the modes already match, which is the common case. + *

+ * Runs before the phase's own listeners are notified, + * so that anything binding to the score director at phase start — list variable selectors, for instance — + * binds to the director the phase will actually run on. + */ + public void phaseStarted(AbstractPhaseScope phaseScope) { + var newSolverContext = contextFor(scoreDirectorFactory, phaseList.get(phaseScope.getPhaseIndex()), + Objects.requireNonNull(currentContext, "Impossible state: solvingStarted() has not run yet.")); + if (newSolverContext != currentContext) { + loadContext(phaseScope.getSolverScope(), bestSolutionRecaller, currentContext, newSolverContext); + currentContext.release(); + currentContext = newSolverContext; + } + } + + /** + * Closes the score director in use, as the solver's normal cleanup does not run on the failure path. + *

+ * Solving can fail before {@link #solvingStarted(SolverScope)} has adopted a context — for instance in + * {@code DefaultSolver.assertCorrectSolutionState()}, or in any listener notified earlier in solving start. + * The score director the solver was built with still has to be closed in that case, + * or a long-lived {@link SolverManager} would accumulate one per failed job. + * Whatever happens here must not throw: the caller is on its way to rethrowing the real failure. + */ + public void solvingError(SolverScope solverScope, Exception exception) { + if (currentContext != null) { + currentContext.release(); + } else { + solverScope.getScoreDirector().close(); + } + } + + // ************************************************************************ + // Utility methods + // ************************************************************************ + + /** + * @return the given context when the phase can run on it, otherwise a new one for the phase's environment mode + */ + private static > SolverContext contextFor( + ScoreDirectorFactory scoreDirectorFactory, Phase phase, + SolverContext context) { + // The environment modes match, and there is no need for any changes. + if (phase.getEnvironmentMode() == context.environmentMode()) { + return context; + } + // The modes differ, so the phase needs its own score director. + // Solver contexts are deliberately not cached; the score director factory caches per mode instead, + // and building a score director on top of an existing factory is cheap. + var newScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(phase.getEnvironmentMode()) + .withLookUpEnabled(true) + .withConstraintMatchPolicy(scoreDirectorFactory.decideConstraintMatchPolicy(phase.getEnvironmentMode())) + .build(); + var newProblemChangeDirector = new DefaultProblemChangeDirector<>(newScoreDirector); + return new SolverContext<>(phase.getEnvironmentMode(), newScoreDirector, newProblemChangeDirector); + } + + /** + * Hands everything a swap must keep continuous from the outgoing score director to the incoming one: + * the working solution, the running score calculation count, + * the {@link SolverScope}'s view of both directors, and the recaller's assertion level. + * Does not close the outgoing director; the caller does that once the hand-over is complete. + */ + private static > void loadContext(SolverScope solverScope, + BestSolutionRecaller bestSolutionRecaller, SolverContext oldSolverContext, + SolverContext newSolverContext) { + solverScope.setScoreDirector(newSolverContext.scoreDirector()); + solverScope.setProblemChangeDirector(newSolverContext.problemChangeDirector()); + // We will use the same working solution set from the previous phase, as it has already been cloned + newSolverContext.scoreDirector().setWorkingSolution(oldSolverContext.scoreDirector().getWorkingSolution()); + bestSolutionRecaller.enableAssertions(newSolverContext.environmentMode()); + // Ensure that the score calculation count is consistent for the new director + newSolverContext.scoreDirector().resetCalculationCount(); + newSolverContext.scoreDirector().incrementCalculationCount(oldSolverContext.scoreDirector().getCalculationCount()); + } + + /** + * The score director the solver is working with, plus what is bound to it. + * Immutable: a change of environment mode produces a new instance rather than mutating this one. + */ + private record SolverContext>(EnvironmentMode environmentMode, + InnerScoreDirector scoreDirector, + DefaultProblemChangeDirector problemChangeDirector) { + + public static > SolverContext + of(SolverScope solverScope) { + return new SolverContext<>(solverScope. getScoreDirector().getEnvironmentMode(), + solverScope. getScoreDirector(), solverScope.getProblemChangeDirector()); + } + + void release() { + scoreDirector.close(); + } + } +} 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 10eb745cf89..109289d30ac 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 @@ -16,6 +16,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.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.LongAdder; @@ -577,43 +578,70 @@ void solveWithProblemChange() throws InterruptedException { } @Test - void solvingEndedRestoresDefaultContext() { + void replacedScoreDirectorIsClosedWhenAPhaseOverridesTheEnvironmentMode() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); - // LS (the last phase) overridden to a different EnvironmentMode than the default, forcing - // AbstractSolver.preparePhase() to swap in a non-default context for it. + // LS (the last phase) overridden to a stricter EnvironmentMode than the global one, + // so SolverContextManager has to swap in a score director for it. solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); SolverFactory solverFactory = SolverFactory.create(solverConfig); var solver = (AbstractSolver) solverFactory.buildSolver(); - var problem = TestdataSolution.generateSolution(2, 2); - solver.solve(problem); - assertThat(solver.defaultSolverContext.scoreDirector().getWorkingSolution()).isNull(); + + var scoreDirectorPerPhase = new ArrayList>(); + var replacedDirectorWasClosedOnSwap = new AtomicBoolean(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + // The swap already happened; solver-level listeners run after SolverContextManager. + if (!scoreDirectorPerPhase.isEmpty()) { + // close() clears the working solution, which is the observable proof of the release. + replacedDirectorWasClosedOnSwap.set(scoreDirectorPerPhase.get(0).getWorkingSolution() == null); + } + scoreDirectorPerPhase.add(phaseScope.getScoreDirector()); + } + }); + solver.solve(TestdataSolution.generateSolution(2, 2)); + + assertThat(scoreDirectorPerPhase).hasSize(2); + assertThat(scoreDirectorPerPhase.get(1)).isNotSameAs(scoreDirectorPerPhase.get(0)); + assertThat(replacedDirectorWasClosedOnSwap).isTrue(); } @Test void ensureScoreCalculationCountConsistent() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); - // LS (the last phase) overridden to a different EnvironmentMode than the default, forcing - // AbstractSolver.preparePhase() to swap to a non-default context for it, and solvingEnded() to - // restore the (already-populated, since CH ran on it first) default context afterward. + // LS (the last phase) overridden to a stricter EnvironmentMode than the global one, + // so SolverContextManager swaps in a fresh score director between the two phases. solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); SolverFactory solverFactory = SolverFactory.create(solverConfig); var solver = (AbstractSolver) solverFactory.buildSolver(); var problem = TestdataSolution.generateSolution(2, 2); - // Capture the score calculation count after the CH phase and - var calculationCountBeforeRestore = new AtomicLong(-1); + var countAfterFirstPhase = new AtomicLong(-1); + var countAtSecondPhaseStart = new AtomicLong(-1); solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { @Override - public void solvingEnded(SolverScope solverScope) { - calculationCountBeforeRestore.set(solverScope.getScoreDirector().getCalculationCount()); + public void phaseStarted(AbstractPhaseScope phaseScope) { + if (phaseScope.getPhaseIndex() == 1) { + countAtSecondPhaseStart.set(phaseScope.getScoreDirector().getCalculationCount()); + } + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + if (phaseScope.getPhaseIndex() == 0) { + countAfterFirstPhase.set(phaseScope.getScoreDirector().getCalculationCount()); + } } }); solver.solve(problem); - // After solvingEnded() restores defaultSolverContext, its calculation count must equal exactly - // the true running total captured above - assertThat(solver.defaultSolverContext.scoreDirector().getCalculationCount()) - .isEqualTo(calculationCountBeforeRestore.get()); + // Terminations count score calculations across the whole solve, + // so the swapped-in score director must continue the running total rather than restart it at zero. + // Not an exact match: runPhases() calls setWorkingSolutionFromBestSolution() between the two phases, + // which scores once more on the outgoing director before the swap copies the total over. + // SolverContextManagerTest pins the exact hand-over. + assertThat(countAfterFirstPhase).hasPositiveValue(); + assertThat(countAtSecondPhaseStart.get()).isGreaterThanOrEqualTo(countAfterFirstPhase.get()); } @Test @@ -2082,7 +2110,57 @@ void failLocalSearchValueRangeAssertion() { } @Test - void solvingErrorRestoresDefaultContextWhenPhaseFails() { + void solvingErrorClosesScoreDirectorWhenSolvingStartedFails() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + var solver = (DefaultSolver) SolverFactory. create(solverConfig).buildSolver(); + // The score director the solver was built with; solving fails before any phase adopts a context for it. + var scoreDirector = solver.getSolverScope().getScoreDirector(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + public void solvingStarted(SolverScope solverScope) { + throw new IllegalStateException("Boom from a listener"); + } + }); + + // The caller must see the real failure, not a follow-up failure from the solver's own cleanup. + assertThatCode(() -> solver.solve(TestdataSolution.generateSolution(2, 2))) + .hasMessageContaining("Boom from a listener"); + + // Otherwise a long-lived SolverManager accumulates one unclosed score director per failed job. + assertThat(scoreDirector.getWorkingSolution()).isNull(); + } + + @Test + void solvingErrorNotifiesListenersBeforeClosingTheScoreDirector() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataListSolution.class, TestdataListEntity.class, + TestdataListValue.class); + var localSearchPhaseConfig = new LocalSearchPhaseConfig(); + localSearchPhaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // Force invalid move factory + localSearchPhaseConfig.setMoveSelectorConfig( + new MoveIteratorFactoryConfig().withMoveIteratorFactoryClass(InvalidMoveListFactory.class)); + solverConfig.setPhaseConfigList(List.of(new ConstructionHeuristicPhaseConfig(), localSearchPhaseConfig)); + + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = (AbstractSolver) solverFactory.buildSolver(); + var workingSolutionSeenByListener = new AtomicReference(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + public void solvingError(SolverScope solverScope, Exception exception) { + workingSolutionSeenByListener.set(solverScope.getScoreDirector().getWorkingSolution()); + } + }); + + assertThatCode(() -> solver.solve(TestdataListSolution.generateUninitializedSolution(2, 2))) + .hasMessageContaining("The value (bad value) from the planning variable (valueList)"); + + // Closing clears the working solution, so releasing before notifying would hand listeners a gutted + // score director exactly when they are trying to diagnose the failure. + assertThat(workingSolutionSeenByListener.get()).isNotNull(); + } + + @Test + void solvingErrorClosesScoreDirectorWhenPhaseFails() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataListSolution.class, TestdataListEntity.class, TestdataListValue.class); var localSearchPhaseConfig = new LocalSearchPhaseConfig(); @@ -2512,7 +2590,7 @@ void ensureListVariableStateIsReleased() { var solver = (AbstractSolver) solverFactory.buildSolver(); var problem = TestdataListSolution.generateUninitializedSolution(10, 4); - var listVariableDescriptor = solver.defaultSolverContext.scoreDirector().getSolutionDescriptor() + var listVariableDescriptor = solver.getScoreDirectorFactory().getSolutionDescriptor() .findEntityDescriptorOrFail(TestdataListEntity.class) .getListVariableDescriptor(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java new file mode 100644 index 00000000000..1723c4c91f7 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java @@ -0,0 +1,177 @@ +package ai.timefold.solver.core.impl.solver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.DelegateScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.testconstraint.DummyConstraintProvider; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class SolverContextManagerTest { + + private static final EnvironmentMode GLOBAL_MODE = EnvironmentMode.PHASE_ASSERT; + + private final DelegateScoreDirectorFactory scoreDirectorFactory = + new DelegateScoreDirectorFactory<>( + new ScoreDirectorFactoryConfig().withConstraintProviderClass(DummyConstraintProvider.class), + TestdataSolution.buildSolutionDescriptor(), GLOBAL_MODE); + + /** + * A solver scope holding a freshly built score director for the global environment mode, + * which is the state {@code DefaultSolverFactory.buildSolver} leaves behind. + */ + private SolverScope buildSolverScope() { + var solverScope = new SolverScope(Clock.systemDefaultZone()); + InnerScoreDirector scoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder(GLOBAL_MODE).withLookUpEnabled(true).build(); + scoreDirector.setWorkingSolution(TestdataSolution.generateSolution(3, 3)); + solverScope.setScoreDirector(scoreDirector); + solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(scoreDirector)); + return solverScope; + } + + private SolverContextManager buildManager(EnvironmentMode... phaseEnvironmentModes) { + var phaseList = new java.util.ArrayList>(); + for (var environmentMode : phaseEnvironmentModes) { + Phase phase = mock(Phase.class); + when(phase.getEnvironmentMode()).thenReturn(environmentMode); + phaseList.add(phase); + } + return new SolverContextManager<>(scoreDirectorFactory, new BestSolutionRecaller<>(), List.copyOf(phaseList)); + } + + private static void startPhase(SolverContextManager manager, + SolverScope solverScope, int phaseIndex) { + manager.phaseStarted(new LocalSearchPhaseScope<>(solverScope, phaseIndex)); + } + + @Test + void phaseWithTheSameEnvironmentModeKeepsTheScoreDirector() { + var solverScope = buildSolverScope(); + var manager = buildManager(GLOBAL_MODE); + var originalScoreDirector = solverScope.getScoreDirector(); + var originalProblemChangeDirector = solverScope.getProblemChangeDirector(); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + + assertThat(solverScope.getScoreDirector()).isSameAs(originalScoreDirector); + assertThat(solverScope.getProblemChangeDirector()).isSameAs(originalProblemChangeDirector); + // Not closed; close() clears the working solution. + assertThat(originalScoreDirector.getWorkingSolution()).isNotNull(); + } + + @Test + void phaseWithAnotherEnvironmentModeSwapsInAScoreDirectorForThatMode() { + var solverScope = buildSolverScope(); + var manager = buildManager(EnvironmentMode.FULL_ASSERT); + var originalScoreDirector = solverScope.getScoreDirector(); + var workingSolution = originalScoreDirector.getWorkingSolution(); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + + var newScoreDirector = solverScope.getScoreDirector(); + assertThat(newScoreDirector).isNotSameAs(originalScoreDirector); + assertThat(newScoreDirector.getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + // The working solution carries over rather than being re-cloned. + assertThat(newScoreDirector.getWorkingSolution()).isSameAs(workingSolution); + // The problem change director follows the score director, so problem changes hit the live one. + assertThat(solverScope.getProblemChangeDirector()).isNotSameAs(originalScoreDirector); + } + + @Test + void replacedScoreDirectorIsClosed() { + var solverScope = buildSolverScope(); + var manager = buildManager(EnvironmentMode.FULL_ASSERT); + var originalScoreDirector = solverScope.getScoreDirector(); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + + // close() clears the working solution, so this is the observable proof it was released. + assertThat(originalScoreDirector.getWorkingSolution()).isNull(); + } + + @Test + void scoreCalculationCountCarriesOverToTheNewScoreDirector() { + var solverScope = buildSolverScope(); + var manager = buildManager(EnvironmentMode.FULL_ASSERT); + var originalScoreDirector = solverScope.getScoreDirector(); + originalScoreDirector.calculateScore(); + originalScoreDirector.calculateScore(); + var countBeforeSwap = originalScoreDirector.getCalculationCount(); + assertThat(countBeforeSwap).isPositive(); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + + // Terminations count calculations across the whole solve, so the running total must not restart at zero. + assertThat(solverScope.getScoreDirector().getCalculationCount()).isEqualTo(countBeforeSwap); + } + + @Test + void everyEnvironmentModeChangeGetsItsOwnScoreDirector() { + var solverScope = buildSolverScope(); + // Back to the global mode for the third phase; score directors are not cached, so it must be a new instance. + var manager = buildManager(GLOBAL_MODE, EnvironmentMode.FULL_ASSERT, GLOBAL_MODE); + var originalScoreDirector = solverScope.getScoreDirector(); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + assertThat(solverScope.getScoreDirector()).isSameAs(originalScoreDirector); + + startPhase(manager, solverScope, 1); + var fullAssertScoreDirector = solverScope.getScoreDirector(); + assertThat(fullAssertScoreDirector).isNotSameAs(originalScoreDirector); + + startPhase(manager, solverScope, 2); + var restoredScoreDirector = solverScope.getScoreDirector(); + assertThat(restoredScoreDirector) + .isNotSameAs(fullAssertScoreDirector) + .isNotSameAs(originalScoreDirector); + assertThat(restoredScoreDirector.getEnvironmentMode()).isEqualTo(GLOBAL_MODE); + } + + @Test + void solvingErrorClosesTheScoreDirectorInUse() { + var solverScope = buildSolverScope(); + var manager = buildManager(EnvironmentMode.FULL_ASSERT); + + manager.solvingStarted(solverScope); + startPhase(manager, solverScope, 0); + var scoreDirectorInUse = solverScope.getScoreDirector(); + + manager.solvingError(solverScope, new IllegalStateException("Boom")); + + // The solver's normal cleanup does not run on the failure path, so this is the only close. + assertThat(scoreDirectorInUse.getWorkingSolution()).isNull(); + } + + @Test + void solvingErrorBeforeSolvingStartedDoesNotThrow() { + var solverScope = buildSolverScope(); + var manager = buildManager(GLOBAL_MODE); + + // Solving can fail before solvingStarted() completes; the original exception must reach the caller + // rather than being replaced by a NullPointerException from in here. + assertThatCode(() -> manager.solvingError(solverScope, new IllegalStateException("Boom"))) + .doesNotThrowAnyException(); + } +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorFailedSolveTest.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorFailedSolveTest.java new file mode 100644 index 00000000000..047ecea47ed --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorFailedSolveTest.java @@ -0,0 +1,85 @@ +package ai.timefold.solver.quarkus; + +import static ai.timefold.solver.quarkus.testdomain.failing.TestdataQuarkusFailingConstraintProvider.FAILING_VALUE; +import static ai.timefold.solver.quarkus.testdomain.failing.TestdataQuarkusFailingConstraintProvider.FAILURE_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.stream.IntStream; + +import jakarta.inject.Inject; + +import ai.timefold.solver.core.api.solver.SolverManager; +import ai.timefold.solver.core.api.solver.SolverStatus; +import ai.timefold.solver.quarkus.testdomain.failing.TestdataQuarkusFailingConstraintProvider; +import ai.timefold.solver.quarkus.testdomain.normal.TestdataQuarkusEntity; +import ai.timefold.solver.quarkus.testdomain.normal.TestdataQuarkusSolution; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +/** + * A Quarkus application keeps one {@link SolverManager} for its whole lifetime, + * so whatever a failed solve leaves behind accumulates rather than being collected along with a request. + * These tests submit failing jobs and check that the application is no worse off afterwards. + *

+ * The failure is raised during score calculation, which puts it inside {@code DefaultSolver.solve}'s try block — + * the window whose {@code catch} calls {@code solvingError}, which is what closes the score director. + * {@code DefaultSolver} otherwise closes it only in {@code outerSolvingEnded}, which a failed solve never reaches. + * The closing itself cannot be observed from here, as nothing in the Quarkus layer hands out the score director: + * {@code DefaultSolverTest.solvingErrorClosesScoreDirectorWhenPhaseFails} and + * {@code DefaultSolverTest.solvingErrorClosesScoreDirectorWhenSolvingStartedFails} pin that, and both fail if + * {@code AbstractSolver.solvingError} stops calling {@code solverContextManager.solvingError(..)}. + */ +class TimefoldProcessorFailedSolveTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .overrideConfigKey("quarkus.timefold.solver.termination.best-score-limit", "0") + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestdataQuarkusEntity.class, + TestdataQuarkusSolution.class, + TestdataQuarkusFailingConstraintProvider.class)); + + @Inject + SolverManager solverManager; + + /** + * @param failing true to put the value that makes score calculation throw into the value range + */ + private static TestdataQuarkusSolution buildProblem(boolean failing) { + var problem = new TestdataQuarkusSolution(); + problem.setValueList(failing ? List.of(FAILING_VALUE) : List.of("v1", "v2")); + problem.setEntityList(IntStream.range(0, 2) + .mapToObj(i -> new TestdataQuarkusEntity()) + .toList()); + return problem; + } + + @Test + void twoFailedSolvesLeaveTheSolverManagerUsable() throws Exception { + for (var problemId : List.of(1L, 2L)) { + var solverJob = solverManager.solve(problemId, buildProblem(true)); + + assertThatThrownBy(solverJob::getFinalBestSolution) + .rootCause() + // The caller gets the real cause. A cleanup step failing on its way out would surface + // here instead, and the actual problem would be lost. + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(FAILURE_MESSAGE); + + // The job is unregistered even though it failed, or the manager retains it for the life of the app. + assertThat(solverManager.getSolverStatus(problemId)).isEqualTo(SolverStatus.NOT_SOLVING); + } + + // The manager is a singleton that outlives its jobs, so it has to still work after both failures. + var solution = solverManager.solve(3L, buildProblem(false)).getFinalBestSolution(); + assertThat(solution).isNotNull(); + assertThat(solution.getScore()).isNotNull(); + } +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/failing/TestdataQuarkusFailingConstraintProvider.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/failing/TestdataQuarkusFailingConstraintProvider.java new file mode 100644 index 00000000000..1b639bd0f8e --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/failing/TestdataQuarkusFailingConstraintProvider.java @@ -0,0 +1,35 @@ +package ai.timefold.solver.quarkus.testdomain.failing; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.quarkus.testdomain.normal.TestdataQuarkusEntity; + +import org.jspecify.annotations.NonNull; + +/** + * Throws while the score is being calculated, but only once a specific value has been assigned. + * That keeps the failure inside the solving process itself, rather than at problem installation: + * the entities start out unassigned, so a solve only fails if its value range contains {@link #FAILING_VALUE}. + */ +public class TestdataQuarkusFailingConstraintProvider implements ConstraintProvider { + + public static final String FAILING_VALUE = "fail"; + public static final String FAILURE_MESSAGE = "Deliberate failure while calculating the score."; + + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory factory) { + return new Constraint[] { + factory.forEach(TestdataQuarkusEntity.class) + .filter(entity -> { + if (FAILING_VALUE.equals(entity.getValue())) { + throw new IllegalStateException(FAILURE_MESSAGE); + } + return false; + }) + .penalize(SimpleScore.ONE) + .asConstraint("Deliberate failure") + }; + } +} From 2165ad82bc725ee6f2a2adff9564a20a1d4c95a7 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 26 Aug 2026 10:05:19 -0300 Subject: [PATCH 18/20] chore: address comments --- .../core/impl/solver/AbstractSolver.java | 2 +- .../impl/solver/DefaultSolverFactory.java | 30 ++++----- .../impl/solver/SolverContextManager.java | 13 ++-- .../impl/solver/DefaultSolverFactoryTest.java | 62 ++++++++++++++++++- .../core/impl/solver/DefaultSolverTest.java | 29 +++++++++ .../impl/solver/SolverContextManagerTest.java | 16 +++++ .../solver-diagnostics.adoc | 21 ++++++- 7 files changed, 147 insertions(+), 26 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index efebd0df340..2d69a14626b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -70,7 +70,7 @@ protected AbstractSolver(EnvironmentMode globalEnvironmentMode, ScoreDirectorFac this.globalTermination = globalTermination; bestSolutionRecaller.setSolverEventSupport(solverEventSupport); this.phaseList = List.copyOf(phaseList); - this.solverContextManager = new SolverContextManager<>(scoreDirectorFactory, bestSolutionRecaller, phaseList); + this.solverContextManager = new SolverContextManager<>(scoreDirectorFactory, bestSolutionRecaller, this.phaseList); } public void solvingStarted(SolverScope solverScope) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 2995c7b7a72..8ed509834bc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -76,8 +76,9 @@ *

* {@link #assertEnvironmentModeConfiguration(SolverConfig)} guards the invariants this relies on: * no phase may be less strict than the global mode, - * at least one phase must actually use the global mode, * and a non-reproducible global mode admits no phase-level override at all. + * Phases are free to override the environment mode, including all of them at once — + * the global environment mode still governs everything outside the phases. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @see SolverFactory @@ -302,23 +303,9 @@ private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig s "Phase-level environmentMode override is only possible when global environmentMode is reproducible, but was %s." .formatted(globalEnvironmentMode.name())); } - // If none of the phase environments use the global environment, we fail fast. - var checkGlobalEnvironment = phaseEnvironmentList.isEmpty(); - for (var phaseEnvironment : phaseEnvironmentList) { - if (phaseEnvironment == globalEnvironmentMode) { - checkGlobalEnvironment = true; - break; - } - } - if (!checkGlobalEnvironment) { - throw new IllegalStateException(""" - The global environment mode is %s, but none of the phase environment modes are using it [%s]. - Maybe adjust at least one of the phase environment modes to match the global environmentMode (%s)""" - .formatted( - globalEnvironmentMode.name(), - String.join(", ", phaseEnvironmentList.stream().map(EnvironmentMode::name).toList()), - globalEnvironmentMode.name())); - } + // Every phase may override the global mode, including all of them at once: + // the global mode still applies outside the phases, and the factory built for it is needed regardless + // by the components which are decoupled from the solving life cycle. var invalidPhaseEnvironmentList = new ArrayList(phaseConfigList.size()); for (var phaseEnvironment : phaseEnvironmentList) { if (phaseEnvironment.ordinal() > globalEnvironmentMode.ordinal()) { @@ -331,6 +318,13 @@ Maybe adjust at least one of the phase environment modes to match the global env "The phase environments must have an assertion level higher than or equal to the global environment level (%s). The following phase environment modes are not valid: [%s]." .formatted(globalEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); } + // If every phase ends up in the same environment mode, that mode becomes the global one. + // There is then nothing to swap away from mid-solve, which spares the solver a second score director + // factory — and, with Constraint Streams, a second constraint network — for a mode no phase ever runs in + var reducedPhaseEnvironmentList = phaseEnvironmentList.stream().distinct().toList(); + if (reducedPhaseEnvironmentList.size() == 1) { + return reducedPhaseEnvironmentList.getFirst(); + } return globalEnvironmentMode; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java index d10789ee833..0b8497b4581 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java @@ -112,10 +112,15 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { * Whatever happens here must not throw: the caller is on its way to rethrowing the real failure. */ public void solvingError(SolverScope solverScope, Exception exception) { - if (currentContext != null) { - currentContext.release(); - } else { - solverScope.getScoreDirector().close(); + try { + if (currentContext != null) { + currentContext.release(); + } else { + solverScope.getScoreDirector().close(); + } + } catch (RuntimeException releaseException) { + // The caller is on its way to rethrowing the real failure; this must not take its place. + exception.addSuppressed(releaseException); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java index 58373941eb7..2e1f3dd9c7d 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactoryTest.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.api.solver.SolverConfigOverride; +import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.SolverConfig; @@ -198,13 +199,70 @@ void assertEnvironmentWithNonReproducibleAndMismatchingPhase() { } @Test - void assertEnvironmentModeWithDefaultNotUsedByAnyPhase() { + void assertEnvironmentModeWithGlobalNotUsedByAnyPhase() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NON_INTRUSIVE_FULL_ASSERT); + // Every phase may override the global mode; it still governs everything outside the phases. + assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)).doesNotThrowAnyException(); + } + + @Test + void identicalPhaseEnvironmentModesBecomeTheGlobalEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + solverConfig.getPhaseConfigList() + .forEach(phaseConfig -> phaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT)); + var solver = (AbstractSolver) SolverFactory. create(solverConfig) + .buildSolver(); + // Every phase agrees, so there is nothing for the global mode to differ from: + // adopting it spares the solver a second score director factory for a mode no phase ever runs in. + assertThat(solver.globalEnvironmentMode).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + + @Test + void differingPhaseEnvironmentModesLeaveTheGlobalEnvironmentModeAlone() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NON_INTRUSIVE_FULL_ASSERT); + var solver = (AbstractSolver) SolverFactory. create(solverConfig) + .buildSolver(); + assertThat(solver.globalEnvironmentMode).isEqualTo(EnvironmentMode.STEP_ASSERT); + } + + @Test + void onePhaseLeftOnTheGlobalEnvironmentMode() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // Only the second phase overrides; the first still runs in the global mode, so it must stay. + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var solver = (AbstractSolver) SolverFactory. create(solverConfig) + .buildSolver(); + assertThat(solver.globalEnvironmentMode).isEqualTo(EnvironmentMode.PHASE_ASSERT); + } + + @Test + void phaseEnvironmentModeCannotMakeTheGlobalNonReproducible() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + solverConfig.getPhaseConfigList() + .forEach(phaseConfig -> phaseConfig.setEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE)); + // NON_REPRODUCIBLE is the most lenient mode, so it is rejected as an override before adoption is + // ever considered. Otherwise a phase-level setting could silently cost the solver its reproducibility. assertThatCode(() -> new DefaultSolverFactory<>(solverConfig)) - .hasMessageContaining("but none of the phase environment modes are using it"); + .hasMessageContaining("must have an assertion level higher than or equal to the global environment level"); + } + + @Test + void solvesWithEveryPhaseOverridingAnUnsetGlobalEnvironmentMode() { + // "Assert everything", expressed per phase, with the solver-level mode left at its default. + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + solverConfig.getPhaseConfigList().forEach(phaseConfig -> phaseConfig + .setEnvironmentMode(EnvironmentMode.FULL_ASSERT)); + var solution = SolverFactory. create(solverConfig) + .buildSolver() + .solve(TestdataSolution.generateSolution(2, 2)); + assertThat(solution).isNotNull(); + assertThat(solution.getScore()).isNotNull(); } @Test 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 109289d30ac..60fadb51674 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 @@ -577,6 +577,35 @@ void solveWithProblemChange() throws InterruptedException { } } + @Test + void identicalPhaseEnvironmentModesShareOneScoreDirector() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // Both phases agree, so the global mode becomes theirs and no phase has anything to swap away from. + solverConfig.getPhaseConfigList() + .forEach(phaseConfig -> phaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT)); + var solver = (DefaultSolver) SolverFactory. create(solverConfig) + .buildSolver(); + // The score director the solver was built with. Comparing against the one the phases run on is what + // detects a swap; comparing the phases against each other would not, since after an initial swap they + // would share the replacement. + var builtScoreDirector = solver.getSolverScope().getScoreDirector(); + var scoreDirectorPerPhase = new ArrayList>(); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter<>() { + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + scoreDirectorPerPhase.add(phaseScope.getScoreDirector()); + } + }); + solver.solve(TestdataSolution.generateSolution(2, 2)); + assertThat(scoreDirectorPerPhase) + .hasSize(2) + // One score director for the whole solve, which is the point: no second factory, and for Constraint + // Streams no second constraint network, just because the mode was expressed per phase. + .allSatisfy(scoreDirector -> assertThat(scoreDirector) + .isSameAs(builtScoreDirector)); + assertThat(builtScoreDirector.getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + @Test void replacedScoreDirectorIsClosedWhenAPhaseOverridesTheEnvironmentMode() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java index 1723c4c91f7..334cee68acc 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -164,6 +165,21 @@ void solvingErrorClosesTheScoreDirectorInUse() { assertThat(scoreDirectorInUse.getWorkingSolution()).isNull(); } + @Test + void solvingErrorReportsAFailedReleaseAsSuppressed() { + var solverScope = new SolverScope(Clock.systemDefaultZone()); + InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); + var releaseFailure = new IllegalStateException("Releasing blew up"); + doThrow(releaseFailure).when(scoreDirector).close(); + solverScope.setScoreDirector(scoreDirector); + var manager = buildManager(GLOBAL_MODE); + var originalFailure = new IllegalStateException("The real failure"); + // The caller rethrows the original failure right after this returns, so a failure in here must not + // take its place; it is attached instead, where it stays visible without hiding the real cause. + assertThatCode(() -> manager.solvingError(solverScope, originalFailure)).doesNotThrowAnyException(); + assertThat(originalFailure.getSuppressed()).containsExactly(releaseFailure); + } + @Test void solvingErrorBeforeSolvingStartedDoesNotThrow() { var solverScope = buildSolverScope(); diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc index d673c1b8f14..97e59ca9a4b 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/solver-diagnostics.adoc @@ -222,10 +222,29 @@ instead of paying the performance cost of a stricter mode (such as `<>`, no phase can override it, because every other mode is <> and therefore stricter. +If every phase ends up in the same environment mode, that mode becomes the solver's environment mode too, +since no phase is left running in the configured one. + +[NOTE] +==== +A phase that runs in a different environment mode than the solver gets its own score director. +With the xref:constraints-and-score/score-calculation.adoc#constraintStreams[Constraint Streams] API, +that means a second constraint network is built for that mode. +It is built once and reused, but it is not free: prefer overriding the phases you actually want to inspect. +==== + +[NOTE] +==== +The solver's environment mode also applies outside the phases, +including to xref:using-timefold-solver/modeling-planning-problems.adoc[`SolutionManager`] operations, +so a stricter solver-level mode makes those slower as well. +==== + [source,java,options="nowrap"] ---- SolverConfig solverConfig = new SolverConfig() From 64abaedf2711ac3cc8ac08c203964fc6b410a565 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 26 Aug 2026 11:07:38 -0300 Subject: [PATCH 19/20] feat: benchmark module with multiple phases --- .../impl/solver/DefaultSolverFactory.java | 50 +----- .../impl/solver/EnvironmentModeResolver.java | 142 ++++++++++++++++++ .../solver/EnvironmentModeResolverTest.java | 107 +++++++++++++ .../impl/report/BenchmarkReport.java | 72 ++++++--- .../impl/result/PlannerBenchmarkResult.java | 26 ++-- .../impl/result/SolverBenchmarkResult.java | 87 ++++++++++- .../impl/report/benchmarkReport.html.ftl | 2 +- .../impl/report/BenchmarkReportTest.java | 91 +++++++++++ .../result/PlannerBenchmarkResultTest.java | 50 +++++- .../result/SolverBenchmarkResultTest.java | 90 +++++++++++ 10 files changed, 631 insertions(+), 86 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolver.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolverTest.java create mode 100644 tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReportTest.java create mode 100644 tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/SolverBenchmarkResultTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 8ed509834bc..41d92f8d13a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -74,9 +74,6 @@ * are such components. * They all get the global environment mode. *

- * {@link #assertEnvironmentModeConfiguration(SolverConfig)} guards the invariants this relies on: - * no phase may be less strict than the global mode, - * and a non-reproducible global mode admits no phase-level override at all. * Phases are free to override the environment mode, including all of them at once — * the global environment mode still governs everything outside the phases. * @@ -105,7 +102,8 @@ public DefaultSolverFactory(SolverConfig solverConfig, DomainAccessType domainAc this.clock = Objects.requireNonNullElse(solverConfig.getClock(), Clock.systemDefaultZone()); this.solverConfig = Objects.requireNonNull(solverConfig, "The solverConfig (%s) cannot be null.".formatted(solverConfig)); - this.globalEnvironmentMode = assertEnvironmentModeConfiguration(solverConfig); + EnvironmentModeResolver.validate(solverConfig); + this.globalEnvironmentMode = EnvironmentModeResolver.resolve(solverConfig); this.solutionDescriptor = buildSolutionDescriptor(); // Caching score director factory for the default environment mode as it potentially does expensive things this.delegateScoreDirectorFactory = @@ -284,50 +282,6 @@ public void ensurePreviewFeature(PreviewFeature previewFeature) { HeuristicConfigPolicy.ensurePreviewFeature(previewFeature, solverConfig.getEnablePreviewFeatureSet()); } - private static EnvironmentMode assertEnvironmentModeConfiguration(SolverConfig solverConfig) { - var globalEnvironmentMode = solverConfig.determineEnvironmentMode(); - var phaseConfigList = solverConfig.getPhaseConfigList(); - if (ConfigUtils.isEmptyCollection(phaseConfigList)) { - return globalEnvironmentMode; - } - var phaseEnvironmentList = - phaseConfigList.stream() - .map(phaseConfig -> Objects.requireNonNullElse(phaseConfig.getEnvironmentMode(), - globalEnvironmentMode)) - .toList(); - if (globalEnvironmentMode == EnvironmentMode.NON_REPRODUCIBLE - && phaseEnvironmentList.stream().anyMatch(environmentMode -> environmentMode != globalEnvironmentMode)) { - // A non-reproducible global environment mode cannot be overridden per phase, - // as a phase-level override would have nothing reproducible to be an override of. - throw new IllegalStateException( - "Phase-level environmentMode override is only possible when global environmentMode is reproducible, but was %s." - .formatted(globalEnvironmentMode.name())); - } - // Every phase may override the global mode, including all of them at once: - // the global mode still applies outside the phases, and the factory built for it is needed regardless - // by the components which are decoupled from the solving life cycle. - var invalidPhaseEnvironmentList = new ArrayList(phaseConfigList.size()); - for (var phaseEnvironment : phaseEnvironmentList) { - if (phaseEnvironment.ordinal() > globalEnvironmentMode.ordinal()) { - invalidPhaseEnvironmentList.add(phaseEnvironment.name()); - } - } - if (!invalidPhaseEnvironmentList.isEmpty()) { - // The phase environments must have an assertion level greater than or equal to the global environment level - throw new IllegalStateException( - "The phase environments must have an assertion level higher than or equal to the global environment level (%s). The following phase environment modes are not valid: [%s]." - .formatted(globalEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); - } - // If every phase ends up in the same environment mode, that mode becomes the global one. - // There is then nothing to swap away from mid-solve, which spares the solver a second score director - // factory — and, with Constraint Streams, a second constraint network — for a mode no phase ever runs in - var reducedPhaseEnvironmentList = phaseEnvironmentList.stream().distinct().toList(); - if (reducedPhaseEnvironmentList.size() == 1) { - return reducedPhaseEnvironmentList.getFirst(); - } - return globalEnvironmentMode; - } - // Required for testability as final classes cannot be mocked. static class MoveThreadCountResolver { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolver.java new file mode 100644 index 00000000000..98ec9d044df --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolver.java @@ -0,0 +1,142 @@ +package ai.timefold.solver.core.impl.solver; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; + +import org.jspecify.annotations.NullMarked; + +/** + * The single answer to "which {@link EnvironmentMode} does this {@link SolverConfig} actually run in". + *

+ * {@link SolverConfig#determineEnvironmentMode()} only reports what the solver config declares. + * Since a phase may override the environment mode, that declared value is no longer the whole truth: + * a phase may run in a stricter mode than the solver, + * and a config whose every phase agrees on one mode adopts that mode as its global one. + * Both the ({@link DefaultSolverFactory}) and anything reporting on a config + * (the benchmark report, notably) need those rules, so they live here rather than in either. + *

+ * The resolution is split in two on purpose: + *

    + *
  • {@link #validate(SolverConfig)} throws on a config whose phase overrides break the rules; + * it is the solver's job to reject such a config.
  • + *
  • {@link #resolve(SolverConfig)} and the methods built on it are total and never throw, + * because a reporting path runs long after the config was accepted + * and must not be able to fail on validation.
  • + *
+ * A resolution of a config which never passed {@link #validate(SolverConfig)} is therefore best-effort: + * it applies the rules to a config the solver would have refused to build. + *

+ * Note that {@link EnvironmentMode} is declared from strictest to most lenient, + * so a lower {@link Enum#ordinal()} means a stricter mode. + */ +@NullMarked +public final class EnvironmentModeResolver { + + /** + * Fails on a config whose phase-level environment modes break either of the two rules: + * no phase may be less strict than the global mode, + * and a non-reproducible global mode admits no phase-level override at all. + * + * @throws IllegalStateException when a phase-level override is not allowed + */ + public static void validate(SolverConfig solverConfig) { + var globalEnvironmentMode = solverConfig.determineEnvironmentMode(); + var phaseEnvironmentModeList = determinePhaseEnvironmentModeList(solverConfig, globalEnvironmentMode); + if (phaseEnvironmentModeList.isEmpty()) { + return; + } + if (globalEnvironmentMode == EnvironmentMode.NON_REPRODUCIBLE + && phaseEnvironmentModeList.stream().anyMatch(environmentMode -> environmentMode != globalEnvironmentMode)) { + // A non-reproducible global environment mode cannot be overridden per phase, + // as a phase-level override would have nothing reproducible to be an override of. + throw new IllegalStateException( + "Phase-level environmentMode override is only possible when global environmentMode is reproducible, but was %s." + .formatted(globalEnvironmentMode.name())); + } + // Every phase may override the global mode, including all of them at once: + // the global mode still applies outside the phases, and the factory built for it is needed regardless + // by the components which are decoupled from the solving life cycle. + var invalidPhaseEnvironmentList = new ArrayList(phaseEnvironmentModeList.size()); + for (var phaseEnvironmentMode : phaseEnvironmentModeList) { + if (phaseEnvironmentMode.ordinal() > globalEnvironmentMode.ordinal()) { + invalidPhaseEnvironmentList.add(phaseEnvironmentMode.name()); + } + } + if (!invalidPhaseEnvironmentList.isEmpty()) { + // The phase environments must have an assertion level greater than or equal to the global environment level + throw new IllegalStateException( + "The phase environments must have an assertion level higher than or equal to the global environment level (%s). The following phase environment modes are not valid: [%s]." + .formatted(globalEnvironmentMode.name(), String.join(", ", invalidPhaseEnvironmentList))); + } + } + + /** + * The environment mode the solver as a whole runs in, + * which is the declared {@link SolverConfig#determineEnvironmentMode()} + * unless every phase agrees on one mode, in which case that mode is adopted as the global one. + * There is then nothing to swap away from mid-solve, which spares the solver a second score director + * factory — and, with Constraint Streams, a second constraint network — for a mode no phase ever runs in. + *

+ * This is not the strictest mode the solve runs in; see {@link #resolveStrictest(SolverConfig)} for that. + * + * @see #validate(SolverConfig) never throws, unlike the validation + */ + public static EnvironmentMode resolve(SolverConfig solverConfig) { + var globalEnvironmentMode = solverConfig.determineEnvironmentMode(); + var phaseEnvironmentModeList = determinePhaseEnvironmentModeList(solverConfig, globalEnvironmentMode); + if (phaseEnvironmentModeList.isEmpty()) { + return globalEnvironmentMode; + } + var distinctPhaseEnvironmentModeList = phaseEnvironmentModeList.stream().distinct().toList(); + return distinctPhaseEnvironmentModeList.size() == 1 + ? distinctPhaseEnvironmentModeList.getFirst() + : globalEnvironmentMode; + } + + /** + * The environment mode of each phase, in the order of {@link SolverConfig#getPhaseConfigList()}; + * a phase which does not override the mode contributes {@link #resolve(SolverConfig)}. + * + * @return empty when the config declares no phases + */ + public static List resolvePhases(SolverConfig solverConfig) { + return determinePhaseEnvironmentModeList(solverConfig, resolve(solverConfig)); + } + + /** + * The strictest environment mode any part of the solve runs in, + * which is the strictest of {@link #resolve(SolverConfig)} and every phase's mode. + * This is what a report has to look at to describe the cost of a config: + * a single phase in {@link EnvironmentMode#FULL_ASSERT} slows the whole run down, + * no matter how lenient the solver-level mode is. + */ + public static EnvironmentMode resolveStrictest(SolverConfig solverConfig) { + var strictestEnvironmentMode = resolve(solverConfig); + for (var phaseEnvironmentMode : determinePhaseEnvironmentModeList(solverConfig, strictestEnvironmentMode)) { + if (phaseEnvironmentMode.ordinal() < strictestEnvironmentMode.ordinal()) { + strictestEnvironmentMode = phaseEnvironmentMode; + } + } + return strictestEnvironmentMode; + } + + private static List determinePhaseEnvironmentModeList(SolverConfig solverConfig, + EnvironmentMode globalEnvironmentMode) { + var phaseConfigList = solverConfig.getPhaseConfigList(); + if (phaseConfigList == null || phaseConfigList.isEmpty()) { + return List.of(); + } + return phaseConfigList.stream() + .map(phaseConfig -> Objects.requireNonNullElse(phaseConfig.getEnvironmentMode(), globalEnvironmentMode)) + .toList(); + } + + private EnvironmentModeResolver() { + // No external instances. + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolverTest.java new file mode 100644 index 00000000000..c2e47b485dc --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/EnvironmentModeResolverTest.java @@ -0,0 +1,107 @@ +package ai.timefold.solver.core.impl.solver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testutil.PlannerTestUtils; + +import org.junit.jupiter.api.Test; + +/** + * {@link DefaultSolverFactoryTest} covers the rules as the solver applies them, through a built solver; + * this covers them at their new home, plus what the resolver adds on top for a reporting caller: + * the per-phase view, the strictest mode, and the guarantee that neither throws. + */ +class EnvironmentModeResolverTest { + + private static SolverConfig buildSolverConfig() { + return PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + } + + @Test + void resolveWithoutPhasesIsTheDeclaredMode() { + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolve(solverConfig)).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolvePhases(solverConfig)).isEmpty(); + assertThat(EnvironmentModeResolver.resolveStrictest(solverConfig)).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThatCode(() -> EnvironmentModeResolver.validate(solverConfig)).doesNotThrowAnyException(); + } + + @Test + void resolveReflectsTheAdoptionOfAUnanimousPhaseMode() { + var solverConfig = buildSolverConfig(); + solverConfig.getPhaseConfigList() + .forEach(phaseConfig -> phaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT)); + // The declared mode is still PHASE_ASSERT, but that is not the mode anything runs in. + assertThat(solverConfig.determineEnvironmentMode()).isEqualTo(EnvironmentMode.PHASE_ASSERT); + assertThat(EnvironmentModeResolver.resolve(solverConfig)).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolvePhases(solverConfig)) + .containsExactly(EnvironmentMode.FULL_ASSERT, EnvironmentMode.FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolveStrictest(solverConfig)).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + + @Test + void oneOverridingPhaseLeavesTheGlobalModeAloneButNotTheStrictestMode() { + var solverConfig = buildSolverConfig(); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolve(solverConfig)).isEqualTo(EnvironmentMode.PHASE_ASSERT); + assertThat(EnvironmentModeResolver.resolvePhases(solverConfig)) + .containsExactly(EnvironmentMode.PHASE_ASSERT, EnvironmentMode.FULL_ASSERT); + // This is the whole point: the solver-level mode is cheap, yet half the run is not. + assertThat(EnvironmentModeResolver.resolveStrictest(solverConfig)).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + + @Test + void resolveTakesTheStrictestOfDifferingPhaseModes() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.NON_INTRUSIVE_FULL_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); + assertThat(EnvironmentModeResolver.resolve(solverConfig)).isEqualTo(EnvironmentMode.STEP_ASSERT); + assertThat(EnvironmentModeResolver.resolveStrictest(solverConfig)) + .isEqualTo(EnvironmentMode.TRACKED_FULL_ASSERT); + } + + @Test + void resolvingNeverThrowsOnConfigValidateRejects() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.NO_ASSERT); + assertThatCode(() -> EnvironmentModeResolver.validate(solverConfig)) + .hasMessageContaining("must have an assertion level higher than or equal to the global environment level"); + // A report runs long after the config was accepted; it must never be able to fail on validation. + assertThatCode(() -> EnvironmentModeResolver.resolve(solverConfig)).doesNotThrowAnyException(); + assertThatCode(() -> EnvironmentModeResolver.resolvePhases(solverConfig)).doesNotThrowAnyException(); + assertThatCode(() -> EnvironmentModeResolver.resolveStrictest(solverConfig)).doesNotThrowAnyException(); + } + + @Test + void nonReproducibleGlobalModeAdmitsNoPhaseOverride() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); + // A stricter override is rejected just like a more lenient one: NON_REPRODUCIBLE reseeds on every run, + // so a phase-level override would have nothing reproducible to be an override of. + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + assertThatCode(() -> EnvironmentModeResolver.validate(solverConfig)) + .hasMessageContaining("is only possible when global environmentMode is reproducible"); + } + + @Test + void nonReproducibleGlobalModeIsValidWhileNoPhaseOverridesIt() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); + // Restating the mode a phase already runs in is not an override, so the rule leaves it alone. + solverConfig.getPhaseConfigList().getFirst().setEnvironmentMode(EnvironmentMode.NON_REPRODUCIBLE); + assertThatCode(() -> EnvironmentModeResolver.validate(solverConfig)).doesNotThrowAnyException(); + assertThat(EnvironmentModeResolver.resolve(solverConfig)).isEqualTo(EnvironmentMode.NON_REPRODUCIBLE); + assertThat(EnvironmentModeResolver.resolveStrictest(solverConfig)).isEqualTo(EnvironmentMode.NON_REPRODUCIBLE); + } + +} diff --git a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java index 5fee501b3f8..7a9e9004731 100644 --- a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java +++ b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReport.java @@ -226,8 +226,9 @@ public void writeReport() { subSingleStatistic.unhibernatePointList(); } catch (IllegalStateException e) { if (!plannerBenchmarkResult.getAggregation()) { - throw new IllegalStateException("Failed to unhibernate point list of SubSingleStatistic (" - + subSingleStatistic + ") of SubSingleBenchmark (" + subSingleBenchmarkResult + ").", + throw new IllegalStateException( + "Failed to unhibernate point list of SubSingleStatistic (%s) of SubSingleBenchmark (%s)." + .formatted(subSingleStatistic, subSingleBenchmarkResult), e); } LOGGER.trace("This is expected, aggregator doesn't copy CSV files. Could not read CSV file " @@ -302,35 +303,62 @@ public List getWarningList() { List warningList = new ArrayList<>(); String javaVmName = System.getProperty("java.vm.name"); if (javaVmName != null && javaVmName.contains("Client VM")) { - warningList.add("The Java VM (" + javaVmName + ") is the Client VM." - + " This decreases performance." - + " Maybe start the java process with the argument \"-server\" to get better results."); + warningList.add( + "The Java VM (%s) is the Client VM. This decreases performance. Maybe start the java process with the argument \"-server\" to get better results." + .formatted(javaVmName)); } Integer parallelBenchmarkCount = plannerBenchmarkResult.getParallelBenchmarkCount(); Integer availableProcessors = plannerBenchmarkResult.getAvailableProcessors(); if (parallelBenchmarkCount != null && availableProcessors != null && parallelBenchmarkCount > availableProcessors) { - warningList.add("The parallelBenchmarkCount (" + parallelBenchmarkCount - + ") is higher than the number of availableProcessors (" + availableProcessors + ")." - + " This decreases performance." - + " Maybe reduce the parallelBenchmarkCount."); - } - EnvironmentMode environmentMode = plannerBenchmarkResult.getEnvironmentMode(); - if (environmentMode != null && environmentMode.isStepAssertOrMore()) { - // Phase assert performance impact is negligible. warningList.add( - "The environmentMode (%s) is step-asserting or more. This decreases performance. Maybe set the environmentMode to %s." - .formatted(environmentMode, EnvironmentMode.PHASE_ASSERT)); + "The parallelBenchmarkCount (%d) is higher than the number of availableProcessors (%d). This decreases performance. Maybe reduce the parallelBenchmarkCount." + .formatted(parallelBenchmarkCount, availableProcessors)); } + addEnvironmentModeWarnings(warningList); LoggingLevel loggingLevelTimefoldCore = plannerBenchmarkResult.getLoggingLevelTimefoldSolverCore(); if (loggingLevelTimefoldCore == LoggingLevel.TRACE) { - warningList.add("The loggingLevel (" + loggingLevelTimefoldCore + ") of ai.timefold.solver.core is high." - + " This decreases performance." - + " Maybe set the loggingLevel to " + LoggingLevel.DEBUG + " or lower."); + warningList.add( + "The loggingLevel (%s) of ai.timefold.solver.core is high. This decreases performance. Maybe set the loggingLevel to %s or lower." + .formatted(loggingLevelTimefoldCore, LoggingLevel.DEBUG)); } return warningList; } + /** + * A phase may override the environment mode, + * so the solver-level mode alone says neither how slow a solver benchmark ran + * nor whether two of them ran under comparable conditions. + * Both are worth a warning: a benchmark is believed precisely because it is supposed to be the objective check, + * and a leftover {@code FULL_ASSERT} on a single phase otherwise handicaps one config with nothing in the report to say so. + */ + private void addEnvironmentModeWarnings(List warningList) { + var solverBenchmarkResultList = plannerBenchmarkResult.getSolverBenchmarkResultList(); + if (solverBenchmarkResultList == null || solverBenchmarkResultList.isEmpty()) { + return; + } + for (var solverBenchmarkResult : solverBenchmarkResultList) { + var strictestEnvironmentMode = solverBenchmarkResult.getStrictestEnvironmentMode(); + if (!strictestEnvironmentMode.isStepAssertOrMore()) { + // Phase assert performance impact is negligible. + continue; + } + warningList.add( + "The environmentMode (%s) of solverBenchmark (%s) is step-asserting or more. This decreases performance. Maybe set the environmentMode to %s." + .formatted(solverBenchmarkResult.getEnvironmentModeLabel(), solverBenchmarkResult.getName(), + EnvironmentMode.PHASE_ASSERT)); + } + var distinctEnvironmentModeLabelList = solverBenchmarkResultList.stream() + .map(SolverBenchmarkResult::getEnvironmentModeLabel) + .distinct() + .toList(); + if (distinctEnvironmentModeLabelList.size() > 1) { + warningList.add( + "The solverBenchmarks do not all run in the same environmentMode (%s). Their results are not comparable. Maybe give every solverBenchmark the same environmentMode." + .formatted(String.join(", ", distinctEnvironmentModeLabelList))); + } + } + private List> createBestScoreSummaryChart() { List> builderList = new ArrayList<>(CHARTED_SCORE_LEVEL_SIZE); for (SolverBenchmarkResult solverBenchmarkResult : plannerBenchmarkResult.getSolverBenchmarkResultList()) { @@ -656,11 +684,11 @@ private void writeHtmlOverviewFile() { Template template = freemarkerCfg.getTemplate(templateFilename); template.process(model, writer); } catch (IOException e) { - throw new IllegalArgumentException("Can not read templateFilename (" + templateFilename - + ") or write htmlOverviewFile (" + htmlOverviewFile + ").", e); + throw new IllegalArgumentException("Can not read templateFilename (%s) or write htmlOverviewFile (%s)." + .formatted(templateFilename, htmlOverviewFile), e); } catch (TemplateException e) { - throw new IllegalArgumentException("Can not process Freemarker templateFilename (" + templateFilename - + ") to htmlOverviewFile (" + htmlOverviewFile + ").", e); + throw new IllegalArgumentException("Can not process Freemarker templateFilename (%s) to htmlOverviewFile (%s)." + .formatted(templateFilename, htmlOverviewFile), e); } } diff --git a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java index c5186eb2dc7..5a2a508ceb1 100644 --- a/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java +++ b/tools/benchmark/src/main/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResult.java @@ -21,7 +21,6 @@ import ai.timefold.solver.benchmark.impl.report.BenchmarkReport; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; -import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.util.MathUtils; @@ -52,7 +51,7 @@ public class PlannerBenchmarkResult { private Integer parallelBenchmarkCount = null; private Long warmUpTimeMillisSpentLimit = null; - private EnvironmentMode environmentMode = null; + private String environmentModeLabel = null; @XmlElement(name = "solverBenchmarkResult") private List solverBenchmarkResultList = null; @@ -145,8 +144,11 @@ public void setWarmUpTimeMillisSpentLimit(Long warmUpTimeMillisSpentLimit) { this.warmUpTimeMillisSpentLimit = warmUpTimeMillisSpentLimit; } - public EnvironmentMode getEnvironmentMode() { - return environmentMode; + /** + * @return null when the solver benchmarks do not all run in the same environment mode + */ + public String getEnvironmentModeLabel() { + return environmentModeLabel; } public List getSolverBenchmarkResultList() { @@ -334,12 +336,12 @@ private > void determineTotalsAndAverages() { var solverBenchmarkCount = 0; var firstSolverBenchmarkResult = true; for (var solverBenchmarkResult : solverBenchmarkResultList) { - var solverEnvironmentMode = solverBenchmarkResult.getEnvironmentMode(); - if (firstSolverBenchmarkResult && solverEnvironmentMode != null) { - environmentMode = solverEnvironmentMode; + var solverEnvironmentModeLabel = solverBenchmarkResult.getEnvironmentModeLabel(); + if (firstSolverBenchmarkResult) { + environmentModeLabel = solverEnvironmentModeLabel; firstSolverBenchmarkResult = false; - } else if (!firstSolverBenchmarkResult && solverEnvironmentMode != environmentMode) { - environmentMode = null; + } else if (!solverEnvironmentModeLabel.equals(environmentModeLabel)) { + environmentModeLabel = null; } var score = (Score_) solverBenchmarkResult.getAverageScore(); @@ -457,7 +459,7 @@ protected static PlannerBenchmarkResult createMergeSingleton(List(); newResult.unifiedProblemBenchmarkResultList = new ArrayList<>(); newResult.startingTimestamp = null; @@ -482,8 +484,8 @@ protected static PlannerBenchmarkResult createMergeSingleton(List describePhaseEnvironmentModeOverrides(EnvironmentMode environmentMode) { + var phaseConfigList = solverConfig.getPhaseConfigList(); + if (phaseConfigList == null || phaseConfigList.isEmpty()) { + return Collections.emptyList(); + } + var phaseEnvironmentModeList = EnvironmentModeResolver.resolvePhases(solverConfig); + var overrideList = new ArrayList(phaseConfigList.size()); + for (var i = 0; i < phaseConfigList.size(); i++) { + // A phase which does not override the mode resolves to the solver's own, so this only lists real overrides. + var phaseEnvironmentMode = phaseEnvironmentModeList.get(i); + if (phaseEnvironmentMode != environmentMode) { + overrideList.add("%s: %s".formatted(describePhase(phaseConfigList, i), phaseEnvironmentMode.name())); + } + } + return overrideList; + } + + /** + * @return the phase's XML element name, suffixed with its position among the phases sharing that name + * when the config has more than one of them + */ + private static String describePhase(List phaseConfigList, int phaseIndex) { + var phaseName = describePhaseType(phaseConfigList.get(phaseIndex)); + var sameNameCount = 0; + var sameNameIndex = 0; + for (var i = 0; i < phaseConfigList.size(); i++) { + if (describePhaseType(phaseConfigList.get(i)).equals(phaseName)) { + sameNameCount++; + if (i == phaseIndex) { + sameNameIndex = sameNameCount; + } + } + } + return sameNameCount == 1 ? phaseName : "%s #%d".formatted(phaseName, sameNameIndex); + } + + private static String describePhaseType(PhaseConfig phaseConfig) { + return switch (phaseConfig) { + case ConstructionHeuristicPhaseConfig ignored -> ConstructionHeuristicPhaseConfig.XML_ELEMENT_NAME; + case CustomPhaseConfig ignored -> CustomPhaseConfig.XML_ELEMENT_NAME; + case ExhaustiveSearchPhaseConfig ignored -> ExhaustiveSearchPhaseConfig.XML_ELEMENT_NAME; + case LocalSearchPhaseConfig ignored -> LocalSearchPhaseConfig.XML_ELEMENT_NAME; + case PartitionedSearchPhaseConfig ignored -> PartitionedSearchPhaseConfig.XML_ELEMENT_NAME; + // A phase type unknown to this module, such as one added by enterprise. + default -> phaseConfig.getClass().getSimpleName(); + }; } @SuppressWarnings("unused") // Used by FreeMarker. diff --git a/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl b/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl index a46060f96a0..4026a17be6d 100644 --- a/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl +++ b/tools/benchmark/src/main/resources/ai/timefold/solver/benchmark/impl/report/benchmarkReport.html.ftl @@ -875,7 +875,7 @@ Environment mode - ${benchmarkReport.plannerBenchmarkResult.environmentMode!"Differs"} + ${benchmarkReport.plannerBenchmarkResult.environmentModeLabel!"Differs"} Logging level for ai.timefold.solver.core diff --git a/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReportTest.java b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReportTest.java new file mode 100644 index 00000000000..13dca7a807e --- /dev/null +++ b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/report/BenchmarkReportTest.java @@ -0,0 +1,91 @@ +package ai.timefold.solver.benchmark.impl.report; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.benchmark.impl.result.PlannerBenchmarkResult; +import ai.timefold.solver.benchmark.impl.result.SolverBenchmarkResult; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class BenchmarkReportTest { + + private static SolverConfig buildSolverConfig() { + return new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()); + } + + private static List buildEnvironmentModeWarningList(SolverConfig... solverConfigs) { + var plannerBenchmarkResult = new PlannerBenchmarkResult(); + var solverBenchmarkResultList = new ArrayList(solverConfigs.length); + for (var i = 0; i < solverConfigs.length; i++) { + var solverBenchmarkResult = new SolverBenchmarkResult(plannerBenchmarkResult); + solverBenchmarkResult.setName("Solver A %d".formatted(i)); + solverBenchmarkResult.setSolverConfig(solverConfigs[i]); + solverBenchmarkResultList.add(solverBenchmarkResult); + } + plannerBenchmarkResult.setSolverBenchmarkResultList(solverBenchmarkResultList); + // Only the environmentMode warnings are of interest; the others depend on the machine running the test. + return new BenchmarkReport(plannerBenchmarkResult).getWarningList().stream() + .filter(warning -> warning.contains("environmentMode")) + .toList(); + } + + @Test + void stepAssertingPhaseIsWarnedAboutAndNamed() { + var solverConfig = buildSolverConfig(); + // The scenario this guards: a leftover debugging override on one phase of one solver benchmark, + // which handicaps it against its peers while the solver-level mode says everything is fine. + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var warningList = buildEnvironmentModeWarningList(solverConfig); + assertThat(warningList).hasSize(1); + assertThat(warningList.getFirst()) + .contains("PHASE_ASSERT (localSearch: FULL_ASSERT)") + .contains("Solver A") + .contains("step-asserting or more"); + } + + @Test + void phaseAssertingSolverIsNotWarnedAbout() { + assertThat(buildEnvironmentModeWarningList(buildSolverConfig())).isEmpty(); + } + + @Test + void stepAssertingSolverLevelModeIsStillWarnedAbout() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var warningList = buildEnvironmentModeWarningList(solverConfig); + assertThat(warningList).hasSize(1); + assertThat(warningList.getFirst()) + .contains("FULL_ASSERT") + .contains("step-asserting or more"); + } + + @Test + void solverBenchmarksInDifferentEnvironmentModesAreWarnedAboutAsIncomparable() { + var otherSolverConfig = buildSolverConfig(); + otherSolverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + // Both solver benchmarks declare PHASE_ASSERT at the solver level, so only the phase override tells them apart. + assertThat(buildEnvironmentModeWarningList(otherSolverConfig, buildSolverConfig())) + .anySatisfy(warning -> assertThat(warning) + .contains("do not all run in the same environmentMode") + .contains("PHASE_ASSERT (localSearch: FULL_ASSERT)") + .contains("Their results are not comparable")); + } + + @Test + void solverBenchmarksInTheSameEnvironmentModeAreNotWarnedAboutAsIncomparable() { + assertThat(buildEnvironmentModeWarningList(buildSolverConfig(), buildSolverConfig())).isEmpty(); + } + +} diff --git a/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResultTest.java b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResultTest.java index fd35acdbc83..3124e176331 100644 --- a/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResultTest.java +++ b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/PlannerBenchmarkResultTest.java @@ -12,8 +12,13 @@ import java.util.Collections; import ai.timefold.solver.benchmark.impl.loader.FileProblemProvider; +import ai.timefold.solver.benchmark.impl.ranking.TotalScoreSolverRankingComparator; +import ai.timefold.solver.benchmark.impl.report.BenchmarkReport; import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import ai.timefold.solver.core.testdomain.TestdataEntity; @@ -90,6 +95,47 @@ void createMergedResult() { assertThat(mergedProblemBenchmarkResultList.get(1).getProblemProvider().getProblemName()).isEqualTo("problemB"); } + @Test + void solverBenchmarksDifferingOnlyInAPhaseEnvironmentMode() { + var otherSolverConfig = buildSolverConfigWithPhases(); + // Both configs declare PHASE_ASSERT at the solver level; only the phase override tells them apart. + otherSolverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var plannerBenchmarkResult = accumulate(otherSolverConfig, buildSolverConfigWithPhases()); + // Null is what the report renders as "Differs". + assertThat(plannerBenchmarkResult.getEnvironmentModeLabel()).isNull(); + } + + @Test + void solverBenchmarksSharingAnEnvironmentModeAggregateAsThatMode() { + var plannerBenchmarkResult = accumulate(buildSolverConfigWithPhases(), buildSolverConfigWithPhases()); + assertThat(plannerBenchmarkResult.getEnvironmentModeLabel()).isEqualTo("PHASE_ASSERT"); + } + + private static SolverConfig buildSolverConfigWithPhases() { + return new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()); + } + + private static PlannerBenchmarkResult accumulate(SolverConfig... solverConfigs) { + var plannerBenchmarkResult = new PlannerBenchmarkResult(); + var solverBenchmarkResultList = new ArrayList(solverConfigs.length); + for (var i = 0; i < solverConfigs.length; i++) { + var solverBenchmarkResult = new SolverBenchmarkResult(plannerBenchmarkResult); + solverBenchmarkResult.setName("Solver " + (char) ('A' + i)); + solverBenchmarkResult.setSolverConfig(solverConfigs[i]); + solverBenchmarkResult.setSingleBenchmarkResultList(new ArrayList<>()); + solverBenchmarkResultList.add(solverBenchmarkResult); + } + plannerBenchmarkResult.setSolverBenchmarkResultList(solverBenchmarkResultList); + plannerBenchmarkResult.setUnifiedProblemBenchmarkResultList(new ArrayList<>()); + var benchmarkReport = new BenchmarkReport(plannerBenchmarkResult); + benchmarkReport.setSolverRankingComparator(new TotalScoreSolverRankingComparator()); + plannerBenchmarkResult.accumulateResults(benchmarkReport); + return plannerBenchmarkResult; + } + protected SingleBenchmarkResult createSingleBenchmarkResult(SolverBenchmarkResult solverBenchmarkResult, ProblemBenchmarkResult problemBenchmarkResult, int score) { var singleBenchmarkResult = new SingleBenchmarkResult(solverBenchmarkResult, problemBenchmarkResult); @@ -128,12 +174,12 @@ void xmlReportRemainsSameAfterReadWrite() throws IOException { // nested classes below are used in the testPlannerBenchmarkResult.xml - private static abstract class DummyEasyScoreCalculator + private abstract static class DummyEasyScoreCalculator implements EasyScoreCalculator { } - private static abstract class DummyDistanceNearbyMeter + private abstract static class DummyDistanceNearbyMeter implements NearbyDistanceMeter { } diff --git a/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/SolverBenchmarkResultTest.java b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/SolverBenchmarkResultTest.java new file mode 100644 index 00000000000..6ca9fa28540 --- /dev/null +++ b/tools/benchmark/src/test/java/ai/timefold/solver/benchmark/impl/result/SolverBenchmarkResultTest.java @@ -0,0 +1,90 @@ +package ai.timefold.solver.benchmark.impl.result; + +import static org.assertj.core.api.Assertions.assertThat; + +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class SolverBenchmarkResultTest { + + private static SolverBenchmarkResult buildSolverBenchmarkResult(SolverConfig solverConfig) { + var solverBenchmarkResult = new SolverBenchmarkResult(new PlannerBenchmarkResult()); + solverBenchmarkResult.setName("Solver X"); + solverBenchmarkResult.setSolverConfig(solverConfig); + return solverBenchmarkResult; + } + + private static SolverConfig buildSolverConfig() { + return new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), new LocalSearchPhaseConfig()); + } + + @Test + void environmentModeOfAConfigWithoutPhases() { + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var solverBenchmarkResult = buildSolverBenchmarkResult(solverConfig); + assertThat(solverBenchmarkResult.getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(solverBenchmarkResult.getStrictestEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(solverBenchmarkResult.getEnvironmentModeLabel()).isEqualTo("FULL_ASSERT"); + } + + @Test + void environmentModeReflectsTheAdoptionOfAUnanimousPhaseMode() { + var solverConfig = buildSolverConfig(); + solverConfig.getPhaseConfigList() + .forEach(phaseConfig -> phaseConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT)); + var solverBenchmarkResult = buildSolverBenchmarkResult(solverConfig); + // The config declares PHASE_ASSERT, but every phase overrides it, so that is the mode the solver adopts. + assertThat(solverBenchmarkResult.getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(solverBenchmarkResult.getStrictestEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + // Nothing runs in a mode other than the adopted one, so there is no override left to report. + assertThat(solverBenchmarkResult.getEnvironmentModeLabel()).isEqualTo("FULL_ASSERT"); + } + + @Test + void strictestEnvironmentModeReflectsASingleOverridingPhase() { + var solverConfig = buildSolverConfig(); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + var solverBenchmarkResult = buildSolverBenchmarkResult(solverConfig); + assertThat(solverBenchmarkResult.getEnvironmentMode()).isEqualTo(EnvironmentMode.PHASE_ASSERT); + assertThat(solverBenchmarkResult.getStrictestEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + assertThat(solverBenchmarkResult.getEnvironmentModeLabel()) + .isEqualTo("PHASE_ASSERT (localSearch: FULL_ASSERT)"); + } + + @Test + void environmentModeLabelNamesEveryOverridingPhase() { + var solverConfig = buildSolverConfig() + .withEnvironmentMode(EnvironmentMode.STEP_ASSERT); + solverConfig.getPhaseConfigList().get(0).setEnvironmentMode(EnvironmentMode.FULL_ASSERT); + solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); + var solverBenchmarkResult = buildSolverBenchmarkResult(solverConfig); + assertThat(solverBenchmarkResult.getEnvironmentModeLabel()) + .isEqualTo("STEP_ASSERT (constructionHeuristic: FULL_ASSERT, localSearch: TRACKED_FULL_ASSERT)"); + } + + @Test + void environmentModeLabelDisambiguatesRepeatedPhaseTypes() { + var solverConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withPhases(new ConstructionHeuristicPhaseConfig(), + new ConstructionHeuristicPhaseConfig().withEnvironmentMode(EnvironmentMode.FULL_ASSERT), + new LocalSearchPhaseConfig()); + var solverBenchmarkResult = buildSolverBenchmarkResult(solverConfig); + assertThat(solverBenchmarkResult.getEnvironmentModeLabel()) + .isEqualTo("PHASE_ASSERT (constructionHeuristic #2: FULL_ASSERT)"); + } + +} From dca0bc9da243d1807a172cc949ff0744ef735b1e Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 26 Aug 2026 12:30:41 -0300 Subject: [PATCH 20/20] feat: address comments --- .../solver/core/config/phase/PhaseConfig.java | 24 +++++++++++++++++-- .../list/AbstractListMoveSelector.java | 17 +++++++++++++ ...eateConstructionHeuristicPhaseBuilder.java | 3 ++- .../generic/list/GenericListMoveSelector.java | 17 +++++++++++++ ...ConstructionHeuristicPhaseBuilderTest.java | 16 +++++++++++++ .../core/impl/solver/DefaultSolverTest.java | 2 +- .../impl/solver/SolverContextManagerTest.java | 5 ++-- .../core/impl/solver/SolverMetricsIT.java | 9 ++++--- 8 files changed, 82 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java index 151b813b720..128c5fbf210 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/phase/PhaseConfig.java @@ -4,6 +4,7 @@ import jakarta.xml.bind.annotation.XmlSeeAlso; import jakarta.xml.bind.annotation.XmlType; +import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.config.AbstractConfig; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; @@ -43,11 +44,30 @@ public abstract class PhaseConfig> extends // Constructors and simple getters/setters // ************************************************************************ - public EnvironmentMode getEnvironmentMode() { + /** + * @return null when this phase runs in the solver's {@link EnvironmentMode} + * @see #setEnvironmentMode(EnvironmentMode) + */ + public @Nullable EnvironmentMode getEnvironmentMode() { return environmentMode; } - public void setEnvironmentMode(EnvironmentMode environmentMode) { + /** + * Overrides the solver's {@link EnvironmentMode} for this phase only. + *

+ * Null, the default, means the phase runs in the solver's environment mode. + * A non-null value must obey two rules, both checked when the + * {@link SolverFactory SolverFactory} is built, + * so that a violation fails there rather than during solving: + *

    + *
  • it may not be less strict than the solver's environment mode;
  • + *
  • it may not be set at all when the solver's environment mode is + * {@link EnvironmentMode#NON_REPRODUCIBLE}.
  • + *
+ * + * @param environmentMode null to run this phase in the solver's environment mode + */ + public void setEnvironmentMode(@Nullable EnvironmentMode environmentMode) { this.environmentMode = environmentMode; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java index 18880762efb..6ba3756d8fd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java @@ -14,6 +14,18 @@ public abstract class AbstractListMoveSelector extends AbstractSelector { protected final ListVariableDescriptor listVariableDescriptor; + + /** + * Non-null for the duration of a phase, null outside one; + * see {@link #phaseStarted(AbstractPhaseScope)} and {@link #phaseEnded(AbstractPhaseScope)}. + *

+ * Subclasses read this field directly rather than through {@link #getListVariableStateSupply()}, + * and that is deliberate: the accessor's null check would sit on the selection path, + * where a selector's {@code iterator()} is not necessarily called only once per step, + * and some subclasses read the supply once per selected element. + * Nothing enforces that the field is non-null when they read it — + * the normal path simply never selects before the phase has started. + */ @Nullable protected ListVariableStateSupply listVariableStateSupply; @@ -21,6 +33,11 @@ protected AbstractListMoveSelector(ListVariableDescriptor listVariabl this.listVariableDescriptor = listVariableDescriptor; } + /** + * For callers off the selection path, + * where naming the cause of a missing supply is worth the null check; + * selection code reads {@link #listVariableStateSupply} directly instead. + */ protected ListVariableStateSupply getListVariableStateSupply() { return Objects.requireNonNull(listVariableStateSupply, "Impossible state: The listVariableStateSupply is not initialized yet."); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java index 6d60b4c1d6b..6424c40a872 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java @@ -55,7 +55,8 @@ public static RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseFactory, PhaseTermination phaseTermination, EntityPlacer entityPlacer, ConstructionHeuristicDecider decider) { - // The R&R uses the root solver environment mode by default + // The config policy here belongs to the phase whose move selector built this nested R&R, + // so the nested construction heuristic runs in the enclosing phase's environment mode super(0, false, configPolicy.getEnvironmentMode(), "", phaseTermination, entityPlacer, decider); this.configPolicy = configPolicy; this.constructionHeuristicPhaseFactory = constructionHeuristicPhaseFactory; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java index e3f99f752f1..ac0580f82da 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java @@ -14,6 +14,18 @@ public abstract class GenericListMoveSelector extends GenericMoveSelector { protected final ListVariableDescriptor listVariableDescriptor; + + /** + * Non-null for the duration of a phase, null outside one; + * see {@link #phaseStarted(AbstractPhaseScope)} and {@link #phaseEnded(AbstractPhaseScope)}. + *

+ * Subclasses read this field directly rather than through {@link #getListVariableStateSupply()}, + * and that is deliberate: the accessor's null check would sit on the selection path, + * where a selector's {@code iterator()} is not necessarily called only once per step, + * and some subclasses read the supply once per selected element. + * Nothing enforces that the field is non-null when they read it — + * the normal path simply never selects before the phase has started. + */ @Nullable protected ListVariableStateSupply listVariableStateSupply; @@ -21,6 +33,11 @@ protected GenericListMoveSelector(ListVariableDescriptor listVariable this.listVariableDescriptor = listVariableDescriptor; } + /** + * For callers off the selection path, + * where naming the cause of a missing supply is worth the null check; + * selection code reads {@link #listVariableStateSupply} directly instead. + */ protected ListVariableStateSupply getListVariableStateSupply() { return Objects.requireNonNull(listVariableStateSupply, "Impossible state: The listVariableStateSupply is not initialized yet."); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java index 8ec45c11615..ad69be8b7c9 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java @@ -30,6 +30,22 @@ void buildSingleThreaded() { assertThat(phase.getEntityPlacer()).isSameAs(builder.getEntityPlacer()); } + @Test + void nestedPhaseRunsInTheEnclosingPhaseEnvironmentMode() { + // A ruin & recreate move selector is built from its enclosing phase's config policy, not the solver's, + // so this policy stands for a local search phase which overrode the solver's environment mode. + var phaseConfigPolicy = new HeuristicConfigPolicy.Builder() + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT) + .withSolutionDescriptor(TestdataSolution.buildSolutionDescriptor()) + .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { + InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY })) + .build(); + var constructionHeuristicConfig = mock(ConstructionHeuristicPhaseConfig.class); + var builder = RuinRecreateConstructionHeuristicPhaseBuilder.create(phaseConfigPolicy, constructionHeuristicConfig); + // The nested construction heuristic is dragged along into the enclosing phase's mode. + assertThat(builder.build().getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); + } + @Test void buildMultiThreaded() { var solverConfigPolicy = new HeuristicConfigPolicy.Builder() 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 60fadb51674..5da4074e34d 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 @@ -2565,7 +2565,7 @@ void assertUpdatedDefaultEnvironmentMode() { void assertPhaseEnvironmentMode() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); solverConfig.setEnvironmentMode(EnvironmentMode.FULL_ASSERT); - // LS with NO_ASSERT + // LS with TRACKED_FULL_ASSERT solverConfig.getPhaseConfigList().get(1).setEnvironmentMode(EnvironmentMode.TRACKED_FULL_ASSERT); var solverFactory = SolverFactory. create(solverConfig); DefaultSolver solver = (DefaultSolver) solverFactory.buildSolver(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java index 334cee68acc..0818cbf6c59 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java @@ -83,18 +83,19 @@ void phaseWithAnotherEnvironmentModeSwapsInAScoreDirectorForThatMode() { var solverScope = buildSolverScope(); var manager = buildManager(EnvironmentMode.FULL_ASSERT); var originalScoreDirector = solverScope.getScoreDirector(); + var originalProblemChangeDirector = solverScope.getProblemChangeDirector(); var workingSolution = originalScoreDirector.getWorkingSolution(); manager.solvingStarted(solverScope); startPhase(manager, solverScope, 0); var newScoreDirector = solverScope.getScoreDirector(); + var newProblemChangeDirector = solverScope.getProblemChangeDirector(); assertThat(newScoreDirector).isNotSameAs(originalScoreDirector); + assertThat(newProblemChangeDirector).isNotSameAs(originalProblemChangeDirector); assertThat(newScoreDirector.getEnvironmentMode()).isEqualTo(EnvironmentMode.FULL_ASSERT); // The working solution carries over rather than being re-cloned. assertThat(newScoreDirector.getWorkingSolution()).isSameAs(workingSolution); - // The problem change director follows the score director, so problem changes hit the live one. - assertThat(solverScope.getProblemChangeDirector()).isNotSameAs(originalScoreDirector); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java index 38b194dab07..2796f16bd24 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverMetricsIT.java @@ -3,7 +3,6 @@ import static ai.timefold.solver.core.testutil.PlannerAssert.assertCode; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.util.ArrayList; import java.util.Arrays; @@ -135,7 +134,7 @@ void checkDefaultMeters() { latch.countDown(); }); solver.solve(solution); - assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -225,7 +224,7 @@ void checkDefaultMetersTags() { }); solver.solve(solution); - assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -288,7 +287,7 @@ void solveMetrics() { }); solution = solver.solve(solution); - assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); meterRegistry.publish(); assertThat(solution).isNotNull(); assertThat(solution.getEntityList().stream() @@ -437,7 +436,7 @@ void solveBestScoreMetrics() { }); solution = solver.solve(solution); - assertDoesNotThrow(() -> latch.await(10, TimeUnit.SECONDS), "Failed waiting for the event to happen."); + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); assertThat(step.get()).isEqualTo(2); meterRegistry.publish(); assertThat(solution).isNotNull();