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..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,12 +4,14 @@ 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; 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 +26,7 @@ PartitionedSearchPhaseConfig.class }) @XmlType(propOrder = { + "environmentMode", "terminationConfig" }) public abstract class PhaseConfig> extends AbstractConfig { @@ -31,6 +34,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 +44,33 @@ public abstract class PhaseConfig> extends // Constructors and simple getters/setters // ************************************************************************ + /** + * @return null when this phase runs in the solver's {@link EnvironmentMode} + * @see #setEnvironmentMode(EnvironmentMode) + */ + public @Nullable EnvironmentMode getEnvironmentMode() { + return 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; + } + public @Nullable TerminationConfig getTerminationConfig() { return terminationConfig; } @@ -50,6 +83,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 +95,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/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java index cae734d261f..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 @@ -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 @@ -227,25 +228,19 @@ public void solvingError(SolverScope solverScope, Exception exception } public static class DefaultConstructionHeuristicPhaseBuilder - extends AbstractPossiblyInitializingPhaseBuilder { + extends AbstractPossiblyInitializingPhaseBuilder> { 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); - return this; - } - public EntityPlacer getEntityPlacer() { return entityPlacer; } 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..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 @@ -52,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,9 +72,9 @@ protected DefaultConstructionHeuristicPhaseBuilder createBuilder( boolean lastInitializingPhase, EntityPlacer entityPlacer) { var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); return new DefaultConstructionHeuristicPhaseBuilder<>(phaseIndex, lastInitializingPhase, - phaseConfigPolicy.getLogIndentation(), phaseTermination, entityPlacer, + phaseConfigPolicy.getEnvironmentMode(), phaseConfigPolicy.getLogIndentation(), phaseTermination, entityPlacer, buildDecider(phaseConfigPolicy, phaseTermination)) - .enableAssertions(phaseConfigPolicy.getEnvironmentMode()); + .enableAssertions(); } @Override 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/domain/variable/ShadowVariableUpdateHelper.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java index b437c8b5e2d..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 @@ -273,12 +273,18 @@ 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); } + /** + * 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() { + public AbstractScoreDirector.AbstractScoreDirectorBuilder + createScoreDirectorBuilder(EnvironmentMode environmentMode) { throw new UnsupportedOperationException(); } } @@ -319,7 +325,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/exhaustivesearch/DefaultExhaustiveSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhase.java index 419903bcdbf..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 @@ -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()); @@ -133,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; @@ -141,20 +142,22 @@ 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; } + @SuppressWarnings("unchecked") @Override - public Builder enableAssertions(EnvironmentMode environmentMode) { - super.enableAssertions(environmentMode); + 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/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseFactory.java index 9a44c060f06..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) @@ -75,9 +76,8 @@ public ExhaustiveSearchPhase buildPhase(int phaseIndex, boolean lastI 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); + 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); @@ -93,9 +93,9 @@ EntitySelectorFactory. create(entitySelectorConfig) decider = buildDecider(phaseConfigPolicy, entitySelector, bestSolutionRecaller, 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, @@ -200,13 +200,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(configPolicy.getEnvironmentMode()); 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/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/HeuristicConfigPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java index a40baa67dbc..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 @@ -130,28 +130,43 @@ 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() { - 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 createPhaseConfigPolicy() { - return cloneBuilder().build(); + public HeuristicConfigPolicy copyPhaseConfigPolicy() { + return copyPhaseConfigPolicy(null); + } + + public HeuristicConfigPolicy copyPhaseConfigPolicy(EnvironmentMode environmentMode) { + var builder = cloneBuilder(); + if (environmentMode != null) { + builder.withEnvironmentMode(environmentMode); + } + return builder.build(); } public HeuristicConfigPolicy copyConfigPolicyWithoutNearbySetting() { @@ -160,7 +175,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/list/AbstractListMoveSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java new file mode 100644 index 00000000000..6ba3756d8fd --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/AbstractListMoveSelector.java @@ -0,0 +1,59 @@ +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 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; + + protected AbstractListMoveSelector(ListVariableDescriptor listVariableDescriptor) { + 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."); + } + + @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 0037a9f9812..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.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.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.solver.scope.SolverScope; 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 ListVariableStateSupply listVariableStateSupply; - public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector valueSelector, boolean randomSelection) { this(entitySelector, null, valueSelector, randomSelection, false); @@ -54,7 +48,7 @@ public ElementDestinationSelector(EntitySelector entitySelector, Iter public ElementDestinationSelector(EntitySelector entitySelector, IterableValueSelector replayingValueSelector, IterableValueSelector valueSelector, boolean randomSelection, boolean isExhaustiveSearch) { - this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); + super((ListVariableDescriptor) valueSelector.getVariableDescriptor()); this.entitySelector = entitySelector; var selector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); this.replayingValueSelector = replayingValueSelector; @@ -65,11 +59,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) { /* @@ -92,19 +81,6 @@ private IterableValueSelector filterUnassignedValues( return FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); } - @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); - } - - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - listVariableStateSupply = null; - } - @Override public long getSize() { if (entitySelector.getSize() == 0) { 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..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 @@ -3,34 +3,32 @@ 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.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.solver.scope.SolverScope; +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 ListVariableStateSupply listVariableStateSupply; public RandomSubListSelector( EntitySelector entitySelector, IterableValueSelector valueSelector, int minimumSubListSize, int maximumSubListSize) { + super((ListVariableDescriptor) valueSelector.getVariableDescriptor()); this.entitySelector = entitySelector; this.valueSelector = filterPinnedListPlanningVariableValuesWithIndex(valueSelector, this::getListVariableStateSupply); - this.listVariableDescriptor = (ListVariableDescriptor) valueSelector.getVariableDescriptor(); if (minimumSubListSize < 1) { throw new IllegalArgumentException("The minimumSubListSize (%d) must be greater than 0." .formatted(minimumSubListSize)); @@ -47,23 +45,16 @@ public RandomSubListSelector( phaseLifecycleSupport.addEventListener(this.valueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - @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(@NonNull AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, workingRandom); } @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - listVariableStateSupply = null; + public void phaseEnded(@NonNull AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + triangleElementFactory = null; } @Override 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..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,9 @@ public static RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseFactory, PhaseTermination phaseTermination, EntityPlacer entityPlacer, ConstructionHeuristicDecider decider) { - super(0, false, "", phaseTermination, entityPlacer, decider); + // 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; this.phaseTermination = phaseTermination; 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..ac0580f82da --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/GenericListMoveSelector.java @@ -0,0 +1,59 @@ +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; + + /** + * 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; + + protected GenericListMoveSelector(ListVariableDescriptor listVariableDescriptor) { + 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."); + } + + @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 02549522ab7..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 @@ -1,29 +1,25 @@ 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.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.solver.scope.SolverScope; 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 ListVariableStateSupply listVariableStateSupply; - public ListChangeMoveSelector(IterableValueSelector sourceValueSelector, DestinationSelector destinationSelector, boolean randomSelection) { + super((ListVariableDescriptor) sourceValueSelector.getVariableDescriptor()); this.sourceValueSelector = filterPinnedListPlanningVariableValuesWithIndex(sourceValueSelector, this::getListVariableStateSupply); this.destinationSelector = destinationSelector; @@ -32,19 +28,6 @@ public ListChangeMoveSelector(IterableValueSelector sourceValueSelect phaseLifecycleSupport.addEventListener(this.destinationSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - - @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var listVariableDescriptor = (ListVariableDescriptor) sourceValueSelector.getVariableDescriptor(); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); - this.listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); - } - public static IterableValueSelector filterPinnedListPlanningVariableValuesWithIndex( IterableValueSelector sourceValueSelector, Supplier> listVariableStateSupplier) { @@ -68,12 +51,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..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 @@ -3,25 +3,20 @@ 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.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.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 ListVariableStateSupply listVariableStateSupply; - public ListSwapMoveSelector(IterableValueSelector leftValueSelector, IterableValueSelector rightValueSelector, boolean randomSelection) { + super((ListVariableDescriptor) leftValueSelector.getVariableDescriptor()); this.leftValueSelector = filterPinnedListPlanningVariableValuesWithIndex(leftValueSelector, this::getListVariableStateSupply); this.rightValueSelector = @@ -32,25 +27,6 @@ public ListSwapMoveSelector(IterableValueSelector leftValueSelector, phaseLifecycleSupport.addEventListener(this.rightValueSelector); } - private ListVariableStateSupply getListVariableStateSupply() { - return Objects.requireNonNull(listVariableStateSupply, - "Impossible state: The listVariableStateSupply is not initialized yet."); - } - - @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var listVariableDescriptor = (ListVariableDescriptor) leftValueSelector.getVariableDescriptor(); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); - } - - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - listVariableStateSupply = null; - } - @Override public Iterator> iterator() { if (randomSelection) { 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..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 @@ -3,21 +3,17 @@ 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.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.solver.scope.SolverScope; 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,12 +22,10 @@ final class KOptListMoveSelector extends GenericMoveSelector listVariableStateSupply; - public KOptListMoveSelector(ListVariableDescriptor listVariableDescriptor, IterableValueSelector originSelector, IterableValueSelector valueSelector, int minK, int maxK, int[] pickedKDistribution) { - this.listVariableDescriptor = listVariableDescriptor; + super(listVariableDescriptor); this.originSelector = createEffectiveValueSelector(originSelector, this::getListVariableStateSupply); this.valueSelector = createEffectiveValueSelector(valueSelector, this::getListVariableStateSupply); this.minK = minK; @@ -50,24 +44,6 @@ 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 solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - var supplyManager = solverScope.getScoreDirector().getSupplyManager(); - listVariableStateSupply = supplyManager.demand(listVariableDescriptor.getStateDemand()); - } - - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - listVariableStateSupply = null; - } - @Override public long getSize() { long total = 0; 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..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 @@ -1,13 +1,11 @@ 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.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; @@ -15,24 +13,23 @@ 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 ListVariableDescriptor listVariableDescriptor; private final RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder; private final CountSupplier minimumSelectedCountSupplier; private final CountSupplier maximumSelectedCountSupplier; private SolverScope solverScope; - private ListVariableStateSupply listVariableStateSupply; public ListRuinRecreateMoveSelector(IterableValueSelector valueSelector, ListVariableDescriptor listVariableDescriptor, RuinRecreateConstructionHeuristicPhaseBuilder constructionHeuristicPhaseBuilder, CountSupplier minimumSelectedCountSupplier, CountSupplier maximumSelectedCountSupplier) { - super(); + super(listVariableDescriptor); this.valueSelector = FilteringValueSelector.ofAssigned(valueSelector, this::getListVariableStateSupply); - this.listVariableDescriptor = listVariableDescriptor; this.constructionHeuristicPhaseBuilder = constructionHeuristicPhaseBuilder; this.minimumSelectedCountSupplier = minimumSelectedCountSupplier; this.maximumSelectedCountSupplier = maximumSelectedCountSupplier; @@ -40,11 +37,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; @@ -64,22 +56,13 @@ public boolean isNeverEnding() { } @Override - public void solvingStarted(SolverScope solverScope) { - super.solvingStarted(solverScope); - this.solverScope = solverScope; - this.listVariableStateSupply = solverScope.getScoreDirector() - .getSupplyManager() - .demand(listVariableDescriptor.getStateDemand()); - } - - @Override - public void solvingEnded(SolverScope solverScope) { - super.solvingEnded(solverScope); - this.listVariableStateSupply = null; + public void phaseStarted(@NonNull AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + this.solverScope = phaseScope.getSolverScope(); } @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { + public void phaseEnded(@NonNull AbstractPhaseScope phaseScope) { super.phaseEnded(phaseScope); this.solverScope = null; } 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/localsearch/DefaultLocalSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java index c3bfe0dbd95..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 @@ -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 @@ -253,22 +254,16 @@ 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; - 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); - return this; - } - @Override public DefaultLocalSearchPhase build() { return new DefaultLocalSearchPhase<>(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..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 @@ -61,11 +61,12 @@ public DefaultLocalSearchPhaseFactory(LocalSearchPhaseConfig phaseConfig) { public LocalSearchPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.createPhaseConfigPolicy(); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(environmentMode); var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination); var decider = buildDecider(phaseConfigPolicy, phaseTermination); - return new DefaultLocalSearchPhase.Builder<>(phaseIndex, solverConfigPolicy.getLogIndentation(), phaseTermination, - decider).enableAssertions(phaseConfigPolicy.getEnvironmentMode()).build(); + return new DefaultLocalSearchPhase.Builder<>(phaseIndex, environmentMode, solverConfigPolicy.getLogIndentation(), + phaseTermination, decider).enableAssertions().build(); } @SuppressWarnings({ "unchecked", "rawtypes" }) @@ -162,7 +163,7 @@ private LocalSearchDecider buildMixedDecider(HeuristicConfigPolicy buildDecider(MoveRepository moveRepository, HeuristicConfigPolicy configPolicy, PhaseTermination termination) { var acceptor = buildAcceptor(configPolicy, moveRepository instanceof NeighborhoodsBasedMoveRepository); - var forager = buildForager(configPolicy); + 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,13 +171,12 @@ 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) - .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; } @@ -191,21 +191,21 @@ protected Acceptor buildAcceptor(HeuristicConfigPolicy con } return buildAcceptor(acceptorConfig, configPolicy); } 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); } } - 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; @@ -224,7 +224,7 @@ private Acceptor buildAcceptor(LocalSearchAcceptorConfig acceptorConf return AcceptorFactory. create(acceptorConfig).buildAcceptor(configPolicy); } - protected LocalSearchForager buildForager(HeuristicConfigPolicy configPolicy) { + protected LocalSearchForager buildForager() { LocalSearchForagerConfig foragerConfig_; if (phaseConfig.getForagerConfig() != null) { if (phaseConfig.getLocalSearchType() != null) { @@ -245,10 +245,7 @@ protected LocalSearchForager buildForager(HeuristicConfigPolicy buildAcceptor(HeuristicConfigPolicy config 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(); @@ -66,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) { @@ -78,175 +77,170 @@ 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(HeuristicConfigPolicy configPolicy) { + 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(configPolicy.getLogIndentation()); - 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 (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)); + 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 (configPolicy.getEnvironmentMode().isFullyAsserted()) { - acceptor.setAssertTabuHashCodeCorrectness(true); + 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)); } - 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(HeuristicConfigPolicy configPolicy) { + 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(configPolicy.getLogIndentation()); - configureFixedSizeTabuAcceptor(acceptor, configPolicy, 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, - 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 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(configPolicy.getLogIndentation()); - configureFixedSizeTabuAcceptor(acceptor, configPolicy, 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/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/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/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/partitionedsearch/DefaultPartitionedSearchPhaseFactory.java index 8037e578172..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 @@ -18,8 +18,10 @@ public DefaultPartitionedSearchPhaseFactory(PartitionedSearchPhaseConfig phaseCo public PartitionedSearchPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, 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, solverConfigPolicy, solverTermination, + .buildPartitionedSearch(phaseIndex, phaseConfig, solverConfigPolicyUpdated, 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..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 @@ -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. @@ -47,8 +48,9 @@ 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; 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); @@ -253,9 +260,10 @@ 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; private final String logIndentation; private final PhaseTermination phaseTermination; @@ -264,20 +272,23 @@ 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) { + @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/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..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 @@ -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; @@ -13,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(); } @@ -53,14 +54,14 @@ protected void ensureCorrectTermination(AbstractPhaseScope phaseScope } } - public static abstract class AbstractPossiblyInitializingPhaseBuilder - extends AbstractPhaseBuilder { + 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..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 @@ -110,33 +110,29 @@ 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()); } public static final class DefaultCustomPhaseBuilder - extends AbstractPossiblyInitializingPhaseBuilder { + extends AbstractPossiblyInitializingPhaseBuilder> { 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); - return this; - } - @Override public DefaultCustomPhase build() { return new DefaultCustomPhase<>(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..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,7 +21,8 @@ public DefaultCustomPhaseFactory(CustomPhaseConfig phaseConfig) { public CustomPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, SolverTermination solverTermination) { - var phaseConfigPolicy = solverConfigPolicy.createPhaseConfigPolicy(); + var environmentMode = resolveEnvironmentMode(solverConfigPolicy); + var phaseConfigPolicy = solverConfigPolicy.copyPhaseConfigPolicy(environmentMode); var customPhaseCommandClassList = phaseConfig.getCustomPhaseCommandClassList(); var customPhaseCommandList = phaseConfig.getCustomPhaseCommandList(); if (ConfigUtils.isEmptyCollection(customPhaseCommandClassList) @@ -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/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 6251f0001cc..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 @@ -63,6 +63,7 @@ public abstract class AbstractScoreDirector moveRepository; protected AbstractScoreDirector(AbstractScoreDirectorBuilder builder) { + 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); + // We set the shadow variable support, + // which will be necessary for obtaining the change notifier this.shadowVariableSupport = ShadowVariableSupport.create(this); - this.shadowVariableSupport.linkShadowVariables(); - this.solutionTracker = this.scoreDirectorFactory.isTrackingWorkingSolution() - ? new SolutionTracker<>(getSolutionDescriptor(), getSupplyManager()) - : null; - this.valueRangeManager = new ValueRangeManager<>(solutionDescriptor); + // 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 it's true, + // a snapshot of the solution is created during the evaluation of moves, + // allowing for certain assertions. + // 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); setAllChangesWillBeUndoneBeforeStepEnds(false); // Make sure the notifier is correctly initialized. - this.isStepAssertOrMore = - scoreDirectorFactory.environmentMode != null && scoreDirectorFactory.environmentMode.isStepAssertOrMore(); + // Enable assertions + this.isAssertClonedSolution = environmentMode.isFullyAsserted(); + this.isStepAssertOrMore = environmentMode.isStepAssertOrMore(); } @Override @@ -151,6 +168,11 @@ public VariableDescriptorCache getVariableDescriptorCache() { return variableDescriptorCache; } + @Override + public EnvironmentMode getEnvironmentMode() { + return environmentMode; + } + @Override @SuppressWarnings("unchecked") public ListVariableStateSupply @@ -206,8 +228,8 @@ public void resetCalculationCount() { } @Override - public void incrementCalculationCount() { - this.calculationCount++; + public void incrementCalculationCount(long count) { + this.calculationCount += count; } @Override @@ -389,7 +411,7 @@ public Solution_ cloneSolution(Solution_ originalSolution) { var originalScore = solutionDescriptor.getScore(originalSolution); var cloneSolution = solutionDescriptor.getSolutionCloner().cloneSolution(originalSolution); var cloneScore = solutionDescriptor.getScore(cloneSolution); - if (scoreDirectorFactory.isAssertClonedSolution()) { + if (isAssertClonedSolution) { if (!Objects.equals(originalScore, cloneScore)) { throw new CloningCorruptionException(""" Cloning corruption: the original's score (%s) is different from the clone's score (%s). @@ -438,20 +460,25 @@ protected void setCalculatedScore(Score_ score) { @Override public InnerScoreDirector 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 PART_THREAD -> { + var childThreadScoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder(environmentMode).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 MOVE_THREAD -> { + var childThreadScoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder(environmentMode).withLookUpEnabled(true) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); + return childThreadScoreDirector; + } + default -> + throw new IllegalStateException("The childThreadType (%s) is not implemented.".formatted(childThreadType)); } } @@ -665,6 +692,31 @@ 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 + */ + @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(environmentMode) + .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(); @@ -717,9 +769,9 @@ 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(workingSolution); + uncorruptedScoreDirector.setWorkingSolution(Objects.requireNonNull(workingSolution)); var uncorruptedInnerScore = uncorruptedScoreDirector.calculateScore(); if (!innerScore.equals(uncorruptedInnerScore)) { var corruptionAnalyzer = new CorruptionAnalyzer<>(this); @@ -912,13 +964,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") 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..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 @@ -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,10 +9,11 @@ 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; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -21,26 +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 boolean assertClonedSolution = false; - protected boolean trackingWorkingSolution = false; - - public AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { - this.solutionDescriptor = solutionDescriptor; - this.environmentMode = environmentMode; + protected AbstractScoreDirectorFactory(SolutionDescriptor solutionDescriptor, + EnvironmentMode globalEnvironmentMode) { + this.solutionDescriptor = Objects.requireNonNull(solutionDescriptor); this.listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); + this.globalEnvironmentMode = globalEnvironmentMode; } @Override @@ -54,15 +57,20 @@ public ScoreDefinition getScoreDefinition() { } @Override - public InitializingScoreTrend getInitializingScoreTrend() { + public @Nullable InitializingScoreTrend getInitializingScoreTrend() { return initializingScoreTrend; } + @Override + public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder() { + return createScoreDirectorBuilder(globalEnvironmentMode); + } + public void setInitializingScoreTrend(InitializingScoreTrend initializingScoreTrend) { this.initializingScoreTrend = initializingScoreTrend; } - public ScoreDirectorFactory getAssertionScoreDirectorFactory() { + public @Nullable ScoreDirectorFactory getAssertionScoreDirectorFactory() { return assertionScoreDirectorFactory; } @@ -70,47 +78,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/DelegateScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java new file mode 100644 index 00000000000..e5b589be22e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactory.java @@ -0,0 +1,293 @@ +package ai.timefold.solver.core.impl.score.director; + +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; +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 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 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: + * 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> + implements ScoreDirectorFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(DelegateScoreDirectorFactory.class); + private final ScoreDirectorFactoryConfig config; + private final SolutionDescriptor solutionDescriptor; + private final EnvironmentMode globalEnvironmentMode; + private final ScoreDirectorFactory 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, + * 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)); + } + + /** + * 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()); + } + + /** + * @param config the score factory configuration + * @param solutionDescriptor the solution descriptor + * @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 DelegateScoreDirectorFactory(ScoreDirectorFactoryConfig config, SolutionDescriptor solutionDescriptor, + EnvironmentMode environmentMode, List metricsRequiringConstraintMatchList) { + this.config = Objects.requireNonNull(config); + assertCorrectDirectorFactory(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; + if (!metricsRequiringConstraintMatchList.isEmpty() && !environmentMode.isStepAssertOrMore()) { + LOGGER.info( + "Enabling constraint matching as required by the enabled metrics ({}). This will impact solver performance.", + metricsRequiringConstraintMatchList); + } + } + + @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 AbstractScoreDirectorBuilder createScoreDirectorBuilder() { + return createScoreDirectorBuilder(globalEnvironmentMode); + } + + @Override + 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. + // 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); + } + } + + 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(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(assertionScoreDirectorFactoryConfig, environmentMode, EnvironmentMode.STEP_ASSERT)); + } + 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()); + } + 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()); + } + + /** + * Unlike the default implementation, + * this also enables constraint matching when a metric requires it. + */ + @Override + public ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { + return !metricsRequiringConstraintMatchList.isEmpty() || environmentMode.isStepAssertOrMore() + ? ConstraintMatchPolicy.ENABLED + : ConstraintMatchPolicy.DISABLED; + } + + private AbstractScoreDirectorFactory decideMultipleScoreDirectorFactories( + SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode) { + // 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) { + return IncrementalScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); + } else if (config.getConstraintProviderClass() != null) { + return BavetConstraintStreamScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, + environmentMode); + } else { + throw new IllegalArgumentException( + "The scoreDirectorFactory lacks configuration for either constraintProviderClass, " + + "easyScoreCalculatorClass or incrementalScoreCalculatorClass."); + } + } + + private static void assertCorrectDirectorFactory(ScoreDirectorFactoryConfig config) { + var easyScoreCalculatorClass = config.getEasyScoreCalculatorClass(); + var hasEasyScoreCalculator = easyScoreCalculatorClass != null; + if (!hasEasyScoreCalculator && config.getEasyScoreCalculatorCustomProperties() != null) { + throw new IllegalStateException( + "If there is no easyScoreCalculatorClass (%s), then there can be no easyScoreCalculatorCustomProperties (%s) either." + .formatted(easyScoreCalculatorClass, config.getEasyScoreCalculatorCustomProperties())); + } + var incrementalScoreCalculatorClass = config.getIncrementalScoreCalculatorClass(); + var hasIncrementalScoreCalculator = incrementalScoreCalculatorClass != null; + if (!hasIncrementalScoreCalculator && config.getIncrementalScoreCalculatorCustomProperties() != null) { + throw new IllegalStateException( + "If there is no incrementalScoreCalculatorClass (%s), then there can be no incrementalScoreCalculatorCustomProperties (%s) either." + .formatted(incrementalScoreCalculatorClass, + config.getIncrementalScoreCalculatorCustomProperties())); + } + var constraintProviderClass = config.getConstraintProviderClass(); + var hasConstraintProvider = constraintProviderClass != null; + if (!hasConstraintProvider && config.getConstraintProviderCustomProperties() != null) { + throw new IllegalStateException( + "If there is no constraintProviderClass (%s), then there can be no constraintProviderCustomProperties (%s) either." + .formatted(constraintProviderClass, config.getConstraintProviderCustomProperties())); + } + if (config.getConstraintStreamProfilingEnabled() != null + && config.getConstraintStreamProfilingEnabled() + && !hasConstraintProvider) { + throw new IllegalStateException( + "If there is no constraintProviderClass (%s), then constraintStreamProfilingEnabled (%s) must be false." + .formatted(constraintProviderClass, config.getConstraintStreamProfilingEnabled())); + } + if (hasEasyScoreCalculator && (hasIncrementalScoreCalculator || hasConstraintProvider) + || (hasIncrementalScoreCalculator && hasConstraintProvider)) { + var scoreDirectorFactoryPropertyList = new ArrayList(3); + if (hasEasyScoreCalculator) { + scoreDirectorFactoryPropertyList + .add("an easyScoreCalculatorClass (%s)".formatted(easyScoreCalculatorClass.getName())); + } + if (hasConstraintProvider) { + scoreDirectorFactoryPropertyList + .add("an constraintProviderClass (%s)".formatted(constraintProviderClass.getName())); + } + if (hasIncrementalScoreCalculator) { + scoreDirectorFactoryPropertyList.add("an incrementalScoreCalculatorClass (%s)" + .formatted(incrementalScoreCalculatorClass.getName())); + } + var joined = String.join(" and ", scoreDirectorFactoryPropertyList); + throw new IllegalArgumentException("The scoreDirectorFactory cannot have %s together." + .formatted(joined)); + } + } + +} 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..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. @@ -209,7 +219,11 @@ default Solution_ cloneWorkingSolution() { void resetCalculationCount(); - void incrementCalculationCount(); + default void incrementCalculationCount() { + incrementCalculationCount(1L); + } + + void incrementCalculationCount(long count); /** * @return never null @@ -225,6 +239,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. * @@ -274,7 +297,6 @@ default Solution_ cloneWorkingSolution() { * @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); @@ -288,7 +310,6 @@ default Solution_ cloneWorkingSolution() { * @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); 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..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 @@ -2,44 +2,98 @@ 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> { + 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 */ - SolutionDescriptor getSolutionDescriptor(); + AbstractScoreDirectorBuilder createScoreDirectorBuilder(EnvironmentMode environmentMode); /** - * @return never null + * As defined by {@link #createScoreDirectorBuilder(EnvironmentMode)}, + * using the environment mode this factory was built for. */ - ScoreDefinition getScoreDefinition(); + AbstractScoreDirectorBuilder createScoreDirectorBuilder(); - AbstractScoreDirector.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 AbstractScoreDirector buildScoreDirector(EnvironmentMode environmentMode) { + return createScoreDirectorBuilder(environmentMode).build(); + } + /** + * As defined by {@link #buildScoreDirector(EnvironmentMode)}, + * using the environment mode this factory was built for. + */ default AbstractScoreDirector buildScoreDirector() { return createScoreDirectorBuilder().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 */ - InitializingScoreTrend getInitializingScoreTrend(); + default ConstraintMatchPolicy decideConstraintMatchPolicy(EnvironmentMode environmentMode) { + return environmentMode.isStepAssertOrMore() ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED; + } /** - * 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 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 */ - void assertScoreFromScratch(Solution_ solution); + @Nullable + InitializingScoreTrend getInitializingScoreTrend(); } 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/ScoreDirectorFactoryFactory.java deleted file mode 100644 index 08f3c3aa547..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java +++ /dev/null @@ -1,127 +0,0 @@ -package ai.timefold.solver.core.impl.score.director; - -import java.util.ArrayList; - -import ai.timefold.solver.core.api.score.Score; -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.impl.domain.solution.descriptor.SolutionDescriptor; -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> { - - private final ScoreDirectorFactoryConfig config; - - public ScoreDirectorFactoryFactory(ScoreDirectorFactoryConfig config) { - this.config = config; - } - - public ScoreDirectorFactory buildScoreDirectorFactory(EnvironmentMode environmentMode, - SolutionDescriptor solutionDescriptor) { - var scoreDirectorFactory = decideMultipleScoreDirectorFactories(solutionDescriptor, environmentMode); - var assertionScoreDirectorFactory = config.getAssertionScoreDirectorFactory(); - if (assertionScoreDirectorFactory != null) { - if (assertionScoreDirectorFactory.getAssertionScoreDirectorFactory() != null) { - throw new IllegalArgumentException( - "A assertionScoreDirectorFactory (%s) cannot have a non-null assertionScoreDirectorFactory (%s)." - .formatted(assertionScoreDirectorFactory, - assertionScoreDirectorFactory.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)); - } - var assertionScoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(assertionScoreDirectorFactory); - scoreDirectorFactory.setAssertionScoreDirectorFactory(assertionScoreDirectorFactoryFactory - .buildScoreDirectorFactory(EnvironmentMode.NON_REPRODUCIBLE, solutionDescriptor)); - } - scoreDirectorFactory.setInitializingScoreTrend(InitializingScoreTrend.parseTrend( - 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); - - // 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); - } else if (config.getIncrementalScoreCalculatorClass() != null) { - return IncrementalScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); - } else if (config.getConstraintProviderClass() != null) { - return BavetConstraintStreamScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, - environmentMode); - } else { - throw new IllegalArgumentException( - "The scoreDirectorFactory lacks configuration for either constraintProviderClass, " + - "easyScoreCalculatorClass or incrementalScoreCalculatorClass."); - } - } - - private static void assertCorrectDirectorFactory(ScoreDirectorFactoryConfig config) { - var easyScoreCalculatorClass = config.getEasyScoreCalculatorClass(); - var hasEasyScoreCalculator = easyScoreCalculatorClass != null; - if (!hasEasyScoreCalculator && config.getEasyScoreCalculatorCustomProperties() != null) { - throw new IllegalStateException( - "If there is no easyScoreCalculatorClass (%s), then there can be no easyScoreCalculatorCustomProperties (%s) either." - .formatted(easyScoreCalculatorClass, config.getEasyScoreCalculatorCustomProperties())); - } - var incrementalScoreCalculatorClass = config.getIncrementalScoreCalculatorClass(); - var hasIncrementalScoreCalculator = incrementalScoreCalculatorClass != null; - if (!hasIncrementalScoreCalculator && config.getIncrementalScoreCalculatorCustomProperties() != null) { - throw new IllegalStateException( - "If there is no incrementalScoreCalculatorClass (%s), then there can be no incrementalScoreCalculatorCustomProperties (%s) either." - .formatted(incrementalScoreCalculatorClass, - config.getIncrementalScoreCalculatorCustomProperties())); - } - var constraintProviderClass = config.getConstraintProviderClass(); - var hasConstraintProvider = constraintProviderClass != null; - if (!hasConstraintProvider && config.getConstraintProviderCustomProperties() != null) { - throw new IllegalStateException( - "If there is no constraintProviderClass (%s), then there can be no constraintProviderCustomProperties (%s) either." - .formatted(constraintProviderClass, config.getConstraintProviderCustomProperties())); - } - if (config.getConstraintStreamProfilingEnabled() != null - && config.getConstraintStreamProfilingEnabled() - && !hasConstraintProvider) { - throw new IllegalStateException( - "If there is no constraintProviderClass (%s), then constraintStreamProfilingEnabled (%s) must be false." - .formatted(constraintProviderClass, config.getConstraintStreamProfilingEnabled())); - } - if (hasEasyScoreCalculator && (hasIncrementalScoreCalculator || hasConstraintProvider) - || (hasIncrementalScoreCalculator && hasConstraintProvider)) { - var scoreDirectorFactoryPropertyList = new ArrayList(3); - if (hasEasyScoreCalculator) { - scoreDirectorFactoryPropertyList - .add("an easyScoreCalculatorClass (%s)".formatted(easyScoreCalculatorClass.getName())); - } - if (hasConstraintProvider) { - scoreDirectorFactoryPropertyList - .add("an constraintProviderClass (%s)".formatted(constraintProviderClass.getName())); - } - if (hasIncrementalScoreCalculator) { - scoreDirectorFactoryPropertyList.add("an incrementalScoreCalculatorClass (%s)" - .formatted(incrementalScoreCalculatorClass.getName())); - } - var joined = String.join(" and ", scoreDirectorFactoryPropertyList); - throw new IllegalArgumentException("The scoreDirectorFactory cannot have %s together." - .formatted(joined)); - } - } - -} 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..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 @@ -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 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..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 @@ -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 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..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 @@ -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,8 +216,9 @@ public static final class Builder> extends AbstractScoreDirectorBuilder, Builder> { - public Builder(BavetConstraintStreamScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); + public Builder(BavetConstraintStreamScoreDirectorFactory scoreDirectorFactory, + EnvironmentMode environmentMode) { + super(scoreDirectorFactory, environmentMode); } @Override @@ -225,8 +227,7 @@ public BavetConstraintStreamScoreDirector build() { } @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 d6dd5f496d1..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 @@ -6,13 +6,16 @@ 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.ScoreDirectorFactory; 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 +42,8 @@ public abstract class AbstractSolver implements Solver { protected final transient Logger LOGGER = LoggerFactory.getLogger(getClass()); + protected final EnvironmentMode globalEnvironmentMode; + private final ScoreDirectorFactory scoreDirectorFactory; private final SolverEventSupport solverEventSupport = new SolverEventSupport<>(this); private final PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); @@ -50,16 +55,22 @@ public abstract class AbstractSolver implements Solver { private RandomGenerator.@Nullable SplittableGenerator savedRandom; + private final SolverContextManager solverContextManager; + // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - protected AbstractSolver(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.solverContextManager = new SolverContextManager<>(scoreDirectorFactory, bestSolutionRecaller, this.phaseList); } public void solvingStarted(SolverScope solverScope) { @@ -75,6 +86,7 @@ public void solvingStarted(SolverScope solverScope) { for (Phase phase : phaseList) { phase.solvingStarted(solverScope); } + solverContextManager.solvingStarted(solverScope); } protected void runPhases(SolverScope solverScope) { @@ -105,13 +117,16 @@ public void solvingEnded(SolverScope 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); } + solverContextManager.solvingError(solverScope, exception); } public void phaseStarted(AbstractPhaseScope phaseScope) { + solverContextManager.phaseStarted(phaseScope); bestSolutionRecaller.phaseStarted(phaseScope); phaseLifecycleSupport.firePhaseStarted(phaseScope); globalTermination.phaseStarted(phaseScope); @@ -183,8 +198,12 @@ public BestSolutionRecaller getBestSolutionRecaller() { return bestSolutionRecaller; } + @SuppressWarnings("unchecked") + public > ScoreDirectorFactory getScoreDirectorFactory() { + return (ScoreDirectorFactory) scoreDirectorFactory; + } + public List> getPhaseList() { return phaseList; } - } 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..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 @@ -39,23 +39,21 @@ @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 Supplier randomFactory; + private final BasicPlumbingTermination basicPlumbingTermination; + private final AtomicBoolean solving = new AtomicBoolean(false); + private final SolverScope solverScope; private final String moveThreadCountDescription; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - public DefaultSolver(EnvironmentMode environmentMode, Supplier randomFactory, - BestSolutionRecaller bestSolutionRecaller, BasicPlumbingTermination basicPlumbingTermination, - UniversalTermination termination, List> phaseList, - SolverScope solverScope, String moveThreadCountDescription) { - super(bestSolutionRecaller, termination, phaseList); - this.environmentMode = environmentMode; + public DefaultSolver(EnvironmentMode globalEnvironmentMode, ScoreDirectorFactory scoreDirectorFactory, + Supplier randomFactory, BestSolutionRecaller bestSolutionRecaller, + BasicPlumbingTermination basicPlumbingTermination, UniversalTermination termination, + List> phaseList, SolverScope solverScope, String moveThreadCountDescription) { + super(globalEnvironmentMode, scoreDirectorFactory, bestSolutionRecaller, termination, phaseList); this.randomFactory = randomFactory; this.basicPlumbingTermination = basicPlumbingTermination; this.solverScope = solverScope; @@ -63,18 +61,10 @@ public DefaultSolver(EnvironmentMode environmentMode, Supplier ran this.moveThreadCountDescription = moveThreadCountDescription; } - public EnvironmentMode getEnvironmentMode() { - return environmentMode; - } - public RandomSource getRandomSource() { return randomFactory.get(); } - public ScoreDirectorFactory getScoreDirectorFactory() { - return solverScope.getScoreDirector().getScoreDirectorFactory(); - } - public SolverScope getSolverScope() { return solverScope; } @@ -207,11 +197,11 @@ 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(), - environmentMode.name(), + globalEnvironmentMode.name(), moveThreadCountDescription, randomFactory); if (LOGGER.isInfoEnabled()) { // Formatting is expensive here. @@ -319,7 +309,7 @@ public void outerSolvingEnded(SolverScope solverScope) { solverScope.getBestScore().raw(), solverScope.getMoveEvaluationSpeed(), phaseList.size(), - 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 e3db6fba7a1..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 @@ -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; @@ -12,13 +11,14 @@ 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; -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; @@ -33,9 +33,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 +54,29 @@ import io.micrometer.core.instrument.Tags; /** + * 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 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 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 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, + * 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 global environment mode. + *

+ * 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 */ @@ -67,7 +89,8 @@ public final class DefaultSolverFactory implements SolverFactory solutionDescriptor; - private final ScoreDirectorFactory scoreDirectorFactory; + private final EnvironmentMode globalEnvironmentMode; + private final DelegateScoreDirectorFactory delegateScoreDirectorFactory; private final DomainAccessType domainAccessType; public DefaultSolverFactory(SolverConfig solverConfig) { @@ -77,10 +100,14 @@ 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)); + EnvironmentModeResolver.validate(solverConfig); + this.globalEnvironmentMode = EnvironmentModeResolver.resolve(solverConfig); this.solutionDescriptor = buildSolutionDescriptor(); - // Caching score director factory as it potentially does expensive things. - this.scoreDirectorFactory = buildScoreDirectorFactory(); + // 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() { @@ -91,9 +118,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) scoreDirectorFactory; + return (ScoreDirectorFactory) delegateScoreDirectorFactory.getDelegate(); } @Override @@ -105,36 +138,21 @@ 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 environmentMode = solverConfig.determineEnvironmentMode(); - var isStepAssertOrMore = environmentMode.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() + var scoreDirector = delegateScoreDirectorFactory.createScoreDirectorBuilder(globalEnvironmentMode) .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. - .withConstraintMatchPolicy( - constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) + .withConstraintMatchPolicy(delegateScoreDirectorFactory.decideConstraintMatchPolicy(globalEnvironmentMode)) .build(); - solverScope.setScoreDirector(castScoreDirector); - solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(castScoreDirector)); - + 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(globalEnvironmentMode); + var randomFactory = buildRandomSupplier(globalEnvironmentMode); var previewFeaturesEnabled = solverConfig.getEnablePreviewFeatureSet(); var scoreDirectorFactoryConfig = solverConfig.getScoreDirectorFactoryConfig(); @@ -149,13 +167,13 @@ public Solver buildSolver(SolverConfigOverride configOverride) { var configPolicy = new HeuristicConfigPolicy.Builder() .withPreviewFeatureSet(previewFeaturesEnabled) - .withEnvironmentMode(environmentMode) + .withEnvironmentMode(globalEnvironmentMode) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(solverConfig.getMoveThreadBufferSize()) .withThreadFactoryClass(solverConfig.getThreadFactoryClass()) .withNearbyDistanceMeterClass(solverConfig.getNearbyDistanceMeterClass()) .withRandom(randomFactory.get()) - .withInitializingScoreTrend(scoreDirectorFactory.getInitializingScoreTrend()) + .withInitializingScoreTrend(delegateScoreDirectorFactory.getInitializingScoreTrend()) .withSolutionDescriptor(solutionDescriptor) .withClassInstanceCache(ClassInstanceCache.create()) .build(); @@ -163,8 +181,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<>(globalEnvironmentMode, delegateScoreDirectorFactory, randomFactory, bestSolutionRecaller, + basicPlumbingTermination, (UniversalTermination) termination, phaseList, solverScope, moveThreadCount == null ? SolverConfig.MOVE_THREAD_COUNT_NONE : Integer.toString(moveThreadCount)); } @@ -182,7 +200,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 +223,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, 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/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..0b8497b4581 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/SolverContextManager.java @@ -0,0 +1,189 @@ +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) { + 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); + } + } + + // ************************************************************************ + // 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/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/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/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/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 6e323af90f5..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 @@ -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] @@ -502,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); @@ -557,10 +559,12 @@ 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); - 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/RuinRecreateConstructionHeuristicPhaseBuilderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java index c610b392c25..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 @@ -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 })) @@ -28,9 +30,26 @@ 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() + .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/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/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/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; 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..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 @@ -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); 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))) .withMessageContaining("The acceptor does not specify any acceptorType"); } @@ -108,11 +111,12 @@ 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)); } @Test - void valueTabuWithoutSizes_throwsException() { + void valueTabuWithoutSizes_throwsException() { var config = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.VALUE_TABU)); var factory = AcceptorFactory.create(config); @@ -121,7 +125,7 @@ void valueTabuWithoutSizes_throwsException() { } @Test - void moveTabuWithoutSizes_throwsException() { + void moveTabuWithoutSizes_throwsException() { var config = new LocalSearchAcceptorConfig() .withAcceptorTypeList(List.of(AcceptorType.MOVE_TABU)); var factory = AcceptorFactory.create(config); 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/neighborhood/NeighborhoodsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java index 5fcfec9505a..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 @@ -73,7 +73,9 @@ void changeMoveBasedLocalSearch() { 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/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 new file mode 100644 index 00000000000..0a26752ae3b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/DelegateScoreDirectorFactoryTest.java @@ -0,0 +1,428 @@ +package ai.timefold.solver.core.impl.score.director; + +import static org.assertj.core.api.Assertions.assertThat; +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.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.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 { + + 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); + } + + private static ScoreDirectorFactoryConfig incrementalConfig() { + return new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); + } + + private static ScoreDirectorFactoryConfig constraintStreamConfig() { + return new ScoreDirectorFactoryConfig() + .withConstraintProviderClass(DummyConstraintProvider.class); + } + + // ************************************************************************ + // Picking the delegate + // ************************************************************************ + + @Test + 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 noScoreCalculation_throwsException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(new ScoreDirectorFactoryConfig())) + .withMessageContaining("lacks configuration"); + } + + @Test + void solverConfigWithoutScoreDirectorFactory_throwsException() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new DelegateScoreDirectorFactory(new SolverConfig(), + TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT)) + .withMessageContaining("lacks configuration"); + } + + @Test + void multipleScoreCalculations_throwsException() { + var config = constraintStreamConfig() + .withEasyScoreCalculatorClass(TestCustomPropertiesEasyScoreCalculator.class); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> buildTestdataScoreDirectorFactory(config)) + .withMessageContaining("scoreDirectorFactory") + .withMessageContaining("together"); + } + + @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); + } + } + + @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()); + } + } + + @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()); + } + } + + @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); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector(EnvironmentMode.PHASE_ASSERT)) { + assertThat(scoreDirector.getScoreDirectorFactory()).isSameAs(scoreDirectorFactory.getDelegate()); + } + } + + @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 + // ************************************************************************ + + @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); + } + } + + @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 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 = incrementalConfig(); + var customProperties = new HashMap(); + customProperties.put("stringProperty", "string 1"); + customProperties.put("intProperty", "7"); + config.setIncrementalScoreCalculatorCustomProperties(customProperties); + + var scoreDirectorFactory = + (IncrementalScoreDirectorFactory) buildTestdataScoreDirectorFactory(config) + .getDelegate(); + try (var scoreDirector = scoreDirectorFactory.createScoreDirectorBuilder(EnvironmentMode.PHASE_ASSERT).build()) { + var scoreCalculator = + (TestCustomPropertiesIncrementalScoreCalculator) scoreDirector.getIncrementalScoreCalculator(); + assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); + assertThat(scoreCalculator.getIntProperty()).isEqualTo(7); + } + } + + @Test + 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()); + } + + @Test + void initializingScoreTrendDefaultsToAny() { + assertThat(buildTestdataScoreDirectorFactory(incrementalConfig()).getInitializingScoreTrend()) + .isEqualTo(InitializingScoreTrend.parseTrend("ANY", 1)); + } + + @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); + 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(); + 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 { + + private String stringProperty; + private int intProperty; + + public String getStringProperty() { + return stringProperty; + } + + public void setStringProperty(String stringProperty) { + this.stringProperty = stringProperty; + } + + public int getIntProperty() { + return intProperty; + } + + public void setIntProperty(int intProperty) { + this.intProperty = 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 + public SimpleScore calculateScore() { + return SimpleScore.ZERO; + } + } + +} 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/ScoreDirectorFactoryFactoryTest.java deleted file mode 100644 index 1f2076cfe1a..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java +++ /dev/null @@ -1,187 +0,0 @@ -package ai.timefold.solver.core.impl.score.director; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -import java.util.HashMap; - -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.impl.score.director.incremental.IncrementalScoreDirectorFactory; -import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; -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 { - - @Test - void multipleScoreCalculations_throwsException() { - var config = new ScoreDirectorFactoryConfig() - .withConstraintProviderClass(TestdataConstraintProvider.class) - .withEasyScoreCalculatorClass(TestCustomPropertiesEasyScoreCalculator.class); - assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> buildTestdataScoreDirectoryFactory(config)) - .withMessageContaining("scoreDirectorFactory") - .withMessageContaining("together"); - } - - private ScoreDirectorFactory - buildTestdataScoreDirectoryFactory(ScoreDirectorFactoryConfig config, EnvironmentMode environmentMode) { - return new ScoreDirectorFactoryFactory(config) - .buildScoreDirectorFactory(environmentMode, TestdataSolution.buildSolutionDescriptor()); - } - - private ScoreDirectorFactory - buildTestdataScoreDirectoryFactory(ScoreDirectorFactoryConfig config) { - return buildTestdataScoreDirectoryFactory(config, EnvironmentMode.PHASE_ASSERT); - } - - @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); - } - - 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; - } - } - - public static class TestdataConstraintProvider implements ConstraintProvider { - @Override - public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { - return new Constraint[0]; - } - } - - @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"); - } - - @Test - void incrementalScoreCalculatorWithCustomProperties() { - var config = new ScoreDirectorFactoryConfig(); - config.setIncrementalScoreCalculatorClass( - TestCustomPropertiesIncrementalScoreCalculator.class); - 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()) { - var scoreCalculator = - (TestCustomPropertiesIncrementalScoreCalculator) scoreDirector.getIncrementalScoreCalculator(); - assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); - assertThat(scoreCalculator.getIntProperty()).isEqualTo(7); - } - } - - @Test - void buildWithAssertionScoreDirectorFactory() { - var assertionScoreDirectorConfig = new ScoreDirectorFactoryConfig() - .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); - var config = new ScoreDirectorFactoryConfig() - .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class) - .withAssertionScoreDirectorFactory(assertionScoreDirectorConfig); - - var scoreDirectorFactory = - (AbstractScoreDirectorFactory) buildTestdataScoreDirectoryFactory(config, - EnvironmentMode.STEP_ASSERT); - - var assertionScoreDirectorFactory = - (IncrementalScoreDirectorFactory) scoreDirectorFactory - .getAssertionScoreDirectorFactory(); - try (var assertionScoreDirector = assertionScoreDirectorFactory.buildScoreDirector()) { - var assertionScoreCalculator = assertionScoreDirector.getIncrementalScoreCalculator(); - assertThat(assertionScoreCalculator).isExactlyInstanceOf(TestCustomPropertiesIncrementalScoreCalculator.class); - } - } - - @NullMarked - public static class TestCustomPropertiesIncrementalScoreCalculator - implements IncrementalScoreCalculator { - - private String stringProperty; - private int intProperty; - - public String getStringProperty() { - return stringProperty; - } - - public void setStringProperty(String stringProperty) { - this.stringProperty = stringProperty; - } - - public int getIntProperty() { - return intProperty; - } - - public void setIntProperty(int intProperty) { - this.intProperty = intProperty; - } - - @Override - public void resetWorkingSolution(TestdataSolution workingSolution) { - - } - - @Override - public void beforeVariableChanged(Object entity, String variableName) { - } - - @Override - public void afterVariableChanged(Object entity, String variableName) { - } - - @Override - public SimpleScore calculateScore() { - return SimpleScore.ZERO; - } - } - -} 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..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 @@ -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; @@ -31,10 +31,8 @@ final class EasyScoreDirectorSemanticsTest extends AbstractScoreDirectorSemantic SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withEasyScoreCalculatorClass(TestdataConstraintWeightOverridesEasyScoreCalculator.class); - var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + 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 ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + 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 ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Test @@ -69,8 +65,8 @@ void easyScoreCalculatorWithCustomProperties() { config.setEasyScoreCalculatorCustomProperties(customProperties); var testdataSolutionScoreDirectorFactory = buildTestdataScoreDirectoryFactory(config); - try (var scoreDirector = - (EasyScoreDirector) testdataSolutionScoreDirectorFactory.buildScoreDirector()) { + try (var scoreDirector = (EasyScoreDirector) 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 ScoreDirectorFactoryFactory(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 3aa3c4b50f8..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 @@ -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; @@ -33,10 +33,8 @@ final class IncrementalScoreDirectorSemanticsTest extends AbstractScoreDirectorS SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataConstraintWeightOverridesIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -44,9 +42,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedListIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + EnvironmentMode.PHASE_ASSERT); } @Override @@ -55,9 +52,8 @@ protected ScoreDirectorFactory buildSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withIncrementalScoreCalculatorClass(TestdataPinnedWithIndexListIncrementalScoreCalculator.class); - var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + 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 e5f0d0e1ab8..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 @@ -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; @@ -28,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()); @@ -38,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(); } @@ -48,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); } } @@ -78,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), @@ -91,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)); @@ -124,10 +130,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 @@ -136,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)); @@ -168,10 +177,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 @@ -180,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) @@ -213,10 +225,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 @@ -225,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)); @@ -264,10 +279,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 @@ -276,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); @@ -311,10 +329,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 @@ -323,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); @@ -358,10 +379,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..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 @@ -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; @@ -22,10 +22,8 @@ final class ConstraintStreamsBavetScoreDirectorSemanticsTest extends AbstractSco SolutionDescriptor solutionDescriptor) { var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() .withConstraintProviderClass(TestdataConstraintWeightOverridesConstraintProvider.class); - var scoreDirectorFactoryFactory = - new ScoreDirectorFactoryFactory( - scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + 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 ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + return new DelegateScoreDirectorFactory<>(scoreDirectorFactoryConfig, solutionDescriptor, + 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 ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); - return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + 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 e1b5108e66c..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; @@ -18,6 +19,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 +167,113 @@ 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("is only possible when global environmentMode is reproducible"); + } + + @Test + 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("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 + 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 global 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 af7e60b60da..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 @@ -16,6 +16,8 @@ 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; import java.util.random.RandomGenerator; @@ -68,9 +70,14 @@ 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.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; 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; @@ -570,6 +577,102 @@ 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); + // 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 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 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); + + var countAfterFirstPhase = new AtomicLong(-1); + var countAtSecondPhaseStart = new AtomicLong(-1); + solver.addPhaseLifecycleListener(new PhaseLifecycleListenerAdapter() { + @Override + 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); + + // 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 void solveRepeatedlyBasicVariable(SoftAssertions softly) { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); @@ -2035,6 +2138,91 @@ 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 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(); + 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, @@ -2354,6 +2542,103 @@ 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 TRACKED_FULL_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); + } + + @Test + void solveWithPhaseEnvironmentModeOverride() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); + // 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(); + } + + @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.getScoreDirectorFactory().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 { 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/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..0818cbf6c59 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/SolverContextManagerTest.java @@ -0,0 +1,194 @@ +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.doThrow; +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 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); + } + + @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 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(); + 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/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..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.assertj.core.api.Assertions.fail; import java.util.ArrayList; import java.util.Arrays; @@ -55,7 +54,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 +134,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); - } + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -231,11 +224,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); - } + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); // Score calculation and problem scale counts should be removed // since registering multiple gauges with the same id @@ -298,11 +287,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); - } + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); meterRegistry.publish(); assertThat(solution).isNotNull(); assertThat(solution.getEntityList().stream() @@ -451,11 +436,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); - } + assertThatCode(() -> latch.await(10, TimeUnit.SECONDS)).doesNotThrowAnyException(); assertThat(step.get()).isEqualTo(2); meterRegistry.publish(); assertThat(solution).isNotNull(); 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..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 @@ -210,6 +210,56 @@ 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. +Any number of phases can override it, including all of them: +the solver's environment mode still applies outside the phases. +If the solver's environment mode is `<>`, 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() + ... + .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 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") + }; + } +} 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/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 @@ + + + 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)"); + } + +}