From eeb764c6a0a95831a2a0a15eac11bb94e9ca5bac Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 6 May 2026 08:56:20 -0300 Subject: [PATCH 1/8] feat: enable nearby for sublist moves --- .../heuristic/selector/move/NearbyUtil.java | 51 ++++++++ .../list/SubListChangeMoveSelectorConfig.java | 18 ++- .../list/SubListSwapMoveSelectorConfig.java | 21 +++- .../selector/list/RandomSubListSelector.java | 2 + .../selector/list/SubListSelector.java | 4 + .../selector/list/SubListSelectorFactory.java | 2 +- .../mimic/MimicRecordingSubListSelector.java | 10 ++ .../mimic/MimicReplayingSubListSelector.java | 10 ++ .../list/mimic/SubListMimicRecorder.java | 4 + .../kopt/SelectorBasedTwoOptListMove.java | 84 ++++++++++--- .../termination/MockablePhaseTermination.java | 2 +- .../selector/move/MoveSelectorConfigTest.java | 4 - ...SubListChangeMoveSelectorFactoryTest.java} | 2 +- ...omSubListSwapMoveSelectorFactoryTest.java} | 2 +- .../kopt/SelectorBasedTwoOptListMoveTest.java | 114 ++++++++++++++++++ .../core/impl/solver/SolverMetricsIT.java | 2 +- 16 files changed, 300 insertions(+), 32 deletions(-) rename core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/{SubListChangeMoveSelectorFactoryTest.java => RandomSubListChangeMoveSelectorFactoryTest.java} (99%) rename core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/{SubListSwapMoveSelectorFactoryTest.java => RandomSubListSwapMoveSelectorFactoryTest.java} (98%) diff --git a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/NearbyUtil.java b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/NearbyUtil.java index 4cdfdc68a3d..b210c9d42cf 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/NearbyUtil.java +++ b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/NearbyUtil.java @@ -5,10 +5,13 @@ import ai.timefold.solver.core.config.heuristic.selector.common.nearby.NearbySelectionConfig; import ai.timefold.solver.core.config.heuristic.selector.entity.EntitySelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.list.DestinationSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.list.SubListSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.ChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.SwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListSwapMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListChangeMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListSwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.kopt.KOptListMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; import ai.timefold.solver.core.config.util.ConfigUtils; @@ -153,6 +156,54 @@ private static ValueSelectorConfig configureSecondaryValueSelector(ValueSelector .withValueSelectorConfig(valueConfig); } + public static @NonNull SubListChangeMoveSelectorConfig enable( + @NonNull SubListChangeMoveSelectorConfig subListChangeMoveSelectorConfig, + @NonNull Class> distanceMeter, @NonNull RandomGenerator random) { + var nearbyConfig = subListChangeMoveSelectorConfig.copyConfig(); + var subListSelectorConfig = configureSubListSelector(nearbyConfig.getSubListSelectorConfig(), random); + var destinationConfig = nearbyConfig.getDestinationSelectorConfig(); + if (destinationConfig == null) { + destinationConfig = new DestinationSelectorConfig(); + } + destinationConfig + .withNearbySelectionConfig(configureNearbySelectionWithSublist(subListSelectorConfig.getId(), distanceMeter)); + return nearbyConfig.withSubListSelectorConfig(subListSelectorConfig) + .withDestinationSelectorConfig(destinationConfig); + } + + public static @NonNull SubListSwapMoveSelectorConfig enable( + @NonNull SubListSwapMoveSelectorConfig subListSwapMoveSelectorConfig, + @NonNull Class> distanceMeter, @NonNull RandomGenerator random) { + var nearbyConfig = subListSwapMoveSelectorConfig.copyConfig(); + var subListSelectorConfig = configureSubListSelector(nearbyConfig.getSubListSelectorConfig(), random); + var secondaryConfig = nearbyConfig.getSecondarySubListSelectorConfig(); + if (secondaryConfig == null) { + secondaryConfig = new SubListSelectorConfig(); + } + secondaryConfig + .withNearbySelectionConfig(configureNearbySelectionWithSublist(subListSelectorConfig.getId(), distanceMeter)); + return nearbyConfig.withSubListSelectorConfig(subListSelectorConfig) + .withSecondarySubListSelectorConfig(secondaryConfig); + } + + private static NearbySelectionConfig configureNearbySelectionWithSublist(String recordingSelectorId, + Class> distanceMeter) { + return new NearbySelectionConfig() + .withOriginSubListSelectorConfig(new SubListSelectorConfig() + .withMimicSelectorRef(recordingSelectorId)) + .withNearbyDistanceMeterClass(distanceMeter); + } + + private static SubListSelectorConfig configureSubListSelector(SubListSelectorConfig subListSelectorConfig, + RandomGenerator random) { + if (subListSelectorConfig == null) { + subListSelectorConfig = new SubListSelectorConfig(); + } + var sublistSelectorId = ConfigUtils.addRandomSuffix("sublistSelector", random); + subListSelectorConfig.withId(sublistSelectorId); + return subListSelectorConfig; + } + private NearbyUtil() { // No instances. } diff --git a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java index 4a40514e97a..91979b04600 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.config.heuristic.selector.move.generic.list; import java.util.function.Consumer; +import java.util.random.RandomGenerator; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlType; @@ -8,7 +9,10 @@ import ai.timefold.solver.core.config.heuristic.selector.list.DestinationSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.list.SubListSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.MoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.NearbyAutoConfigurationEnabled; +import ai.timefold.solver.core.config.heuristic.selector.move.NearbyUtil; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -18,7 +22,8 @@ "subListSelectorConfig", "destinationSelectorConfig" }) -public final class SubListChangeMoveSelectorConfig extends MoveSelectorConfig { +public final class SubListChangeMoveSelectorConfig extends MoveSelectorConfig + implements NearbyAutoConfigurationEnabled { public static final String XML_ELEMENT_NAME = "subListChangeMoveSelector"; @@ -106,6 +111,17 @@ public void visitReferencedClasses(@NonNull Consumer> classVisitor) { } } + @Override + public boolean canEnableNearbyInMixedModels() { + return true; + } + + @Override + public @NonNull SubListChangeMoveSelectorConfig enableNearbySelection( + @NonNull Class> distanceMeter, @NonNull RandomGenerator random) { + return NearbyUtil.enable(this, distanceMeter, random); + } + @Override public boolean hasNearbySelectionConfig() { return (subListSelectorConfig != null && subListSelectorConfig.hasNearbySelectionConfig()) diff --git a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java index 0da185748b8..20dfc58f37a 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java @@ -1,13 +1,17 @@ package ai.timefold.solver.core.config.heuristic.selector.move.generic.list; import java.util.function.Consumer; +import java.util.random.RandomGenerator; import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlType; import ai.timefold.solver.core.config.heuristic.selector.list.SubListSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.MoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.NearbyAutoConfigurationEnabled; +import ai.timefold.solver.core.config.heuristic.selector.move.NearbyUtil; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -17,7 +21,8 @@ "subListSelectorConfig", "secondarySubListSelectorConfig" }) -public final class SubListSwapMoveSelectorConfig extends MoveSelectorConfig { +public final class SubListSwapMoveSelectorConfig extends MoveSelectorConfig + implements NearbyAutoConfigurationEnabled { public static final String XML_ELEMENT_NAME = "subListSwapMoveSelector"; @@ -80,8 +85,7 @@ public void setSecondarySubListSelectorConfig(@Nullable SubListSelectorConfig se this.subListSelectorConfig = ConfigUtils.inheritOverwritableProperty(subListSelectorConfig, inheritedConfig.subListSelectorConfig); this.secondarySubListSelectorConfig = - ConfigUtils.inheritOverwritableProperty(secondarySubListSelectorConfig, - inheritedConfig.secondarySubListSelectorConfig); + ConfigUtils.inheritConfig(secondarySubListSelectorConfig, inheritedConfig.secondarySubListSelectorConfig); return this; } @@ -107,6 +111,17 @@ public boolean hasNearbySelectionConfig() { || (secondarySubListSelectorConfig != null && secondarySubListSelectorConfig.hasNearbySelectionConfig()); } + @Override + public boolean canEnableNearbyInMixedModels() { + return true; + } + + @Override + public @NonNull SubListSwapMoveSelectorConfig enableNearbySelection( + @NonNull Class> distanceMeter, @NonNull RandomGenerator random) { + return NearbyUtil.enable(this, distanceMeter, random); + } + @Override public String toString() { return getClass().getSimpleName() + "(" + subListSelectorConfig 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..71a6f70c874 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 @@ -162,10 +162,12 @@ protected SubList createUpcomingSelection() { } } + @Override public int getMinimumSubListSize() { return minimumSubListSize; } + @Override public int getMaximumSubListSize() { return maximumSubListSize; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelector.java index c52e558a833..9c38246c94e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelector.java @@ -12,4 +12,8 @@ public interface SubListSelector extends IterableSelector endingValueIterator(); long getValueCount(); + + int getMinimumSubListSize(); + + int getMaximumSubListSize(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelectorFactory.java index fded80af3b5..c9feede96ea 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/SubListSelectorFactory.java @@ -97,7 +97,7 @@ private SubListSelector applyMimicRecording(HeuristicConfigPolicy applyNearbySelection(HeuristicConfigPolicy configPolicy, SelectionCacheType minimumCacheType, SelectionOrder resolvedSelectionOrder, - RandomSubListSelector subListSelector) { + SubListSelector subListSelector) { NearbySelectionConfig nearbySelectionConfig = config.getNearbySelectionConfig(); if (nearbySelectionConfig == null) { return subListSelector; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicRecordingSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicRecordingSubListSelector.java index 47f0516bb0a..a65e8a785ff 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicRecordingSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicRecordingSubListSelector.java @@ -92,6 +92,16 @@ public long getValueCount() { return childSubListSelector.getValueCount(); } + @Override + public int getMinimumSubListSize() { + return childSubListSelector.getMinimumSubListSize(); + } + + @Override + public int getMaximumSubListSize() { + return childSubListSelector.getMaximumSubListSize(); + } + @Override public boolean equals(Object other) { if (this == other) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicReplayingSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicReplayingSubListSelector.java index af03cda208e..b2e4df13c44 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicReplayingSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/MimicReplayingSubListSelector.java @@ -76,6 +76,16 @@ public long getValueCount() { return subListMimicRecorder.getValueCount(); } + @Override + public int getMinimumSubListSize() { + return subListMimicRecorder.getMinimumSubListSize(); + } + + @Override + public int getMaximumSubListSize() { + return subListMimicRecorder.getMaximumSubListSize(); + } + @Override public Iterator iterator() { return new ReplayingSubListIterator(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/SubListMimicRecorder.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/SubListMimicRecorder.java index 6f4c43cc4c4..e9acd02f67a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/SubListMimicRecorder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/mimic/SubListMimicRecorder.java @@ -17,4 +17,8 @@ public interface SubListMimicRecorder { Iterator endingValueIterator(); long getValueCount(); + + int getMinimumSubListSize(); + + int getMaximumSubListSize(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMove.java index 11a48169763..235e0731d79 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMove.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.SequencedCollection; import ai.timefold.solver.core.api.domain.common.Lookup; @@ -54,17 +55,24 @@ public final class SelectorBasedTwoOptListMove extends AbstractSelect private final Object secondEntity; private final int firstEdgeEndpoint; private final int secondEdgeEndpoint; + private final boolean reverseTail; private final int shift; private final int entityFirstUnpinnedIndex; public SelectorBasedTwoOptListMove(ListVariableDescriptor variableDescriptor, Object firstEntity, Object secondEntity, int firstEdgeEndpoint, int secondEdgeEndpoint) { + this(variableDescriptor, firstEntity, secondEntity, firstEdgeEndpoint, secondEdgeEndpoint, false); + } + + public SelectorBasedTwoOptListMove(ListVariableDescriptor variableDescriptor, Object firstEntity, + Object secondEntity, int firstEdgeEndpoint, int secondEdgeEndpoint, boolean reverseTail) { this.variableDescriptor = variableDescriptor; this.firstEntity = firstEntity; this.secondEntity = secondEntity; this.firstEdgeEndpoint = firstEdgeEndpoint; this.secondEdgeEndpoint = secondEdgeEndpoint; + this.reverseTail = reverseTail; if (firstEntity == secondEntity) { entityFirstUnpinnedIndex = variableDescriptor.getFirstUnpinnedIndex(firstEntity); if (firstEdgeEndpoint == 0) { @@ -87,7 +95,8 @@ public SelectorBasedTwoOptListMove(ListVariableDescriptor variableDes } private SelectorBasedTwoOptListMove(ListVariableDescriptor variableDescriptor, Object firstEntity, - Object secondEntity, int firstEdgeEndpoint, int secondEdgeEndpoint, int entityFirstUnpinnedIndex, int shift) { + Object secondEntity, int firstEdgeEndpoint, int secondEdgeEndpoint, int entityFirstUnpinnedIndex, int shift, + boolean reverseTail) { this.variableDescriptor = variableDescriptor; this.firstEntity = firstEntity; this.secondEntity = secondEntity; @@ -95,26 +104,28 @@ private SelectorBasedTwoOptListMove(ListVariableDescriptor variableDe this.secondEdgeEndpoint = secondEdgeEndpoint; this.entityFirstUnpinnedIndex = entityFirstUnpinnedIndex; this.shift = shift; + this.reverseTail = reverseTail; } @Override protected void execute(VariableDescriptorAwareScoreDirector scoreDirector) { if (firstEntity == secondEntity) { doSublistReversal(scoreDirector); + } else if (reverseTail) { + doTailSwapWithReversion(scoreDirector); } else { doTailSwap(scoreDirector); } } - private void doTailSwap(ScoreDirector scoreDirector) { - var castScoreDirector = (VariableDescriptorAwareScoreDirector) scoreDirector; + private void doTailSwap(VariableDescriptorAwareScoreDirector scoreDirector) { var firstListVariable = variableDescriptor.getValue(firstEntity); var secondListVariable = variableDescriptor.getValue(secondEntity); var firstOriginalSize = firstListVariable.size(); var secondOriginalSize = secondListVariable.size(); - castScoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, firstOriginalSize); - castScoreDirector.beforeListVariableChanged(variableDescriptor, secondEntity, secondEdgeEndpoint, secondOriginalSize); + scoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, firstOriginalSize); + scoreDirector.beforeListVariableChanged(variableDescriptor, secondEntity, secondEdgeEndpoint, secondOriginalSize); var firstListVariableTail = firstListVariable.subList(firstEdgeEndpoint, firstOriginalSize); var secondListVariableTail = secondListVariable.subList(secondEdgeEndpoint, secondOriginalSize); @@ -127,22 +138,51 @@ private void doTailSwap(ScoreDirector scoreDirector) { secondListVariableTail.clear(); secondListVariable.addAll(firstListVariableTailCopy); - castScoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, + scoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, firstOriginalSize + tailSizeDifference); - castScoreDirector.afterListVariableChanged(variableDescriptor, secondEntity, secondEdgeEndpoint, + scoreDirector.afterListVariableChanged(variableDescriptor, secondEntity, secondEdgeEndpoint, secondOriginalSize - tailSizeDifference); } - private void doSublistReversal(ScoreDirector scoreDirector) { - var castScoreDirector = (VariableDescriptorAwareScoreDirector) scoreDirector; + private void doTailSwapWithReversion(VariableDescriptorAwareScoreDirector scoreDirector) { + var firstListVariable = variableDescriptor.getValue(firstEntity); + var secondListVariable = variableDescriptor.getValue(secondEntity); + var firstOriginalSize = firstListVariable.size(); + var secondOriginalSize = secondListVariable.size(); + + scoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint + 1, firstOriginalSize); + scoreDirector.beforeListVariableChanged(variableDescriptor, secondEntity, 0, secondEdgeEndpoint + 1); + + var firstListVariableTail = + firstListVariable.subList(Math.min(firstEdgeEndpoint + 1, firstOriginalSize), firstOriginalSize); + var secondListVariableTail = secondListVariable.subList(0, Math.min(secondEdgeEndpoint + 1, secondOriginalSize)); + + var firstListVariableTailSize = firstListVariableTail.size(); + var secondListVariableTailSize = secondListVariableTail.size(); + + var firstListVariableTailCopy = new ArrayList<>(firstListVariableTail); + firstListVariableTail.clear(); + for (var value : secondListVariableTail) { + firstListVariable.add(firstEdgeEndpoint + 1, value); + } + secondListVariableTail.clear(); + for (var value : firstListVariableTailCopy) { + secondListVariable.add(0, value); + } + scoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint + 1, + firstEdgeEndpoint + secondListVariableTailSize + 1); + scoreDirector.afterListVariableChanged(variableDescriptor, secondEntity, 0, firstListVariableTailSize); + } + + private void doSublistReversal(VariableDescriptorAwareScoreDirector scoreDirector) { var listVariable = variableDescriptor.getValue(firstEntity); if (firstEdgeEndpoint < secondEdgeEndpoint) { if (firstEdgeEndpoint > 0) { - castScoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, + scoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, secondEdgeEndpoint); } else { - castScoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, + scoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, listVariable.size()); } @@ -165,14 +205,14 @@ private void doSublistReversal(ScoreDirector scoreDirector) { } if (firstEdgeEndpoint > 0) { - castScoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, + scoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, firstEdgeEndpoint, secondEdgeEndpoint); } else { - castScoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, + scoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, listVariable.size()); } } else { - castScoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, + scoreDirector.beforeListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, listVariable.size()); if (shift > 0) { @@ -192,7 +232,7 @@ private void doSublistReversal(ScoreDirector scoreDirector) { Collections.rotate(listVariable.subList(entityFirstUnpinnedIndex, listVariable.size()), shift); } } - castScoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, + scoreDirector.afterListVariableChanged(variableDescriptor, firstEntity, entityFirstUnpinnedIndex, listVariable.size()); } } @@ -225,14 +265,16 @@ public boolean isMoveDoable(ScoreDirector scoreDirector) { @Override public SelectorBasedTwoOptListMove rebase(Lookup lookup) { - return new SelectorBasedTwoOptListMove<>(variableDescriptor, lookup.lookUpWorkingObject(firstEntity), - lookup.lookUpWorkingObject(secondEntity), firstEdgeEndpoint, secondEdgeEndpoint, entityFirstUnpinnedIndex, - shift); + return new SelectorBasedTwoOptListMove<>(variableDescriptor, + Objects.requireNonNull(lookup.lookUpWorkingObject(firstEntity)), + Objects.requireNonNull(lookup.lookUpWorkingObject(secondEntity)), firstEdgeEndpoint, secondEdgeEndpoint, + entityFirstUnpinnedIndex, + shift, reverseTail); } @Override public String describe() { - return "2-Opt(" + variableDescriptor.getSimpleEntityAndVariableName() + ")"; + return "2-Opt(" + variableDescriptor.getSimpleEntityAndVariableName() + ", reverseTail=" + reverseTail + ")"; } @Override @@ -282,6 +324,10 @@ public Object getSecondEdgeEndpoint() { return secondEdgeEndpoint; } + public boolean isReverseTail() { + return reverseTail; + } + @Override public String toString() { return "2-Opt(firstEntity=" + firstEntity + ", secondEntity=" + secondEntity + ", firstEndpointIndex=" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/MockablePhaseTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/MockablePhaseTermination.java index 7cd1ba37dcf..611d8fb524e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/MockablePhaseTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/MockablePhaseTermination.java @@ -9,6 +9,6 @@ * @param */ @NullMarked -non-sealed interface MockablePhaseTermination extends PhaseTermination { +public non-sealed interface MockablePhaseTermination extends PhaseTermination { } diff --git a/core/src/test/java/ai/timefold/solver/core/config/heuristic/selector/move/MoveSelectorConfigTest.java b/core/src/test/java/ai/timefold/solver/core/config/heuristic/selector/move/MoveSelectorConfigTest.java index f20986b172a..e4d0550739c 100644 --- a/core/src/test/java/ai/timefold/solver/core/config/heuristic/selector/move/MoveSelectorConfigTest.java +++ b/core/src/test/java/ai/timefold/solver/core/config/heuristic/selector/move/MoveSelectorConfigTest.java @@ -17,8 +17,6 @@ import ai.timefold.solver.core.config.heuristic.selector.move.generic.SwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListSwapMoveSelectorConfig; -import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListChangeMoveSelectorConfig; -import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListSwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.kopt.KOptListMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; import ai.timefold.solver.core.testdomain.list.TestDistanceMeter; @@ -323,8 +321,6 @@ void assertDisabledNearbyAutoConfiguration() { new CartesianProductMoveSelectorConfig(), new MoveIteratorFactoryConfig(), new MoveListFactoryConfig(), - new SubListChangeMoveSelectorConfig(), - new SubListSwapMoveSelectorConfig(), new PillarChangeMoveSelectorConfig(), new PillarSwapMoveSelectorConfig()); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListChangeMoveSelectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorFactoryTest.java similarity index 99% rename from core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListChangeMoveSelectorFactoryTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorFactoryTest.java index 8d8884b5c63..3db0154b275 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListChangeMoveSelectorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorFactoryTest.java @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; -class SubListChangeMoveSelectorFactoryTest { +class RandomSubListChangeMoveSelectorFactoryTest { @Test void buildMoveSelector() { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListSwapMoveSelectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorFactoryTest.java similarity index 98% rename from core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListSwapMoveSelectorFactoryTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorFactoryTest.java index 651a9a29638..91621c7ca0f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SubListSwapMoveSelectorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorFactoryTest.java @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test; -class SubListSwapMoveSelectorFactoryTest { +class RandomSubListSwapMoveSelectorFactoryTest { @Test void buildBaseMoveSelector() { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMoveTest.java index 04df6843317..4fb0db4bfc6 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/kopt/SelectorBasedTwoOptListMoveTest.java @@ -187,6 +187,120 @@ void doTailSwap() { verify(scoreDirector).afterListVariableChanged(variableDescriptor, e2, 2, 4); } + @Test + void doTailSwapWithReversion() { + TestdataListValue v1 = new TestdataListValue("1"); + TestdataListValue v2 = new TestdataListValue("2"); + TestdataListValue v3 = new TestdataListValue("3"); + TestdataListValue v4 = new TestdataListValue("4"); + TestdataListValue v5 = new TestdataListValue("5"); + TestdataListValue v6 = new TestdataListValue("6"); + TestdataListValue v7 = new TestdataListValue("7"); + TestdataListValue v8 = new TestdataListValue("8"); + TestdataListValue v9 = new TestdataListValue("9"); + TestdataListEntity e1 = new TestdataListEntity("e1", v1, v2, v3, v4); + TestdataListEntity e2 = new TestdataListEntity("e2", v5, v6, v7, v8, v9); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v5, v4, v3, v6, v7, v8, v9)); + SolutionManager.updateShadowVariables(solution); + scoreDirector.setWorkingSolution(solution); + + // 2-Opt((v2, v3), (v7, v8)) + var move = new SelectorBasedTwoOptListMove<>(variableDescriptor, e1, e2, 1, 2, true); + move.execute(scoreDirector); + assertThat(e1.getValueList()).containsExactly(v1, v2, v7, v6, v5); + assertThat(e2.getValueList()).containsExactly(v4, v3, v8, v9); + + verify(scoreDirector).beforeListVariableChanged(variableDescriptor, e1, 2, 4); + verify(scoreDirector).afterListVariableChanged(variableDescriptor, e1, 2, 5); + verify(scoreDirector).beforeListVariableChanged(variableDescriptor, e2, 0, 3); + verify(scoreDirector).afterListVariableChanged(variableDescriptor, e2, 0, 2); + } + + @Test + void doTailSwapWithReversionOtherCutPoint() { + TestdataListValue v1 = new TestdataListValue("1"); + TestdataListValue v2 = new TestdataListValue("2"); + TestdataListValue v3 = new TestdataListValue("3"); + TestdataListValue v4 = new TestdataListValue("4"); + TestdataListValue v5 = new TestdataListValue("5"); + TestdataListValue v6 = new TestdataListValue("6"); + TestdataListValue v7 = new TestdataListValue("7"); + TestdataListValue v8 = new TestdataListValue("8"); + TestdataListValue v9 = new TestdataListValue("9"); + TestdataListEntity e1 = new TestdataListEntity("e1", v1, v2, v3, v4); + TestdataListEntity e2 = new TestdataListEntity("e2", v5, v6, v7, v8, v9); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v5, v4, v3, v6, v7, v8, v9)); + SolutionManager.updateShadowVariables(solution); + scoreDirector.setWorkingSolution(solution); + + // 2-Opt((v7, v8), (v2, v3)) + var move = new SelectorBasedTwoOptListMove<>(variableDescriptor, e2, e1, 2, 1, true); + move.execute(scoreDirector); + assertThat(e2.getValueList()).containsExactly(v5, v6, v7, v2, v1); + assertThat(e1.getValueList()).containsExactly(v9, v8, v3, v4); + + verify(scoreDirector).beforeListVariableChanged(variableDescriptor, e2, 3, 5); + verify(scoreDirector).afterListVariableChanged(variableDescriptor, e2, 3, 5); + verify(scoreDirector).beforeListVariableChanged(variableDescriptor, e1, 0, 2); + verify(scoreDirector).afterListVariableChanged(variableDescriptor, e1, 0, 2); + } + + @Test + void undoTailSwapWithReversion() { + TestdataListValue v1 = new TestdataListValue("1"); + TestdataListValue v2 = new TestdataListValue("2"); + TestdataListValue v3 = new TestdataListValue("3"); + TestdataListValue v4 = new TestdataListValue("4"); + TestdataListValue v5 = new TestdataListValue("5"); + TestdataListValue v6 = new TestdataListValue("6"); + TestdataListValue v7 = new TestdataListValue("7"); + TestdataListValue v8 = new TestdataListValue("8"); + TestdataListValue v9 = new TestdataListValue("9"); + TestdataListEntity e1 = new TestdataListEntity("e1", v1, v2, v3, v4); + TestdataListEntity e2 = new TestdataListEntity("e2", v5, v6, v7, v8, v9); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v5, v4, v3, v6, v7, v8, v9)); + SolutionManager.updateShadowVariables(solution); + scoreDirector.setWorkingSolution(solution); + + // 2-Opt((v2, v3), (v7, v8)) + var move = new SelectorBasedTwoOptListMove<>(variableDescriptor, e1, e2, 1, 2, true); + scoreDirector.executeTemporaryMove(move, false); + assertThat(e1.getValueList()).containsExactly(v1, v2, v3, v4); + assertThat(e2.getValueList()).containsExactly(v5, v6, v7, v8, v9); + } + + @Test + void undoTailSwapWithReversionOtherCutPoint() { + TestdataListValue v1 = new TestdataListValue("1"); + TestdataListValue v2 = new TestdataListValue("2"); + TestdataListValue v3 = new TestdataListValue("3"); + TestdataListValue v4 = new TestdataListValue("4"); + TestdataListValue v5 = new TestdataListValue("5"); + TestdataListValue v6 = new TestdataListValue("6"); + TestdataListValue v7 = new TestdataListValue("7"); + TestdataListValue v8 = new TestdataListValue("8"); + TestdataListValue v9 = new TestdataListValue("9"); + TestdataListEntity e1 = new TestdataListEntity("e1", v1, v2, v3, v4); + TestdataListEntity e2 = new TestdataListEntity("e2", v5, v6, v7, v8, v9); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v5, v4, v3, v6, v7, v8, v9)); + SolutionManager.updateShadowVariables(solution); + scoreDirector.setWorkingSolution(solution); + + // 2-Opt((v7, v8), (v2, v3)) + var move = new SelectorBasedTwoOptListMove<>(variableDescriptor, e2, e1, 2, 1, true); + scoreDirector.executeTemporaryMove(move, false); + assertThat(e1.getValueList()).containsExactly(v1, v2, v3, v4); + assertThat(e2.getValueList()).containsExactly(v5, v6, v7, v8, v9); + } + @Test void doMoveSecondEndsBeforeFirst() { var v1 = new TestdataListValue("1"); 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 cd8203b55c6..f272ac1327e 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 @@ -868,7 +868,7 @@ public void solvingEnded(SolverScope solverScope) { .getMeasurement(SolverMetric.MOVE_COUNT_PER_TYPE.getMeterId() + "." + swapMoveKey, "VALUE"); moveCountPerSwap.set(counter.longValue()); } - var twoOptMoveKey = "2-Opt(TestdataListEntity.valueList)"; + var twoOptMoveKey = "2-Opt(TestdataListEntity.valueList, reverseTail=false)"; if (solverScope.getMoveCountTypes().contains(twoOptMoveKey)) { var counter = meterRegistry .getMeasurement(SolverMetric.MOVE_COUNT_PER_TYPE.getMeterId() + "." + twoOptMoveKey, "VALUE"); From f91f1c3707d06379c59ca899d65e7b65a4eefe0c Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 6 May 2026 08:59:53 -0300 Subject: [PATCH 2/8] feat: support for disabling logging, solution events, and demand removal. --- .../DefaultConstructionHeuristicPhase.java | 8 ++--- .../support/VariableListenerSupport.java | 23 ++++++++++++- .../domain/variable/supply/SupplyManager.java | 15 +++++++++ ...uinRecreateConstructionHeuristicPhase.java | 6 ++++ .../localsearch/DefaultLocalSearchPhase.java | 32 +++++++++++-------- .../solver/core/impl/phase/AbstractPhase.java | 14 ++++++++ .../solver/recaller/BestSolutionRecaller.java | 23 ++++++++++--- 7 files changed, 97 insertions(+), 24 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java index cae734d261f..f08acb7c62b 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 @@ -89,7 +89,7 @@ public void solve(SolverScope solverScope) { if (stepScope.getStep() == null) { if (phaseTermination.isPhaseTerminated(phaseScope)) { var logLevel = Level.TRACE; - if (decider.isLoggingEnabled() && logger.isEnabledForLevel(logLevel)) { + if (isLoggingEnabled() && logger.isEnabledForLevel(logLevel)) { logger.atLevel(logLevel).log( "{} Step index ({}), time spent ({}) terminated without picking a nextStep.", logIndentation, stepScope.getStepIndex(), @@ -97,7 +97,7 @@ public void solve(SolverScope solverScope) { } } else if (stepScope.getSelectedMoveCount() == 0L) { var logLevel = Level.WARN; - if (decider.isLoggingEnabled() && logger.isEnabledForLevel(logLevel)) { + if (isLoggingEnabled() && logger.isEnabledForLevel(logLevel)) { logger.atLevel(logLevel).log( "{} No doable selected move at step index ({}), time spent ({}). Terminating phase early.", logIndentation, stepScope.getStepIndex(), @@ -170,7 +170,7 @@ public void stepEnded(ConstructionHeuristicStepScope stepScope) { super.stepEnded(stepScope); moveRepository.stepEnded(stepScope); decider.stepEnded(stepScope); - if (decider.isLoggingEnabled() && logger.isDebugEnabled()) { + if (isLoggingEnabled() && logger.isDebugEnabled()) { var timeMillisSpent = stepScope.getPhaseScope().calculateSolverTimeMillisSpentUpToNow(); logger.debug("{} CH step ({}), time spent ({}), score ({}), selected move count ({}), picked move ({}).", logIndentation, @@ -188,7 +188,7 @@ public void phaseEnded(ConstructionHeuristicPhaseScope phaseScope) { moveRepository.phaseEnded(phaseScope); decider.phaseEnded(phaseScope); phaseScope.endingNow(); - if (decider.isLoggingEnabled() && logger.isInfoEnabled()) { + if (isLoggingEnabled() && logger.isInfoEnabled()) { logger.info( """ {}Construction Heuristic phase ({}) ended: time spent ({}), best score ({}), \ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java index b6b998e4c04..2fb34adf924 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java @@ -83,6 +83,8 @@ public static VariableListenerSupport create(InnerScoreDi private ListVariableStateSupply listVariableStateSupply = null; private final List supportedShadowVariableTypeList; + private boolean enableDemandRemoval = true; + VariableListenerSupport(InnerScoreDirector scoreDirector, NotifiableRegistry notifiableRegistry, @NonNull IntFunction shadowVariableGraphCreator) { this.scoreDirector = Objects.requireNonNull(scoreDirector); @@ -188,6 +190,16 @@ public Supply_ demand(Demand demand) { } } + @Override + public void enableDemandCancellation() { + this.enableDemandRemoval = true; + } + + @Override + public void disableDemandCancellation() { + this.enableDemandRemoval = false; + } + @SuppressWarnings("unchecked") private Supply createSupply(Demand demand) { var supply = demand.createExternalizedSupply(this); @@ -211,7 +223,9 @@ public boolean cancel(Demand demand) { return false; } if (supplyWithDemandCount.demandCount == 1L) { - supplyMap.remove(demand); + if (enableDemandRemoval) { + supplyMap.remove(demand); + } } else { supplyMap.put(demand, new SupplyWithDemandCount(supplyWithDemandCount.supply, supplyWithDemandCount.demandCount - 1L)); @@ -219,6 +233,13 @@ public boolean cancel(Demand demand) { return true; } + @Override + public void cancelAll() { + if (enableDemandRemoval) { + supplyMap.clear(); + } + } + @Override public long getActiveCount(Demand demand) { var supplyAndDemandCounter = supplyMap.get(demand); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/supply/SupplyManager.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/supply/SupplyManager.java index 3831baa6d3b..2e9fe04c7c1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/supply/SupplyManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/supply/SupplyManager.java @@ -33,6 +33,16 @@ public interface SupplyManager { */ Supply_ demand(Demand demand); + /** + * Enable demand cancellation/removal when the demand counter reaches zero. + */ + void enableDemandCancellation(); + + /** + * Disable demand cancellation/removal when the demand counter reaches zero. + */ + void disableDemandCancellation(); + /** * Cancel an active {@link #demand(Demand)}. * Once the number of active demands reaches zero, the {@link Supply} in question is removed. @@ -47,6 +57,11 @@ public interface SupplyManager { */ boolean cancel(Demand demand); + /** + * Cancel all existing active {@link #demand(Demand) demands}. + */ + void cancelAll(); + /** * @param demand * @return 0 when there is no active {@link Supply} for the given {@link Demand}, more when there is one. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java index bca04a4a7c5..ca69dc48fcc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java @@ -31,6 +31,12 @@ public final class RuinRecreateConstructionHeuristicPhase this.missingUpdatedElementsMap = new IdentityHashMap<>(); } + @Override + public void phaseStarted(ConstructionHeuristicPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + disableLogging(); + } + @Override protected ConstructionHeuristicPhaseScope buildPhaseScope(SolverScope solverScope, int phaseIndex) { return new RuinRecreateConstructionHeuristicPhaseScope<>(solverScope, phaseIndex); 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..d35ac1ae188 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 @@ -68,7 +68,9 @@ public void solve(SolverScope solverScope) { // Reaching local search means that the solution is already fully initialized. // Yet the problem size indicates there is only 1 possible solution. // Therefore, this solution must be it and there is nothing to improve. - logger.info("{}Local Search phase ({}) has no entities or values to move.", logIndentation, phaseIndex); + if (isLoggingEnabled()) { + logger.info("{}Local Search phase ({}) has no entities or values to move.", logIndentation, phaseIndex); + } return; } @@ -88,7 +90,7 @@ public void solve(SolverScope solverScope) { stepStarted(stepScope); decider.decideNextStep(stepScope); if (stepScope.getStep() == null) { - if (phaseTermination.isPhaseTerminated(phaseScope)) { + if (isLoggingEnabled() && phaseTermination.isPhaseTerminated(phaseScope)) { logger.trace("{} Step index ({}), time spent ({}) terminated without picking a nextStep.", logIndentation, stepScope.getStepIndex(), @@ -227,18 +229,20 @@ public void phaseEnded(LocalSearchPhaseScope phaseScope) { super.phaseEnded(phaseScope); decider.phaseEnded(phaseScope); phaseScope.endingNow(); - logger.info(""" - {}Local Search phase ({}) ended: time spent ({}), best score ({}), \ - {}move evaluation speed ({}/sec), step total ({}).""", - logIndentation, - phaseIndex, - phaseScope.calculateSolverTimeMillisSpentUpToNow(), - phaseScope.getBestScore().raw(), - // Multithreaded solving uses "effective" move evaluation speed, since not all evaluated moves - // are foraged - (decider.getClass().equals(LocalSearchDecider.class)) ? "" : "effective ", - phaseScope.getPhaseMoveEvaluationSpeed(), - phaseScope.getNextStepIndex()); + if (isLoggingEnabled()) { + logger.info(""" + {}Local Search phase ({}) ended: time spent ({}), best score ({}), \ + {}move evaluation speed ({}/sec), step total ({}).""", + logIndentation, + phaseIndex, + phaseScope.calculateSolverTimeMillisSpentUpToNow(), + phaseScope.getBestScore().raw(), + // Multithreaded solving uses "effective" move evaluation speed, since not all evaluated moves + // are foraged + (decider.getClass().equals(LocalSearchDecider.class)) ? "" : "effective ", + phaseScope.getPhaseMoveEvaluationSpeed(), + phaseScope.getNextStepIndex()); + } } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/AbstractPhase.java index 1d43cc4c39e..b3500a4a0b9 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 @@ -44,6 +44,8 @@ public abstract class AbstractPhase implements Phase { protected final boolean assertExpectedStepScore; protected final boolean assertShadowVariablesAreNotStaleAfterStep; + private boolean loggingEnabled = true; + /** Used for {@link #addPhaseLifecycleListener(PhaseLifecycleListener)}. */ protected PhaseLifecycleSupport phaseLifecycleSupport = new PhaseLifecycleSupport<>(); @@ -79,6 +81,18 @@ public boolean isAssertShadowVariablesAreNotStaleAfterStep() { public abstract PhaseType getPhaseType(); + public void disableLogging() { + loggingEnabled = false; + } + + public void enableLogging() { + loggingEnabled = true; + } + + public boolean isLoggingEnabled() { + return loggingEnabled; + } + // ************************************************************************ // Lifecycle methods // ************************************************************************ 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..523c2e3df98 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 @@ -22,6 +22,8 @@ public class BestSolutionRecaller extends PhaseLifecycleListenerAdapt protected boolean assertShadowVariablesAreNotStale = false; protected boolean assertBestScoreIsUnmodified = false; + protected boolean enableUpdateEvents = true; + protected SolverEventSupport solverEventSupport; public void setAssertInitialScoreFromScratch(boolean assertInitialScoreFromScratch) { @@ -40,6 +42,14 @@ public void setSolverEventSupport(SolverEventSupport solverEventSuppo this.solverEventSupport = solverEventSupport; } + public boolean isEnableUpdateEvents() { + return enableUpdateEvents; + } + + public void setEnableUpdateEvents(boolean enableUpdateEvents) { + this.enableUpdateEvents = enableUpdateEvents; + } + // ************************************************************************ // Worker methods // ************************************************************************ @@ -122,22 +132,25 @@ public > void processWorkingSolutionDuringMove(Inne public void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope) { updateBestSolutionWithoutFiring(solverScope); - solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); + if (enableUpdateEvents) { + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); + } } public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, EventProducerId eventProducerId) { updateBestSolutionWithoutFiring(solverScope); - if (solverScope.isBestSolutionInitialized()) { + if (solverScope.isBestSolutionInitialized() && enableUpdateEvents) { solverEventSupport.fireBestSolutionChanged(solverScope, eventProducerId, solverScope.getBestSolution()); } } private void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope, - InnerScore bestScore, - Solution_ bestSolution) { + InnerScore bestScore, Solution_ bestSolution) { updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution); - solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); + if (enableUpdateEvents) { + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); + } } @SuppressWarnings({ "unchecked", "rawtypes" }) From b75dc5b59abea6e558bedaeade9da15907267acf Mon Sep 17 00:00:00 2001 From: fred Date: Mon, 11 May 2026 10:19:04 -0300 Subject: [PATCH 3/8] feat: enable HGS for list variables --- .../api/solver/event/EventProducerId.java | 4 + .../EvolutionaryAlgorithmPhaseConfig.java | 85 +++ .../EvolutionaryCustomPhaseConfig.java | 90 +++ .../EvolutionaryPopulationConfig.java | 114 ++++ .../EvolutionaryWorkerConfig.java | 85 +++ .../evolutionaryalgorithm/package-info.java | 9 + .../list/SubListChangeMoveSelectorConfig.java | 4 +- .../list/SubListSwapMoveSelectorConfig.java | 2 +- .../solver/core/config/phase/PhaseConfig.java | 2 + .../core/config/solver/PreviewFeature.java | 3 +- .../core/config/solver/SolverConfig.java | 3 + .../TimefoldSolverEnterpriseService.java | 21 +- .../core/impl/AbstractFromConfigFactory.java | 23 +- ...aultConstructionHeuristicPhaseFactory.java | 18 +- .../placer/AbstractEntityPlacerFactory.java | 5 +- .../placer/QueuedEntityPlacerFactory.java | 27 +- .../placer/QueuedValuePlacerFactory.java | 8 +- .../DefaultEvolutionaryAlgorithmPhase.java | 128 +++++ ...aultEvolutionaryAlgorithmPhaseFactory.java | 484 ++++++++++++++++ .../EvolutionaryAlgorithmPhase.java | 14 + .../evolutionaryalgorithm/common/Utils.java | 74 +++ .../bestsolution/BestSolutionUpdater.java | 12 + .../DefaultBestSolutionUpdater.java | 51 ++ ...ionaryAlgorithmPhaseLifecycleListener.java | 21 + .../common/phase/NoBestEventPhase.java | 88 +++ .../EvolutionaryAlgorithmPhaseScope.java | 51 ++ .../scope/EvolutionaryAlgorithmStepScope.java | 47 ++ .../common/state/SolutionState.java | 11 + .../common/state/SolutionStateManager.java | 27 + .../common/state/list/ListSolutionState.java | 21 + .../state/list/ListSolutionStateManager.java | 126 +++++ .../common/state/list/ListValueState.java | 9 + .../crossover/CrossoverContext.java | 9 + .../crossover/CrossoverResult.java | 8 + .../crossover/CrossoverStrategy.java | 15 + .../crossover/list/AbstractListCrossover.java | 109 ++++ .../crossover/list/ListMixedCrossover.java | 32 ++ .../crossover/list/ListOXCrossover.java | 123 ++++ .../crossover/list/ListRXCrossover.java | 120 ++++ .../decider/EvolutionaryDecider.java | 56 ++ .../HybridGeneticSearchConfiguration.java | 37 ++ .../decider/HybridGeneticSearchDecider.java | 270 +++++++++ .../decider/HybridGeneticSearchWorker.java | 278 +++++++++ .../population/DefaultPopulation.java | 421 ++++++++++++++ .../population/Population.java | 70 +++ .../population/PopulationDiffMap.java | 58 ++ .../population/PopulationStatistics.java | 4 + .../individual/AbstractIndividual.java | 51 ++ .../individual/ChromosomeEntry.java | 7 + .../population/individual/Individual.java | 62 +++ .../individual/IndividualBuilder.java | 17 + .../individual/ListVariableIndividual.java | 133 +++++ .../ConstructionIndividualStrategy.java | 17 + ...DefaultConstructionIndividualStrategy.java | 79 +++ .../ListRuinRecreateIndividualStrategy.java | 163 ++++++ .../swapstar/ListSwapStarPhase.java | 350 ++++++++++++ .../impl/heuristic/HeuristicConfigPolicy.java | 25 + .../localsearch/DefaultLocalSearchPhase.java | 2 +- .../core/impl/phase/AbstractPhaseFactory.java | 4 + .../solver/core/impl/phase/PhaseFactory.java | 4 + .../solver/core/impl/phase/PhaseType.java | 5 + .../custom/DefaultPhaseCommandContext.java | 2 +- .../impl/solver/DefaultSolverFactory.java | 11 + .../core/impl/solver/scope/SolverScope.java | 34 +- core/src/main/java/module-info.java | 15 +- core/src/main/resources/solver.xsd | 88 +++ .../entity/QueuedEntityPlacerFactoryTest.java | 2 +- .../list/ListSolutionStateManagerTest.java | 429 ++++++++++++++ .../crossover/list/ListOXCrossoverTest.java | 526 ++++++++++++++++++ .../crossover/list/ListRXCrossoverTest.java | 504 +++++++++++++++++ ...ultConstructionIndividualStrategyTest.java | 141 +++++ ...istRuinRecreateIndividualStrategyTest.java | 218 ++++++++ .../core/testutil/PlannerTestUtils.java | 2 + .../src/main/resources/benchmark.xsd | 132 +++++ 74 files changed, 6244 insertions(+), 56 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryCustomPhaseConfig.java create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryPopulationConfig.java create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/package-info.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/EvolutionaryAlgorithmPhase.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/BestSolutionUpdater.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/EvolutionaryAlgorithmPhaseLifecycleListener.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionState.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverContext.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverResult.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationStatistics.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/AbstractIndividual.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/Individual.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/IndividualBuilder.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/swapstar/ListSwapStarPhase.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java index 354efe42edb..71075e5efe4 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/event/EventProducerId.java @@ -69,6 +69,10 @@ static EventProducerId partitionedSearch(int phaseIndex) { return new PhaseEventProducerId(PhaseType.PARTITIONED_SEARCH, phaseIndex); } + static EventProducerId evolutionaryAlgorithm(int phaseIndex) { + return new PhaseEventProducerId(PhaseType.EVOLUTIONARY_ALGORITHM, phaseIndex); + } + static EventProducerId customPhase(int phaseIndex) { return new PhaseEventProducerId(PhaseType.CUSTOM_PHASE, phaseIndex); } diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java new file mode 100644 index 00000000000..cfa51f08526 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java @@ -0,0 +1,85 @@ +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import java.util.function.Consumer; + +import jakarta.xml.bind.annotation.XmlType; + +import ai.timefold.solver.core.config.phase.PhaseConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@XmlType(propOrder = { + "populationConfig", + "workerConfig", +}) +@NullMarked +public class EvolutionaryAlgorithmPhaseConfig extends PhaseConfig { + + public static final String XML_ELEMENT_NAME = "evolutionaryAlgorithm"; + + @Nullable + private EvolutionaryPopulationConfig populationConfig = null; + + @Nullable + private EvolutionaryWorkerConfig workerConfig = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable EvolutionaryPopulationConfig getPopulationConfig() { + return populationConfig; + } + + public void setPopulationConfig(@Nullable EvolutionaryPopulationConfig populationConfig) { + this.populationConfig = populationConfig; + } + + public @Nullable EvolutionaryWorkerConfig getWorkerConfig() { + return workerConfig; + } + + public void setWorkerConfig(@Nullable EvolutionaryWorkerConfig workerConfig) { + this.workerConfig = workerConfig; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryAlgorithmPhaseConfig withPopulationConfig(EvolutionaryPopulationConfig populationConfig) { + setPopulationConfig(populationConfig); + return this; + } + + public EvolutionaryAlgorithmPhaseConfig withWorkerConfig(EvolutionaryWorkerConfig evolutionaryWorkerConfig) { + setWorkerConfig(evolutionaryWorkerConfig); + return this; + } + + @Override + public EvolutionaryAlgorithmPhaseConfig inherit(EvolutionaryAlgorithmPhaseConfig inheritedConfig) { + super.inherit(inheritedConfig); + populationConfig = ConfigUtils.inheritConfig(populationConfig, inheritedConfig.getPopulationConfig()); + workerConfig = + ConfigUtils.inheritConfig(workerConfig, inheritedConfig.getWorkerConfig()); + return this; + } + + @Override + public EvolutionaryAlgorithmPhaseConfig copyConfig() { + return new EvolutionaryAlgorithmPhaseConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + if (populationConfig != null) { + populationConfig.visitReferencedClasses(classVisitor); + } + if (workerConfig != null) { + workerConfig.visitReferencedClasses(classVisitor); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryCustomPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryCustomPhaseConfig.java new file mode 100644 index 00000000000..22c858d756b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryCustomPhaseConfig.java @@ -0,0 +1,90 @@ +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlType; +import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.config.phase.PhaseConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.io.jaxb.JaxbCustomPropertiesAdapter; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@XmlType(propOrder = { + "customPhaseCommandClassList", + "customProperties", +}) +@NullMarked +public class EvolutionaryCustomPhaseConfig extends PhaseConfig { + + @XmlElement(name = "customPhaseCommandClass") + @Nullable + private List> customPhaseCommandClassList = null; + + @XmlJavaTypeAdapter(JaxbCustomPropertiesAdapter.class) + @Nullable + private Map customProperties = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable List> getCustomPhaseCommandClassList() { + return customPhaseCommandClassList; + } + + public void setCustomPhaseCommandClassList( + @Nullable List> customPhaseCommandClassList) { + this.customPhaseCommandClassList = customPhaseCommandClassList; + } + + public @Nullable Map getCustomProperties() { + return customProperties; + } + + public void setCustomProperties(@Nullable Map customProperties) { + this.customProperties = customProperties; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryCustomPhaseConfig withCustomPhaseCommandClassList( + List> customPhaseCommandClassList) { + setCustomPhaseCommandClassList(customPhaseCommandClassList); + return this; + } + + public EvolutionaryCustomPhaseConfig withCustomProperties(Map customProperties) { + setCustomProperties(customProperties); + return this; + } + + @Override + public EvolutionaryCustomPhaseConfig inherit(EvolutionaryCustomPhaseConfig inheritedConfig) { + super.inherit(inheritedConfig); + customPhaseCommandClassList = ConfigUtils.inheritMergeableListProperty(customPhaseCommandClassList, + inheritedConfig.getCustomPhaseCommandClassList()); + customProperties = ConfigUtils.inheritMergeableMapProperty(customProperties, inheritedConfig.getCustomProperties()); + return this; + } + + @Override + public EvolutionaryCustomPhaseConfig copyConfig() { + return new EvolutionaryCustomPhaseConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + if (customPhaseCommandClassList != null) { + customPhaseCommandClassList.forEach(classVisitor); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryPopulationConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryPopulationConfig.java new file mode 100644 index 00000000000..e8ac56e07bd --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryPopulationConfig.java @@ -0,0 +1,114 @@ +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import java.util.function.Consumer; + +import jakarta.xml.bind.annotation.XmlType; + +import ai.timefold.solver.core.config.phase.PhaseConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@XmlType(propOrder = { + "populationSize", + "generationSize", + "eliteSolutionSize", + "populationRestartCount", +}) +@NullMarked +public class EvolutionaryPopulationConfig extends PhaseConfig { + + @Nullable + private Integer populationSize = null; + + @Nullable + private Integer generationSize = null; + + @Nullable + private Integer eliteSolutionSize = null; + + @Nullable + private Integer populationRestartCount = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable Integer getPopulationSize() { + return populationSize; + } + + public void setPopulationSize(@Nullable Integer populationSize) { + this.populationSize = populationSize; + } + + public @Nullable Integer getGenerationSize() { + return generationSize; + } + + public void setGenerationSize(@Nullable Integer generationSize) { + this.generationSize = generationSize; + } + + public @Nullable Integer getEliteSolutionSize() { + return eliteSolutionSize; + } + + public void setEliteSolutionSize(@Nullable Integer eliteSolutionSize) { + this.eliteSolutionSize = eliteSolutionSize; + } + + public @Nullable Integer getPopulationRestartCount() { + return populationRestartCount; + } + + public void setPopulationRestartCount(@Nullable Integer populationRestartCount) { + this.populationRestartCount = populationRestartCount; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryPopulationConfig withPopulationSize(Integer populationSize) { + setPopulationSize(populationSize); + return this; + } + + public EvolutionaryPopulationConfig withGenerationSize(Integer generationSize) { + setGenerationSize(generationSize); + return this; + } + + public EvolutionaryPopulationConfig withEliteSolutionSize(Integer eliteSolutionSize) { + setEliteSolutionSize(eliteSolutionSize); + return this; + } + + public EvolutionaryPopulationConfig withPopulationRestartCount(Integer populationRestartCount) { + setPopulationRestartCount(populationRestartCount); + return this; + } + + @Override + public EvolutionaryPopulationConfig inherit(EvolutionaryPopulationConfig inheritedConfig) { + super.inherit(inheritedConfig); + populationSize = ConfigUtils.inheritOverwritableProperty(populationSize, inheritedConfig.getPopulationSize()); + generationSize = ConfigUtils.inheritOverwritableProperty(generationSize, inheritedConfig.getGenerationSize()); + eliteSolutionSize = ConfigUtils.inheritOverwritableProperty(eliteSolutionSize, inheritedConfig.getEliteSolutionSize()); + populationRestartCount = + ConfigUtils.inheritOverwritableProperty(populationRestartCount, inheritedConfig.getPopulationRestartCount()); + return this; + } + + @Override + public EvolutionaryPopulationConfig copyConfig() { + return new EvolutionaryPopulationConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + // Do nothing + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java new file mode 100644 index 00000000000..92882d3bb24 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java @@ -0,0 +1,85 @@ +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import java.util.function.Consumer; + +import jakarta.xml.bind.annotation.XmlType; + +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.phase.PhaseConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@XmlType(propOrder = { + "customIndividualPhaseConfig", + "localSearchPhaseConfig", +}) +@NullMarked +public class EvolutionaryWorkerConfig extends PhaseConfig { + + @Nullable + private EvolutionaryCustomPhaseConfig customIndividualPhaseConfig = null; + + @Nullable + private LocalSearchPhaseConfig localSearchPhaseConfig = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable EvolutionaryCustomPhaseConfig getCustomIndividualPhaseConfig() { + return customIndividualPhaseConfig; + } + + public void setCustomIndividualPhaseConfig(@Nullable EvolutionaryCustomPhaseConfig customIndividualPhaseConfig) { + this.customIndividualPhaseConfig = customIndividualPhaseConfig; + } + + public @Nullable LocalSearchPhaseConfig getLocalSearchPhaseConfig() { + return localSearchPhaseConfig; + } + + public void setLocalSearchPhaseConfig(@Nullable LocalSearchPhaseConfig localSearchPhaseConfig) { + this.localSearchPhaseConfig = localSearchPhaseConfig; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryWorkerConfig + withCustomIndividualPhaseConfig(EvolutionaryCustomPhaseConfig customIndividualPhaseConfig) { + setCustomIndividualPhaseConfig(customIndividualPhaseConfig); + return this; + } + + public EvolutionaryWorkerConfig withLocalSearchPhaseConfig(LocalSearchPhaseConfig localSearchPhaseConfig) { + setLocalSearchPhaseConfig(localSearchPhaseConfig); + return this; + } + + @Override + public EvolutionaryWorkerConfig inherit(EvolutionaryWorkerConfig inheritedConfig) { + super.inherit(inheritedConfig); + customIndividualPhaseConfig = + ConfigUtils.inheritConfig(customIndividualPhaseConfig, inheritedConfig.getCustomIndividualPhaseConfig()); + localSearchPhaseConfig = ConfigUtils.inheritConfig(localSearchPhaseConfig, inheritedConfig.getLocalSearchPhaseConfig()); + return this; + } + + @Override + public EvolutionaryWorkerConfig copyConfig() { + return new EvolutionaryWorkerConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + if (customIndividualPhaseConfig != null) { + customIndividualPhaseConfig.visitReferencedClasses(classVisitor); + } + if (localSearchPhaseConfig != null) { + localSearchPhaseConfig.visitReferencedClasses(classVisitor); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/package-info.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/package-info.java new file mode 100644 index 00000000000..89b8188e40f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/package-info.java @@ -0,0 +1,9 @@ +@XmlSchema( + namespace = SolverConfig.XML_NAMESPACE, + elementFormDefault = XmlNsForm.QUALIFIED) +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import jakarta.xml.bind.annotation.XmlNsForm; +import jakarta.xml.bind.annotation.XmlSchema; + +import ai.timefold.solver.core.config.solver.SolverConfig; diff --git a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java index 91979b04600..85f55d9255b 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListChangeMoveSelectorConfig.java @@ -89,9 +89,9 @@ public void setDestinationSelectorConfig(@Nullable DestinationSelectorConfig des this.selectReversingMoveToo = ConfigUtils.inheritOverwritableProperty(selectReversingMoveToo, inheritedConfig.selectReversingMoveToo); this.subListSelectorConfig = - ConfigUtils.inheritOverwritableProperty(subListSelectorConfig, inheritedConfig.subListSelectorConfig); + ConfigUtils.inheritConfig(subListSelectorConfig, inheritedConfig.subListSelectorConfig); this.destinationSelectorConfig = - ConfigUtils.inheritOverwritableProperty(destinationSelectorConfig, inheritedConfig.destinationSelectorConfig); + ConfigUtils.inheritConfig(destinationSelectorConfig, inheritedConfig.destinationSelectorConfig); return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java index 20dfc58f37a..0216422d7bd 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/heuristic/selector/move/generic/list/SubListSwapMoveSelectorConfig.java @@ -83,7 +83,7 @@ public void setSecondarySubListSelectorConfig(@Nullable SubListSelectorConfig se this.selectReversingMoveToo = ConfigUtils.inheritOverwritableProperty(selectReversingMoveToo, inheritedConfig.selectReversingMoveToo); this.subListSelectorConfig = - ConfigUtils.inheritOverwritableProperty(subListSelectorConfig, inheritedConfig.subListSelectorConfig); + ConfigUtils.inheritConfig(subListSelectorConfig, inheritedConfig.subListSelectorConfig); this.secondarySubListSelectorConfig = ConfigUtils.inheritConfig(secondarySubListSelectorConfig, inheritedConfig.secondarySubListSelectorConfig); return this; 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..20f6c611817 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 @@ -6,6 +6,7 @@ import ai.timefold.solver.core.config.AbstractConfig; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; @@ -21,6 +22,7 @@ CustomPhaseConfig.class, ExhaustiveSearchPhaseConfig.class, LocalSearchPhaseConfig.class, + EvolutionaryAlgorithmPhaseConfig.class, PartitionedSearchPhaseConfig.class }) @XmlType(propOrder = { diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java b/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java index 1629f6970eb..6cc6b14b70c 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java @@ -26,6 +26,7 @@ public enum PreviewFeature { * It is intended to simplify the creation of custom moves, eventually replacing move selectors. * The component is under development, and many key features are yet to be delivered. */ - NEIGHBORHOODS + NEIGHBORHOODS, + EVOLUTIONARY_ALGORITHM } diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java index ed43fb1bceb..fd2da902352 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java @@ -34,6 +34,7 @@ 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.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; @@ -241,6 +242,8 @@ public final class SolverConfig extends AbstractConfig { @XmlElement(name = CustomPhaseConfig.XML_ELEMENT_NAME, type = CustomPhaseConfig.class), @XmlElement(name = ExhaustiveSearchPhaseConfig.XML_ELEMENT_NAME, type = ExhaustiveSearchPhaseConfig.class), @XmlElement(name = LocalSearchPhaseConfig.XML_ELEMENT_NAME, type = LocalSearchPhaseConfig.class), + @XmlElement(name = EvolutionaryAlgorithmPhaseConfig.XML_ELEMENT_NAME, + type = EvolutionaryAlgorithmPhaseConfig.class), @XmlElement(name = PartitionedSearchPhaseConfig.XML_ELEMENT_NAME, type = PartitionedSearchPhaseConfig.class) }) private List phaseConfigList = null; diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 24431ceb65b..a4ce5522bd1 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -30,11 +30,16 @@ import ai.timefold.solver.core.impl.constructionheuristic.decider.forager.ConstructionHeuristicForager; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.declarative.TopologicalOrderGraph; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.list.DestinationSelector; import ai.timefold.solver.core.impl.heuristic.selector.list.ElementDestinationSelector; -import ai.timefold.solver.core.impl.heuristic.selector.list.RandomSubListSelector; import ai.timefold.solver.core.impl.heuristic.selector.list.SubListSelector; import ai.timefold.solver.core.impl.heuristic.selector.move.AbstractMoveSelectorFactory; import ai.timefold.solver.core.impl.heuristic.selector.value.ValueSelector; @@ -43,10 +48,12 @@ import ai.timefold.solver.core.impl.localsearch.decider.forager.LocalSearchForager; import ai.timefold.solver.core.impl.neighborhood.MoveRepository; import ai.timefold.solver.core.impl.partitionedsearch.PartitionedSearchPhase; +import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.solver.DefaultSolverFactory; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; @@ -173,6 +180,16 @@ PartitionedSearchPhase buildPartitionedSearch(int phaseIn SolverTermination solverTermination, BiFunction, SolverTermination, PhaseTermination> phaseTerminationFunction); + , State_ extends SolutionState> + EvolutionaryDecider buildHybridGeneticSearch(int populationSize, int generationSize, + int eliteGroupSize, int populationRestartCount, + ConstructionIndividualStrategy constructionIndividualStrategy, + Phase localSearchPhase, Phase swapStarPhase, + CrossoverStrategy crossoverStrategy, + IndividualBuilder individualBuilder, + SolutionStateManager solutionInitializer, + PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller); + EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, HeuristicConfigPolicy configPolicy, NearbySelectionConfig nearbySelectionConfig, SelectionCacheType minimumCacheType, SelectionOrder resolvedSelectionOrder, @@ -184,7 +201,7 @@ ValueSelector applyNearbySelection(ValueSelectorConfig va SubListSelector applyNearbySelection(SubListSelectorConfig subListSelectorConfig, HeuristicConfigPolicy configPolicy, SelectionCacheType minimumCacheType, - SelectionOrder resolvedSelectionOrder, RandomSubListSelector subListSelector); + SelectionOrder resolvedSelectionOrder, SubListSelector subListSelector); DestinationSelector applyNearbySelection(DestinationSelectorConfig destinationSelectorConfig, HeuristicConfigPolicy configPolicy, SelectionCacheType minimumCacheType, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/AbstractFromConfigFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/AbstractFromConfigFactory.java index 35100e0cb72..5d8f5e0fa42 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/AbstractFromConfigFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/AbstractFromConfigFactory.java @@ -44,14 +44,19 @@ public static EntitySelectorConfig deduceEntitySortManner(HeuristicC protected EntityDescriptor deduceEntityDescriptor(HeuristicConfigPolicy configPolicy, Class entityClass) { + return deduceEntityDescriptor(configPolicy, config, entityClass); + } + + public static > EntityDescriptor deduceEntityDescriptor( + HeuristicConfigPolicy configPolicy, AbstractConfig config, Class entityClass) { var solutionDescriptor = configPolicy.getSolutionDescriptor(); return entityClass == null - ? getTheOnlyEntityDescriptor(solutionDescriptor) - : getEntityDescriptorForClass(solutionDescriptor, entityClass); + ? getTheOnlyEntityDescriptor(solutionDescriptor, config) + : getEntityDescriptorForClass(solutionDescriptor, config, entityClass); } - private EntityDescriptor getEntityDescriptorForClass(SolutionDescriptor solutionDescriptor, - Class entityClass) { + private static > EntityDescriptor getEntityDescriptorForClass( + SolutionDescriptor solutionDescriptor, AbstractConfig config, Class entityClass) { var entityDescriptor = solutionDescriptor.getEntityDescriptorStrict(entityClass); if (entityDescriptor == null) { throw new IllegalArgumentException( @@ -65,6 +70,11 @@ Check your solver configuration. If that class (%s) is not in the entityClassSet } protected EntityDescriptor getTheOnlyEntityDescriptor(SolutionDescriptor solutionDescriptor) { + return getTheOnlyEntityDescriptor(solutionDescriptor, config); + } + + protected static > EntityDescriptor + getTheOnlyEntityDescriptor(SolutionDescriptor solutionDescriptor, AbstractConfig config) { var entityDescriptors = solutionDescriptor.getGenuineEntityDescriptors(); if (entityDescriptors.size() != 1) { throw new IllegalArgumentException( @@ -74,8 +84,9 @@ protected EntityDescriptor getTheOnlyEntityDescriptor(SolutionDescrip return entityDescriptors.iterator().next(); } - protected EntityDescriptor - getTheOnlyEntityDescriptorWithBasicVariables(SolutionDescriptor solutionDescriptor) { + protected static > EntityDescriptor + getTheOnlyEntityDescriptorWithBasicVariables(SolutionDescriptor solutionDescriptor, + AbstractConfig config) { var entityDescriptors = solutionDescriptor.getGenuineEntityDescriptors() .stream() .filter(EntityDescriptor::hasAnyBasicVariables) 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..e19fd851aec 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 @@ -59,7 +59,7 @@ public final DefaultConstructionHeuristicPhaseBuilder getBuilder(int .withValueSorterManner(valueSorterManner) .build(); var entityPlacerConfig_ = getValidEntityPlacerConfig() - .orElseGet(() -> buildDefaultEntityPlacerConfig(phaseConfigPolicy, constructionHeuristicType_)); + .orElseGet(() -> buildDefaultEntityPlacerConfig(phaseConfigPolicy, phaseConfig, constructionHeuristicType_)); var entityPlacer = EntityPlacerFactory. create(entityPlacerConfig_) .buildEntityPlacer(phaseConfigPolicy); return createBuilder(phaseConfigPolicy, solverTermination, phaseIndex, lastInitializingPhase, entityPlacer); @@ -102,14 +102,15 @@ private Optional> getValidEntityPlacerConfig() { return Optional.of(entityPlacerConfig); } - private EntityPlacerConfig buildDefaultEntityPlacerConfig(HeuristicConfigPolicy configPolicy, + public static EntityPlacerConfig buildDefaultEntityPlacerConfig( + HeuristicConfigPolicy configPolicy, ConstructionHeuristicPhaseConfig phaseConfig, ConstructionHeuristicType constructionHeuristicType) { return findValidListVariableDescriptor(configPolicy.getSolutionDescriptor()) .map(listVariableDescriptor -> buildListVariableQueuedValuePlacerConfig(configPolicy, listVariableDescriptor)) - .orElseGet(() -> buildUnfoldedEntityPlacerConfig(configPolicy, constructionHeuristicType)); + .orElseGet(() -> buildUnfoldedEntityPlacerConfig(configPolicy, phaseConfig, constructionHeuristicType)); } - private Optional> + private static Optional> findValidListVariableDescriptor(SolutionDescriptor solutionDescriptor) { var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); if (listVariableDescriptor == null) { @@ -175,7 +176,8 @@ protected ConstructionHeuristicForager buildForager(HeuristicConfigPo return ConstructionHeuristicForagerFactory. create(foragerConfig_).buildForager(configPolicy); } - private EntityPlacerConfig buildUnfoldedEntityPlacerConfig(HeuristicConfigPolicy phaseConfigPolicy, + private static EntityPlacerConfig buildUnfoldedEntityPlacerConfig( + HeuristicConfigPolicy phaseConfigPolicy, ConstructionHeuristicPhaseConfig phaseConfig, ConstructionHeuristicType constructionHeuristicType) { return switch (constructionHeuristicType) { case FIRST_FIT, FIRST_FIT_DECREASING, WEAKEST_FIT, WEAKEST_FIT_DECREASING, STRONGEST_FIT, STRONGEST_FIT_DECREASING, @@ -187,20 +189,20 @@ private EntityPlacerConfig buildUnfoldedEntityPlacerConfig(HeuristicConfigPol } case ALLOCATE_TO_VALUE_FROM_QUEUE -> { if (!ConfigUtils.isEmptyCollection(phaseConfig.getMoveSelectorConfigList())) { - yield QueuedValuePlacerFactory.unfoldNew(checkSingleMoveSelectorConfig()); + yield QueuedValuePlacerFactory.unfoldNew(checkSingleMoveSelectorConfig(phaseConfig)); } yield new QueuedValuePlacerConfig(); } case CHEAPEST_INSERTION, ALLOCATE_FROM_POOL -> { if (!ConfigUtils.isEmptyCollection(phaseConfig.getMoveSelectorConfigList())) { - yield PooledEntityPlacerFactory.unfoldNew(phaseConfigPolicy, checkSingleMoveSelectorConfig()); + yield PooledEntityPlacerFactory.unfoldNew(phaseConfigPolicy, checkSingleMoveSelectorConfig(phaseConfig)); } yield new PooledEntityPlacerConfig(); } }; } - private MoveSelectorConfig checkSingleMoveSelectorConfig() { // Non-null guaranteed by the caller. + private static MoveSelectorConfig checkSingleMoveSelectorConfig(ConstructionHeuristicPhaseConfig phaseConfig) { // Non-null guaranteed by the caller. var moveSelectorConfigList = Objects.requireNonNull(phaseConfig.getMoveSelectorConfigList()); if (moveSelectorConfigList.size() != 1) { throw new IllegalArgumentException(""" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacerFactory.java index 43c11557208..6da156141e9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacerFactory.java @@ -19,8 +19,9 @@ protected AbstractEntityPlacerFactory(EntityPlacerConfig_ placerConfig) { super(placerConfig); } - protected ChangeMoveSelectorConfig buildChangeMoveSelectorConfig(HeuristicConfigPolicy configPolicy, - String entitySelectorConfigId, GenuineVariableDescriptor variableDescriptor) { + protected static ChangeMoveSelectorConfig buildChangeMoveSelectorConfig( + HeuristicConfigPolicy configPolicy, String entitySelectorConfigId, + GenuineVariableDescriptor variableDescriptor) { ChangeMoveSelectorConfig changeMoveSelectorConfig = new ChangeMoveSelectorConfig(); changeMoveSelectorConfig.setEntitySelectorConfig( EntitySelectorConfig.newMimicSelectorConfig(entitySelectorConfigId)); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java index 5fc5e40793e..384c93a7e17 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; @@ -12,8 +13,8 @@ import ai.timefold.solver.core.config.heuristic.selector.move.composite.CartesianProductMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.ChangeMoveSelectorConfig; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; -import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelectorFactory; import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector; import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelectorFactory; @@ -27,8 +28,8 @@ public class QueuedEntityPlacerFactory public static QueuedEntityPlacerConfig unfoldNew(HeuristicConfigPolicy configPolicy, List templateMoveSelectorConfigList) { var config = new QueuedEntityPlacerConfig(); - var entitySelectorConfig = new QueuedEntityPlacerFactory(config) - .buildEntitySelectorConfig(configPolicy); + var entitySelectorConfig = + new QueuedEntityPlacerFactory(config).buildEntitySelectorConfig(configPolicy, config); config.setEntitySelectorConfig(entitySelectorConfig); var moveSelectorConfigList = new ArrayList(templateMoveSelectorConfigList.size()); config.setMoveSelectorConfigList(moveSelectorConfigList); @@ -65,11 +66,14 @@ public QueuedEntityPlacerFactory(QueuedEntityPlacerConfig placerConfig) { @Override public QueuedEntityPlacer buildEntityPlacer(HeuristicConfigPolicy configPolicy) { - var entitySelectorConfig_ = buildEntitySelectorConfig(configPolicy); + var entitySelectorConfig_ = Objects.requireNonNullElseGet(config.getEntitySelectorConfig(), + () -> buildEntitySelectorConfig(configPolicy, config)); var entitySelector = EntitySelectorFactory. create(entitySelectorConfig_).buildEntitySelector(configPolicy, SelectionCacheType.PHASE, SelectionOrder.ORIGINAL); - var moveSelectorConfigList_ = getMoveSelectorConfigs(configPolicy, entitySelector, entitySelectorConfig_); + var moveSelectorConfigList_ = + Objects.requireNonNullElseGet(config.getMoveSelectorConfigList(), () -> buildMoveSelectorConfig(configPolicy, + config, entitySelector.getEntityDescriptor(), entitySelectorConfig_)); var moveSelectorList = new ArrayList>(moveSelectorConfigList_.size()); for (var moveSelectorConfig : moveSelectorConfigList_) { var moveSelector = MoveSelectorFactory. create(moveSelectorConfig) @@ -80,13 +84,13 @@ public QueuedEntityPlacer buildEntityPlacer(HeuristicConfigPolicy getMoveSelectorConfigs(HeuristicConfigPolicy configPolicy, - EntitySelector entitySelector, EntitySelectorConfig entitySelectorConfig_) { + public static @NonNull List buildMoveSelectorConfig( + HeuristicConfigPolicy configPolicy, QueuedEntityPlacerConfig config, + EntityDescriptor entityDescriptor, EntitySelectorConfig entitySelectorConfig_) { var moveSelectorConfigList = config.getMoveSelectorConfigList(); if (!ConfigUtils.isEmptyCollection(moveSelectorConfigList)) { return moveSelectorConfigList; } - var entityDescriptor = entitySelector.getEntityDescriptor(); var variableDescriptorList = entityDescriptor.getGenuineVariableDescriptorList().stream() .filter(variableDescriptor -> !variableDescriptor.isListVariable()) .toList(); @@ -103,15 +107,16 @@ public QueuedEntityPlacer buildEntityPlacer(HeuristicConfigPolicy configPolicy) { + public static EntitySelectorConfig buildEntitySelectorConfig(HeuristicConfigPolicy configPolicy, + QueuedEntityPlacerConfig config) { var entitySelectorConfig = config.getEntitySelectorConfig(); if (entitySelectorConfig == null) { - var entityDescriptor = getTheOnlyEntityDescriptorWithBasicVariables(configPolicy.getSolutionDescriptor()); + var entityDescriptor = getTheOnlyEntityDescriptorWithBasicVariables(configPolicy.getSolutionDescriptor(), config); entitySelectorConfig = getDefaultEntitySelectorConfigForEntity(configPolicy, entityDescriptor); } else { // The default phase configuration generates the entity selector config without an updated version of the configuration policy. // We need to ensure that there are no missing sorting settings. - var entityDescriptor = deduceEntityDescriptor(configPolicy, entitySelectorConfig.getEntityClass()); + var entityDescriptor = deduceEntityDescriptor(configPolicy, config, entitySelectorConfig.getEntityClass()); entitySelectorConfig = deduceEntitySortManner(configPolicy, entityDescriptor, entitySelectorConfig); } var cacheType = entitySelectorConfig.getCacheType(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java index e8b5ba8ee6f..d805ea8ac6e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java @@ -43,7 +43,7 @@ public QueuedValuePlacer buildEntityPlacer(HeuristicConfigPolicy moveSelectorConfig_ = config.getMoveSelectorConfig() == null - ? buildChangeMoveSelectorConfig(configPolicy, valueSelectorConfig_.getId(), + ? buildMoveSelectorConfig(configPolicy, valueSelectorConfig_.getId(), valueSelector.getVariableDescriptor()) : config.getMoveSelectorConfig(); @@ -84,10 +84,8 @@ private ValueSelectorConfig buildValueSelectorConfig(HeuristicConfigPolicy configPolicy, String valueSelectorConfigId, - GenuineVariableDescriptor variableDescriptor) { + private ChangeMoveSelectorConfig buildMoveSelectorConfig(HeuristicConfigPolicy configPolicy, + String valueSelectorConfigId, GenuineVariableDescriptor variableDescriptor) { ChangeMoveSelectorConfig changeMoveSelectorConfig = new ChangeMoveSelectorConfig(); EntityDescriptor entityDescriptor = variableDescriptor.getEntityDescriptor(); EntitySelectorConfig changeEntitySelectorConfig = new EntitySelectorConfig() diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java new file mode 100644 index 00000000000..8639f17147e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java @@ -0,0 +1,128 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm; + +import java.util.function.IntFunction; + +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase.EvolutionaryAlgorithmPhaseLifecycleListener; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; +import ai.timefold.solver.core.impl.phase.AbstractPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; + +public final class DefaultEvolutionaryAlgorithmPhase extends AbstractPhase + implements EvolutionaryAlgorithmPhase, EvolutionaryAlgorithmPhaseLifecycleListener { + + private final EvolutionaryDecider evolutionaryDecider; + private final boolean overConstrained; + + public DefaultEvolutionaryAlgorithmPhase(Builder builder) { + super(builder); + this.evolutionaryDecider = builder.evolutionaryDecider; + this.overConstrained = builder.overConstrained; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public PhaseType getPhaseType() { + return PhaseType.EVOLUTIONARY_ALGORITHM; + } + + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::evolutionaryAlgorithm; + } + + @Override + public void solve(SolverScope solverScope) { + var phaseScope = new EvolutionaryAlgorithmPhaseScope<>(solverScope, phaseIndex); + phaseStarted(phaseScope); + // Generate individuals and load the initial population + evolutionaryDecider.loadPopulation(phaseScope); + while (!phaseTermination.isPhaseTerminated(phaseScope)) { + var stepScope = new EvolutionaryAlgorithmStepScope<>(phaseScope); + stepStarted(stepScope); + // Evolve the current population using the related evolutionary strategy. + // All logic related to executing operators, + // individual selection, post-optimization, and so on is handled in this step. + evolutionaryDecider.evolvePopulation(stepScope); + stepEnded(stepScope); + } + phaseEnded(phaseScope); + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void solvingStarted(SolverScope solverScope) { + super.solvingStarted(solverScope); + evolutionaryDecider.solvingStarted(solverScope); + solverScope.startingNow(); + } + + @Override + public void solvingEnded(SolverScope solverScope) { + super.solvingEnded(solverScope); + evolutionaryDecider.solvingEnded(solverScope); + } + + @Override + public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + // Initialize the population to allow other operations to execute from a fresh instance + phaseScope.setPopulation(evolutionaryDecider.emptyPopulation(phaseScope)); + evolutionaryDecider.phaseStarted(phaseScope); + phaseScope.reset(); + } + + @Override + public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + evolutionaryDecider.phaseEnded(phaseScope); + phaseScope.endingNow(); + var statistics = phaseScope.getPopulation().getStatistics(); + logger.info( + "Evolutionary Algorithm phase ({}) ended: time spent ({}), best score ({}), best generation ({}), best iteration ({}), generation total ({}), iteration total ({}), overconstrained ({}).", + phaseScope.getPhaseIndex(), phaseScope.calculateSolverTimeMillisSpentUpToNow(), phaseScope.getBestScore().raw(), + statistics.bestGeneration(), statistics.bestIteration(), statistics.generationCount(), + statistics.individualCount(), overConstrained); + } + + @Override + public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { + super.stepStarted(stepScope); + evolutionaryDecider.stepStarted(stepScope); + } + + @Override + public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { + super.stepEnded(stepScope); + evolutionaryDecider.stepEnded(stepScope); + var solver = stepScope.getPhaseScope().getSolverScope().getSolver(); + solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); + } + + public static class Builder extends AbstractPhaseBuilder { + + private final EvolutionaryDecider evolutionaryDecider; + private final boolean overConstrained; + + public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, + EvolutionaryDecider evolutionaryDecider, boolean overConstrained) { + super(phaseIndex, logIndentation, phaseTermination); + this.evolutionaryDecider = evolutionaryDecider; + this.overConstrained = overConstrained; + } + + @Override + public DefaultEvolutionaryAlgorithmPhase build() { + return new DefaultEvolutionaryAlgorithmPhase<>(this); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java new file mode 100644 index 00000000000..edb7a441d3a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -0,0 +1,484 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm; + +import static ai.timefold.solver.core.impl.AbstractFromConfigFactory.deduceEntityDescriptor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicType; +import ai.timefold.solver.core.config.constructionheuristic.placer.EntityPlacerConfig; +import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; +import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryCustomPhaseConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryPopulationConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryWorkerConfig; +import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; +import ai.timefold.solver.core.config.heuristic.selector.common.SelectionOrder; +import ai.timefold.solver.core.config.heuristic.selector.common.nearby.NearbySelectionConfig; +import ai.timefold.solver.core.config.heuristic.selector.entity.EntitySelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.entity.pillar.PillarSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.list.DestinationSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.list.SubListSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.MoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.NearbyAutoConfigurationEnabled; +import ai.timefold.solver.core.config.heuristic.selector.move.composite.CartesianProductMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.composite.UnionMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.ChangeMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.PillarChangeMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.PillarSwapMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.SwapMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListSwapMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListChangeMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.SubListSwapMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.kopt.KOptListMoveSelectorConfig; +import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchType; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.PreviewFeature; +import ai.timefold.solver.core.config.solver.termination.DiminishedReturnsTerminationConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; +import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedEntityPlacerFactory; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase.NoBestEventPhase; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list.ListSolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list.ListOXCrossover; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider.Builder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ListVariableIndividual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.list.ListRuinRecreateIndividualStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.swapstar.ListSwapStarPhase; +import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; +import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelectorFactory; +import ai.timefold.solver.core.impl.phase.AbstractPhaseFactory; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.PhaseFactory; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.termination.SolverTermination; + +import org.jspecify.annotations.Nullable; + +public final class DefaultEvolutionaryAlgorithmPhaseFactory + extends AbstractPhaseFactory { + + public DefaultEvolutionaryAlgorithmPhaseFactory(EvolutionaryAlgorithmPhaseConfig phaseConfig) { + super(phaseConfig); + } + + @Override + public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean lastInitializingPhase, + HeuristicConfigPolicy solverConfigPolicy, BestSolutionRecaller bestSolutionRecaller, + SolverTermination solverTermination) { + if (solverConfigPolicy.getSolutionDescriptor().hasBothBasicAndListVariables()) { + throw new UnsupportedOperationException("The evolutionary algorithm cannot be applied to mixed models."); + } + if (solverConfigPolicy.getSolutionDescriptor().hasBasicVariable()) { + throw new UnsupportedOperationException("Basic variables are not supported yet."); + } + var populationConfig = phaseConfig.getPopulationConfig(); + if (populationConfig == null) { + populationConfig = new EvolutionaryPopulationConfig(); + } + var populationSize = Objects.requireNonNullElse(populationConfig.getPopulationSize(), 40); + var generationSize = Objects.requireNonNullElse(populationConfig.getGenerationSize(), 20); + var eliteGroupSize = Objects.requireNonNullElse(populationConfig.getEliteSolutionSize(), 10); + var populationRestartCount = Objects.requireNonNullElse(populationConfig.getPopulationRestartCount(), 400); + var workerConfig = phaseConfig.getWorkerConfig(); + if (workerConfig == null) { + workerConfig = new EvolutionaryWorkerConfig(); + } + var isListVariable = solverConfigPolicy.getSolutionDescriptor().hasListVariable(); + var phaseTermination = buildPhaseTermination(solverConfigPolicy, solverTermination); + // Research has shown that models with a maximum of four constraints perform better in operations that do not require a high inheritance rate. + // Conversely, + // overconstrained models that work with complex datasets tend to be more effective when the inheritance rate is at least 95%. + // This means that an individual will incorporate 95% of a parent's solution for crossover operations + // or ruin only 5% of it when creating a new individual. + var overConstrained = isOverConstrained(solverConfigPolicy); + var evolutionaryDecider = + buildEvolutionaryAlgorithmDecider(workerConfig, solverConfigPolicy, solverTermination, phaseTermination, + bestSolutionRecaller, overConstrained, isListVariable, populationSize, generationSize, eliteGroupSize, + populationRestartCount); + return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider, + overConstrained).build(); + } + + private static boolean isOverConstrained(HeuristicConfigPolicy solverConfigPolicy) { + // TODO - It might not be sufficient to infer conclusions based solely on the number of constraints, or the four constraints may be too low. + // TODO - Improve the reasoning to determine whether the model or problem is overconstrained. + return solverConfigPolicy.getConstraintCount() > 4; + } + + /** + * The method creates the evolutionary decider. By default, the Hybrid Genetic Search approach is created. + */ + private static , State_ extends SolutionState> + EvolutionaryDecider + buildEvolutionaryAlgorithmDecider(EvolutionaryWorkerConfig workerConfig, + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller, + boolean overConstrained, boolean isListVariable, int populationSize, int generationSize, int eliteGroupSize, + int populationRestartCount) { + + IndividualBuilder individualBuilder = buildIndividualBuilder(isListVariable); + SolutionStateManager solutionStateManager = buildSolutionStateManager(isListVariable); + Phase deterministicBestFitConstructionPhase = + disableBestSolutionUpdate(buildBestFitConstructionHeuristicPhase(solverConfigPolicy, solverTermination, false)); + Phase shuffledBestFitConstructionPhase = + disableBestSolutionUpdate(buildBestFitConstructionHeuristicPhase(solverConfigPolicy, solverTermination, true)); + Phase localSearchPhase = + disableBestSolutionUpdate(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchPhaseConfig(), + solverTermination, bestSolutionRecaller, overConstrained, isListVariable)); + Phase swapStarPhase = + disableBestSolutionUpdate(buildSwapStarPhase(solverConfigPolicy, solverTermination, isListVariable)); + ConstructionIndividualStrategy constructionIndividualStrategy = + buildConstructionIndividualPhase(workerConfig, workerConfig.getCustomIndividualPhaseConfig(), + deterministicBestFitConstructionPhase, localSearchPhase, + swapStarPhase, solutionStateManager, individualBuilder, overConstrained, isListVariable); + CrossoverStrategy crossoverStrategy = + buildCrossoverStrategy(localSearchPhase, swapStarPhase, overConstrained, isListVariable); + + return new Builder>() + .withPopulationSize(populationSize) + .withGenerationSize(generationSize) + .withEliteSolutionSize(eliteGroupSize) + .withPopulationRestartCount(populationRestartCount) + .withConstructionIndividualStrategy(constructionIndividualStrategy) + .withLocalSearchPhase(localSearchPhase) + .withSwapStarPhase(swapStarPhase) + .withCrossoverStrategy(crossoverStrategy) + .withIndividualBuilder(individualBuilder) + .withSolutionStateManager(solutionStateManager) + .withPhaseTermination(phaseTermination) + .withBestSolutionRecaller(bestSolutionRecaller) + .build(); + } + + @SuppressWarnings("unchecked") + private static , State_ extends SolutionState> + SolutionStateManager buildSolutionStateManager(boolean isListVariable) { + if (!isListVariable) { + throw new UnsupportedOperationException("Basic variables are not supported yet."); + } + return (SolutionStateManager) new ListSolutionStateManager<>(); + } + + private static > IndividualBuilder + buildIndividualBuilder(boolean isListVariable) { + if (!isListVariable) { + throw new UnsupportedOperationException("Basic variables are not supported yet."); + } + return ListVariableIndividual::new; + } + + private static > CrossoverStrategy buildCrossoverStrategy( + Phase localSearchPhase, Phase swapStarPhase, boolean overConstrained, + boolean isListVariable) { + if (!isListVariable) { + throw new UnsupportedOperationException("Basic variables are not supported yet."); + } + if (overConstrained) { + return new ListOXCrossover<>(localSearchPhase, swapStarPhase, 0.95, false); + } else { + return new ListOXCrossover<>(localSearchPhase, swapStarPhase, 0.5, true); + } + } + + private static Phase buildBestFitConstructionHeuristicPhase( + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + boolean shuffle) { + var constructionHeuristicPhaseConfig = new ConstructionHeuristicPhaseConfig(); + if (shuffle) { + var entityPlacerConfig = DefaultConstructionHeuristicPhaseFactory.buildDefaultEntityPlacerConfig(solverConfigPolicy, + constructionHeuristicPhaseConfig, ConstructionHeuristicType.ALLOCATE_FROM_POOL); + shuffleEntityPlacerConfig(solverConfigPolicy, entityPlacerConfig); + constructionHeuristicPhaseConfig.setEntityPlacerConfig(entityPlacerConfig); + } + var constructionConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .build(); + return PhaseFactory. create(constructionHeuristicPhaseConfig).buildPhase(0, false, + constructionConfigPolicy, null, solverTermination); + } + + private static , State_ extends SolutionState> + ConstructionIndividualStrategy + buildConstructionIndividualPhase(EvolutionaryWorkerConfig workerConfig, + EvolutionaryCustomPhaseConfig customIndividualPhaseConfig, + Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, + Phase swapStarPhase, SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, boolean overConstrained, boolean isListVariable) { + if (!isListVariable) { + throw new UnsupportedOperationException("Basic variables are not supported yet."); + } + List> customIndividualPhaseCommandList = + buildPhaseCommandList(workerConfig, customIndividualPhaseConfig); + if (overConstrained) { + return new ListRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, + deterministicBestFitConstructionPhase, localSearchPhase, swapStarPhase, solutionStateManager, + individualBuilder, 0.95); + } else { + return new ListRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, + deterministicBestFitConstructionPhase, localSearchPhase, swapStarPhase, solutionStateManager, + individualBuilder, 0.1); + } + } + + private static List> buildPhaseCommandList( + EvolutionaryWorkerConfig workerConfig, EvolutionaryCustomPhaseConfig customPhaseConfig) { + var customIndividualPhaseCommandList = Collections.> emptyList(); + if (customPhaseConfig != null && customPhaseConfig.getCustomPhaseCommandClassList() != null) { + customIndividualPhaseCommandList = + new ArrayList<>(customPhaseConfig.getCustomPhaseCommandClassList().size()); + for (var customPhaseCommandClass : customPhaseConfig.getCustomPhaseCommandClassList()) { + if (customPhaseCommandClass == null) { + throw new IllegalArgumentException(""" + The customPhaseCommandClass (%s) cannot be null in the evolutionary custom phase config (%s). + Maybe there was a typo in the class name provided in the solver config XML?""" + .formatted(customPhaseCommandClass, workerConfig)); + } + PhaseCommand customPhaseCommand = + ConfigUtils.newInstance(workerConfig, "customPhaseCommandClass", customPhaseCommandClass); + ConfigUtils.applyCustomProperties(customPhaseCommand, "customPhaseCommandClass", + customPhaseConfig.getCustomProperties(), "customProperties"); + customIndividualPhaseCommandList.add(customPhaseCommand); + } + } + return customIndividualPhaseCommandList; + } + + /** + * The method ensures that the source entity or source value selectors from the entity placer selector is shuffled, + * allowing for the generation of different solutions whenever the phase is restarted. + *

+ * The proposed approach avoids shuffling the move selector, + * eliminating the need to generate the entire move list upfront and then randomize it. + */ + private static void shuffleEntityPlacerConfig(HeuristicConfigPolicy solverConfigPolicy, + EntityPlacerConfig entityPlacerConfig) { + if (entityPlacerConfig instanceof QueuedEntityPlacerConfig queuedEntityPlacerConfig) { + // Basic variable, then we randomize the entity selector + var entitySelectorConfig = Objects.requireNonNullElseGet(queuedEntityPlacerConfig.getEntitySelectorConfig(), + () -> QueuedEntityPlacerFactory.buildEntitySelectorConfig(solverConfigPolicy, queuedEntityPlacerConfig)); + var entityDescriptor = + deduceEntityDescriptor(solverConfigPolicy, entitySelectorConfig, + Objects.requireNonNull(entitySelectorConfig).getEntityClass()); + queuedEntityPlacerConfig.setEntitySelectorConfig(entitySelectorConfig); + shuffleEntitySelectorConfig(entitySelectorConfig); + var moveSelectorConfigList = Objects.requireNonNullElseGet(queuedEntityPlacerConfig.getMoveSelectorConfigList(), + () -> QueuedEntityPlacerFactory.buildMoveSelectorConfig(solverConfigPolicy, queuedEntityPlacerConfig, + entityDescriptor, entitySelectorConfig)); + if (moveSelectorConfigList.size() != 1) { + throw new IllegalStateException( + "Impossible state: the move configuration list %s cannot be empty or contain multiple items." + .formatted(moveSelectorConfigList)); + } + queuedEntityPlacerConfig.setMoveSelectorConfigList(moveSelectorConfigList); + var moveSelectorConfig = Objects.requireNonNull(moveSelectorConfigList.get(0)); + switch (moveSelectorConfig) { + case ChangeMoveSelectorConfig changeMoveSelectorConfig -> + shuffleValueSelectorConfig(changeMoveSelectorConfig.getValueSelectorConfig()); + case CartesianProductMoveSelectorConfig cartesianProductMoveSelectorConfig -> { + for (var innerMoveSelectorConfig : Objects + .requireNonNull(cartesianProductMoveSelectorConfig.getMoveSelectorList())) { + if (!(innerMoveSelectorConfig instanceof ChangeMoveSelectorConfig changeMoveSelectorConfig)) { + throw new IllegalStateException( + "Impossible state: the inner move configration (%s) must match the type (%s)" + .formatted(innerMoveSelectorConfig, + ChangeMoveSelectorConfig.class.getSimpleName())); + } + shuffleValueSelectorConfig(changeMoveSelectorConfig.getValueSelectorConfig()); + } + } + default -> + throw new IllegalStateException("Impossible state: the move configration (%s) must match the types (%s, %s)" + .formatted(moveSelectorConfig, ChangeMoveSelectorConfig.class.getSimpleName(), + CartesianProductMoveSelectorConfig.class.getSimpleName())); + } + } else if (entityPlacerConfig instanceof QueuedValuePlacerConfig queuedValuePlacerConfig) { + // List variable, then we shuffle the source value selector + var valueSelectorConfig = Objects.requireNonNull(queuedValuePlacerConfig.getValueSelectorConfig()); + shuffleValueSelectorConfig(valueSelectorConfig); + // The move list has only one list change move + var moveSelectorConfig = Objects.requireNonNull(queuedValuePlacerConfig.getMoveSelectorConfig()); + if (!(moveSelectorConfig instanceof ListChangeMoveSelectorConfig listChangeMoveSelectorConfig)) { + throw new IllegalStateException("Impossible state: the move configration (%s) must match the type (%s)" + .formatted(moveSelectorConfig, ListChangeMoveSelectorConfig.class.getSimpleName())); + } + var destinationSelectorConfig = Objects.requireNonNullElseGet( + listChangeMoveSelectorConfig.getDestinationSelectorConfig(), DestinationSelectorConfig::new); + listChangeMoveSelectorConfig.setDestinationSelectorConfig(destinationSelectorConfig); + var entitySelectorConfig = Objects.requireNonNullElseGet(destinationSelectorConfig.getEntitySelectorConfig(), + EntitySelectorConfig::new); + destinationSelectorConfig.setEntitySelectorConfig(entitySelectorConfig); + } + } + + private static void shuffleEntitySelectorConfig(EntitySelectorConfig entitySelectorConfig) { + Objects.requireNonNull(entitySelectorConfig).setSelectionOrder(SelectionOrder.SHUFFLED); + Objects.requireNonNull(entitySelectorConfig).setCacheType(SelectionCacheType.PHASE); + } + + private static void shuffleValueSelectorConfig(ValueSelectorConfig valueSelectorConfig) { + Objects.requireNonNull(valueSelectorConfig).setSelectionOrder(SelectionOrder.SHUFFLED); + Objects.requireNonNull(valueSelectorConfig).setCacheType(SelectionCacheType.PHASE); + } + + /** + * The method creates a local search phase based on Diversified Late Acceptance and customized diminished termination. + */ + private static Phase buildLocalSearchPhase(HeuristicConfigPolicy solverConfigPolicy, + @Nullable LocalSearchPhaseConfig localSearchPhaseConfig, SolverTermination solverTermination, + BestSolutionRecaller bestSolutionRecaller, boolean overconstrained, boolean isListVariable) { + var updatedLocalSearchPhaseConfig = localSearchPhaseConfig; + if (updatedLocalSearchPhaseConfig == null) { + updatedLocalSearchPhaseConfig = new LocalSearchPhaseConfig(); + updatedLocalSearchPhaseConfig.setLocalSearchType(LocalSearchType.DIVERSIFIED_LATE_ACCEPTANCE); + } + if (updatedLocalSearchPhaseConfig.getTerminationConfig() == null) { + var terminationConfig = new TerminationConfig(); + if (overconstrained) { + terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() + .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(20L)); + } else { + terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() + .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(1L)); + } + updatedLocalSearchPhaseConfig.setTerminationConfig(terminationConfig); + } + var clearNearbyClass = updatedLocalSearchPhaseConfig.getMoveSelectorConfig() == null; + loadMoveSelectorConfig(solverConfigPolicy, updatedLocalSearchPhaseConfig, isListVariable); + var localSearchConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .withPreviewFeature(PreviewFeature.DIVERSIFIED_LATE_ACCEPTANCE); + if (clearNearbyClass) { + localSearchConfigPolicy.withNearbyDistanceMeterClass(null); + } + return PhaseFactory. create(updatedLocalSearchPhaseConfig).buildPhase(0, false, + localSearchConfigPolicy.build(), bestSolutionRecaller, solverTermination); + } + + /** + * The move config for list variable includes all move types used by the HGS original article: + * 2.1 - Reallocate planning value U after a planning value V (regular change and list change moves) + * 2.2 - Swap planning value U with a planning value V (regular swap and list swap moves) + * 2.3 - Reallocate planning value U and its successor X after a planning value V: (U, X) -> Entity[position] + * 2.4 - Reallocate planning value U and its successor X after a planning value V, and invert the values: (X, U) -> + * Entity[position] + * 2.5 - Swap two planning values U and X with a planning value: (U, X) <-> V + * 2.6 - Swap two planning values U and X with a planning value V, and invert the values: (X, U) <-> V + * 2.7 - Intra route 2-opt move: (U, X) (V, Y) -> (U, V) (X, Y) + * 2.8 - Inter route 2-opt move: (U, X) (V, Y) -> (U, V) (X, Y) + * 2.9 - Inter route 2-opt move: (U, X) (V, Y) -> (U, Y) (V, X) + *

+ * As for the basic variables, four types of moves are included: change move, swap move, pillar change move, and pillar swap + * move. + */ + private static void loadMoveSelectorConfig(HeuristicConfigPolicy solverConfigPolicy, + LocalSearchPhaseConfig localSearchPhaseConfig, boolean isListVariable) { + if (localSearchPhaseConfig.getMoveSelectorConfig() == null) { + var updatedUnionMoveSelectorConfig = new UnionMoveSelectorConfig(); + var moveList = new ArrayList(); + if (isListVariable) { + // Move 2.1 + moveList.add(new ListChangeMoveSelectorConfig()); + // Move 2.2 + moveList.add(new ListSwapMoveSelectorConfig()); + // Move 2.3 and 2.4 + moveList.add(new SubListChangeMoveSelectorConfig() + .withSelectReversingMoveToo(true).withSubListSelectorConfig( + new SubListSelectorConfig().withMinimumSubListSize(2).withMaximumSubListSize(2))); + // Move 2.5 and 2.6 + moveList.add(new SubListSwapMoveSelectorConfig() + .withSelectReversingMoveToo(true) + .withSubListSelectorConfig( + new SubListSelectorConfig().withMinimumSubListSize(2).withMaximumSubListSize(2)) + .withSecondarySubListSelectorConfig( + new SubListSelectorConfig().withMinimumSubListSize(1).withMaximumSubListSize(1))); + // Moves 2.7, 2.8 and 2.9 + moveList.add(new KOptListMoveSelectorConfig().withMinimumK(2).withMaximumK(2)); + } else { + moveList.add(new ChangeMoveSelectorConfig()); + moveList.add(new SwapMoveSelectorConfig()); + moveList.add(new PillarChangeMoveSelectorConfig().withPillarSelectorConfig( + new PillarSelectorConfig().withMinimumSubPillarSize(2).withMaximumSubPillarSize(2))); + moveList.add(new PillarSwapMoveSelectorConfig().withPillarSelectorConfig( + new PillarSelectorConfig().withMinimumSubPillarSize(2).withMaximumSubPillarSize(2))); + } + updatedUnionMoveSelectorConfig.setMoveSelectorList(moveList); + localSearchPhaseConfig.setMoveSelectorConfig(updatedUnionMoveSelectorConfig); + } + if (solverConfigPolicy.getNearbyDistanceMeterClass() != null && localSearchPhaseConfig + .getMoveSelectorConfig() instanceof NearbyAutoConfigurationEnabled nearbyAutoConfiguration) { + var nearbyDistanceMeterClass = + (Class>) solverConfigPolicy.getNearbyDistanceMeterClass(); + // The article uses a granular neighborhood with 20 closest customers, + // but some experiments have shown that relying solely on a neighborhood with nearby feature enabled can be counterproductive, + // potentially preventing the solver from exploring better areas of the solution space. + // Additionally, + // the local search phase used by the method, + // by default, + // does not depend on the size of the neighborhood to complete the refinement step. + var updatedUnionMoveSelectorConfig = + nearbyAutoConfiguration.enableNearbySelection(nearbyDistanceMeterClass, solverConfigPolicy.getRandom()); + localSearchPhaseConfig.setMoveSelectorConfig(updatedUnionMoveSelectorConfig); + } + } + + /** + * The method creates an optimization phase to implement the SWAP* approach as outlined in the HGS article. + */ + private static Phase buildSwapStarPhase(HeuristicConfigPolicy solverConfigPolicy, + SolverTermination solverTermination, boolean enableSwapStar) { + if (enableSwapStar && solverConfigPolicy.getNearbyDistanceMeterClass() != null) { + var entityClass = solverConfigPolicy.getSolutionDescriptor().getListVariableDescriptor().getEntityDescriptor() + .getEntityClass(); + var originalEntitySelectorConfig = new EntitySelectorConfig() + .withId(ConfigUtils.addRandomSuffix(entityClass.getName(), solverConfigPolicy.getRandom())) + .withEntityClass(entityClass); + var originalEntitySelector = EntitySelectorFactory. create(originalEntitySelectorConfig) + .buildEntitySelector(solverConfigPolicy, SelectionCacheType.JUST_IN_TIME, SelectionOrder.ORIGINAL); + var innerEntitySelectorConfig = new EntitySelectorConfig() + .withNearbySelectionConfig(new NearbySelectionConfig() + .withOriginEntitySelectorConfig( + EntitySelectorConfig.newMimicSelectorConfig( + Objects.requireNonNull(originalEntitySelectorConfig.getId()))) + .withNearbyDistanceMeterClass(solverConfigPolicy.getNearbyDistanceMeterClass())); + var innerEntitySelector = EntitySelectorFactory. create(innerEntitySelectorConfig) + .buildEntitySelector(solverConfigPolicy, SelectionCacheType.JUST_IN_TIME, SelectionOrder.ORIGINAL); + + return new ListSwapStarPhase.Builder<>(0, "", PhaseTermination.bridge(solverTermination), originalEntitySelector, + innerEntitySelector).build(); + } + return null; + } + + /** + * Utility method that disables updates to the best solution events during the evolutionary inner phases. + * + * @param phase the phase to be configured + * @return a phase that disables the best solution updates and run the same logic as the inner phase. + */ + private static Phase disableBestSolutionUpdate(Phase phase) { + if (phase == null) { + return null; + } + return new NoBestEventPhase<>(phase); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/EvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/EvolutionaryAlgorithmPhase.java new file mode 100644 index 00000000000..5a25347dd96 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/EvolutionaryAlgorithmPhase.java @@ -0,0 +1,14 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.phase.Phase; + +/** + * A {@link EvolutionaryAlgorithmPhase} is a {@link Phase} which applies an evolutionary algorithm, + * such as Genetic Algorithm, Hybrid Genetic Search, Genetic Programming, Particle Swarm Optimization, etc. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public interface EvolutionaryAlgorithmPhase extends Phase { + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java new file mode 100644 index 00000000000..3e12eff70c3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java @@ -0,0 +1,74 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common; + +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; + +public final class Utils { + + private Utils() { + // No external instances. + } + + /** + * Generate a cut-point and ensure that the expected number of planning values is included in the interval. + */ + public static int[] generateIndexes(RandomGenerator workingRandom, int size, double inheritanceRate) { + var minSize = (int) (size * inheritanceRate); + if (minSize == 0) { + return generateIndexes(workingRandom, size); + } + var maxStart = size - minSize + 1; + var start = workingRandom.nextInt(0, maxStart); + var minEnd = start + minSize - 1; + var maxEnd = start == 0 ? size - 1 : size; + var end = start == maxStart - 1 ? size - 1 : workingRandom.nextInt(minEnd, maxEnd); + return new int[] { start, end }; + } + + /** + * Differ from {@link #generateIndexes(RandomGenerator, int, double)} as it generates a cut-point without a minimum number + * of values that the interval must contain. + */ + public static int[] generateIndexes(RandomGenerator workingRandom, int size) { + var start = workingRandom.nextInt(size); + var end = workingRandom.nextInt(size); + while (start == end) { + end = workingRandom.nextInt(size); + } + if (start > end) { + var newEndIdx = start; + start = end; + end = newEndIdx; + } + // Avoid copying all values from the first individual (only enforceable when size > 2) + if (start == 0 && end == size - 1 && size > 2) { + // Pick a new end index in [1, size-2] to leave at least one value from the second parent + end = 1 + workingRandom.nextInt(size - 2); + } + return new int[] { start, end }; + } + + /** + * The method adjusts the position to encompass all values assigned to the target entity and avoids breaking constraints + * that require groups of values to be assigned together. + */ + public static int fixIndex(ChromosomeEntry[] chromosome, int position, boolean backward) { + int index; + var target = chromosome[position].entity(); + if (backward) { + index = position - 1; + while (index >= 0 && chromosome[index].entity() == target) { + index--; + } + // We need to increment because the starting position is inclusive in the interval [index, end]. + index++; + } else { + index = position + 1; + while (index < chromosome.length && chromosome[index].entity() == target) { + index++; + } + } + return index; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/BestSolutionUpdater.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/BestSolutionUpdater.java new file mode 100644 index 00000000000..367d41081db --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/BestSolutionUpdater.java @@ -0,0 +1,12 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution; + +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +@FunctionalInterface +public interface BestSolutionUpdater { + + void updateBestSolution(EvolutionaryAlgorithmStepScope stepScope); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java new file mode 100644 index 00000000000..8f75c6d25fb --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java @@ -0,0 +1,51 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class DefaultBestSolutionUpdater, State_ extends SolutionState> + implements BestSolutionUpdater { + + private final EvolutionaryAlgorithmPhaseScope sharedPhaseScope; + private final BestSolutionRecaller sharedBestSolutionRecaller; + private final Population sharedPopulation; + private final SolutionStateManager solutionStateManager; + + public DefaultBestSolutionUpdater(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + BestSolutionRecaller sharedBestSolutionRecaller, Population sharedPopulation, + SolutionStateManager solutionStateManager) { + this.sharedPhaseScope = sharedPhaseScope; + this.sharedBestSolutionRecaller = sharedBestSolutionRecaller; + this.sharedPopulation = sharedPopulation; + this.solutionStateManager = solutionStateManager; + } + + @Override + public void updateBestSolution(EvolutionaryAlgorithmStepScope stepScope) { + var newIndividual = stepScope.getStepIndividual(); + if (sharedPopulation.getBestIndividual() == stepScope.getStepIndividual()) { + // The proposed approach avoids using `scoreDirector::setWorkingSolution` + // to prevent the need to read all planning and fact values and recalculate statistics. + // Instead, + // it uses the solution state manager to save the individual state. + // This method reads the values once and then assigns them to the current working solution, + // requiring one additional read of the values. + var individualState = solutionStateManager.saveSolutionState(stepScope.getStepIndividual()); + solutionStateManager.restoreSolutionState(sharedPhaseScope.getScoreDirector(), individualState); + var bestSolutionStepScope = new EvolutionaryAlgorithmStepScope<>(sharedPhaseScope, newIndividual); + bestSolutionStepScope.setScore(newIndividual.getScore()); + var oldState = sharedBestSolutionRecaller.isEnableUpdateEvents(); + sharedBestSolutionRecaller.setEnableUpdateEvents(true); + sharedBestSolutionRecaller.processWorkingSolutionDuringStep(bestSolutionStepScope); + sharedBestSolutionRecaller.setEnableUpdateEvents(oldState); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/EvolutionaryAlgorithmPhaseLifecycleListener.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/EvolutionaryAlgorithmPhaseLifecycleListener.java new file mode 100644 index 00000000000..958516f2186 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/EvolutionaryAlgorithmPhaseLifecycleListener.java @@ -0,0 +1,21 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.solver.event.SolverLifecycleListener; + +/** + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public interface EvolutionaryAlgorithmPhaseLifecycleListener extends SolverLifecycleListener { + + void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope); + + void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope); + + void stepStarted(EvolutionaryAlgorithmStepScope stepScope); + + void stepEnded(EvolutionaryAlgorithmStepScope stepScope); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java new file mode 100644 index 00000000000..e8e06569a51 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java @@ -0,0 +1,88 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase; + +import java.util.function.IntFunction; + +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.impl.phase.AbstractPhase; +import ai.timefold.solver.core.impl.phase.Phase; +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; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; + +/** + * The phase receives an inner phase and disables any best solution events, + * which is required when running the inner phase in the evolutionary process. + * + * @param the solution type + */ +public final class NoBestEventPhase implements Phase { + private final Phase innerPhase; + private boolean previousState; + + public NoBestEventPhase(Phase innerPhase) { + this.innerPhase = innerPhase; + if (innerPhase instanceof AbstractPhase abstractPhase) { + abstractPhase.disableLogging(); + } + } + + @Override + public void addPhaseLifecycleListener(PhaseLifecycleListener phaseLifecycleListener) { + innerPhase.addPhaseLifecycleListener(phaseLifecycleListener); + } + + @Override + public void removePhaseLifecycleListener(PhaseLifecycleListener phaseLifecycleListener) { + innerPhase.removePhaseLifecycleListener(phaseLifecycleListener); + } + + @Override + public void solve(SolverScope solverScope) { + innerPhase.solve(solverScope); + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return innerPhase.getEventProducerIdSupplier(); + } + + @Override + public void solvingStarted(SolverScope solverScope) { + var solver = solverScope.getSolver(); + if (solver != null) { + previousState = solver.getBestSolutionRecaller().isEnableUpdateEvents(); + solver.getBestSolutionRecaller().setEnableUpdateEvents(false); + } + innerPhase.solvingStarted(solverScope); + } + + @Override + public void solvingEnded(SolverScope solverScope) { + var solver = solverScope.getSolver(); + if (solver != null) { + solver.getBestSolutionRecaller().setEnableUpdateEvents(previousState); + } + innerPhase.solvingEnded(solverScope); + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + innerPhase.phaseStarted(phaseScope); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + innerPhase.phaseEnded(phaseScope); + } + + @Override + public void stepStarted(AbstractStepScope stepScope) { + // Do nothing + } + + @Override + public void stepEnded(AbstractStepScope stepScope) { + // Do nothing + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java new file mode 100644 index 00000000000..9600ee00319 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java @@ -0,0 +1,51 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; + +public final class EvolutionaryAlgorithmPhaseScope extends AbstractPhaseScope { + + private EvolutionaryAlgorithmStepScope lastCompletedStepScope; + private Population population; + + public EvolutionaryAlgorithmPhaseScope(SolverScope solverScope, int phaseIndex) { + super(solverScope, phaseIndex); + this.lastCompletedStepScope = new EvolutionaryAlgorithmStepScope<>(this, 0, null); + } + + public void setLastCompletedStepScope(EvolutionaryAlgorithmStepScope lastCompletedStepScope) { + this.lastCompletedStepScope = lastCompletedStepScope; + } + + @Override + public AbstractStepScope getLastCompletedStepScope() { + return lastCompletedStepScope; + } + + @SuppressWarnings("unchecked") + public > Population getPopulation() { + return (Population) population; + } + + public void setPopulation(Population population) { + this.population = population; + } + + public EvolutionaryAlgorithmPhaseScope copy(InnerScoreDirector scoreDirector) { + var solverScopeCopy = getSolverScope().copy(scoreDirector); + var copy = new EvolutionaryAlgorithmPhaseScope<>(solverScopeCopy, phaseIndex); + copy.startingSystemTimeMillis = startingSystemTimeMillis; + copy.startingScoreCalculationCount = startingScoreCalculationCount; + copy.startingMoveEvaluationCount = startingMoveEvaluationCount; + copy.startingScore = startingScore; + copy.setTermination(getTermination()); + copy.lastCompletedStepScope = lastCompletedStepScope; + copy.population = population; + return copy; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java new file mode 100644 index 00000000000..63cf86f166c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; + +public final class EvolutionaryAlgorithmStepScope extends AbstractStepScope { + + private final EvolutionaryAlgorithmPhaseScope phaseScope; + private Individual stepIndividual; + + public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope) { + this(phaseScope, phaseScope.getNextStepIndex(), null); + } + + public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope, + Individual stepIndividual) { + this(phaseScope, phaseScope.getNextStepIndex(), stepIndividual); + } + + public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope, int stepIndex, + Individual stepIndividual) { + super(stepIndex); + this.phaseScope = phaseScope; + this.stepIndividual = stepIndividual; + } + + @SuppressWarnings("unchecked") + public > Individual getStepIndividual() { + return (Individual) stepIndividual; + } + + public void setStepIndividual(Individual stepIndividual) { + this.stepIndividual = stepIndividual; + } + + @Override + public EvolutionaryAlgorithmPhaseScope getPhaseScope() { + return phaseScope; + } + + @Override + public Solution_ cloneWorkingSolution() { + return getScoreDirector().cloneSolution(stepIndividual.getSolution()); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionState.java new file mode 100644 index 00000000000..76ceefc83d9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionState.java @@ -0,0 +1,11 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; + +public interface SolutionState> { + + Solution_ getSolution(); + + InnerScore getScore(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java new file mode 100644 index 00000000000..6b4ab373da7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; + +/** + * Base contract for defining solution state managers used by the {@link EvolutionaryDecider evolutionary decider} to save and + * restore states. + * + * @param the solution type + * @param the score type + * @param the solution state type + */ +@NullMarked +public interface SolutionStateManager, State_ extends SolutionState> { + + State_ saveSolutionState(InnerScoreDirector scoreDirector, boolean saveAssigned); + + State_ saveSolutionState(Individual individual); + + void restoreSolutionState(InnerScoreDirector scoreDirector, State_ stateToRestore); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java new file mode 100644 index 00000000000..c14987b78e3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java @@ -0,0 +1,21 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list; + +import java.util.List; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.score.director.InnerScore; + +public record ListSolutionState>(Solution_ solution, + List assignedValueList, InnerScore score) implements SolutionState { + + @Override + public Solution_ getSolution() { + return solution; + } + + @Override + public InnerScore getScore() { + return score; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java new file mode 100644 index 00000000000..9cc987d94eb --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java @@ -0,0 +1,126 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +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.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +/** + * Handles the saving and restoring of the working solution state for solutions using a {@link ListVariableDescriptor list + * variable}. + *

+ * {@link SolutionStateManager#saveSolutionState} captures a snapshot of the current working solution by cloning it + * and recording which planning values are assigned to each planning entity. + *

+ * {@link #restoreSolutionState} rolls the working solution back to a previously saved snapshot. + * It selectively unassigns all values that are currently assigned and reassign the ones from the saved state. + *

+ * This class is used by the evolutionary algorithm to reset the working solution to a clean baseline before generating + * new individuals. + */ +public final class ListSolutionStateManager> + implements SolutionStateManager> { + + @Override + public ListSolutionState saveSolutionState(InnerScoreDirector scoreDirector, + boolean saveAssigned) { + var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); + try (var listVariableSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var solution = scoreDirector.getWorkingSolution(); + var size = + (int) scoreDirector.getValueRangeManager().countOnSolution(listVariableDescriptor.getValueRangeDescriptor(), + solution) - listVariableSupply.getUnassignedCount(); + if (size == 0) { + return new ListSolutionState<>(scoreDirector.cloneSolution(scoreDirector.getWorkingSolution()), + Collections.emptyList(), scoreDirector.calculateScore()); + } + var valueRange = + scoreDirector.getValueRangeManager().getFromSolution(listVariableDescriptor.getValueRangeDescriptor(), + solution); + var assignedValueList = new ArrayList(size); + for (var iterator = valueRange.createOriginalIterator(); iterator.hasNext();) { + var value = iterator.next(); + if (saveAssigned && listVariableSupply.isAssigned(value)) { + assignedValueList + .add(new ListValueState(value, listVariableSupply.getElementPosition(value).ensureAssigned())); + } + } + assignedValueList.sort(Comparator.comparing(ListValueState::index)); + return new ListSolutionState<>(scoreDirector.getWorkingSolution(), assignedValueList, + scoreDirector.calculateScore()); + } + } + + @Override + public ListSolutionState saveSolutionState(Individual individual) { + var assignedValues = Arrays.stream(individual.getChromosome()) + .map(chromosomeEntry -> new ListValueState(chromosomeEntry.value(), + ElementPosition.of(chromosomeEntry.entity(), chromosomeEntry.index()))) + .toList(); + return new ListSolutionState<>(individual.getSolution(), assignedValues, individual.getScore()); + } + + @Override + public void restoreSolutionState(InnerScoreDirector scoreDirector, + ListSolutionState stateToRestore) { + var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); + var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); + try (var listVariableSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var solution = scoreDirector.getWorkingSolution(); + var size = + (int) scoreDirector.getValueRangeManager().countOnSolution(listVariableDescriptor.getValueRangeDescriptor(), + solution) - listVariableSupply.getUnassignedCount(); + var needRebase = stateToRestore.getSolution() != solution; + var moveList = unassignAll(listVariableMetaModel, listVariableDescriptor, listVariableSupply, solution, size); + for (var assignedValue : stateToRestore.assignedValueList()) { + if (needRebase) { + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(assignedValue.value())); + var rebasedEntity = + Objects.requireNonNull(scoreDirector.lookUpWorkingObject(assignedValue.positionInList().entity())); + moveList.add(Moves.assign(listVariableMetaModel, rebasedValue, rebasedEntity, + assignedValue.positionInList().index())); + } else { + moveList.add(Moves.assign(listVariableMetaModel, assignedValue.value(), assignedValue.positionInList())); + } + } + if (!moveList.isEmpty()) { + var compositeMove = Moves.compose(moveList); + scoreDirector.getMoveDirector().execute(compositeMove); + } + } + } + + private List> unassignAll( + PlanningListVariableMetaModel planningListVariableMetaModel, + ListVariableDescriptor listVariableDescriptor, + ListVariableStateSupply listVariableSupply, Solution_ solution, int size) { + var unassignMoveList = new ArrayList>(size); + var allEntities = listVariableDescriptor.getEntityDescriptor().extractEntities(solution); + for (var entity : allEntities) { + var start = listVariableDescriptor.getFirstUnpinnedIndex(entity); + var end = listVariableDescriptor.getListSize(entity); + var values = listVariableDescriptor.getValue(entity); + for (var i = start; i < end; i++) { + var value = values.get(i); + if (!listVariableSupply.isPinned(value) && listVariableSupply.isAssigned(value)) { + unassignMoveList.add(Moves.unassign(planningListVariableMetaModel, entity, i)); + } + } + } + Collections.reverse(unassignMoveList); + return unassignMoveList; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java new file mode 100644 index 00000000000..ac1f1fa0bb3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java @@ -0,0 +1,9 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; + +record ListValueState(Object value, PositionInList positionInList) { + int index() { + return positionInList().index(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverContext.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverContext.java new file mode 100644 index 00000000000..fe307787158 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverContext.java @@ -0,0 +1,9 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; + +public record CrossoverContext>(EvolutionaryAlgorithmPhaseScope phaseScope, + Individual firstIndividual, Individual secondIndividual) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverResult.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverResult.java new file mode 100644 index 00000000000..5a36bef05a5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverResult.java @@ -0,0 +1,8 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; + +public record CrossoverResult>(Solution_ solution, InnerScore score, + InnerScore firstParentScore, InnerScore secondParentScore) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java new file mode 100644 index 00000000000..7fd4dbce464 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; + +import ai.timefold.solver.core.api.score.Score; + +import org.jspecify.annotations.NullMarked; + +/** + * Base contract for defining crossover operations. + */ +@FunctionalInterface +@NullMarked +public interface CrossoverStrategy> { + + CrossoverResult apply(CrossoverContext context); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java new file mode 100644 index 00000000000..17d997c4bc9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java @@ -0,0 +1,109 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import java.util.Objects; +import java.util.Set; + +import ai.timefold.solver.core.api.score.Score; +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.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ValueRangeManager; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract sealed class AbstractListCrossover> + implements CrossoverStrategy + permits ListOXCrossover, ListRXCrossover { + + final Phase localSearchPhase; + final @Nullable Phase refinementPhase; + final double inheritanceRate; + + AbstractListCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, + double inheritanceRate) { + this.localSearchPhase = localSearchPhase; + this.refinementPhase = refinementPhase; + this.inheritanceRate = inheritanceRate; + } + + /** + * Applies a best-insertion method for all values given by {@code chromosome}. + */ + static > void applyBestFit(InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + ListVariableDescriptor listVariableDescriptor, + PlanningListVariableMetaModel listVariableMetaModel, + ValueRangeManager valueRangeManager, ChromosomeEntry[] chromosome, Set excludeValuesSet) { + for (var entry : chromosome) { + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(entry.value())); + applyBestFit(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, + valueRangeManager, rebasedValue, excludeValuesSet); + } + } + + public static > void applyBestFit( + InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + PlanningListVariableMetaModel listVariableMetaModel, + ListVariableDescriptor listVariableDescriptor, ValueRangeManager valueRangeManager, + Object value, Set excludeValuesSet) { + if (excludeValuesSet.contains(value) || listVariableStateSupply.isPinned(value) + || listVariableStateSupply.isAssigned(value)) { + return; + } + var bestScore = scoreDirector.calculateScore().raw(); + var reachableEntities = valueRangeManager.getReachableValues(listVariableDescriptor).extractEntitiesAsList(value); + MoveDescriptor bestMove = null; + for (var entity : reachableEntities) { + var entityMove = applyBestFit(scoreDirector, listVariableMetaModel, listVariableDescriptor, entity, value); + if (bestMove == null || entityMove.score().raw().compareTo(bestMove.score().raw()) > 0) { + bestMove = entityMove; + } + } + if (bestMove != null && (!listVariableDescriptor.allowsUnassignedValues() + || (bestMove.score().raw().compareTo(bestScore) > 0))) { + // If the model accepts unassigned values and the move does not improve the best score, + // we leave it unassigned + scoreDirector.getMoveDirector().execute(bestMove.move(), true); + } + } + + private static > MoveDescriptor applyBestFit( + InnerScoreDirector scoreDirector, + PlanningListVariableMetaModel listVariableMetaModel, + ListVariableDescriptor listVariableDescriptor, Object entity, Object valueToAssign) { + MoveDescriptor bestMoveDescriptor = null; + var size = listVariableDescriptor.getListSize(entity); + var startPos = size == 0 ? 0 : listVariableDescriptor.getFirstUnpinnedIndex(entity); + for (var i = startPos; i <= size; i++) { + var descriptor = testMove(scoreDirector, listVariableMetaModel, entity, valueToAssign, i); + if (bestMoveDescriptor == null + || descriptor.score().raw().compareTo(bestMoveDescriptor.score().raw()) > 0) { + bestMoveDescriptor = descriptor; + } + } + return Objects.requireNonNull(bestMoveDescriptor); + } + + private static > MoveDescriptor testMove( + InnerScoreDirector scoreDirector, + PlanningListVariableMetaModel planningListVariableMetaModel, Object entity, + Object valueToAssign, int pos) { + var move = Moves.assign(planningListVariableMetaModel, valueToAssign, entity, pos); + var moveScore = scoreDirector.executeTemporaryMove(move, false); + return new MoveDescriptor<>(move, moveScore, moveScore.raw().isFeasible()); + } + + private record MoveDescriptor>(Move move, InnerScore score, + boolean feasible) { + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java new file mode 100644 index 00000000000..94146e8ea8f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverResult; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class ListMixedCrossover> implements CrossoverStrategy { + + private final CrossoverStrategy firstStrategy; + private final CrossoverStrategy secondStrategy; + + public ListMixedCrossover(CrossoverStrategy firstStrategy, + CrossoverStrategy secondStrategy) { + this.firstStrategy = Objects.requireNonNull(firstStrategy); + this.secondStrategy = Objects.requireNonNull(secondStrategy); + } + + @Override + public CrossoverResult apply(CrossoverContext context) { + if (context.phaseScope().getWorkingRandom().nextBoolean()) { + return firstStrategy.apply(context); + } else { + return secondStrategy.apply(context); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java new file mode 100644 index 00000000000..90ee8fc500d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java @@ -0,0 +1,123 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.common.Utils.fixIndex; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.common.Utils.generateIndexes; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.applyPhases; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.updateScope; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +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.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverResult; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ValueRangeManager; +import ai.timefold.solver.core.impl.util.CollectionUtils; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Implementation of the OX crossover strategy for list variables. + * The method incorporates genetic material from both parents into offspring by analyzing the solution as a single sequence of + * planning values. + * Let's consider a solution with two entities e1[v1, v2, v3] and e2[v4, v5]. + * The encoded solution is represented by a single sequence of planning values [v1, v2, v3, v4, v5]. + *

+ * Let's assume the cut point is [1, 3]. + * The planning values from the first parent to incorporate into the offspring are [v2, v3, v4]. + * The remaining values are added based on the solution provided by the second parent. + */ +@NullMarked +public final class ListOXCrossover> + extends AbstractListCrossover { + + private final boolean applyBestFitFirstPhase; + + public ListOXCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, + double inheritanceRate, boolean applyBestFitFirstPhase) { + super(localSearchPhase, refinementPhase, inheritanceRate); + this.applyBestFitFirstPhase = applyBestFitFirstPhase; + } + + @Override + public CrossoverResult apply(CrossoverContext context) { + var phaseScope = context.phaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = phaseScope. getScoreDirector(); + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); + var listVariableModel = listVariableDescriptor.getVariableMetaModel(); + try (var listVariableStateSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var valueRangeManager = scoreDirector.getValueRangeManager(); + // Produce the offspring based on the two parents. + generateOffspring(scoreDirector, listVariableStateSupply, listVariableDescriptor, listVariableModel, + valueRangeManager, context.firstIndividual(), context.secondIndividual(), inheritanceRate, + applyBestFitFirstPhase, phaseScope.getWorkingRandom()); + // We need to update the best solution, best score, + // and initialized score to avoid inconsistencies in the next phases + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + return new CrossoverResult<>(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + context.firstIndividual().getScore(), context.secondIndividual().getScore()); + } + } + + private static > void generateOffspring( + InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + ListVariableDescriptor listVariableDescriptor, + PlanningListVariableMetaModel listVariableMetaModel, + ValueRangeManager valueRangeManager, Individual firstIndividual, + Individual secondIndividual, double inheritanceRate, boolean applyBestFitFirstPhase, + RandomGenerator workingRandom) { + var indexes = generateIndexes(workingRandom, firstIndividual.size(), inheritanceRate); + var start = fixIndex(firstIndividual.getChromosome(), indexes[0], true); + var end = fixIndex(firstIndividual.getChromosome(), indexes[1], false); + // Add the values from the first parent within the specified interval + var assignedValues = applyFirstPhase(scoreDirector, listVariableStateSupply, listVariableDescriptor, + listVariableMetaModel, firstIndividual.getChromosome(), start, end, applyBestFitFirstPhase); + // Add the remaining values from the second parent using the best-fit method with the provided sequence + applyBestFit(scoreDirector, listVariableStateSupply, listVariableDescriptor, listVariableMetaModel, valueRangeManager, + secondIndividual.getChromosome(), assignedValues); + } + + /** + * The values from the first parent are included in the same order they were provided. + */ + private static > Set applyFirstPhase( + InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + ListVariableDescriptor listVariableDescriptor, + PlanningListVariableMetaModel listVariableMetaModel, + ChromosomeEntry[] parentChromosome, int start, int end, boolean applyBestFit) { + var assignedValues = CollectionUtils.newIdentityHashSet(end - start); + for (var i = start; i < end; i++) { + var entry = parentChromosome[i]; + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(entry.value())); + if (applyBestFit) { + applyBestFit(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, + scoreDirector.getValueRangeManager(), rebasedValue, Collections.emptySet()); + } else { + if (listVariableStateSupply.isPinned(rebasedValue) || listVariableStateSupply.isAssigned(rebasedValue)) { + continue; + } + var rebasedEntity = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(entry.entity())); + scoreDirector.executeMove(Moves.assign(listVariableMetaModel, rebasedValue, rebasedEntity, + listVariableDescriptor.getListSize(rebasedEntity))); + } + assignedValues.add(rebasedValue); + } + return assignedValues; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java new file mode 100644 index 00000000000..e88c600688c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java @@ -0,0 +1,120 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.applyPhases; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.updateScope; + +import java.util.Collections; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +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.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverResult; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ValueRangeManager; +import ai.timefold.solver.core.impl.util.CollectionUtils; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Implementation of the Route Exchange (RX) crossover strategy for list variables. + *

+ * Unlike {@link ListOXCrossover}, which operates on a flat positional cut across all entities, RX treats each + * entity's complete route as the unit of inheritance. For each entity in the working solution, a coin flip determines + * whether its route is inherited from the first or the second parent. The values from the winning parent's route are + * placed on that entity preserving the within-route order from the parent. + *

+ * Values not placed during the entity inheritance phase — because they belonged to a route whose entity chose the other + * parent, and all those values were already claimed — are placed in a second phase that follows P2's chromosome order + * and selects the best-scoring entity for each remaining value, identical to the second-parent phase of OX. + *

+ * This operator follows the same pattern as the Selective Route Exchange (SREX) used in the Hybrid Genetic Search (HGS) + * literature for vehicle routing problems (Vidal et al.). + */ +@NullMarked +public final class ListRXCrossover> + extends AbstractListCrossover { + + private final boolean applyBestFitFirstPhase; + + public ListRXCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, + double inheritanceRage, boolean applyBestFitFirstPhase) { + super(localSearchPhase, refinementPhase, inheritanceRage); + this.applyBestFitFirstPhase = applyBestFitFirstPhase; + } + + @Override + public CrossoverResult apply(CrossoverContext context) { + var phaseScope = context.phaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = phaseScope. getScoreDirector(); + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); + var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); + try (var listVariableStateSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var valueRangeManager = scoreDirector.getValueRangeManager(); + // Produce the offspring based on the two parents. + // The offspring is expected to inherit approximately 90% of their planning values from the first parent. + // Some experiments have demonstrated that this approach is more effective in overconstrained models. + generateOffspring(scoreDirector, listVariableStateSupply, listVariableDescriptor, listVariableMetaModel, + valueRangeManager, context.firstIndividual(), context.secondIndividual(), phaseScope.getWorkingRandom(), + inheritanceRate, applyBestFitFirstPhase); + // We need to update the best solution, best score, + // and initialized score to avoid inconsistencies in the next phases + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + return new CrossoverResult<>(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + context.firstIndividual().getScore(), context.secondIndividual().getScore()); + } + } + + private static > void generateOffspring( + InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + ListVariableDescriptor listVariableDescriptor, + PlanningListVariableMetaModel listVariableMetaModel, + ValueRangeManager valueRangeManager, Individual firstIndividual, + Individual secondIndividual, RandomGenerator workingRandom, double firstParentInheritanceRate, + boolean applyBestFitFirstPhase) { + + var workingSolution = scoreDirector.getWorkingSolution(); + var allEntities = listVariableDescriptor.getEntityDescriptor().extractEntities(workingSolution); + var p1Entities = listVariableDescriptor.getEntityDescriptor().extractEntities(firstIndividual.getSolution()); + var p2Entities = listVariableDescriptor.getEntityDescriptor().extractEntities(secondIndividual.getSolution()); + var assignedValues = CollectionUtils.newIdentityHashSet(firstIndividual.size()); + + // Phase 1: for each entity, inherit its complete route from a randomly chosen parent. + // Values already claimed by an earlier entity (duplicates across parents) are skipped. + for (var i = 0; i < allEntities.size(); i++) { + var entity = allEntities.get(i); + var parentEntity = workingRandom.nextDouble() < firstParentInheritanceRate ? p1Entities.get(i) : p2Entities.get(i); + var parentValueList = listVariableDescriptor.getValue(parentEntity); + for (var value : parentValueList) { + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(value)); + if (applyBestFitFirstPhase) { + applyBestFit(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, + scoreDirector.getValueRangeManager(), rebasedValue, Collections.emptySet()); + } else { + if (listVariableStateSupply.isPinned(rebasedValue) || listVariableStateSupply.isAssigned(rebasedValue)) { + continue; + } + scoreDirector.executeMove( + Moves.assign(listVariableMetaModel, rebasedValue, entity, + listVariableDescriptor.getListSize(entity))); + } + assignedValues.add(rebasedValue); + } + } + + // Phase 2: assign remaining values in P2's chromosome order using cross-entity best-fit. + applyBestFit(scoreDirector, listVariableStateSupply, listVariableDescriptor, listVariableMetaModel, valueRangeManager, + secondIndividual.getChromosome(), assignedValues); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java new file mode 100644 index 00000000000..8ee46dda709 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java @@ -0,0 +1,56 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; + +/** + * Basic contract for implementing evolutionary algorithms. + * + * @param the solution type + * @param the score type + */ +public interface EvolutionaryDecider> { + + /** + * Returns an empty population that will be filled by later actions. + * + * @param phaseScope the phase scope. + * + * @return A fresh instance of the population without any individuals. + */ + Population emptyPopulation(EvolutionaryAlgorithmPhaseScope phaseScope); + + /** + * Creates new individuals and load population, serving as a foundation for the subsequent generations. + */ + void loadPopulation(EvolutionaryAlgorithmPhaseScope phaseScope); + + /** + * The population is updated using this method, and the logic used will vary according to the evolutionary strategy. + * This method will manage all operations related to the evolutionary strategy + * (genetic search, hybrid genetic search, genetic programming, etc.), + * such as parent selection, recombination, mutation, and survival selection. + * + * @param stepScope the step scope. + */ + void evolvePopulation(EvolutionaryAlgorithmStepScope stepScope); + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + void solvingStarted(SolverScope solverScope); + + void solvingEnded(SolverScope solverScope); + + void phaseStarted(EvolutionaryAlgorithmPhaseScope abstractPhaseScope); + + void phaseEnded(EvolutionaryAlgorithmPhaseScope abstractPhaseScope); + + void stepStarted(EvolutionaryAlgorithmStepScope abstractStepScope); + + void stepEnded(EvolutionaryAlgorithmStepScope abstractStepScope); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java new file mode 100644 index 00000000000..2636978baab --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java @@ -0,0 +1,37 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; + +import org.jspecify.annotations.Nullable; + +public record HybridGeneticSearchConfiguration, State_ extends SolutionState>( + int populationSize, int generationSize, int eliteSolutionSize, int populationRestartCount, + ConstructionIndividualStrategy constructionIndividualStrategy, Phase localSearchPhase, + @Nullable Phase refinementPhase, CrossoverStrategy crossoverStrategy, + IndividualBuilder individualBuilder, + SolutionStateManager solutionStateManager, PhaseTermination phaseTermination, + BestSolutionRecaller bestSolutionRecaller) { + + static , State_ extends SolutionState> + HybridGeneticSearchConfiguration + of(HybridGeneticSearchDecider.Builder builder) { + return new HybridGeneticSearchConfiguration<>(builder.populationSize, + builder.generationSize, builder.eliteSolutionSize, builder.populationRestartCount, + Objects.requireNonNull(builder.constructionIndividualStrategy), + Objects.requireNonNull(builder.localSearchPhase), + builder.refinementPhase, Objects.requireNonNull(builder.crossoverStrategy), + Objects.requireNonNull(builder.individualBuilder), Objects.requireNonNull(builder.solutionStateManager), + Objects.requireNonNull(builder.phaseTermination), + Objects.requireNonNull(builder.bestSolutionRecaller)); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java new file mode 100644 index 00000000000..3feabadb602 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -0,0 +1,270 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution.DefaultBestSolutionUpdater; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.DefaultPopulation; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Implementation of the Hybrid Genetic Search algorithm, as described in: + * Hybrid Genetic Search for the CVRP: Open-Source Implementation and SWAP* Neighborhood + * by Thibaut Vidal. + *

+ * The algorithm has in three phases: + *

    + *
  1. Initialize population — loads the population with + * {@code populationSize × populationSizeMultiplier} individuals generated via + * construction heuristic followed by local search, without survival selection.
  2. + *
  3. Evolve population — each step selects two distinct individuals with different scores + * via binary tournament, applies the crossover strategy to produce an offspring, + * and adds it to the population with survival selection enabled. Survival selection + * trims the population back to {@code populationSize} using the biased-fitness + * criterion (see {@link DefaultPopulation}).
  4. + *
  5. Restart population — if no best individual has been added for more than + * {@code populationRestartCount} iterations, + * the population is cleared except for the {@code eliteSolutionSize} best individuals, + * and fresh individuals are generated to replace the rest using the same logic as the first phase.
  6. + *
+ *

+ * All computation — construction, local search, crossover, and solution-state management — + * is delegated to {@link HybridGeneticSearchWorker}, which operates on a dedicated + * score director for isolation from the main solver scope. + * + * @param the solution type + * @param the score type + * @param the solution state type used to save and restore the working solution + * during crossover + */ +@NullMarked +public final class HybridGeneticSearchDecider, State_ extends SolutionState> + implements EvolutionaryDecider { + + private final HybridGeneticSearchConfiguration configuration; + + @Nullable + private HybridGeneticSearchWorker worker = null; + + private long lastBestIter; + + public HybridGeneticSearchDecider(Builder builder) { + this.configuration = HybridGeneticSearchConfiguration.of(builder); + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public Population emptyPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { + return new DefaultPopulation<>(phaseScope.getWorkingRandom(), configuration.populationSize(), + configuration.generationSize(), configuration.eliteSolutionSize()); + } + + @Override + public void loadPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { + var population = phaseScope. getPopulation(); + var nonNullWorker = Objects.requireNonNull(worker); + while (population.size() < configuration.populationSize()) { + if (phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + nonNullWorker.generateIndividual(phaseScope, individual -> population.addIndividual(individual, false)); + // Each individual produced contributes one additional movement. + // Therefore, the movement speed will be the number of produced individuals divided by time. + phaseScope.getSolverScope().addMoveEvaluationCount(1L); + } + } + + @Override + public void evolvePopulation(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + var population = phaseScope. getPopulation(); + var firstIndividual = population.selectIndividual(); + var secondIndividual = population.selectIndividual(); + var bailout = population.size(); + while (bailout > 0 + && (firstIndividual == secondIndividual || firstIndividual.getScore().equals(secondIndividual.getScore()))) { + secondIndividual = population.selectIndividual(); + bailout--; + } + Objects.requireNonNull(worker).applyCrossover(stepScope, firstIndividual, secondIndividual, + individual -> population.addIndividual(individual, true)); + // Each individual produced contributes one additional movement. + // Therefore, the movement speed will be the number of produced individuals divided by time. + phaseScope.getSolverScope().addMoveEvaluationCount(1L); + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void solvingStarted(SolverScope solverScope) { + // Do nothing + } + + @Override + public void solvingEnded(SolverScope solverScope) { + // Do nothing + } + + @Override + public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { + var workerScoreDirector = + phaseScope. getScoreDirector().createChildThreadScoreDirector(ChildThreadType.MOVE_THREAD); + var workerSolutionUpdater = + new DefaultBestSolutionUpdater<>(phaseScope, configuration.bestSolutionRecaller(), phaseScope.getPopulation(), + configuration.solutionStateManager()); + this.worker = + new HybridGeneticSearchWorker<>(configuration.constructionIndividualStrategy(), + configuration.localSearchPhase(), + configuration.refinementPhase(), configuration.crossoverStrategy(), configuration.individualBuilder(), + configuration.solutionStateManager(), workerSolutionUpdater, workerScoreDirector); + this.worker.phaseStarted(phaseScope); + this.lastBestIter = 0; + } + + @Override + public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + Objects.requireNonNull(worker).phaseEnded(phaseScope); + worker = null; + } + + @Override + public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + if (lastBestIter == 0) { + this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); + } else { + var size = configuration.populationSize() - configuration.eliteSolutionSize(); + var restart = (phaseScope.getPopulation().getStatistics().individualCount() - lastBestIter) > configuration + .populationRestartCount(); + if (restart) { + // Each individual produced contributes one additional movement. + // Therefore, the movement speed will be the number of produced individuals divided by time. + Objects.requireNonNull(worker).restartPopulation(phaseScope, size, + individual -> stepScope.getPhaseScope().getSolverScope().addMoveEvaluationCount(1L)); + this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); + } + } + } + + @Override + public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { + // Do nothing + } + + @NullMarked + @SuppressWarnings("rawtypes") + public static class Builder, State_ extends SolutionState, Type_ extends EvolutionaryDecider> { + + int populationSize; + int generationSize; + int eliteSolutionSize; + int populationRestartCount; + @Nullable + ConstructionIndividualStrategy constructionIndividualStrategy; + @Nullable + Phase localSearchPhase; + @Nullable + Phase refinementPhase; + @Nullable + CrossoverStrategy crossoverStrategy; + @Nullable + IndividualBuilder individualBuilder; + @Nullable + SolutionStateManager solutionStateManager; + @Nullable + PhaseTermination phaseTermination; + @Nullable + BestSolutionRecaller bestSolutionRecaller; + + public Builder withPopulationSize(int populationSize) { + this.populationSize = populationSize; + return this; + } + + public Builder withGenerationSize(int generationSize) { + this.generationSize = generationSize; + return this; + } + + public Builder withEliteSolutionSize(int eliteSolutionSize) { + this.eliteSolutionSize = eliteSolutionSize; + return this; + } + + public Builder withPopulationRestartCount(int populationRestartCount) { + this.populationRestartCount = populationRestartCount; + return this; + } + + public Builder + withConstructionIndividualStrategy( + ConstructionIndividualStrategy constructionIndividualStrategy) { + this.constructionIndividualStrategy = constructionIndividualStrategy; + return this; + } + + public Builder withLocalSearchPhase(Phase localSearchPhase) { + this.localSearchPhase = localSearchPhase; + return this; + } + + public Builder withSwapStarPhase(@Nullable Phase swapStarPhase) { + this.refinementPhase = swapStarPhase; + return this; + } + + public Builder + withCrossoverStrategy(CrossoverStrategy crossoverStrategy) { + this.crossoverStrategy = crossoverStrategy; + return this; + } + + public Builder + withIndividualBuilder(IndividualBuilder individualBuilder) { + this.individualBuilder = individualBuilder; + return this; + } + + public Builder + withSolutionStateManager(SolutionStateManager solutionInitializer) { + this.solutionStateManager = solutionInitializer; + return this; + } + + public Builder withPhaseTermination(PhaseTermination phaseTermination) { + this.phaseTermination = phaseTermination; + return this; + } + + public Builder + withBestSolutionRecaller(BestSolutionRecaller bestSolutionRecaller) { + this.bestSolutionRecaller = bestSolutionRecaller; + return this; + } + + @SuppressWarnings("unchecked") + public Type_ build() { + return (Type_) new HybridGeneticSearchDecider<>(this); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java new file mode 100644 index 00000000000..5b6a3cccdcc --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java @@ -0,0 +1,278 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.function.Consumer; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution.BestSolutionUpdater; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +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 org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Implementation of the foundational components of the HGS algorithm. Each worker has its own score director, allowing it to + * apply its logic for generating and refining new individuals without affecting the state of the root solver's scope during + * execution. + *

+ * To ensure that the generation of individuals starts from a fresh phase scope, a copy of the scope is always created when + * executing the related logic, allowing the inner phases to operate consistently. The approach described is necessary to ensure + * consistency during the execution of the inner phases. For instance, if the {@code constructionIndividualStrategy} generates a + * solution that is worse than the current best solution, and the {@code localSearchPhase} is subsequently applied, the LA + * acceptance table will be initialized with the current best solution. This could lead to unexpected behavior since the method + * should start from the solution produced by the construction strategy as the best solution. + * + * @param the solution stype + * @param the score type + * @param the solution state type + */ +@NullMarked +public class HybridGeneticSearchWorker, State_ extends SolutionState> { + + private final ConstructionIndividualStrategy constructionIndividualStrategy; + private final Phase localSearchPhase; + private final @Nullable Phase refinementPhase; + private final CrossoverStrategy crossoverStrategy; + private final IndividualBuilder individualBuilder; + private final SolutionStateManager solutionManager; + private final BestSolutionUpdater bestSolutionUpdater; + private final InnerScoreDirector ownScoreDirector; + + @Nullable + private State_ initialState; + + public HybridGeneticSearchWorker(ConstructionIndividualStrategy constructionIndividualStrategy, + Phase localSearchPhase, @Nullable Phase refinementPhase, + CrossoverStrategy crossoverStrategy, IndividualBuilder individualBuilder, + SolutionStateManager solutionManager, + BestSolutionUpdater bestSolutionUpdater, InnerScoreDirector ownScoreDirector) { + this.constructionIndividualStrategy = constructionIndividualStrategy; + this.localSearchPhase = localSearchPhase; + this.refinementPhase = refinementPhase; + this.crossoverStrategy = crossoverStrategy; + this.individualBuilder = individualBuilder; + this.solutionManager = solutionManager; + this.bestSolutionUpdater = bestSolutionUpdater; + this.ownScoreDirector = ownScoreDirector; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + /** + * Generate a new individual and try to update the best solution. + * + * @param sharedPhaseScope the shared phase scope + * @param individualConsumer the individual consumer + */ + public void generateIndividual(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + Consumer> individualConsumer) { + if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { + return; + } + // The solver's working solution is restored to its initial state, and a separate phase scope is created. + var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); + var stepScope = new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope); + var newIndividual = constructionIndividualStrategy.apply(stepScope); + var addIndividual = true; + var oldScore = newIndividual.getScore(); + if (!newIndividual.getScore().raw().isFeasible() + && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { + individualConsumer.accept(newIndividual.clone(ownScoreDirector)); + applyPhases(restoredPhaseScope, localSearchPhase, refinementPhase); + if (newIndividual.getScore().compareTo(oldScore) == 0) { + addIndividual = false; + } + } + if (addIndividual) { + individualConsumer.accept(newIndividual); + } + bestSolutionUpdater.updateBestSolution(new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope, newIndividual)); + } + + /** + * Apply the crossover operation and generates a new offspring. + * + * @param sharedStepScope the shared step scope + * @param firstIndividual the first parent + * @param secondIndividual the second parent + * @param individualConsumer the individual consumer + */ + public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepScope, + Individual firstIndividual, Individual secondIndividual, + Consumer> individualConsumer) { + var sharedPhaseScope = sharedStepScope.getPhaseScope(); + if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { + return; + } + var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); + var crossoverContext = new CrossoverContext<>(restoredPhaseScope, firstIndividual, secondIndividual); + var offspringResult = crossoverStrategy.apply(crossoverContext); + var offspringIndividual = individualBuilder.build(offspringResult.solution(), offspringResult.score(), + offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownScoreDirector); + var addIndividual = true; + var oldScore = offspringIndividual.getScore(); + if (!offspringIndividual.getScore().raw().isFeasible() + && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { + individualConsumer.accept(offspringIndividual.clone(ownScoreDirector)); + applyPhases(restoredPhaseScope, localSearchPhase, refinementPhase); + if (offspringIndividual.getScore().compareTo(oldScore) == 0) { + addIndividual = false; + } + } + if (addIndividual) { + individualConsumer.accept(offspringIndividual); + } + sharedStepScope.setStepIndividual(offspringIndividual); + sharedStepScope.setScore(offspringResult.score()); + } + + /** + * This process restores the score director's working solution to the specified state and create a separate phase scope for + * the inner phases to work with. + * + * @param state the state to be restored + */ + private EvolutionaryAlgorithmPhaseScope restoreState(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + State_ state) { + solutionManager.restoreSolutionState(ownScoreDirector, state); + var restoredPhaseScope = sharedPhaseScope.copy(ownScoreDirector); + restoredPhaseScope.getSolverScope().setBestScore(state.getScore()); + restoredPhaseScope.getSolverScope().setBestSolutionTimeMillis(restoredPhaseScope.getSolverScope().getClock().millis()); + return restoredPhaseScope; + } + + /** + * The restart process rebuilds and replaces the individuals of the current population. + * + * @param sharedPhaseScope the shared phase scope + * @param size the size of the population + */ + public void restartPopulation(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, int size, + Consumer> individualConsumer) { + var individualList = new ArrayList>(size); + while (individualList.size() < size) { + if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { + break; + } + generateIndividual(sharedPhaseScope, individual -> { + individualList.add(individual); + individualConsumer.accept(individual); + }); + } + sharedPhaseScope. getPopulation().restart(individualList); + } + + public static void updateScope(EvolutionaryAlgorithmPhaseScope phaseScope) { + // We need to update the best solution, best score, + // and initialized score to avoid inconsistencies when running inner phases + var solverScope = phaseScope.getSolverScope(); + var newBestScore = solverScope.getScoreDirector().calculateScore(); + solverScope.setBestSolution(solverScope.getScoreDirector().getWorkingSolution()); + solverScope.setBestScore(newBestScore); + solverScope.setBestSolutionTimeMillis(solverScope.getClock().millis()); + solverScope.setStartingInitializedScore(newBestScore.raw()); + phaseScope.reset(); + } + + @SafeVarargs + public static void applyPhases(AbstractPhaseScope phaseScope, + @Nullable Phase @Nullable... phases) { + if (phases == null) { + return; + } + var solverScope = phaseScope.getSolverScope(); + switch (phases.length) { + case 1: { + if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[0].solvingStarted(solverScope); + phases[0].solve(solverScope); + phases[0].solvingEnded(solverScope); + break; + } + case 2: { + if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[0].solvingStarted(solverScope); + phases[0].solve(solverScope); + phases[0].solvingEnded(solverScope); + if (phases[1] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[1].solvingStarted(solverScope); + phases[1].solve(solverScope); + phases[1].solvingEnded(solverScope); + break; + } + case 3: { + if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[0].solvingStarted(solverScope); + phases[0].solve(solverScope); + phases[0].solvingEnded(solverScope); + if (phases[1] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[1].solvingStarted(solverScope); + phases[1].solve(solverScope); + phases[1].solvingEnded(solverScope); + if (phases[2] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phases[2].solvingStarted(solverScope); + phases[2].solve(solverScope); + phases[2].solvingEnded(solverScope); + break; + } + default: { + // Execute all phases + for (var phase : phases) { + if (phase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + phase.solvingStarted(solverScope); + phase.solve(solverScope); + phase.solvingEnded(solverScope); + } + } + } + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { + // The cancellation of demand is disabled, so when a resource counter reaches zero, it is not removed. + // This allows the algorithm + // to function without recalculating resources such as nearby matrices and value range caches. + ownScoreDirector.getSupplyManager().disableDemandCancellation(); + // A solution that has only pinned values assigned is preferred for generating new individuals + this.initialState = solutionManager.saveSolutionState(ownScoreDirector, false); + } + + public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + // Enable the cancellation of demand again and cancel all to clean up the supply manager, + // so it doesn't hold on to any resources. + ownScoreDirector.getSupplyManager().enableDemandCancellation(); + ownScoreDirector.getSupplyManager().cancelAll(); + this.initialState = null; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java new file mode 100644 index 00000000000..8b41b135e56 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java @@ -0,0 +1,421 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the population of candidate solutions for the evolutionary algorithm, + * following the biased-fitness survival strategy described in the HGS + * (Hybrid Genetic Search) original article. + *

+ * Individuals are split into two subpopulations — feasible and infeasible, + * each kept sorted by score in descending order (best individual first). + * The combined capacity is {@code populationSize + generationSize} and once that threshold + * is reached, survival selection reduces the population back to {@code populationSize} + * by removing the least-fit individuals. + *

+ * Fitness is a composite measure that rewards both solution quality and + * contribution to population diversity. + * Lower fitness is better, + * and individuals whose solution is identical to all others (zero diversity) are always removed first. + *

+ * Selection uses binary tournament: two candidates are selected at random from + * the combined population, and the one with the lower fitness is returned. + *

+ * Restart preserves the {@code eliteSolutionSize} best individuals + * (feasible first, then infeasible) before clearing the population and seeding + * it with a new set of individuals. + */ +@NullMarked +public final class DefaultPopulation> implements Population { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultPopulation.class); + private final RandomGenerator workingRandom; + private final int populationSize; + private final int eliteSolutionSize; + private final int maxSize; + private final List> feasibleIndividualList; + private final List> infeasibleIndividualList; + private final PopulationDiffMap diffMap; + @Nullable + private Individual bestIndividual = null; + private long bestGeneration = 0L; + private long bestIteration = 0L; + private long generationCount = 0L; + private long individualCount = 0L; + + private boolean feasibleFitnessUpdated = false; + private boolean infeasibleFitnessUpdated = false; + + public DefaultPopulation(RandomGenerator workingRandom, int populationSize, int generationSize, int eliteSolutionSize) { + this.workingRandom = workingRandom; + this.populationSize = populationSize; + this.eliteSolutionSize = eliteSolutionSize; + this.maxSize = populationSize + generationSize; + this.feasibleIndividualList = new ArrayList<>(maxSize); + this.infeasibleIndividualList = new ArrayList<>(maxSize); + // The map can store at most maxSize elements from both lists + this.diffMap = new PopulationDiffMap<>(maxSize * 2); + } + + @Override + public void addIndividual(Individual individual, boolean enableSurvivalSelection) { + var internalIndividual = addIndividualToList(individual); + if (enableSurvivalSelection) { + // Calculate the difference between the new individual and each individual in the related list + var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; + computeDiff(internalIndividual, individualList); + // Analyze and apply the survival selection strategy + analyzeSubpopulationList(individualList); + } + } + + private InternalIndividual addIndividualToList(Individual individual) { + var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; + var pos = 0; + var internalIndividual = new InternalIndividual<>(individual); + if (!individualList.isEmpty()) { + // The list is kept sorted by score descending (best first). + // Comparator.reverseOrder() ensures the best scores are added to the beginning of the list + pos = Collections.binarySearch(individualList, internalIndividual, Comparator.reverseOrder()); + if (pos < 0) { + pos = -pos - 1; + } + } + individualList.add(pos, internalIndividual); + individualCount++; + if (individualList == feasibleIndividualList) { + feasibleFitnessUpdated = false; + } else { + infeasibleFitnessUpdated = false; + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Added individual iteration ({}), generation({}), feasible population size({}), infeasible population size({}), individual score ({}), first parent score ({}), second parent score ({}), best score ({}), best generation ({}), best iteration ({})", + individualCount, generationCount, feasibleIndividualList.size(), infeasibleIndividualList.size(), + individual.getScore().raw(), + individual.getFirstParentScore() != null ? individual.getFirstParentScore().raw() : "-", + individual.getSecondParentScore() != null ? individual.getSecondParentScore().raw() : "-", + bestIndividual != null ? bestIndividual.getScore().raw() : "-", bestGeneration, bestIteration); + } + if (bestIndividual == null || internalIndividual.compareTo(bestIndividual) > 0) { + bestIndividual = individual; + bestGeneration = generationCount; + bestIteration = individualCount; + } + return internalIndividual; + } + + @Override + public void restart(List> individuals) { + var eliteIndividuals = new ArrayList>(eliteSolutionSize); + var feasibleCount = feasibleIndividualList.size(); + if (feasibleCount >= eliteSolutionSize) { + eliteIndividuals.addAll(feasibleIndividualList.subList(0, eliteSolutionSize)); + } else { + eliteIndividuals.addAll(feasibleIndividualList); + var infeasibleEliteCount = Math.min(eliteSolutionSize - feasibleCount, infeasibleIndividualList.size()); + eliteIndividuals.addAll(infeasibleIndividualList.subList(0, infeasibleEliteCount)); + } + feasibleIndividualList.clear(); + infeasibleIndividualList.clear(); + diffMap.clear(); + eliteIndividuals.forEach(individual -> this.addIndividualToList(individual.innerIndividual)); + individuals.forEach(this::addIndividualToList); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Restarting population, size({}), generation({}), best generation({}), best iscore ({})", size(), + generationCount, bestGeneration, bestIndividual != null ? bestIndividual.getScore().raw() : "-"); + } + } + + @Override + public PopulationStatistics getStatistics() { + return new PopulationStatistics(generationCount, individualCount, bestGeneration, bestIteration); + } + + @Override + public int size() { + return feasibleIndividualList.size() + infeasibleIndividualList.size(); + } + + /** + * Calculates the difference between the given individual and all other individuals from the given list. + * + * @param individual the first individual + * @param individualList the list to be evaluated + */ + private void computeDiff(Individual individual, + List> individualList) { + for (var otherIndividual : individualList) { + if (individual == otherIndividual) { + continue; + } + var diff = individual.diff(otherIndividual.innerIndividual); + diffMap.addIndividualDiff(individual, otherIndividual, diff); + } + } + + /** + * The survival method removes the worst individual from the population until the population size is restored. + * This removal is based on the fitness of each individual, + * which is calculated according to their contribution to the diversity of the population. + * + * @param subpopulationList the population to be analyzed + */ + private void analyzeSubpopulationList(List> subpopulationList) { + if (subpopulationList.size() < maxSize) { + return; + } + // Remove extra individuals until the population is restored to populationSize. + // Fitness is computed once before the removal loop, + // and subsequent removals use the pre-computed ranks, + // which is a valid approximation since generationSize is small relative to populationSize. + var sizeToRemove = subpopulationList.size() - populationSize; + updateSubpopulationFitness(subpopulationList); + for (int i = 0; i < sizeToRemove; i++) { + // Find and remove the worst individual + var worstIndividualIndex = 0; + InternalIndividual worstIndividual = null; + // It means all other individuals from the subpopulation have the same solution + var hasWorstIndividualSameSolution = false; + for (var j = 0; j < subpopulationList.size(); j++) { + var otherIndividual = subpopulationList.get(j); + // The average value will be the sum of all diffs. + // If all other solutions have diff equal to zero, + // it means all individuals have the same solution + var hasSameSolution = averageDiff(otherIndividual, 1) == 0d; + // 1 - We select the individual if it has no diff and the current worst element has any diff + // 2 - We also select the individual if the fitness is higher, which means it is worse + if ((worstIndividual == null) || (hasSameSolution && !hasWorstIndividualSameSolution) + || (hasWorstIndividualSameSolution == hasSameSolution + && otherIndividual.getFitness() > worstIndividual.getFitness())) { + worstIndividualIndex = j; + worstIndividual = otherIndividual; + hasWorstIndividualSameSolution = hasSameSolution; + } + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Removed individual, position({}), individual fitness ({}), individual score ({}), best score ({})", + worstIndividualIndex, subpopulationList.get(worstIndividualIndex).getFitness(), + subpopulationList.get(worstIndividualIndex).getScore().raw(), + bestIndividual != null ? bestIndividual.getScore().raw() : "-"); + } + subpopulationList.remove(worstIndividualIndex); + diffMap.removeIndividualDiff(worstIndividual); + if (subpopulationList == feasibleIndividualList) { + feasibleFitnessUpdated = false; + } else { + infeasibleFitnessUpdated = false; + } + } + generationCount++; + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "New generation ({}), Best generation ({}), Feasible population size ({}), Infeasible population size ({})", + generationCount, bestGeneration, feasibleIndividualList.size(), infeasibleIndividualList.size()); + } + + } + + /** + * The ranking method follows the logic proposed in the HGS article, + * using both solution quality and diversity contribution to estimate fitness. + */ + private void updateSubpopulationFitness(List> subpopulationList) { + var isFeasiblePopulation = subpopulationList == feasibleIndividualList; + if ((isFeasiblePopulation && feasibleFitnessUpdated) || (!isFeasiblePopulation && infeasibleFitnessUpdated)) { + return; + } + var subpopulationSize = subpopulationList.size(); + var avgDiffs = new double[subpopulationSize]; + for (var i = 0; i < subpopulationSize; i++) { + avgDiffs[i] = averageDiff(subpopulationList.get(i), eliteSolutionSize); + } + // sortedIndices[rank] = original index in subpopulationList, sorted by descending avgDiff + var sortedIndices = new Integer[subpopulationSize]; + for (var i = 0; i < subpopulationSize; i++) { + sortedIndices[i] = i; + } + // Rank according to the average diff and contribution to the diversity + Arrays.sort(sortedIndices, Comparator.comparingDouble(i -> -avgDiffs[i])); + if (subpopulationSize > 1) { + var rankRatio = 1.0 / (double) (subpopulationSize - 1); + var diversityWeight = (subpopulationSize >= eliteSolutionSize) + ? 1.0 - (double) eliteSolutionSize / (double) subpopulationSize + : 0.0; + for (var rank = 0; rank < subpopulationSize; rank++) { + int idx = sortedIndices[rank]; + // The list is already sorted by the score + var scoreRank = rank * rankRatio; + var diffRank = idx * rankRatio; + subpopulationList.get(idx).setFitness(diffRank + diversityWeight * scoreRank); + } + } + if (isFeasiblePopulation) { + feasibleFitnessUpdated = true; + } else { + infeasibleFitnessUpdated = true; + } + + } + + /** + * Calculates the average diff to the {@code size} nearest (most similar) individuals. + * + * @param individual the individual to be evaluated + * @param size the number of nearest individuals + * @return a double where a higher value reflects the greater average difference to nearest individuals + */ + private double averageDiff(Individual individual, int size) { + var individualDiffMap = diffMap.getIndividualDiffMap(individual); + var otherIndividualsCount = individualDiffMap.size(); + if (otherIndividualsCount == 0) { + return 0.0; + } + // Hot path for a size of one + if (size == 1) { + var min = Double.MAX_VALUE; + for (var diff : individualDiffMap.values()) { + if (diff < min) { + min = diff; + } + } + return min; + } + // All other individuals fit within the limit, so we can calculate the average without sorting + if (otherIndividualsCount <= size) { + var result = 0.d; + for (var diff : individualDiffMap.values()) { + result += diff; + } + return result / (double) otherIndividualsCount; + } + // Sort the individuals ascending and compute only k nearst ones + var diffs = new double[otherIndividualsCount]; + var i = 0; + for (var diff : individualDiffMap.values()) { + diffs[i++] = diff; + } + Arrays.sort(diffs); + var result = 0.d; + for (var j = 0; j < size; j++) { + result += diffs[j]; + } + return result / (double) size; + } + + @Override + public Individual selectIndividual() { + var size = feasibleIndividualList.size() + infeasibleIndividualList.size(); + var firstIdx = workingRandom.nextInt(0, size); + var secondIdx = size > 1 ? workingRandom.nextInt(0, size - 1) : firstIdx; + if (size > 1 && secondIdx >= firstIdx) { + secondIdx++; + } + var firstIndividual = (firstIdx >= feasibleIndividualList.size()) + ? infeasibleIndividualList.get(firstIdx - feasibleIndividualList.size()) + : feasibleIndividualList.get(firstIdx); + var secondIndividual = (secondIdx >= feasibleIndividualList.size()) + ? infeasibleIndividualList.get(secondIdx - feasibleIndividualList.size()) + : feasibleIndividualList.get(secondIdx); + var updateFeasiblePopulation = firstIdx >= feasibleIndividualList.size() || secondIdx < feasibleIndividualList.size(); + var updateInfeasiblePopulation = + firstIdx >= feasibleIndividualList.size() || secondIdx >= feasibleIndividualList.size(); + if (updateFeasiblePopulation) { + updateSubpopulationFitness(feasibleIndividualList); + } + if (updateInfeasiblePopulation) { + updateSubpopulationFitness(infeasibleIndividualList); + } + return firstIndividual.getFitness() < secondIndividual.getFitness() ? firstIndividual : secondIndividual; + } + + @Override + public @Nullable Individual getBestIndividual() { + return bestIndividual; + } + + private static class InternalIndividual> implements Individual { + + private final Individual innerIndividual; + private double fitness; + + private InternalIndividual(Individual innerIndividual) { + this.innerIndividual = innerIndividual; + } + + @Override + public Solution_ getSolution() { + return innerIndividual.getSolution(); + } + + @Override + public ChromosomeEntry[] getChromosome() { + return innerIndividual.getChromosome(); + } + + @Override + public int size() { + return innerIndividual.size(); + } + + @Override + public double diff(Individual otherIndividual) { + return innerIndividual.diff(otherIndividual); + } + + @Override + public boolean isFeasible() { + return innerIndividual.isFeasible(); + } + + @Override + public Individual clone(InnerScoreDirector scoreDirector) { + return innerIndividual.clone(scoreDirector); + } + + @Override + public InnerScore getFirstParentScore() { + return innerIndividual.getFirstParentScore(); + } + + @Override + public InnerScore getSecondParentScore() { + return innerIndividual.getSecondParentScore(); + } + + @Override + public InnerScore getScore() { + return innerIndividual.getScore(); + } + + @Override + public int compareTo(Individual o) { + return innerIndividual.compareTo(o); + } + + public double getFitness() { + return fitness; + } + + public void setFitness(double fitness) { + this.fitness = fitness; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java new file mode 100644 index 00000000000..ff35eb80e44 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java @@ -0,0 +1,70 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +import java.util.List; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents the population of candidate solutions maintained by an evolutionary algorithm. + *

+ * A population maintains a collection of {@link Individual individuals} and is responsible for + * three core operations that drive the evolutionary process: + *

    + *
  • Insertion — adding newly produced offspring, optionally triggering survival + * selection to keep the population within its limit.
  • + *
  • Selection — choosing individuals to act as parents for producing offsprings.
  • + *
  • Restart — periodically clearing stale diversity and seeding fresh individuals + * while preserving elite solutions.
  • + *
+ * + * @param the solution type + * @param the score type + * + * @see DefaultPopulation + * @see EvolutionaryDecider + */ + +@NullMarked +public interface Population> { + + /** + * Add a new individual to the population + * + * @param individual the individual to be added + */ + void addIndividual(Individual individual, boolean enableSurvivalSelection); + + /** + * Select an individual from the population. + * Different strategies, such as binary tournament selection, can be used to choose the individual. + * + * @return an individual from the population. + */ + Individual selectIndividual(); + + /** + * Recreate the population with new individuals. + */ + void restart(List> individuals); + + /** + * @return the current best individual or null otherwise. + */ + @Nullable + Individual getBestIndividual(); + + /** + * @return the size of the population. + */ + int size(); + + /** + * @return statistics on population evolution during the optimization process. + */ + PopulationStatistics getStatistics(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java new file mode 100644 index 00000000000..84aba09ea62 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; + +/** + * Stores information about individual differences, which are used for survival selection methods. + */ +final class PopulationDiffMap> { + + private final int size; + private final Map, Map, Double>> individualMap; + + PopulationDiffMap(int maxPopulationSize) { + this.size = maxPopulationSize; + this.individualMap = new IdentityHashMap<>(maxPopulationSize); + } + + /** + * Add the computed diff between the source and target individuals to the diff map. + * The method updates both sides of the relationship: source[target] and target[source]. + * + * @param source the source individual + * @param target the target individual + * @param diff the calculated diff + */ + void addIndividualDiff(Individual source, Individual target, double diff) { + var sourceDiffMap = individualMap.computeIfAbsent(source, k -> new IdentityHashMap<>(size)); + sourceDiffMap.put(target, diff); + var targetDiffMap = individualMap.computeIfAbsent(target, k -> new IdentityHashMap<>(size)); + targetDiffMap.put(source, diff); + } + + /** + * Remove all diff computations associated to the given individual. + * + * @param individual the individual to be removed + */ + void removeIndividualDiff(Individual individual) { + individualMap.remove(individual); + for (var diffMap : individualMap.values()) { + diffMap.remove(individual); + } + } + + Map, Double> getIndividualDiffMap(Individual individual) { + var map = individualMap.get(individual); + return map != null ? map : Collections.emptyMap(); + } + + void clear() { + individualMap.clear(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationStatistics.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationStatistics.java new file mode 100644 index 00000000000..8fd3347e0f8 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationStatistics.java @@ -0,0 +1,4 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +public record PopulationStatistics(long generationCount, long individualCount, long bestGeneration, long bestIteration) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/AbstractIndividual.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/AbstractIndividual.java new file mode 100644 index 00000000000..fd0c840f1e1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/AbstractIndividual.java @@ -0,0 +1,51 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; + +abstract sealed class AbstractIndividual> implements Individual + permits ListVariableIndividual { + + protected final Solution_ solution; + protected final InnerScore score; + protected final InnerScore firstParentScore; + protected final InnerScore secondParentScore; + + protected AbstractIndividual(Solution_ solution, InnerScore score, InnerScore firstParentScore, + InnerScore secondParentScore) { + this.solution = solution; + this.score = score; + this.firstParentScore = firstParentScore; + this.secondParentScore = secondParentScore; + } + + @Override + public Solution_ getSolution() { + return solution; + } + + @Override + public boolean isFeasible() { + return score.raw().isFeasible(); + } + + @Override + public InnerScore getFirstParentScore() { + return firstParentScore; + } + + @Override + public InnerScore getSecondParentScore() { + return secondParentScore; + } + + @Override + public InnerScore getScore() { + return score; + } + + @Override + public int compareTo(Individual otherIndividual) { + return score.compareTo(otherIndividual.getScore()); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java new file mode 100644 index 00000000000..39eafe925f6 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java @@ -0,0 +1,7 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record ChromosomeEntry(Object value, Object entity, int index) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/Individual.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/Individual.java new file mode 100644 index 00000000000..c0d7910d486 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/Individual.java @@ -0,0 +1,62 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +/** + * Basic representation of an individual. + */ +public interface Individual> extends Comparable> { + + /** + * The solution representation of the individual. + */ + Solution_ getSolution(); + + /** + * @return a representation of the solution encoded as an array of chromosome entries, + * each carrying the planning value and its owning entity. + */ + ChromosomeEntry[] getChromosome(); + + /** + * @return the individual size + */ + int size(); + + /** + * Calculates the difference between two individuals according to some strategy. + * + * @param otherIndividual the other individual + * @return a double where a higher value reflects a greater difference between the two individuals. + */ + double diff(Individual otherIndividual); + + /** + * The method analyzes the feasibility based on the score of the solution. + * + * @return true if the individual is feasible. + */ + boolean isFeasible(); + + /** + * Clone the individual and its related information. + */ + Individual clone(InnerScoreDirector scoreDirector); + + /** + * @return the score of the first parent that generated this individual. + */ + InnerScore getFirstParentScore(); + + /** + * @return the score of the second parent that generated this individual. + */ + InnerScore getSecondParentScore(); + + /** + * The individual raw score. + */ + InnerScore getScore(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/IndividualBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/IndividualBuilder.java new file mode 100644 index 00000000000..06646fd4afe --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/IndividualBuilder.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +@FunctionalInterface +public interface IndividualBuilder> { + + Individual build(Solution_ solution, InnerScore score, + @Nullable InnerScore firstParentScore, @Nullable InnerScore secondParentScore, + InnerScoreDirector scoreDirector); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java new file mode 100644 index 00000000000..c303b31d1ac --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java @@ -0,0 +1,133 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Default representation of an individual for list variables. + * + * @param the solution type + * @param the score type + */ +@NullMarked +public final class ListVariableIndividual> + extends AbstractIndividual { + + private final MemberAccessor planningIdAccessor; + private final Map predecessorAndSuccessorMap; + private final ChromosomeEntry[] chromosome; + + public ListVariableIndividual(Solution_ solution, InnerScore score, @Nullable InnerScore firstParentScore, + @Nullable InnerScore secondParentScore, InnerScoreDirector scoreDirector) { + super(solution, score, firstParentScore, secondParentScore); + var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); + this.planningIdAccessor = + scoreDirector.getSolutionDescriptor().getPlanningIdAccessor(listVariableDescriptor.getElementType()); + if (planningIdAccessor == null) { + throw new IllegalStateException( + "The planning value class (%s) must include a planning value id field." + .formatted(listVariableDescriptor.getElementType())); + } + var size = (int) scoreDirector.getValueRangeManager().getProblemSizeStatistics().approximateValueCount(); + this.predecessorAndSuccessorMap = HashMap.newHashMap(size); + var chromosomeList = new ArrayList(size); + load(solution, listVariableDescriptor, chromosomeList, predecessorAndSuccessorMap, planningIdAccessor); + this.chromosome = chromosomeList.toArray(ChromosomeEntry[]::new); + } + + private ListVariableIndividual(Solution_ solution, InnerScore score, InnerScore firstParentScore, + InnerScore secondParentScore, MemberAccessor planningIdAccessor, + Map predecessorAndSuccessorMap, ChromosomeEntry[] chromosome) { + super(solution, score, firstParentScore, secondParentScore); + this.planningIdAccessor = planningIdAccessor; + this.predecessorAndSuccessorMap = predecessorAndSuccessorMap; + this.chromosome = chromosome; + } + + private static void load(Solution_ solution, ListVariableDescriptor listVariableDescriptor, + List chromosomeList, Map predecessorAndSuccessorMap, + MemberAccessor planningIdAccessor) { + var allEntities = listVariableDescriptor.getEntityDescriptor().extractEntities(solution); + for (var entity : allEntities) { + var valueList = listVariableDescriptor.getValue(entity); + var size = valueList.size(); + if (size == 0) { + continue; + } + // Collect all IDs in a single pass to avoid redundant accessor calls. + var ids = new Object[size]; + for (var i = 0; i < size; i++) { + var value = valueList.get(i); + ids[i] = planningIdAccessor.executeGetter(value); + chromosomeList.add(new ChromosomeEntry(value, entity, i)); + } + for (var i = 0; i < size; i++) { + predecessorAndSuccessorMap.put(ids[i], + new PositionPair(i > 0 ? ids[i - 1] : null, i < size - 1 ? ids[i + 1] : null)); + } + } + } + + @Override + public ChromosomeEntry[] getChromosome() { + return chromosome; + } + + @Override + public int size() { + return predecessorAndSuccessorMap.size(); + } + + @Override + public double diff(Individual otherIndividual) { + var otherListIndividual = (ListVariableIndividual) otherIndividual; + var diff = 0; + for (var valueEntry : predecessorAndSuccessorMap.entrySet()) { + var valuePosition = valueEntry.getValue(); + var otherValuePosition = otherListIndividual.predecessorAndSuccessorMap.get(valueEntry.getKey()); + if (otherValuePosition == null) { + diff++; + continue; + } + // No match like: [0, 1] and [1, 0] + if (!Objects.equals(valuePosition.successor(), otherValuePosition.successor()) + && !Objects.equals(valuePosition.successor(), otherValuePosition.predecessor())) { + diff++; + } + // No match between the first element and the last element of each value + if (valuePosition.predecessor() == null && otherValuePosition.predecessor() != null + && otherValuePosition.successor() != null) { + diff++; + } + } + return (double) diff / (double) predecessorAndSuccessorMap.size(); + } + + @Override + public Individual clone(InnerScoreDirector scoreDirector) { + var newSolution = scoreDirector.cloneSolution(solution); + var newPredecessorAndSuccessorMap = HashMap. newHashMap(predecessorAndSuccessorMap.size()); + var chromosomeList = new ArrayList(chromosome.length); + load(solution, scoreDirector.getSolutionDescriptor().getListVariableDescriptor(), chromosomeList, + newPredecessorAndSuccessorMap, planningIdAccessor); + var newChromosome = chromosomeList.toArray(ChromosomeEntry[]::new); + return new ListVariableIndividual<>(newSolution, score, firstParentScore, secondParentScore, + planningIdAccessor, newPredecessorAndSuccessorMap, newChromosome); + } + + private record PositionPair(@Nullable Object predecessor, @Nullable Object successor) { + + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java new file mode 100644 index 00000000000..e153b8b1d2d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; + +import org.jspecify.annotations.NullMarked; + +/** + * Base contract for defining individuals' creation operations. + */ +@NullMarked +@FunctionalInterface +public interface ConstructionIndividualStrategy> { + + Individual apply(EvolutionaryAlgorithmStepScope stepScope); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java new file mode 100644 index 00000000000..7126668ed43 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java @@ -0,0 +1,79 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator; + +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.applyPhases; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.updateScope; + +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.custom.DefaultPhaseCommandContext; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Generates individuals for the initial population using construction heuristics followed by local search. + *

+ * The first individual is built with a deterministic best-fit phase (always produces the same solution). + * Every subsequent individual uses a shuffled best-fit phase to introduce diversity. + * An optional list of custom {@link PhaseCommand}s may be applied beforehand to pre-shape the working solution. + *

+ * The strategy can be used for both variable types, considering that the inner phases can handle each variable type. + */ +@NullMarked +public final class DefaultConstructionIndividualStrategy> + implements ConstructionIndividualStrategy { + + private final List> customPhaseIndividualCommandList; + private final Phase deterministicBestFitConstructionPhase; + private final Phase shuffledBestFitConstructionPhase; + private final Phase localSearchPhase; + private final @Nullable Phase refinementPhase; + private final IndividualBuilder individualBuilder; + + public DefaultConstructionIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, Phase shuffledBestFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + IndividualBuilder individualBuilder) { + this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); + this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); + this.shuffledBestFitConstructionPhase = Objects.requireNonNull(shuffledBestFitConstructionPhase); + this.localSearchPhase = Objects.requireNonNull(localSearchPhase); + this.refinementPhase = refinementPhase; + this.individualBuilder = Objects.requireNonNull(individualBuilder); + } + + @Override + public Individual apply(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = solverScope. getScoreDirector(); + // The first step is to apply the custom phase commands, if any. + // This allows to modify the working solution before the construction phase. + if (!customPhaseIndividualCommandList.isEmpty()) { + var commandContext = new DefaultPhaseCommandContext<>(stepScope.getMoveDirector(), + () -> phaseScope.getTermination().isPhaseTerminated(phaseScope)); + customPhaseIndividualCommandList.forEach(command -> command.changeWorkingSolution(commandContext)); + } + updateScope(phaseScope); + // Build and refine the solution + applyPhases(phaseScope, getConstructionPhase(stepScope), localSearchPhase, refinementPhase); + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + null, null, scoreDirector); + } + + private Phase getConstructionPhase(EvolutionaryAlgorithmStepScope stepScope) { + if (stepScope.getPhaseScope().getPopulation().getBestIndividual() == null) { + // The deterministic phase is used only once as its behavior always returns the same solution. + // The shuffled phase is expected to shuffle the selector and produce different solutions. + return deterministicBestFitConstructionPhase; + } + return shuffledBestFitConstructionPhase; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java new file mode 100644 index 00000000000..7cf0f030fa7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -0,0 +1,163 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.list; + +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.common.Utils.fixIndex; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list.AbstractListCrossover.applyBestFit; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.applyPhases; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.updateScope; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +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.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.DefaultConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.phase.custom.DefaultPhaseCommandContext; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ValueRangeManager; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Generates individuals for the population by applying a ruin-and-recreate method to the current best individual, + * followed by local search. + *

+ * When the population is empty the first individual is built using a deterministic best-fit construction phase, + * identical to {@link DefaultConstructionIndividualStrategy}. + * For every subsequent individual the strategy selects a random contiguous segment from the best individual, + * unassigns those values (ruin phase), and reinserts them via best-fit insertion + * (recreate phase) before running the local search. The segment boundaries are snapped to entity borders so that + * all values belonging to the same entity are always ruined or kept together. + */ +@NullMarked +public final class ListRuinRecreateIndividualStrategy, State_ extends SolutionState> + implements ConstructionIndividualStrategy { + + private final List> customPhaseIndividualCommandList; + private final Phase deterministicBestFitConstructionPhase; + private final Phase localSearchPhase; + private final @Nullable Phase refinementPhase; + private final SolutionStateManager solutionStateManager; + private final IndividualBuilder individualBuilder; + private final double inheritanceRate; + + public ListRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, + @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, double inheritanceRate) { + this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); + this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); + this.localSearchPhase = Objects.requireNonNull(localSearchPhase); + this.refinementPhase = refinementPhase; + this.solutionStateManager = solutionStateManager; + this.individualBuilder = Objects.requireNonNull(individualBuilder); + this.inheritanceRate = inheritanceRate; + } + + @Override + public Individual apply(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = solverScope. getScoreDirector(); + // The first step is to apply the custom phase commands, if any. + // This allows to modify the working solution before the construction phase. + if (!customPhaseIndividualCommandList.isEmpty()) { + var commandContext = new DefaultPhaseCommandContext<>(stepScope.getMoveDirector(), + () -> phaseScope.getTermination().isPhaseTerminated(phaseScope)); + customPhaseIndividualCommandList.forEach(command -> command.changeWorkingSolution(commandContext)); + } + updateScope(stepScope.getPhaseScope()); + // If the population has no best individual, use the deterministic construction phase + var population = phaseScope. getPopulation(); + if (population.getBestIndividual() == null) { + applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); + } else { + applyRuinRecreate(solverScope, scoreDirector, population); + updateScope(stepScope.getPhaseScope()); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + } + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + null, null, scoreDirector); + } + + void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, + Population population) { + var bestIndividual = Objects.requireNonNull(population.getBestIndividual()); + var bestSolutionState = solutionStateManager.saveSolutionState(bestIndividual); + solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); + var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); + var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); + var valueRangeManager = scoreDirector.getValueRangeManager(); + try (var listVariableStateSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var ruinedValues = applyRuinPhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, + solverScope.getWorkingRandom(), bestIndividual); + Collections.shuffle(ruinedValues, solverScope.getWorkingRandom()); + applyRecreatePhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, + valueRangeManager, ruinedValues); + } + } + + private List applyRuinPhase(InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + PlanningListVariableMetaModel listVariableMetaModel, RandomGenerator workingRandom, + Individual bestIndividual) { + var indexes = generateIndexes(workingRandom, bestIndividual.size(), inheritanceRate); + var start = fixIndex(bestIndividual.getChromosome(), indexes[0], true); + var end = fixIndex(bestIndividual.getChromosome(), indexes[1], false); + var chromosome = bestIndividual.getChromosome(); + var unassignMoveList = new ArrayList>(end - start); + var ruinedValues = new ArrayList<>(end - start); + for (var i = start; i < end; i++) { + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(chromosome[i].value())); + if (listVariableStateSupply.isPinned(rebasedValue)) { + continue; + } + var position = listVariableStateSupply.getElementPosition(rebasedValue).ensureAssigned(); + unassignMoveList.add(Moves.unassign(listVariableMetaModel, position)); + ruinedValues.add(rebasedValue); + } + Collections.reverse(unassignMoveList); + if (!unassignMoveList.isEmpty()) { + var compositeMove = Moves.compose(unassignMoveList); + scoreDirector.getMoveDirector().execute(compositeMove); + } + return ruinedValues; + } + + private void applyRecreatePhase(InnerScoreDirector scoreDirector, + ListVariableStateSupply listVariableStateSupply, + PlanningListVariableMetaModel listVariableMetaModel, + ListVariableDescriptor listVariableDescriptor, ValueRangeManager valueRangeManager, + List ruinedValues) { + for (var value : ruinedValues) { + applyBestFit(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, + valueRangeManager, value, Collections.emptySet()); + } + } + + private static int[] generateIndexes(RandomGenerator workingRandom, int size, double inheritanceRate) { + var start = workingRandom.nextInt(size); + // An inheritance rate of 95% means no more than 5% of the solution can be ruined. + // Some experiments have shown that a higher rate is more effective for overconstrained models + var maxSize = size * (1 - inheritanceRate); + var end = Math.min((int) (start + maxSize), size - 1); + return new int[] { start, end }; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/swapstar/ListSwapStarPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/swapstar/ListSwapStarPhase.java new file mode 100644 index 00000000000..d741f0b52f5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/swapstar/ListSwapStarPhase.java @@ -0,0 +1,350 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.swapstar; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.function.IntFunction; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.solver.event.EventProducerId; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; +import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; +import ai.timefold.solver.core.impl.phase.AbstractPhase; +import ai.timefold.solver.core.impl.phase.PhaseType; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +/** + * Implementation of the SWAP* method described in the article: + *

+ * Hybrid Genetic Search for the CVRP: Open-Source Implementation and SWAP* Neighborhood by Thibaut Vidal + *

+ * The author explains + * that the method involves selecting the best swap move between two planning values from different planning entities. + * Instead of being applied in place, the swap move allows each planning value to be positioned differently, + * resembling a change move instead. + *

+ * The original implementation uses a geometric calculation based on polar sectors + * to apply the move only to overlapping routes. + * Conversely, the author mentions the option of using other strategies to locate nearby planning entities, + * such as distance. + * Therefore, + * the proposed approach uses the Nearby feature + * and evaluates only the three closest planning entities for a given source. + * + * @param the solution type + */ +public final class ListSwapStarPhase extends AbstractPhase { + + private final EntitySelector originalEntitySelector; + private final EntitySelector innerEntitySelector; + + private ListVariableDescriptor listVariableDescriptor; + + protected ListSwapStarPhase(Builder builder) { + super(builder); + this.originalEntitySelector = builder.originalEntitySelector; + this.innerEntitySelector = builder.innerEntitySelector; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public PhaseType getPhaseType() { + return PhaseType.LOCAL_SEARCH; + } + + @Override + public IntFunction getEventProducerIdSupplier() { + return EventProducerId::localSearch; + } + + @Override + public void solve(SolverScope solverScope) { + var phaseScope = new LocalSearchPhaseScope<>(solverScope, 0); + phaseStarted(phaseScope); + var originalEntityIterator = originalEntitySelector.iterator(); + var lastUpdateEntityMap = new LastUpdateVersionMap(); + BestMoveMap bestMoveMap = new BestMoveMap<>((int) solverScope.getProblemSizeStatistics().entityCount()); + while (originalEntityIterator.hasNext()) { + if (phaseTermination.isPhaseTerminated(phaseScope)) { + break; + } + var sourceEntity = originalEntityIterator.next(); + if (listVariableDescriptor.getListSize(sourceEntity) == 0) { + continue; + } + for (Object otherEntity : innerEntitySelector) { + var sourceEntityVersion = lastUpdateEntityMap.getVersion(sourceEntity); + var otherEntityVersion = lastUpdateEntityMap.getVersion(otherEntity); + if (listVariableDescriptor.getListSize(otherEntity) == 0 + || (otherEntityVersion > -1 && sourceEntityVersion >= otherEntityVersion)) { + continue; + } + swapStar(phaseScope, lastUpdateEntityMap, bestMoveMap, sourceEntity, otherEntity); + } + } + phaseEnded(phaseScope); + } + + private > void swapStar(LocalSearchPhaseScope phaseScope, + LastUpdateVersionMap lastUpdateVersionMap, + BestMoveMap bestMoveMap, Object sourceEntity, Object otherEntity) { + var stepIndex = phaseScope.getNextStepIndex(); + var solverScope = phaseScope.getSolverScope(); + // Compute the best three moves for both entities + findThreeBestLocations(solverScope, bestMoveMap, sourceEntity, otherEntity); + + // Evaluate all 9 composite move combinations from the three best locations of each entity. + // bestPairScore starts as null so that only composite (swap) moves are considered + var sourceBestMoveLocation = bestMoveMap.getBestMoveLocation(sourceEntity, otherEntity); + var otherBestMoveLocation = bestMoveMap.getBestMoveLocation(otherEntity, sourceEntity); + MoveDescriptor bestPairScore = null; + for (var i = 0; i < 3; i++) { + if (sourceBestMoveLocation.bestMoves[i] == null) { + continue; + } + for (var j = 0; j < 3; j++) { + if (otherBestMoveLocation.bestMoves[j] == null) { + continue; + } + var compositeMoveDescriptor = computeCompositeBestLocation(solverScope, sourceBestMoveLocation.bestMoves[i], + otherBestMoveLocation.bestMoves[j]); + if (bestPairScore == null || compositeMoveDescriptor.score().compareTo(bestPairScore.score()) > 0) { + bestPairScore = compositeMoveDescriptor; + } + } + } + + // Apply the best score + if (bestPairScore != null && bestPairScore.score().compareTo(solverScope. getBestScore()) > 0) { + solverScope. getScoreDirector().getMoveDirector().execute(bestPairScore.move(), true); + var step = new LocalSearchStepScope<>(phaseScope, stepIndex); + step.setStep(bestPairScore.move()); + step.setScore(bestPairScore.score()); + solverScope.getSolver().getBestSolutionRecaller().processWorkingSolutionDuringMove(bestPairScore.score(), step); + solverScope.addMoveEvaluationCount(1L); + phaseScope.setLastCompletedStepScope(step); + lastUpdateVersionMap.updateVersion(sourceEntity, otherEntity); + } + } + + private > void findThreeBestLocations(SolverScope solverScope, + BestMoveMap bestMoveMap, Object sourceEntity, Object otherEntity) { + var scoreDirector = solverScope. getScoreDirector(); + // Reset before recomputing to avoid stale entries with out-of-bounds indices + // from previous iterations where list sizes may have been different. + bestMoveMap.resetBestMoveLocation(sourceEntity, otherEntity); + bestMoveMap.resetBestMoveLocation(otherEntity, sourceEntity); + var sourceStartPos = listVariableDescriptor.getFirstUnpinnedIndex(sourceEntity); + var otherStartPos = listVariableDescriptor.getFirstUnpinnedIndex(otherEntity); + + // Find the best move for each value between both entities + var sourceEntityListSize = listVariableDescriptor.getListSize(sourceEntity); + var otherEntityListSize = listVariableDescriptor.getListSize(otherEntity); + for (var i = sourceStartPos; i < sourceEntityListSize; i++) { + for (var j = otherStartPos; j <= otherEntityListSize; j++) { + var listChangeMove = + Moves.change(listVariableDescriptor.getVariableMetaModel(), sourceEntity, i, otherEntity, j); + var moveScore = scoreDirector.getMoveDirector() + .executeTemporary(listChangeMove, (score, move) -> score); + bestMoveMap.updateBestLocation(sourceEntity, i, otherEntity, j, listChangeMove, moveScore); + solverScope.addMoveEvaluationCount(1L); + if (j < otherEntityListSize) { + var otherListChangeMove = + Moves.change(listVariableDescriptor.getVariableMetaModel(), otherEntity, j, sourceEntity, i); + var otherMoveScore = scoreDirector.getMoveDirector() + .executeTemporary(otherListChangeMove, (score, move) -> score); + solverScope.addMoveEvaluationCount(1L); + bestMoveMap.updateBestLocation(otherEntity, j, sourceEntity, i, otherListChangeMove, otherMoveScore); + } + } + } + // One last iteration to compute last position for otherEntity + for (var j = otherStartPos; j < otherEntityListSize; j++) { + var otherListChangeMove = Moves.change(listVariableDescriptor.getVariableMetaModel(), otherEntity, j, sourceEntity, + sourceEntityListSize); + var otherMoveScore = scoreDirector.getMoveDirector() + .executeTemporary(otherListChangeMove, (score, move) -> score); + solverScope.addMoveEvaluationCount(1L); + bestMoveMap.updateBestLocation(otherEntity, j, sourceEntity, sourceEntityListSize, otherListChangeMove, + otherMoveScore); + } + } + + private > MoveDescriptor computeCompositeBestLocation( + SolverScope solverScope, MoveDescriptor sourceMoveDescriptor, + MoveDescriptor otherMoveDescriptor) { + var scoreDirector = solverScope. getScoreDirector(); + var sourceList = listVariableDescriptor.getValue(sourceMoveDescriptor.sourceEntity()); + var otherList = listVariableDescriptor.getValue(otherMoveDescriptor.sourceEntity()); + + var unassignSourceMove = Moves.unassign(listVariableDescriptor.getVariableMetaModel(), + sourceMoveDescriptor.sourceEntity(), sourceMoveDescriptor.i()); + var sourceJ = sourceMoveDescriptor.j(); + if (sourceJ == otherList.size()) { + sourceJ--; + } + var assignSourceMove = Moves.assign(listVariableDescriptor.getVariableMetaModel(), + sourceList.get(sourceMoveDescriptor.i()), sourceMoveDescriptor.otherEntity, sourceJ); + + var unassignOtherMove = Moves.unassign(listVariableDescriptor.getVariableMetaModel(), + otherMoveDescriptor.sourceEntity(), otherMoveDescriptor.i()); + var otherJ = otherMoveDescriptor.j(); + if (otherJ == sourceList.size()) { + otherJ--; + } + var assignOtherMove = Moves.assign(listVariableDescriptor.getVariableMetaModel(), + otherList.get(otherMoveDescriptor.i()), otherMoveDescriptor.otherEntity, otherJ); + + // Unassign both values and reassign them + var compositeMove = Moves.compose(unassignSourceMove, unassignOtherMove, assignSourceMove, assignOtherMove); + var moveScore = scoreDirector.getMoveDirector().executeTemporary(compositeMove, (score, move) -> score); + solverScope.addMoveEvaluationCount(1L); + return new MoveDescriptor<>(sourceMoveDescriptor.sourceEntity(), -1, otherMoveDescriptor.sourceEntity(), -1, + compositeMove, moveScore); + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void solvingStarted(SolverScope solverScope) { + super.solvingStarted(solverScope); + originalEntitySelector.solvingStarted(solverScope); + innerEntitySelector.solvingStarted(solverScope); + } + + @Override + public void solvingEnded(SolverScope solverScope) { + super.solvingEnded(solverScope); + originalEntitySelector.solvingEnded(solverScope); + innerEntitySelector.solvingEnded(solverScope); + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + originalEntitySelector.phaseStarted(phaseScope); + innerEntitySelector.phaseStarted(phaseScope); + this.listVariableDescriptor = phaseScope.getScoreDirector().getSolutionDescriptor().getListVariableDescriptor(); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + originalEntitySelector.phaseEnded(phaseScope); + innerEntitySelector.phaseEnded(phaseScope); + this.listVariableDescriptor = null; + } + + public static class Builder extends AbstractPhaseBuilder { + + private final EntitySelector originalEntitySelector; + private final EntitySelector innerEntitySelector; + + public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, + EntitySelector originalEntitySelector, EntitySelector innerEntitySelector) { + super(phaseIndex, logIndentation, phaseTermination); + this.originalEntitySelector = originalEntitySelector; + this.innerEntitySelector = innerEntitySelector; + } + + @Override + public ListSwapStarPhase build() { + return new ListSwapStarPhase<>(this); + } + } + + private static class LastUpdateVersionMap { + private int currentVersion = 0; + private final Map versionMap = new IdentityHashMap<>(); + + void updateVersion(Object entity, Object otherEntity) { + currentVersion++; + versionMap.put(entity, currentVersion); + versionMap.put(otherEntity, currentVersion); + } + + int getVersion(Object entity) { + var version = versionMap.get(entity); + if (version == null) { + versionMap.put(entity, currentVersion); + return -1; + } + return version; + } + } + + private static class BestMoveMap> { + + private final int entitySize; + private final Map>> valuesMap; + + BestMoveMap(int entitySize) { + this.entitySize = entitySize; + valuesMap = new IdentityHashMap<>(entitySize); + } + + void updateBestLocation(Object sourceEntity, int i, Object otherEntity, int j, Move move, + InnerScore score) { + var bestMoveLocation = getBestMoveLocation(sourceEntity, otherEntity); + bestMoveLocation.updateLocation(sourceEntity, i, otherEntity, j, move, score); + } + + void resetBestMoveLocation(Object sourceEntity, Object otherEntity) { + getBestMoveLocation(sourceEntity, otherEntity).reset(); + } + + BestMoveLocation getBestMoveLocation(Object sourceEntity, Object otherEntity) { + var sourceMap = valuesMap.get(sourceEntity); + if (sourceMap == null) { + sourceMap = new IdentityHashMap<>(entitySize); + valuesMap.put(sourceEntity, sourceMap); + } + var bestMoveLocation = sourceMap.get(otherEntity); + if (bestMoveLocation == null) { + bestMoveLocation = new BestMoveLocation<>(); + sourceMap.put(otherEntity, bestMoveLocation); + } + return bestMoveLocation; + } + } + + private static class BestMoveLocation> { + + private MoveDescriptor[] bestMoves = new MoveDescriptor[3]; + + void reset() { + bestMoves[0] = null; + bestMoves[1] = null; + bestMoves[2] = null; + } + + void updateLocation(Object sourceEntity, int i, Object otherEntity, int j, Move move, + InnerScore score) { + if (bestMoves[2] == null || score.compareTo(bestMoves[2].score()) > 0) { + bestMoves[0] = bestMoves[1]; + bestMoves[1] = bestMoves[2]; + bestMoves[2] = new MoveDescriptor<>(sourceEntity, i, otherEntity, j, move, score); + } else if (bestMoves[1] == null || score.compareTo(bestMoves[1].score()) > 0) { + bestMoves[0] = bestMoves[1]; + bestMoves[1] = new MoveDescriptor<>(sourceEntity, i, otherEntity, j, move, score); + } else if (bestMoves[0] == null || score.compareTo(bestMoves[0].score()) > 0) { + bestMoves[0] = new MoveDescriptor<>(sourceEntity, i, otherEntity, j, move, score); + } + } + } + + private record MoveDescriptor>(Object sourceEntity, int i, Object otherEntity, + int j, Move move, InnerScore score) { + } +} 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 22ce614d31b..1525651062d 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 @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ThreadFactory; @@ -33,6 +34,7 @@ public class HeuristicConfigPolicy { private final String logIndentation; private final Integer moveThreadCount; private final Integer moveThreadBufferSize; + private final Integer constraintCount; private final Class threadFactoryClass; private final InitializingScoreTrend initializingScoreTrend; private final SolutionDescriptor solutionDescriptor; @@ -54,6 +56,7 @@ private HeuristicConfigPolicy(Builder builder) { this.logIndentation = builder.logIndentation; this.moveThreadCount = builder.moveThreadCount; this.moveThreadBufferSize = builder.moveThreadBufferSize; + this.constraintCount = builder.constraintCount; this.threadFactoryClass = builder.threadFactoryClass; this.initializingScoreTrend = builder.initializingScoreTrend; this.solutionDescriptor = builder.solutionDescriptor; @@ -82,6 +85,10 @@ public Integer getMoveThreadBufferSize() { return moveThreadBufferSize; } + public Integer getConstraintCount() { + return constraintCount; + } + public InitializingScoreTrend getInitializingScoreTrend() { return initializingScoreTrend; } @@ -132,6 +139,7 @@ public Builder cloneBuilder() { .withEnvironmentMode(environmentMode) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(moveThreadBufferSize) + .withConstraintCount(constraintCount) .withThreadFactoryClass(threadFactoryClass) .withNearbyDistanceMeterClass(nearbyDistanceMeterClass) .withRandom(random) @@ -261,6 +269,7 @@ public static class Builder { private EnvironmentMode environmentMode; private Integer moveThreadCount; private Integer moveThreadBufferSize; + private Integer constraintCount; private Class threadFactoryClass; private InitializingScoreTrend initializingScoreTrend; private SolutionDescriptor solutionDescriptor; @@ -282,6 +291,17 @@ public Builder withPreviewFeatureSet(Set previewFeatu return this; } + public Builder withPreviewFeature(PreviewFeature previewFeature) { + if (this.previewFeatureSet == null) { + this.previewFeatureSet = Set.of(previewFeature); + } else { + var updatedPreviewFeatureSet = new HashSet<>(this.previewFeatureSet); + updatedPreviewFeatureSet.add(previewFeature); + this.previewFeatureSet = updatedPreviewFeatureSet; + } + return this; + } + public Builder withEnvironmentMode(EnvironmentMode environmentMode) { this.environmentMode = environmentMode; return this; @@ -302,6 +322,11 @@ public Builder withThreadFactoryClass(Class return this; } + public Builder withConstraintCount(Integer constraintCount) { + this.constraintCount = constraintCount; + return this; + } + public Builder withNearbyDistanceMeterClass(Class> nearbyDistanceMeterClass) { this.nearbyDistanceMeterClass = nearbyDistanceMeterClass; 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 d35ac1ae188..a76f434e1e6 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 @@ -151,7 +151,7 @@ public void stepEnded(LocalSearchStepScope stepScope) { decider.stepEnded(stepScope); collectMetrics(stepScope); var phaseScope = stepScope.getPhaseScope(); - if (logger.isDebugEnabled()) { + if (isLoggingEnabled() && logger.isDebugEnabled()) { if (stepScope.getAcceptedMoveCount() == 0 && phaseTermination.isPhaseTerminated(phaseScope)) { // Terminated early logger.debug(""" 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..4e18f89834a 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 @@ -4,6 +4,7 @@ import java.util.Objects; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; @@ -11,6 +12,7 @@ import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; import ai.timefold.solver.core.impl.exhaustivesearch.scope.ExhaustiveSearchPhaseScope; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; @@ -90,6 +92,8 @@ The class (%s) is not found. """ .formatted("ai.timefold.solver.enterprise.core.partitioned.PartitionedSearchPhaseScope")); } + } else if (phaseConfig instanceof EvolutionaryAlgorithmPhaseConfig) { + return EvolutionaryAlgorithmPhaseScope.class; } else { throw new IllegalStateException("Unsupported phaseConfig class: %s".formatted(phaseConfig.getClass())); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java index 94ea9a44642..50c32bd6131 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseFactory.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig; import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; @@ -12,6 +13,7 @@ import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.DefaultEvolutionaryAlgorithmPhaseFactory; import ai.timefold.solver.core.impl.exhaustivesearch.DefaultExhaustiveSearchPhaseFactory; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhaseFactory; @@ -29,6 +31,8 @@ static PhaseFactory create(PhaseConfig phaseConfig) { return new DefaultConstructionHeuristicPhaseFactory<>((ConstructionHeuristicPhaseConfig) phaseConfig); } else if (PartitionedSearchPhaseConfig.class.isAssignableFrom(phaseConfig.getClass())) { return new DefaultPartitionedSearchPhaseFactory<>((PartitionedSearchPhaseConfig) phaseConfig); + } else if (EvolutionaryAlgorithmPhaseConfig.class.isAssignableFrom(phaseConfig.getClass())) { + return new DefaultEvolutionaryAlgorithmPhaseFactory<>((EvolutionaryAlgorithmPhaseConfig) phaseConfig); } else if (CustomPhaseConfig.class.isAssignableFrom(phaseConfig.getClass())) { return new DefaultCustomPhaseFactory<>((CustomPhaseConfig) phaseConfig); } else if (ExhaustiveSearchPhaseConfig.class.isAssignableFrom(phaseConfig.getClass())) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java index 89c734cd34d..876ea8c7698 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/PhaseType.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.phase; import ai.timefold.solver.core.impl.constructionheuristic.ConstructionHeuristicPhase; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.EvolutionaryAlgorithmPhase; import ai.timefold.solver.core.impl.exhaustivesearch.ExhaustiveSearchPhase; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhase; import ai.timefold.solver.core.impl.localsearch.LocalSearchPhase; @@ -32,6 +33,10 @@ public enum PhaseType { * The type of phase associated with {@link PartitionedSearchPhase}. */ PARTITIONED_SEARCH("Partitioned Search"), + /** + * The type of phase associated with {@link EvolutionaryAlgorithmPhase}. + */ + EVOLUTIONARY_ALGORITHM("Evolutionary Algorithm"), /** * The type of phase associated with {@link CustomPhase}. */ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java index a9758c55102..7c244f8e421 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java @@ -14,7 +14,7 @@ import org.jspecify.annotations.Nullable; @NullMarked -final class DefaultPhaseCommandContext implements PhaseCommandContext { +public final class DefaultPhaseCommandContext implements PhaseCommandContext { private final MoveDirector moveDirector; private final BooleanSupplier isPhaseTerminated; 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 8d841424c32..bb7e2d04f31 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 @@ -36,6 +36,7 @@ import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; +import ai.timefold.solver.core.impl.score.stream.common.AbstractConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.random.DelegatingSplittableRandomGenerator; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; @@ -132,6 +133,7 @@ public Solver buildSolver(SolverConfigOverride configOverride) { solverScope.setProblemChangeDirector(new DefaultProblemChangeDirector<>(castScoreDirector)); var moveThreadCount = resolveMoveThreadCount(true); + var constraintCount = calculateConstraintCount(); var bestSolutionRecaller = BestSolutionRecallerFactory.create(). buildBestSolutionRecaller(environmentMode); var randomFactory = buildRandomSupplier(environmentMode); var previewFeaturesEnabled = solverConfig.getEnablePreviewFeatureSet(); @@ -151,6 +153,7 @@ public Solver buildSolver(SolverConfigOverride configOverride) { .withEnvironmentMode(environmentMode) .withMoveThreadCount(moveThreadCount) .withMoveThreadBufferSize(solverConfig.getMoveThreadBufferSize()) + .withConstraintCount(constraintCount) .withThreadFactoryClass(solverConfig.getThreadFactoryClass()) .withNearbyDistanceMeterClass(solverConfig.getNearbyDistanceMeterClass()) .withRandom(randomFactory.get()) @@ -167,6 +170,14 @@ public Solver buildSolver(SolverConfigOverride configOverride) { moveThreadCount == null ? SolverConfig.MOVE_THREAD_COUNT_NONE : Integer.toString(moveThreadCount)); } + private int calculateConstraintCount() { + // Only constraint stream includes the concept of multiple separate constraints + if (scoreDirectorFactory instanceof AbstractConstraintStreamScoreDirectorFactory streamScoreDirectorFactory) { + return streamScoreDirectorFactory.getConstraintMetaModel().getConstraints().size(); + } + return 0; + } + public @Nullable Integer resolveMoveThreadCount(boolean enforceMaximum) { var maybeCount = new MoveThreadCountResolver().resolveMoveThreadCount(solverConfig.getMoveThreadCount(), enforceMaximum); 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 096557360de..e1a67d7ec6b 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 @@ -347,22 +347,28 @@ public void setInitialSolution(Solution_ initialSolution) { } public SolverScope createChildThreadSolverScope(ChildThreadType childThreadType) { - SolverScope childThreadSolverScope = new SolverScope<>(clock); - childThreadSolverScope.bestSolution.set(null); - childThreadSolverScope.bestScore.set(null); - childThreadSolverScope.monitoringTags = monitoringTags; - childThreadSolverScope.solverMetricSet = solverMetricSet; - childThreadSolverScope.startingSolverCount = startingSolverCount; + var childThreadScoreDirector = scoreDirector.createChildThreadScoreDirector(childThreadType); + return copy(childThreadScoreDirector); + } + + public > SolverScope copy(InnerScoreDirector newScoreDirector) { + var copy = new SolverScope(clock); + copy.solver = solver; + copy.scoreDirector = newScoreDirector; + copy.bestSolution.set(null); + copy.bestScore.set(null); + copy.monitoringTags = monitoringTags; + copy.solverMetricSet = solverMetricSet; + copy.startingSolverCount = startingSolverCount; // Experiments show that this trick to attain reproducibility doesn't break uniform distribution var delegatingRandom = (DelegatingSplittableRandomGenerator) workingRandom; - childThreadSolverScope.workingRandom = - new DelegatingSplittableRandomGenerator(delegatingRandom.getSeed(), delegatingRandom.split()); - childThreadSolverScope.scoreDirector = scoreDirector.createChildThreadScoreDirector(childThreadType); - childThreadSolverScope.startingSystemTimeMillis.set(startingSystemTimeMillis.get()); - resetAtomicLongTimeMillis(childThreadSolverScope.endingSystemTimeMillis); - childThreadSolverScope.startingInitializedScore = null; - childThreadSolverScope.bestSolutionTimeMillis = null; - return childThreadSolverScope; + copy.workingRandom = new DelegatingSplittableRandomGenerator(delegatingRandom.getSeed(), delegatingRandom.split()); + copy.startingSystemTimeMillis.set(startingSystemTimeMillis.get()); + resetAtomicLongTimeMillis(copy.endingSystemTimeMillis); + copy.startingInitializedScore = null; + copy.bestSolutionTimeMillis = null; + copy.problemSizeStatistics.set(problemSizeStatistics.get()); + return copy; } public void initializeYielding() { diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 6ab448f37a6..ae8a99162fb 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -49,6 +49,7 @@ exports ai.timefold.solver.core.config.localsearch.decider.acceptor.stepcountinghillclimbing; exports ai.timefold.solver.core.config.localsearch.decider.forager; exports ai.timefold.solver.core.config.partitionedsearch; + exports ai.timefold.solver.core.config.evolutionaryalgorithm; exports ai.timefold.solver.core.config.phase; exports ai.timefold.solver.core.config.phase.custom; exports ai.timefold.solver.core.config.score.director; @@ -84,6 +85,13 @@ exports ai.timefold.solver.core.impl.heuristic.selector.move.generic.list; exports ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.kopt; exports ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ruin; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; // explicit exports to other modules exports ai.timefold.solver.core.impl.constructionheuristic.event to @@ -135,6 +143,8 @@ ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.localsearch.scope to ai.timefold.solver.enterprise.core, ai.timefold.solver.benchmark; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope + to ai.timefold.solver.enterprise.core, ai.timefold.solver.benchmark; exports ai.timefold.solver.core.impl.partitionedsearch.partitioner to ai.timefold.solver.quarkus.deployment, ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.phase.event to ai.timefold.solver.jackson, ai.timefold.solver.benchmark, @@ -202,6 +212,8 @@ exports ai.timefold.solver.core.impl.move to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.neighborhood to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.partitionedsearch to ai.timefold.solver.enterprise.core; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm to ai.timefold.solver.enterprise.core; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.decider to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.phase to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.score.stream.bavet to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.score.stream.bavet.uni to ai.timefold.solver.enterprise.core; @@ -242,6 +254,7 @@ to jakarta.xml.bind, org.glassfish.jaxb.runtime; opens ai.timefold.solver.core.config.localsearch.decider.forager to jakarta.xml.bind, org.glassfish.jaxb.runtime; opens ai.timefold.solver.core.config.partitionedsearch to jakarta.xml.bind, org.glassfish.jaxb.runtime; + opens ai.timefold.solver.core.config.evolutionaryalgorithm to jakarta.xml.bind, org.glassfish.jaxb.runtime; opens ai.timefold.solver.core.config.phase to jakarta.xml.bind, org.glassfish.jaxb.runtime; opens ai.timefold.solver.core.config.phase.custom to jakarta.xml.bind, org.glassfish.jaxb.runtime; opens ai.timefold.solver.core.config.score.director to jakarta.xml.bind, org.glassfish.jaxb.runtime; @@ -258,4 +271,4 @@ requires org.slf4j; requires io.quarkus.gizmo2; -} \ No newline at end of file +} diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index a5d10b20c96..d2023acda99 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -47,6 +47,8 @@ + + @@ -1375,6 +1377,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1462,6 +1548,8 @@ + + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerFactoryTest.java index eab64879bc0..8bb9809ad1a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerFactoryTest.java @@ -91,7 +91,7 @@ void buildWithEntitySortManner() { QueuedEntityPlacerFactory.unfoldNew(configPolicy, List.of(primaryMoveSelectorConfig)); var entityPlacer = new QueuedEntityPlacerFactory(placerConfig); - var entitySelectorConfig = entityPlacer.buildEntitySelectorConfig(configPolicy); + var entitySelectorConfig = entityPlacer.buildEntitySelectorConfig(configPolicy, placerConfig); assertThat(entitySelectorConfig.getSelectionOrder()).isEqualTo(SelectionOrder.SORTED); assertThat(entitySelectorConfig.getSorterManner()).isEqualTo(EntitySorterManner.DESCENDING_IF_AVAILABLE); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java new file mode 100644 index 00000000000..6916556f91b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java @@ -0,0 +1,429 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list; + +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListValue; + +import org.junit.jupiter.api.Test; + +class ListSolutionStateManagerTest { + + private InnerScoreDirector buildScoreDirector(boolean pinned) { + InnerScoreDirector scoreDirector; + if (pinned) { + var factory = new BavetConstraintStreamScoreDirectorFactory( + TestdataPinnedListSolution.buildSolutionDescriptor(), + constraintFactory -> new Constraint[] { constraintFactory + .forEach(TestdataPinnedListEntity.class).penalize(SimpleScore.ONE) + .asConstraint("Dummy constraint") }, + EnvironmentMode.FULL_ASSERT); + scoreDirector = + (InnerScoreDirector) new BavetConstraintStreamScoreDirector.Builder<>( + factory) + .withLookUpEnabled(true) + .build(); + } else { + var factory = new BavetConstraintStreamScoreDirectorFactory( + TestdataListSolution.buildSolutionDescriptor(), constraintFactory -> new Constraint[] { constraintFactory + .forEach(TestdataListEntity.class).penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, + EnvironmentMode.FULL_ASSERT); + scoreDirector = + (InnerScoreDirector) new BavetConstraintStreamScoreDirector.Builder<>( + factory) + .withLookUpEnabled(true) + .build(); + } + return scoreDirector; + } + + @Test + void saveStateWithNoValuesAssigned() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + + var scoreDirector = mockScoreDirector(TestdataListSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new ListSolutionStateManager() + .saveSolutionState(scoreDirector, true); + + assertThat(state.assignedValueList()).isEmpty(); + } + + @Test + void saveStateWithAllValuesAssigned() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + a.setValueList(List.of(v1, v2, v3)); + + var scoreDirector = mockScoreDirector(TestdataListSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new ListSolutionStateManager() + .saveSolutionState(scoreDirector, true); + + assertThat(state.assignedValueList()) + .hasSize(3) + .extracting(lv -> ((TestdataListValue) lv.value()).getCode()) + .containsExactlyInAnyOrder("v1", "v2", "v3"); + } + + @Test + void saveStateWithoutAssignedValues() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + a.setValueList(List.of(v1, v2, v3)); + + var scoreDirector = mockScoreDirector(TestdataListSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new ListSolutionStateManager() + .saveSolutionState(scoreDirector, false); + + assertThat(state.assignedValueList()).isEmpty(); + } + + @Test + void saveStateWithPartiallyAssigned() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + // v3 left unassigned + a.setValueList(List.of(v1, v2)); + + var scoreDirector = mockScoreDirector(TestdataListSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new ListSolutionStateManager() + .saveSolutionState(scoreDirector, true); + + assertThat(state.assignedValueList()) + .hasSize(2) + .extracting(lv -> ((TestdataListValue) lv.value()).getCode()) + .containsExactlyInAnyOrder("v1", "v2"); + } + + @Test + void restoreEmptyState() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + + var scoreDirector = mockScoreDirector(TestdataListSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + + // Save state while nothing is assigned + var emptyState = manager.saveSolutionState(scoreDirector, true); + + // Restore to empty state + a.setValueList(new ArrayList<>(List.of(v1, v2, v3))); + scoreDirector.setWorkingSolution(solution); + manager.restoreSolutionState(scoreDirector, emptyState); + + assertThat(a.getValueList()).isEmpty(); + assertThat(b.getValueList()).isEmpty(); + } + + @Test + void restorePartialState() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + a.setValueList(new ArrayList<>(List.of(v1, v2))); + + InnerScoreDirector scoreDirector = buildScoreDirector(false); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Assign v3 after the snapshot + scoreDirector.executeMove(Moves + .assign(scoreDirector.getSolutionDescriptor().getListVariableDescriptor().getVariableMetaModel(), v3, b, 0)); + + // Restore: v3 should be unassigned, v1 and v2 stay at original positions + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(a.getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2"); + assertThat(b.getValueList()).isEmpty(); + } + + @Test + void restoreStateFromMultipleEntities() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3, v4, v5)); + solution.setEntityList(List.of(a, b)); + a.setValueList(new ArrayList<>(List.of(v1, v2))); + b.setValueList(new ArrayList<>(List.of(v3))); + + InnerScoreDirector scoreDirector = buildScoreDirector(false); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Add more values after snapshot: a=[v1,v2,v4], b=[v3,v5] + var variableMetaModel = scoreDirector.getSolutionDescriptor().getListVariableDescriptor().getVariableMetaModel(); + scoreDirector.executeMove(Moves.assign(variableMetaModel, v4, a, 2)); + scoreDirector.executeMove(Moves.assign(variableMetaModel, v5, b, 1)); + + // Restore: v4 and v5 should be unassigned, v1 and v2 back to a[0], v3 back to b[0] + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(a.getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2"); + assertThat(b.getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v3"); + } + + @Test + void preserveState() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(a, b)); + a.setValueList(new ArrayList<>(List.of(v1, v2))); + SolutionManager.updateShadowVariables(solution); + + InnerScoreDirector scoreDirector = buildScoreDirector(false); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + + // Assign v1 and v2, save state, then immediately restore + var savedState = manager.saveSolutionState(scoreDirector, true); + + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(a.getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2"); + assertThat(b.getValueList()).isEmpty(); + } + + @Test + void restoreStateWithRebase() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3, v4, v5)); + solution.setEntityList(List.of(a, b)); + a.setValueList(new ArrayList<>(List.of(v1, v2))); + b.setValueList(new ArrayList<>(List.of(v3))); + + InnerScoreDirector scoreDirector = buildScoreDirector(false); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Add more values after snapshot: a=[v1,v2,v4], b=[v3,v5] + a.getValueList().add(v4); + b.getValueList().add(v5); + // Update the working solution to force the rebasing + scoreDirector.setWorkingSolution(scoreDirector.cloneWorkingSolution()); + + // Restore: v4 and v5 should be unassigned, v1 and v2 back to a[0], v3 back to b[0] + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(scoreDirector.getWorkingSolution().getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2"); + assertThat(scoreDirector.getWorkingSolution().getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v3"); + } + + @Test + @SuppressWarnings("unchecked") + void saveStateFromIndividualWithEmptyChromosome() { + var solution = new TestdataListSolution(); + solution.setValueList(List.of()); + solution.setEntityList(List.of()); + + var individual = (Individual) mock(Individual.class); + doReturn(solution).when(individual).getSolution(); + doReturn(new ChromosomeEntry[0]).when(individual).getChromosome(); + var score = (InnerScore) mock(InnerScore.class); + doReturn(score).when(individual).getScore(); + + var state = new ListSolutionStateManager() + .saveSolutionState(individual); + + assertThat(state.getSolution()).isSameAs(solution); + assertThat(state.assignedValueList()).isEmpty(); + assertThat(state.getScore()).isSameAs(score); + } + + @Test + @SuppressWarnings("unchecked") + void saveStateFromIndividualWithAssignedValues() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + + var solution = new TestdataListSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(a, b)); + + var individual = (Individual) mock(Individual.class); + doReturn(solution).when(individual).getSolution(); + doReturn(new ChromosomeEntry[] { + new ChromosomeEntry(v1, a, 0), + new ChromosomeEntry(v2, a, 1), + new ChromosomeEntry(v3, b, 0) + }).when(individual).getChromosome(); + var score = (InnerScore) mock(InnerScore.class); + doReturn(score).when(individual).getScore(); + + var state = new ListSolutionStateManager() + .saveSolutionState(individual); + + assertThat(state.getSolution()).isSameAs(solution); + assertThat(state.getScore()).isSameAs(score); + + var assignedValues = state.assignedValueList(); + assertThat(assignedValues).hasSize(3); + + assertThat(assignedValues.get(0).value()).isSameAs(v1); + assertThat((Object) assignedValues.get(0).positionInList().entity()).isSameAs(a); + assertThat(assignedValues.get(0).positionInList().index()).isZero(); + + assertThat(assignedValues.get(1).value()).isSameAs(v2); + assertThat((Object) assignedValues.get(1).positionInList().entity()).isSameAs(a); + assertThat(assignedValues.get(1).positionInList().index()).isEqualTo(1); + + assertThat(assignedValues.get(2).value()).isSameAs(v3); + assertThat((Object) assignedValues.get(2).positionInList().entity()).isSameAs(b); + assertThat(assignedValues.get(2).positionInList().index()).isZero(); + } + + @Test + void restorePartialStateWithOnlyPinned() { + var v1 = new TestdataPinnedListValue("v1"); + var v2 = new TestdataPinnedListValue("v2"); + var v3 = new TestdataPinnedListValue("v3"); + var v4 = new TestdataPinnedListValue("v4"); + + // Entity a is pinned — its values (v1, v2) will be captured by onlyPinned=true. + var a = new TestdataPinnedListEntity("a", v1, v2); + a.setPinned(true); + // Entity b is not pinned — v3 is assigned but will not be captured by onlyPinned=true. + var b = new TestdataPinnedListEntity("b", v3); + + var solution = new TestdataPinnedListSolution(); + solution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4))); + solution.setEntityList(List.of(a, b)); + + InnerScoreDirector scoreDirector = buildScoreDirector(true); + scoreDirector.setWorkingSolution(solution); + var manager = new ListSolutionStateManager(); + + // Save no values + var savedState = manager.saveSolutionState(scoreDirector, false); + + // Assign v4 to entity b after the snapshot — b now holds [v3, v4]. + var variableMetaModel = scoreDirector.getSolutionDescriptor().getListVariableDescriptor().getVariableMetaModel(); + scoreDirector.executeMove(Moves.assign(variableMetaModel, v4, b, 1)); + + // Restore: all non-pinned values (v3, v4) are unassigned; pinned v1, v2 remain in entity a. + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(a.getValueList()) + .extracting(TestdataPinnedListValue::getCode) + .containsExactly("v1", "v2"); + assertThat(b.getValueList()).isEmpty(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java new file mode 100644 index 00000000000..5d22d4a7831 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java @@ -0,0 +1,526 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.MockablePhaseTermination; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; + +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalAnswers; + +class ListOXCrossoverTest { + + private static InnerScoreDirector buildScoreDirector() { + var factory = new EasyScoreDirectorFactory<>(TestdataListSolution.buildSolutionDescriptor(), + solution -> SimpleScore.of(0), EnvironmentMode.PHASE_ASSERT); + factory.setInitializingScoreTrend( + InitializingScoreTrend.buildUniformTrend(InitializingScoreTrendLevel.ONLY_DOWN, 1)); + var delegate = factory.createScoreDirectorBuilder() + .withLookUpEnabled(true) + .build(); + return mock(InnerScoreDirector.class, AdditionalAnswers.delegatesTo(delegate)); + } + + @Test + void crossoverOneEntity() { + // Uninitialized solution + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + var c = new TestdataListEntity("c"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of(a, b, c))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // First parent: a=[v1..v10], b=[], c=[] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + + var a1 = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10); + var b1 = new TestdataListEntity("b"); + var c1 = new TestdataListEntity("c"); + + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(a1, b1, c1))); + SolutionManager.updateShadowVariables(firstParent); + + // Second parent: a=[v10,v9,v8,v7,v6,v5,v4,v3,v2,v1], b=[], c=[] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + + var a2 = new TestdataListEntity("a", p2v10, p2v9, p2v8, p2v7, p2v6, p2v5, p2v4, p2v3, p2v2, p2v1); + var b2 = new TestdataListEntity("b"); + var c2 = new TestdataListEntity("c"); + + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(a2, b2, c2))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + // Cut [2, 5] → both within entity a → fixIndex snaps to [0, 10] → all P1 values + when(random.nextInt(10)).thenReturn(2, 5); + var localSearchPhase = mock(Phase.class); + var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + // No inheritance rate + var result = + new ListOXCrossover(localSearchPhase, null, 0, false).apply(context); + var offspring = result.solution(); + + // a inherit all P1 values with the same position from the parent + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10"); + assertThat(offspring.getEntityList().get(1).getValueList()).isEmpty(); + assertThat(offspring.getEntityList().get(2).getValueList()).isEmpty(); + } + + @Test + void crossoverTwoEntities() { + // Uninitialized solution + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + var c = new TestdataListEntity("c"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of(a, b, c))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // First parent: a=[v1..v5], b=[v6..v10], c=[] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + + var a1 = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4, p1v5); + var b1 = new TestdataListEntity("b", p1v6, p1v7, p1v8, p1v9, p1v10); + var c1 = new TestdataListEntity("c"); + + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(a1, b1, c1))); + SolutionManager.updateShadowVariables(firstParent); + + // Second parent: a=[v6..v10], b=[v5,v4,v3,v2,v1], c=[] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + + var a2 = new TestdataListEntity("a", p2v6, p2v7, p2v8, p2v9, p2v10); + var b2 = new TestdataListEntity("b", p2v5, p2v4, p2v3, p2v2, p2v1); + var c2 = new TestdataListEntity("c"); + + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(a2, b2, c2))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + // Cut [3, 7] → start mid-a (snaps to 0), end mid-b (snaps to 10) → all P1 + when(random.nextInt(10)).thenReturn(3, 7); + var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + // No inheritance rate + var result = + new ListOXCrossover(localSearchPhase, null, 0, false) + .apply(context); + var offspring = result.solution(); + + // a and b inherit all P1 values with the same position from the parent + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2", "v3", "v4", "v5"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v6", "v7", "v8", "v9", "v10"); + assertThat(offspring.getEntityList().get(2).getValueList()).isEmpty(); + } + + @Test + void crossoverThreeEntities() { + // Uninitialized solution + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + var c = new TestdataListEntity("c"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of(a, b, c))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // First parent: a=[v1..v4], b=[v5,v6,v7], c=[v8,v9,v10] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + + var a1 = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4); + var b1 = new TestdataListEntity("b", p1v5, p1v6, p1v7); + var c1 = new TestdataListEntity("c", p1v8, p1v9, p1v10); + + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(a1, b1, c1))); + SolutionManager.updateShadowVariables(firstParent); + + // Second parent: a=[v10,v9,v8], b=[v7,v6,v5,v4], c=[v3,v2,v1] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + + var a2 = new TestdataListEntity("a", p2v10, p2v9, p2v8); + var b2 = new TestdataListEntity("b", p2v7, p2v6, p2v5, p2v4); + var c2 = new TestdataListEntity("c", p2v3, p2v2, p2v1); + + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(a2, b2, c2))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + // Cut [2, 8] → start mid-a (snaps to 0), end mid-c (snaps to 10) → all P1 + when(random.nextInt(10)).thenReturn(2, 8); + var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + // No inheritance rate + var result = + new ListOXCrossover(localSearchPhase, null, 0, false) + .apply(context); + var offspring = result.solution(); + + // a, b and c inherit all P1 values with the same position from the parent + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v1", "v2", "v3", "v4"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v5", "v6", "v7"); + assertThat(offspring.getEntityList().get(2).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v8", "v9", "v10"); + } + + @Test + @SuppressWarnings("unchecked") + void crossoverThreeEntitiesWithInheritanceRate() { + // Uninitialized solution + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var a = new TestdataListEntity("a"); + var b = new TestdataListEntity("b"); + var c = new TestdataListEntity("c"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of(a, b, c))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // First parent: a=[v1..v4], b=[v5,v6,v7], c=[v8,v9,v10] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + + var a1 = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4); + var b1 = new TestdataListEntity("b", p1v5, p1v6, p1v7); + var c1 = new TestdataListEntity("c", p1v8, p1v9, p1v10); + + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(a1, b1, c1))); + SolutionManager.updateShadowVariables(firstParent); + + // Second parent: a=[v10,v9,v8], b=[v7,v6,v5,v4], c=[v3,v2,v1] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + + var a2 = new TestdataListEntity("a", p2v10, p2v9, p2v8); + var b2 = new TestdataListEntity("b", p2v7, p2v6, p2v5, p2v4); + var c2 = new TestdataListEntity("c", p2v3, p2v2, p2v1); + + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(a2, b2, c2))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + // inheritanceRate=0.5, size=10 → minSize=5, maxStart=6 + // nextInt(0,6)=2 → start=2; minEnd=6, maxEnd=10 → nextInt(6,10)=8 → end=8 + // fixIndex snaps start mid-a to 0, end mid-c to 10 → all P1 values inherited + when(random.nextInt(0, 6)).thenReturn(2); + when(random.nextInt(6, 10)).thenReturn(8); + var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = + new ListOXCrossover(localSearchPhase, null, 0.5, false) + .apply(context); + var offspring = result.solution(); + + // a, b and c inherit all P1 values with the same position from the parent + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v1", "v2", "v3", "v4"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v5", "v6", "v7"); + assertThat(offspring.getEntityList().get(2).getValueList()) + .extracting(TestdataListValue::getCode).containsExactly("v8", "v9", "v10"); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java new file mode 100644 index 00000000000..ea9ddbe99b8 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java @@ -0,0 +1,504 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.MockablePhaseTermination; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; + +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalAnswers; + +class ListRXCrossoverTest { + + private static InnerScoreDirector buildScoreDirector() { + var factory = new EasyScoreDirectorFactory<>(TestdataListSolution.buildSolutionDescriptor(), + solution -> SimpleScore.of(0), EnvironmentMode.PHASE_ASSERT); + factory.setInitializingScoreTrend( + InitializingScoreTrend.buildUniformTrend(InitializingScoreTrendLevel.ONLY_DOWN, 1)); + var delegate = factory.createScoreDirectorBuilder() + .withLookUpEnabled(true) + .build(); + return mock(InnerScoreDirector.class, AdditionalAnswers.delegatesTo(delegate)); + } + + private static EvolutionaryAlgorithmPhaseScope buildPhaseScope( + InnerScoreDirector scoreDirector, RandomGenerator random) { + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + return phaseScope; + } + + /** + * All entities inherit from P1 (inheritanceRate=1.0 → always picks P1). + * Phase 2 finds nothing to place because every value was claimed in Phase 1. + *

+ * P1: a=[v1..v5], b=[v6..v10], c=[] + * P2: a=[v6..v10], b=[v1..v5], c=[] + */ + @Test + void crossoverOneEntityFirstParent() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of( + new TestdataListEntity("a"), + new TestdataListEntity("b"), + new TestdataListEntity("c")))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + var p1a = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4, p1v5); + var p1b = new TestdataListEntity("b", p1v6, p1v7, p1v8, p1v9, p1v10); + var p1c = new TestdataListEntity("c"); + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(p1a, p1b, p1c))); + SolutionManager.updateShadowVariables(firstParent); + + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + var p2a = new TestdataListEntity("a", p2v6, p2v7, p2v8, p2v9, p2v10); + var p2b = new TestdataListEntity("b", p2v1, p2v2, p2v3, p2v4, p2v5); + var p2c = new TestdataListEntity("c"); + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(p2a, p2b, p2c))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var random = mock(RandomGenerator.class); + var phaseScope = buildPhaseScope(scoreDirector, random); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + localSearchPhase, null, 1.0, false).apply(context); + var offspring = result.solution(); + + // inheritanceRate=1.0 → all entities pick P1; Phase 1 appends in order; Phase 2 finds nothing + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2", "v3", "v4", "v5"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v6", "v7", "v8", "v9", "v10"); + assertThat(offspring.getEntityList().get(2).getValueList()).isEmpty(); + } + + /** + * All entities inherit from P2 (inheritanceRate=0 → always picks P2). + * Phase 2 finds nothing to place because every value was claimed in Phase 1. + *

+ * P1: a=[v1..v5], b=[v6..v10], c=[] + * P2: a=[v6..v10], b=[v1..v5], c=[] + */ + @Test + void crossoverOneEntitySecondParent() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of( + new TestdataListEntity("a"), + new TestdataListEntity("b"), + new TestdataListEntity("c")))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + var p1a = new TestdataListEntity("a", p1v1, p1v2, p1v3, p1v4, p1v5); + var p1b = new TestdataListEntity("b", p1v6, p1v7, p1v8, p1v9, p1v10); + var p1c = new TestdataListEntity("c"); + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(p1a, p1b, p1c))); + SolutionManager.updateShadowVariables(firstParent); + + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + var p2a = new TestdataListEntity("a", p2v6, p2v7, p2v8, p2v9, p2v10); + var p2b = new TestdataListEntity("b", p2v1, p2v2, p2v3, p2v4, p2v5); + var p2c = new TestdataListEntity("c"); + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(p2a, p2b, p2c))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var random = mock(RandomGenerator.class); + var phaseScope = buildPhaseScope(scoreDirector, random); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + localSearchPhase, null, 0, false).apply(context); + var offspring = result.solution(); + + // inheritanceRate=0 → all entities pick P2; Phase 1 appends in order; Phase 2 finds nothing + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v6", "v7", "v8", "v9", "v10"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2", "v3", "v4", "v5"); + assertThat(offspring.getEntityList().get(2).getValueList()).isEmpty(); + } + + /** + * All entities inherit from P2 (inheritanceRate=0 → always picks P2). + * Phase 2 finds nothing to place because every value was claimed in Phase 1. + *

+ * P1: a=[v1,v2,v3], b=[v4,v5,v6,v7], c=[v8,v9,v10] + * P2: a=[v8,v9,v10,v4], b=[v1,v2,v3,v5], c=[v6,v7] + *

+ * Phase 1 (all entities → P2): + * - a gets P2's [v8,v9,v10,v4] → appended in order → a=[v8,v9,v10,v4] + * - b gets P2's [v1,v2,v3,v5] → appended in order → b=[v1,v2,v3,v5] + * - c gets P2's [v6,v7] → appended in order → c=[v6,v7] + *

+ * Phase 2: all values already assigned → nothing placed + */ + @Test + void crossoverTwoEntities() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of( + new TestdataListEntity("a"), + new TestdataListEntity("b"), + new TestdataListEntity("c")))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // P1: a=[v1,v2,v3], b=[v4,v5,v6,v7], c=[v8,v9,v10] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + var p1a = new TestdataListEntity("a", p1v1, p1v2, p1v3); + var p1b = new TestdataListEntity("b", p1v4, p1v5, p1v6, p1v7); + var p1c = new TestdataListEntity("c", p1v8, p1v9, p1v10); + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(p1a, p1b, p1c))); + SolutionManager.updateShadowVariables(firstParent); + + // P2: a=[v8,v9,v10,v4], b=[v1,v2,v3,v5], c=[v6,v7] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + var p2a = new TestdataListEntity("a", p2v8, p2v9, p2v10, p2v4); + var p2b = new TestdataListEntity("b", p2v1, p2v2, p2v3, p2v5); + var p2c = new TestdataListEntity("c", p2v6, p2v7); + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(p2a, p2b, p2c))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var random = mock(RandomGenerator.class); + var phaseScope = buildPhaseScope(scoreDirector, random); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + localSearchPhase, null, 0, false).apply(context); + var offspring = result.solution(); + + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v8", "v9", "v10", "v4"); + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v1", "v2", "v3", "v5"); + assertThat(offspring.getEntityList().get(2).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v6", "v7"); + } + + /** + * Mixed inheritance: a=P1, b=P2, c=P1 (inheritanceRate=0.5, nextDouble returns 0.0, 0.9, 0.0). + *

+ * P1: a=[v1,v2,v3], b=[v4,v5,v6,v7], c=[v8,v9,v10] + * P2: a=[v8,v9,v10,v4], b=[v1,v2,v3,v5], c=[v6,v7] + *

+ * Phase 1 (a→P1, b→P2, c→P1): + * - a gets P1's [v1,v2,v3] → appended in order → a=[v1,v2,v3] + * - b gets P2's [v1,v2,v3,v5] → v1,v2,v3 already assigned → only v5 placed → b=[v5] + * - c gets P1's [v8,v9,v10] → appended in order → c=[v8,v9,v10] + *

+ * Phase 2 (P2 order: v8,v9,v10,v4 from a; v1,v2,v3,v5 from b; v6,v7 from c): + * - v8,v9,v10: already assigned → skip + * - v4: unassigned → best fit across entities → entity a at pos 0 → a=[v4,v1,v2,v3] + * - v1,v2,v3,v5: already assigned → skip + * - v6: unassigned → entity a at pos 0 → a=[v6,v4,v1,v2,v3] + * - v7: unassigned → entity a at pos 0 → a=[v7,v6,v4,v1,v2,v3] + */ + @Test + void crossoverTwoEntitiesWithInheritanceRate() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var v5 = new TestdataListValue("v5"); + var v6 = new TestdataListValue("v6"); + var v7 = new TestdataListValue("v7"); + var v8 = new TestdataListValue("v8"); + var v9 = new TestdataListValue("v9"); + var v10 = new TestdataListValue("v10"); + + var uninitializedSolution = new TestdataListSolution(); + uninitializedSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, v4, v5, v6, v7, v8, v9, v10))); + uninitializedSolution.setEntityList(new ArrayList<>(List.of( + new TestdataListEntity("a"), + new TestdataListEntity("b"), + new TestdataListEntity("c")))); + SolutionManager.updateShadowVariables(uninitializedSolution); + + // P1: a=[v1,v2,v3], b=[v4,v5,v6,v7], c=[v8,v9,v10] + var p1v1 = new TestdataListValue("v1"); + var p1v2 = new TestdataListValue("v2"); + var p1v3 = new TestdataListValue("v3"); + var p1v4 = new TestdataListValue("v4"); + var p1v5 = new TestdataListValue("v5"); + var p1v6 = new TestdataListValue("v6"); + var p1v7 = new TestdataListValue("v7"); + var p1v8 = new TestdataListValue("v8"); + var p1v9 = new TestdataListValue("v9"); + var p1v10 = new TestdataListValue("v10"); + var p1a = new TestdataListEntity("a", p1v1, p1v2, p1v3); + var p1b = new TestdataListEntity("b", p1v4, p1v5, p1v6, p1v7); + var p1c = new TestdataListEntity("c", p1v8, p1v9, p1v10); + var firstParent = new TestdataListSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1v1, p1v2, p1v3, p1v4, p1v5, p1v6, p1v7, p1v8, p1v9, p1v10))); + firstParent.setEntityList(new ArrayList<>(List.of(p1a, p1b, p1c))); + SolutionManager.updateShadowVariables(firstParent); + + // P2: a=[v8,v9,v10,v4], b=[v1,v2,v3,v5], c=[v6,v7] + var p2v1 = new TestdataListValue("v1"); + var p2v2 = new TestdataListValue("v2"); + var p2v3 = new TestdataListValue("v3"); + var p2v4 = new TestdataListValue("v4"); + var p2v5 = new TestdataListValue("v5"); + var p2v6 = new TestdataListValue("v6"); + var p2v7 = new TestdataListValue("v7"); + var p2v8 = new TestdataListValue("v8"); + var p2v9 = new TestdataListValue("v9"); + var p2v10 = new TestdataListValue("v10"); + var p2a = new TestdataListEntity("a", p2v8, p2v9, p2v10, p2v4); + var p2b = new TestdataListEntity("b", p2v1, p2v2, p2v3, p2v5); + var p2c = new TestdataListEntity("c", p2v6, p2v7); + var secondParent = new TestdataListSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2v1, p2v2, p2v3, p2v4, p2v5, p2v6, p2v7, p2v8, p2v9, p2v10))); + secondParent.setEntityList(new ArrayList<>(List.of(p2a, p2b, p2c))); + SolutionManager.updateShadowVariables(secondParent); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(uninitializedSolution); + scoreDirector.calculateScore(); + var random = mock(RandomGenerator.class); + // 0.0 < 0.5 → P1 for a; 0.9 < 0.5 → false → P2 for b; 0.0 < 0.5 → P1 for c + when(random.nextDouble()).thenReturn(0.0, 0.9, 0.0); + var phaseScope = buildPhaseScope(scoreDirector, random); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(10); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(10); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + localSearchPhase, null, 0.5, false).apply(context); + var offspring = result.solution(); + + // a: Phase 1 [v1,v2,v3] from P1; Phase 2 prepends v4, v6, v7 → [v7,v6,v4,v1,v2,v3] + assertThat(offspring.getEntityList().get(0).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v7", "v6", "v4", "v1", "v2", "v3"); + // b: Phase 1 only v5 from P2 (v1,v2,v3 already taken) → [v5] + assertThat(offspring.getEntityList().get(1).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v5"); + // c: Phase 1 [v8,v9,v10] from P1 → [v8,v9,v10] + assertThat(offspring.getEntityList().get(2).getValueList()) + .extracting(TestdataListValue::getCode) + .containsExactly("v8", "v9", "v10"); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java new file mode 100644 index 00000000000..5ea4112f224 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java @@ -0,0 +1,141 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator; + +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.Collections; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class DefaultConstructionIndividualStrategyTest { + + private EvolutionaryAlgorithmStepScope prepareStepScope() { + var solverScope = mock(SolverScope.class); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(solverScope).when(phaseScope).getSolverScope(); + var solutionDescriptor = TestdataListSolution.buildSolutionDescriptor(); + var heuristicConfigPolicy = + new HeuristicConfigPolicy.Builder().withSolutionDescriptor(solutionDescriptor).build(); + var termination = (PhaseTermination) TerminationFactory + . create(new TerminationConfig().withStepCountLimit(1)) + .buildTermination(heuristicConfigPolicy); + doReturn(termination).when(phaseScope).getTermination(); + var stepScope = mock(EvolutionaryAlgorithmStepScope.class); + doReturn(phaseScope).when(stepScope).getPhaseScope(); + var population = mock(Population.class); + doReturn(population).when(phaseScope).getPopulation(); + var scoreDirector = mockScoreDirector(solutionDescriptor); + var problem = TestdataListSolution.generateInitializedSolution(1, 1); + scoreDirector.setWorkingSolution(problem); + var score = scoreDirector.calculateScore(); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(problem).when(solverScope).getBestSolution(); + doReturn(score).when(solverScope).getBestScore(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + return stepScope; + } + + @Test + void apply() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + var constructionPhase = + new DefaultConstructionIndividualStrategy(Collections.emptyList(), + deterministicPhase, shuffledPhase, localSearchPhase, null, individualBuilder); + + // First call + var generatedIndividual = constructionPhase.apply(stepScope); + assertThat(generatedIndividual).isNotNull(); + verify(deterministicPhase).solve(solverScope); + verify(shuffledPhase, never()).solve(any()); + + // Second call + var bestIndividual = mock(Individual.class); + var population = stepScope.getPhaseScope().getPopulation(); + doReturn(bestIndividual).when(population).getBestIndividual(); + constructionPhase.apply(stepScope); + verify(deterministicPhase).solve(solverScope); + verify(shuffledPhase).solve(solverScope); + } + + @Test + void applyWithPhaseCommands() { + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var command = mock(PhaseCommand.class); + var constructionPhase = new DefaultConstructionIndividualStrategy(List.of(command), + deterministicPhase, shuffledPhase, localSearchPhase, null, individualBuilder); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + constructionPhase.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + } + + @Test + void applyWithRefinement() { + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var refinementPhase = mock(Phase.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var command = mock(PhaseCommand.class); + var constructionPhase = new DefaultConstructionIndividualStrategy(List.of(command), + deterministicPhase, shuffledPhase, localSearchPhase, refinementPhase, individualBuilder); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + constructionPhase.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase, refinementPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + inOrder.verify(refinementPhase).solve(solverScope); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java new file mode 100644 index 00000000000..681a71158ac --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java @@ -0,0 +1,218 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.list; + +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class ListRuinRecreateIndividualStrategyTest { + + @SuppressWarnings("unchecked") + private EvolutionaryAlgorithmStepScope prepareStepScope() { + var solverScope = mock(SolverScope.class); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(solverScope).when(phaseScope).getSolverScope(); + var solutionDescriptor = TestdataListSolution.buildSolutionDescriptor(); + var heuristicConfigPolicy = + new HeuristicConfigPolicy.Builder().withSolutionDescriptor(solutionDescriptor).build(); + var termination = (PhaseTermination) TerminationFactory + . create(new TerminationConfig().withStepCountLimit(1)) + .buildTermination(heuristicConfigPolicy); + doReturn(termination).when(phaseScope).getTermination(); + var stepScope = mock(EvolutionaryAlgorithmStepScope.class); + doReturn(phaseScope).when(stepScope).getPhaseScope(); + var population = mock(Population.class); + doReturn(population).when(phaseScope).getPopulation(); + var scoreDirector = mockScoreDirector(solutionDescriptor); + var problem = TestdataListSolution.generateInitializedSolution(1, 1); + scoreDirector.setWorkingSolution(problem); + var score = scoreDirector.calculateScore(); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(problem).when(solverScope).getBestSolution(); + doReturn(score).when(solverScope).getBestScore(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + return stepScope; + } + + @Test + @SuppressWarnings("unchecked") + void applyWithNoBestIndividual() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + var strategy = + new ListRuinRecreateIndividualStrategy>( + Collections.emptyList(), deterministicPhase, localSearchPhase, null, solutionStateManager, + individualBuilder, 0.95); + + var generatedIndividual = strategy.apply(stepScope); + + assertThat(generatedIndividual).isNotNull(); + verify(deterministicPhase).solve(solverScope); + verify(localSearchPhase).solve(solverScope); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithBestIndividual() { + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var a = new TestdataListEntity("a"); + + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var scoreDirector = (InnerScoreDirector) solverScope.getScoreDirector(); + + var problem = new TestdataListSolution(); + problem.setValueList(new ArrayList<>(List.of(v1, v2))); + problem.setEntityList(List.of(a)); + a.setValueList(new ArrayList<>(List.of(v1, v2))); + scoreDirector.setWorkingSolution(problem); + + doReturn(v1).when(scoreDirector).lookUpWorkingObject(v1); + doReturn(v2).when(scoreDirector).lookUpWorkingObject(v2); + doReturn(a).when(scoreDirector).lookUpWorkingObject(a); + doNothing().when(scoreDirector).executeMove(any()); + + // Return distinct indices to avoid retry in generateIndexes + var workingRandom = mock(Random.class); + when(workingRandom.nextInt(anyInt())).thenReturn(0, 1); + doReturn(workingRandom).when(solverScope).getWorkingRandom(); + + var deterministicPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var solutionState = mock(SolutionState.class); + var bestIndividual = mock(Individual.class); + doReturn(new ChromosomeEntry[] { + new ChromosomeEntry(v1, a, 0), + new ChromosomeEntry(v2, a, 1) + }).when(bestIndividual).getChromosome(); + doReturn(2).when(bestIndividual).size(); + doReturn(solutionState).when(solutionStateManager).saveSolutionState(bestIndividual); + + var population = stepScope.getPhaseScope().getPopulation(); + doReturn(bestIndividual).when(population).getBestIndividual(); + + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + var strategy = + new ListRuinRecreateIndividualStrategy>( + Collections.emptyList(), deterministicPhase, localSearchPhase, null, solutionStateManager, + individualBuilder, 0.95); + + var generatedIndividual = strategy.apply(stepScope); + + assertThat(generatedIndividual).isNotNull(); + // Ruin-recreate path: deterministic phase is skipped + verify(deterministicPhase, never()).solve(any()); + verify(localSearchPhase).solve(solverScope); + verify(solutionStateManager).saveSolutionState(bestIndividual); + verify(solutionStateManager).restoreSolutionState(any(), any()); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithPhaseCommands() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + + var command = mock(PhaseCommand.class); + var strategy = + new ListRuinRecreateIndividualStrategy>( + List.of(command), deterministicPhase, localSearchPhase, null, solutionStateManager, individualBuilder, + 0.95); + + strategy.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithRefinement() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var refinementPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + + var command = mock(PhaseCommand.class); + var strategy = + new ListRuinRecreateIndividualStrategy>( + List.of(command), deterministicPhase, localSearchPhase, refinementPhase, solutionStateManager, + individualBuilder, 0.95); + + strategy.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase, refinementPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + inOrder.verify(refinementPhase).solve(solverScope); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java index 4f8c99eb3c5..f71c90aea53 100644 --- a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java +++ b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerTestUtils.java @@ -169,6 +169,8 @@ public static TestdataSolution generateTestdataSolution(String code, int entityA } throw new IllegalStateException("No method mocked for parameter (" + externalObject + ")."); }); + when(scoreDirector.lookUpWorkingObject(any())) + .thenAnswer(invocation -> moveDirector.lookUpWorkingObject(invocation.getArguments()[0])); when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor); when(scoreDirector.getMoveDirector()).thenReturn(moveDirector); return scoreDirector; diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 87ef47a9121..55bc485b9a2 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -371,6 +371,9 @@ + + + @@ -2354,6 +2357,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2421,6 +2550,9 @@ + + + From 1f3302d4f3169dfcb7d486f5b6a533e5369be3f2 Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 13 May 2026 16:40:55 -0300 Subject: [PATCH 4/8] feat: enable HGS for basic variables --- core/src/build/revapi-differences.json | 11 + .../EvolutionaryAlgorithmPhaseConfig.java | 18 + ...volutionaryIndividualGeneratorConfig.java} | 53 ++- .../EvolutionaryLocalSearchConfig.java | 80 ++++ .../EvolutionaryWorkerConfig.java | 47 +- .../DefaultEvolutionaryAlgorithmPhase.java | 15 +- ...aultEvolutionaryAlgorithmPhaseFactory.java | 219 ++++++---- .../evolutionaryalgorithm/common/Utils.java | 29 +- .../DefaultBestSolutionUpdater.java | 3 +- .../common/state/SolutionStateManager.java | 4 +- .../state/basic/BasicSolutionState.java | 24 ++ .../basic/BasicSolutionStateManager.java | 114 +++++ .../common/state/basic/BasicValueState.java | 20 + .../common/state/list/ListSolutionState.java | 4 +- .../state/list/ListSolutionStateManager.java | 16 +- .../common/state/list/ListValueState.java | 3 + .../crossover/basic/BasicOXCrossover.java | 84 ++++ .../crossover/list/ListOXCrossover.java | 2 +- .../decider/HybridGeneticSearchDecider.java | 2 +- .../population/DefaultPopulation.java | 4 + .../individual/AbstractIndividual.java | 2 +- .../individual/BasicVariableIndividual.java | 93 ++++ .../individual/ChromosomeEntry.java | 3 +- .../individual/ListVariableIndividual.java | 19 +- ...DefaultConstructionIndividualStrategy.java | 20 +- .../BasicRuinRecreateIndividualStrategy.java | 128 ++++++ .../ListRuinRecreateIndividualStrategy.java | 29 +- .../DiminishedReturnsTermination.java | 1 + core/src/main/resources/solver.xsd | 32 +- ...DefaultEvolutionaryAlgorithmPhaseTest.java | 141 ++++++ .../BasicSolutionDefaultStateManagerTest.java | 403 ++++++++++++++++++ .../list/ListSolutionStateManagerTest.java | 26 +- .../crossover/basic/BasicOXCrossoverTest.java | 274 ++++++++++++ .../crossover/list/ListOXCrossoverTest.java | 16 +- .../crossover/list/ListRXCrossoverTest.java | 16 +- ...sicRuinRecreateIndividualStrategyTest.java | 215 ++++++++++ ...istRuinRecreateIndividualStrategyTest.java | 8 +- .../multivar/TestdataMultiVarEntity.java | 5 + .../src/main/resources/benchmark.xsd | 45 +- 39 files changed, 2004 insertions(+), 224 deletions(-) rename core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/{EvolutionaryCustomPhaseConfig.java => EvolutionaryIndividualGeneratorConfig.java} (57%) create mode 100644 core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryLocalSearchConfig.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionState.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionStateManager.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicValueState.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/BasicVariableIndividual.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionDefaultStateManagerTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json index caa59d0ea5a..343e8ddcbd0 100644 --- a/core/src/build/revapi-differences.json +++ b/core/src/build/revapi-differences.json @@ -44,6 +44,17 @@ "old": "field ai.timefold.solver.core.config.heuristic.selector.move.generic.AbstractPillarMoveSelectorConfig>.subPillarSequenceComparatorClass", "new": "field ai.timefold.solver.core.config.heuristic.selector.move.generic.AbstractPillarMoveSelectorConfig>.subPillarSequenceComparatorClass", "justification": "Internal protected fields; 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.XmlSeeAlso", + "attribute": "value", + "oldValue": "{ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig.class, ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig.class, ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig.class, ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig.class, ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig.class}", + "newValue": "{ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig.class, ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig.class, ai.timefold.solver.core.config.exhaustivesearch.ExhaustiveSearchPhaseConfig.class, ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig.class, ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig.class, ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig.class}", + "justification": "Add new evolutionary config" } ] } diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java index cfa51f08526..b9a9dbf2ef1 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java @@ -11,6 +11,7 @@ import org.jspecify.annotations.Nullable; @XmlType(propOrder = { + "complexProblem", "populationConfig", "workerConfig", }) @@ -19,6 +20,9 @@ public class EvolutionaryAlgorithmPhaseConfig extends PhaseConfig { +public class EvolutionaryIndividualGeneratorConfig extends PhaseConfig { + + @Nullable + private Double inheritanceRate = null; @XmlElement(name = "customPhaseCommandClass") @Nullable @@ -31,10 +37,21 @@ public class EvolutionaryCustomPhaseConfig extends PhaseConfig customProperties = null; + @Nullable + private ConstructionHeuristicPhaseConfig constructionHeuristic = null; + // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ + public @Nullable Double getInheritanceRate() { + return inheritanceRate; + } + + public void setInheritanceRate(@Nullable Double inheritanceRate) { + this.inheritanceRate = inheritanceRate; + } + public @Nullable List> getCustomPhaseCommandClassList() { return customPhaseCommandClassList; } @@ -52,33 +69,54 @@ public void setCustomProperties(@Nullable Map customProperties) this.customProperties = customProperties; } + public @Nullable ConstructionHeuristicPhaseConfig getConstructionHeuristic() { + return constructionHeuristic; + } + + public void setConstructionHeuristic(@Nullable ConstructionHeuristicPhaseConfig constructionHeuristic) { + this.constructionHeuristic = constructionHeuristic; + } + // ************************************************************************ // With methods // ************************************************************************ - public EvolutionaryCustomPhaseConfig withCustomPhaseCommandClassList( + public EvolutionaryIndividualGeneratorConfig withInheritanceRate(@Nullable Double inheritanceRate) { + setInheritanceRate(inheritanceRate); + return this; + } + + public EvolutionaryIndividualGeneratorConfig withCustomPhaseCommandClassList( List> customPhaseCommandClassList) { setCustomPhaseCommandClassList(customPhaseCommandClassList); return this; } - public EvolutionaryCustomPhaseConfig withCustomProperties(Map customProperties) { + public EvolutionaryIndividualGeneratorConfig withCustomProperties(Map customProperties) { setCustomProperties(customProperties); return this; } + public EvolutionaryIndividualGeneratorConfig + withConstructionHeuristic(@Nullable ConstructionHeuristicPhaseConfig constructionHeuristic) { + setConstructionHeuristic(constructionHeuristic); + return this; + } + @Override - public EvolutionaryCustomPhaseConfig inherit(EvolutionaryCustomPhaseConfig inheritedConfig) { + public EvolutionaryIndividualGeneratorConfig inherit(EvolutionaryIndividualGeneratorConfig inheritedConfig) { super.inherit(inheritedConfig); + inheritanceRate = ConfigUtils.inheritOverwritableProperty(inheritanceRate, inheritedConfig.getInheritanceRate()); customPhaseCommandClassList = ConfigUtils.inheritMergeableListProperty(customPhaseCommandClassList, inheritedConfig.getCustomPhaseCommandClassList()); customProperties = ConfigUtils.inheritMergeableMapProperty(customProperties, inheritedConfig.getCustomProperties()); + constructionHeuristic = ConfigUtils.inheritConfig(constructionHeuristic, inheritedConfig.getConstructionHeuristic()); return this; } @Override - public EvolutionaryCustomPhaseConfig copyConfig() { - return new EvolutionaryCustomPhaseConfig().inherit(this); + public EvolutionaryIndividualGeneratorConfig copyConfig() { + return new EvolutionaryIndividualGeneratorConfig().inherit(this); } @Override @@ -86,5 +124,8 @@ public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { if (customPhaseCommandClassList != null) { customPhaseCommandClassList.forEach(classVisitor); } + if (constructionHeuristic != null) { + constructionHeuristic.visitReferencedClasses(classVisitor); + } } } diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryLocalSearchConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryLocalSearchConfig.java new file mode 100644 index 00000000000..c16763a68d4 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryLocalSearchConfig.java @@ -0,0 +1,80 @@ +package ai.timefold.solver.core.config.evolutionaryalgorithm; + +import java.util.function.Consumer; + +import jakarta.xml.bind.annotation.XmlType; + +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.phase.PhaseConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@XmlType(propOrder = { + "inheritanceRate", + "localSearch", +}) +@NullMarked +public class EvolutionaryLocalSearchConfig extends PhaseConfig { + + @Nullable + private Double inheritanceRate = null; + + @Nullable + private LocalSearchPhaseConfig localSearch = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable Double getInheritanceRate() { + return inheritanceRate; + } + + public void setInheritanceRate(@Nullable Double inheritanceRate) { + this.inheritanceRate = inheritanceRate; + } + + public @Nullable LocalSearchPhaseConfig getLocalSearch() { + return localSearch; + } + + public void setLocalSearch(@Nullable LocalSearchPhaseConfig localSearch) { + this.localSearch = localSearch; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryLocalSearchConfig withInheritanceRate(@Nullable Double inheritanceRate) { + setInheritanceRate(inheritanceRate); + return this; + } + + public EvolutionaryLocalSearchConfig withLocalSearch(LocalSearchPhaseConfig localSearch) { + setLocalSearch(localSearch); + return this; + } + + @Override + public EvolutionaryLocalSearchConfig inherit(EvolutionaryLocalSearchConfig inheritedConfig) { + super.inherit(inheritedConfig); + inheritanceRate = ConfigUtils.inheritOverwritableProperty(inheritanceRate, inheritedConfig.getInheritanceRate()); + localSearch = ConfigUtils.inheritConfig(localSearch, inheritedConfig.getLocalSearch()); + return this; + } + + @Override + public EvolutionaryLocalSearchConfig copyConfig() { + return new EvolutionaryLocalSearchConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + if (localSearch != null) { + localSearch.visitReferencedClasses(classVisitor); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java index 92882d3bb24..c047c0f32f5 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java @@ -4,7 +4,6 @@ import jakarta.xml.bind.annotation.XmlType; -import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; import ai.timefold.solver.core.config.phase.PhaseConfig; import ai.timefold.solver.core.config.util.ConfigUtils; @@ -12,36 +11,36 @@ import org.jspecify.annotations.Nullable; @XmlType(propOrder = { - "customIndividualPhaseConfig", - "localSearchPhaseConfig", + "individualGeneratorConfig", + "localSearchConfig", }) @NullMarked public class EvolutionaryWorkerConfig extends PhaseConfig { @Nullable - private EvolutionaryCustomPhaseConfig customIndividualPhaseConfig = null; + private EvolutionaryIndividualGeneratorConfig individualGeneratorConfig = null; @Nullable - private LocalSearchPhaseConfig localSearchPhaseConfig = null; + private EvolutionaryLocalSearchConfig localSearchConfig = null; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - public @Nullable EvolutionaryCustomPhaseConfig getCustomIndividualPhaseConfig() { - return customIndividualPhaseConfig; + public @Nullable EvolutionaryIndividualGeneratorConfig getIndividualGeneratorConfig() { + return individualGeneratorConfig; } - public void setCustomIndividualPhaseConfig(@Nullable EvolutionaryCustomPhaseConfig customIndividualPhaseConfig) { - this.customIndividualPhaseConfig = customIndividualPhaseConfig; + public void setIndividualGeneratorConfig(@Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { + this.individualGeneratorConfig = individualGeneratorConfig; } - public @Nullable LocalSearchPhaseConfig getLocalSearchPhaseConfig() { - return localSearchPhaseConfig; + public @Nullable EvolutionaryLocalSearchConfig getLocalSearchConfig() { + return localSearchConfig; } - public void setLocalSearchPhaseConfig(@Nullable LocalSearchPhaseConfig localSearchPhaseConfig) { - this.localSearchPhaseConfig = localSearchPhaseConfig; + public void setLocalSearchConfig(@Nullable EvolutionaryLocalSearchConfig localSearchConfig) { + this.localSearchConfig = localSearchConfig; } // ************************************************************************ @@ -49,22 +48,22 @@ public void setLocalSearchPhaseConfig(@Nullable LocalSearchPhaseConfig localSear // ************************************************************************ public EvolutionaryWorkerConfig - withCustomIndividualPhaseConfig(EvolutionaryCustomPhaseConfig customIndividualPhaseConfig) { - setCustomIndividualPhaseConfig(customIndividualPhaseConfig); + withIndividualGeneratorConfig(EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { + setIndividualGeneratorConfig(individualGeneratorConfig); return this; } - public EvolutionaryWorkerConfig withLocalSearchPhaseConfig(LocalSearchPhaseConfig localSearchPhaseConfig) { - setLocalSearchPhaseConfig(localSearchPhaseConfig); + public EvolutionaryWorkerConfig withLocalSearchConfig(EvolutionaryLocalSearchConfig localSearchConfig) { + setLocalSearchConfig(localSearchConfig); return this; } @Override public EvolutionaryWorkerConfig inherit(EvolutionaryWorkerConfig inheritedConfig) { super.inherit(inheritedConfig); - customIndividualPhaseConfig = - ConfigUtils.inheritConfig(customIndividualPhaseConfig, inheritedConfig.getCustomIndividualPhaseConfig()); - localSearchPhaseConfig = ConfigUtils.inheritConfig(localSearchPhaseConfig, inheritedConfig.getLocalSearchPhaseConfig()); + individualGeneratorConfig = + ConfigUtils.inheritConfig(individualGeneratorConfig, inheritedConfig.getIndividualGeneratorConfig()); + localSearchConfig = ConfigUtils.inheritConfig(localSearchConfig, inheritedConfig.getLocalSearchConfig()); return this; } @@ -75,11 +74,11 @@ public EvolutionaryWorkerConfig copyConfig() { @Override public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { - if (customIndividualPhaseConfig != null) { - customIndividualPhaseConfig.visitReferencedClasses(classVisitor); + if (individualGeneratorConfig != null) { + individualGeneratorConfig.visitReferencedClasses(classVisitor); } - if (localSearchPhaseConfig != null) { - localSearchPhaseConfig.visitReferencedClasses(classVisitor); + if (localSearchConfig != null) { + localSearchConfig.visitReferencedClasses(classVisitor); } } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java index 8639f17147e..17341a60612 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java @@ -12,16 +12,18 @@ import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import org.jspecify.annotations.NullMarked; + public final class DefaultEvolutionaryAlgorithmPhase extends AbstractPhase implements EvolutionaryAlgorithmPhase, EvolutionaryAlgorithmPhaseLifecycleListener { private final EvolutionaryDecider evolutionaryDecider; - private final boolean overConstrained; + private final boolean isComplex; public DefaultEvolutionaryAlgorithmPhase(Builder builder) { super(builder); this.evolutionaryDecider = builder.evolutionaryDecider; - this.overConstrained = builder.overConstrained; + this.isComplex = builder.isComplex; } // ************************************************************************ @@ -91,7 +93,7 @@ public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { "Evolutionary Algorithm phase ({}) ended: time spent ({}), best score ({}), best generation ({}), best iteration ({}), generation total ({}), iteration total ({}), overconstrained ({}).", phaseScope.getPhaseIndex(), phaseScope.calculateSolverTimeMillisSpentUpToNow(), phaseScope.getBestScore().raw(), statistics.bestGeneration(), statistics.bestIteration(), statistics.generationCount(), - statistics.individualCount(), overConstrained); + statistics.individualCount(), isComplex); } @Override @@ -108,16 +110,17 @@ public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); } + @NullMarked public static class Builder extends AbstractPhaseBuilder { private final EvolutionaryDecider evolutionaryDecider; - private final boolean overConstrained; + private final boolean isComplex; public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, - EvolutionaryDecider evolutionaryDecider, boolean overConstrained) { + EvolutionaryDecider evolutionaryDecider, boolean isComplex) { super(phaseIndex, logIndentation, phaseTermination); this.evolutionaryDecider = evolutionaryDecider; - this.overConstrained = overConstrained; + this.isComplex = isComplex; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java index edb7a441d3a..9ba37388b33 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -11,11 +11,14 @@ import ai.timefold.solver.core.api.solver.phase.PhaseCommand; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicType; +import ai.timefold.solver.core.config.constructionheuristic.decider.forager.ConstructionHeuristicForagerConfig; +import ai.timefold.solver.core.config.constructionheuristic.decider.forager.ConstructionHeuristicPickEarlyType; import ai.timefold.solver.core.config.constructionheuristic.placer.EntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; -import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryCustomPhaseConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryIndividualGeneratorConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryLocalSearchConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryPopulationConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryWorkerConfig; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; @@ -51,15 +54,19 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase.NoBestEventPhase; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic.BasicSolutionStateManager; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list.ListSolutionStateManager; import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.basic.BasicOXCrossover; import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list.ListOXCrossover; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider.Builder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.BasicVariableIndividual; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ListVariableIndividual; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.basic.BasicRuinRecreateIndividualStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.list.ListRuinRecreateIndividualStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.swapstar.ListSwapStarPhase; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; @@ -72,8 +79,10 @@ import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +@NullMarked public final class DefaultEvolutionaryAlgorithmPhaseFactory extends AbstractPhaseFactory { @@ -88,9 +97,6 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean if (solverConfigPolicy.getSolutionDescriptor().hasBothBasicAndListVariables()) { throw new UnsupportedOperationException("The evolutionary algorithm cannot be applied to mixed models."); } - if (solverConfigPolicy.getSolutionDescriptor().hasBasicVariable()) { - throw new UnsupportedOperationException("Basic variables are not supported yet."); - } var populationConfig = phaseConfig.getPopulationConfig(); if (populationConfig == null) { populationConfig = new EvolutionaryPopulationConfig(); @@ -105,24 +111,20 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean } var isListVariable = solverConfigPolicy.getSolutionDescriptor().hasListVariable(); var phaseTermination = buildPhaseTermination(solverConfigPolicy, solverTermination); - // Research has shown that models with a maximum of four constraints perform better in operations that do not require a high inheritance rate. + // Research has shown + // that simpler models perform better in operations with a higher perturbation rate. // Conversely, - // overconstrained models that work with complex datasets tend to be more effective when the inheritance rate is at least 95%. + // complex models that work with complex datasets tend + // to be more effective with smaller perturbation rates, such as an inheritance rate of at least 95%. // This means that an individual will incorporate 95% of a parent's solution for crossover operations // or ruin only 5% of it when creating a new individual. - var overConstrained = isOverConstrained(solverConfigPolicy); + boolean isComplex = phaseConfig.getComplexProblem() != null && phaseConfig.getComplexProblem(); var evolutionaryDecider = buildEvolutionaryAlgorithmDecider(workerConfig, solverConfigPolicy, solverTermination, phaseTermination, - bestSolutionRecaller, overConstrained, isListVariable, populationSize, generationSize, eliteGroupSize, + bestSolutionRecaller, isComplex, isListVariable, populationSize, generationSize, eliteGroupSize, populationRestartCount); return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider, - overConstrained).build(); - } - - private static boolean isOverConstrained(HeuristicConfigPolicy solverConfigPolicy) { - // TODO - It might not be sufficient to infer conclusions based solely on the number of constraints, or the four constraints may be too low. - // TODO - Improve the reasoning to determine whether the model or problem is overconstrained. - return solverConfigPolicy.getConstraintCount() > 4; + isComplex).build(); } /** @@ -133,26 +135,28 @@ private static boolean isOverConstrained(HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller, - boolean overConstrained, boolean isListVariable, int populationSize, int generationSize, int eliteGroupSize, + boolean isComplex, boolean isListVariable, int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount) { IndividualBuilder individualBuilder = buildIndividualBuilder(isListVariable); SolutionStateManager solutionStateManager = buildSolutionStateManager(isListVariable); Phase deterministicBestFitConstructionPhase = - disableBestSolutionUpdate(buildBestFitConstructionHeuristicPhase(solverConfigPolicy, solverTermination, false)); - Phase shuffledBestFitConstructionPhase = - disableBestSolutionUpdate(buildBestFitConstructionHeuristicPhase(solverConfigPolicy, solverTermination, true)); + disableBestSolutionUpdate(buildDeterministicConstructionHeuristicPhase(solverConfigPolicy, + workerConfig.getIndividualGeneratorConfig(), solverTermination)); + Phase shuffledFirstFitConstructionPhase = disableBestSolutionUpdate( + buildShuffledConstructionHeuristicPhase(solverConfigPolicy, solverTermination, isListVariable)); Phase localSearchPhase = - disableBestSolutionUpdate(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchPhaseConfig(), - solverTermination, bestSolutionRecaller, overConstrained, isListVariable)); - Phase swapStarPhase = - disableBestSolutionUpdate(buildSwapStarPhase(solverConfigPolicy, solverTermination, isListVariable)); + disableBestSolutionUpdate(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), + solverTermination, bestSolutionRecaller, isComplex, isListVariable)); + Phase refinmentPhase = + disableBestSolutionUpdate(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); ConstructionIndividualStrategy constructionIndividualStrategy = - buildConstructionIndividualPhase(workerConfig, workerConfig.getCustomIndividualPhaseConfig(), - deterministicBestFitConstructionPhase, localSearchPhase, - swapStarPhase, solutionStateManager, individualBuilder, overConstrained, isListVariable); + buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, + refinmentPhase, solutionStateManager, individualBuilder, isComplex, isListVariable); CrossoverStrategy crossoverStrategy = - buildCrossoverStrategy(localSearchPhase, swapStarPhase, overConstrained, isListVariable); + buildCrossoverStrategy(workerConfig.getLocalSearchConfig(), localSearchPhase, refinmentPhase, isComplex, + isListVariable); return new Builder>() .withPopulationSize(populationSize) @@ -161,7 +165,7 @@ private static boolean isOverConstrained(HeuristicConfigPolicy boolean isOverConstrained(HeuristicConfigPolicy, State_ extends SolutionState> SolutionStateManager buildSolutionStateManager(boolean isListVariable) { - if (!isListVariable) { - throw new UnsupportedOperationException("Basic variables are not supported yet."); + if (isListVariable) { + return (SolutionStateManager) new ListSolutionStateManager<>(); + } else { + return (SolutionStateManager) new BasicSolutionStateManager<>(); } - return (SolutionStateManager) new ListSolutionStateManager<>(); } private static > IndividualBuilder buildIndividualBuilder(boolean isListVariable) { - if (!isListVariable) { - throw new UnsupportedOperationException("Basic variables are not supported yet."); + if (isListVariable) { + return (solution, score, firstParentScore, secondParentScore, scoreDirector) -> new ListVariableIndividual<>( + scoreDirector, solution, score, firstParentScore, secondParentScore); + } else { + return (solution, score, firstParentScore, secondParentScore, scoreDirector) -> new BasicVariableIndividual<>( + scoreDirector, solution, score, firstParentScore, secondParentScore); } - return ListVariableIndividual::new; } private static > CrossoverStrategy buildCrossoverStrategy( - Phase localSearchPhase, Phase swapStarPhase, boolean overConstrained, + @Nullable EvolutionaryLocalSearchConfig localSearchConfig, Phase localSearchPhase, + @Nullable Phase refinementPhase, boolean isComplex, boolean isListVariable) { - if (!isListVariable) { - throw new UnsupportedOperationException("Basic variables are not supported yet."); + var inheritanceRate = isComplex ? 0.95 : 0.5; + if (localSearchConfig != null && localSearchConfig.getInheritanceRate() != null) { + inheritanceRate = localSearchConfig.getInheritanceRate(); } - if (overConstrained) { - return new ListOXCrossover<>(localSearchPhase, swapStarPhase, 0.95, false); + if (isListVariable) { + return new ListOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, !isComplex); } else { - return new ListOXCrossover<>(localSearchPhase, swapStarPhase, 0.5, true); + return new BasicOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate); } } - private static Phase buildBestFitConstructionHeuristicPhase( - HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, - boolean shuffle) { + private static Phase buildDeterministicConstructionHeuristicPhase( + HeuristicConfigPolicy solverConfigPolicy, + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, + SolverTermination solverTermination) { var constructionHeuristicPhaseConfig = new ConstructionHeuristicPhaseConfig(); - if (shuffle) { - var entityPlacerConfig = DefaultConstructionHeuristicPhaseFactory.buildDefaultEntityPlacerConfig(solverConfigPolicy, - constructionHeuristicPhaseConfig, ConstructionHeuristicType.ALLOCATE_FROM_POOL); - shuffleEntityPlacerConfig(solverConfigPolicy, entityPlacerConfig); - constructionHeuristicPhaseConfig.setEntityPlacerConfig(entityPlacerConfig); + if (individualGeneratorConfig != null && individualGeneratorConfig.getConstructionHeuristic() != null) { + constructionHeuristicPhaseConfig = individualGeneratorConfig.getConstructionHeuristic(); } var constructionConfigPolicy = solverConfigPolicy.cloneBuilder() .withEnvironmentMode(EnvironmentMode.NO_ASSERT) @@ -217,36 +225,56 @@ private static Phase buildBestFitConstructionHeuristicPha constructionConfigPolicy, null, solverTermination); } + private static Phase buildShuffledConstructionHeuristicPhase( + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + boolean isListVariable) { + var constructionHeuristicPhaseConfig = new ConstructionHeuristicPhaseConfig(); + var entityPlacerConfig = DefaultConstructionHeuristicPhaseFactory.buildDefaultEntityPlacerConfig(solverConfigPolicy, + constructionHeuristicPhaseConfig, isListVariable ? ConstructionHeuristicType.ALLOCATE_TO_VALUE_FROM_QUEUE + : ConstructionHeuristicType.ALLOCATE_ENTITY_FROM_QUEUE); + shuffleEntityPlacerConfig(solverConfigPolicy, entityPlacerConfig); + constructionHeuristicPhaseConfig.setEntityPlacerConfig(entityPlacerConfig); + constructionHeuristicPhaseConfig.setForagerConfig(new ConstructionHeuristicForagerConfig() + .withPickEarlyType(ConstructionHeuristicPickEarlyType.FIRST_FEASIBLE_SCORE_OR_NON_DETERIORATING_HARD)); + var constructionConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .build(); + return PhaseFactory. create(constructionHeuristicPhaseConfig).buildPhase(0, false, + constructionConfigPolicy, null, solverTermination); + } + private static , State_ extends SolutionState> ConstructionIndividualStrategy buildConstructionIndividualPhase(EvolutionaryWorkerConfig workerConfig, - EvolutionaryCustomPhaseConfig customIndividualPhaseConfig, - Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, - Phase swapStarPhase, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, boolean overConstrained, boolean isListVariable) { - if (!isListVariable) { - throw new UnsupportedOperationException("Basic variables are not supported yet."); + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, + Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, boolean isComplex, boolean isListVariable) { + var inheritanceRate = isComplex ? 0.95 : 0.5; + if (individualGeneratorConfig != null && individualGeneratorConfig.getInheritanceRate() != null) { + inheritanceRate = individualGeneratorConfig.getInheritanceRate(); } List> customIndividualPhaseCommandList = - buildPhaseCommandList(workerConfig, customIndividualPhaseConfig); - if (overConstrained) { + buildPhaseCommandList(workerConfig, individualGeneratorConfig); + if (isListVariable) { return new ListRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, - deterministicBestFitConstructionPhase, localSearchPhase, swapStarPhase, solutionStateManager, - individualBuilder, 0.95); + deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase, solutionStateManager, + individualBuilder, inheritanceRate); } else { - return new ListRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, - deterministicBestFitConstructionPhase, localSearchPhase, swapStarPhase, solutionStateManager, - individualBuilder, 0.1); + return new BasicRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, refinementPhase, + solutionStateManager, individualBuilder, inheritanceRate); } } - private static List> buildPhaseCommandList( - EvolutionaryWorkerConfig workerConfig, EvolutionaryCustomPhaseConfig customPhaseConfig) { + private static List> buildPhaseCommandList(EvolutionaryWorkerConfig workerConfig, + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { var customIndividualPhaseCommandList = Collections.> emptyList(); - if (customPhaseConfig != null && customPhaseConfig.getCustomPhaseCommandClassList() != null) { + if (individualGeneratorConfig != null && individualGeneratorConfig.getCustomPhaseCommandClassList() != null) { customIndividualPhaseCommandList = - new ArrayList<>(customPhaseConfig.getCustomPhaseCommandClassList().size()); - for (var customPhaseCommandClass : customPhaseConfig.getCustomPhaseCommandClassList()) { + new ArrayList<>(individualGeneratorConfig.getCustomPhaseCommandClassList().size()); + for (var customPhaseCommandClass : individualGeneratorConfig.getCustomPhaseCommandClassList()) { if (customPhaseCommandClass == null) { throw new IllegalArgumentException(""" The customPhaseCommandClass (%s) cannot be null in the evolutionary custom phase config (%s). @@ -256,7 +284,7 @@ The customPhaseCommandClass (%s) cannot be null in the evolutionary custom phase PhaseCommand customPhaseCommand = ConfigUtils.newInstance(workerConfig, "customPhaseCommandClass", customPhaseCommandClass); ConfigUtils.applyCustomProperties(customPhaseCommand, "customPhaseCommandClass", - customPhaseConfig.getCustomProperties(), "customProperties"); + individualGeneratorConfig.getCustomProperties(), "customProperties"); customIndividualPhaseCommandList.add(customPhaseCommand); } } @@ -293,7 +321,7 @@ private static void shuffleEntityPlacerConfig(HeuristicConfigPolicy< var moveSelectorConfig = Objects.requireNonNull(moveSelectorConfigList.get(0)); switch (moveSelectorConfig) { case ChangeMoveSelectorConfig changeMoveSelectorConfig -> - shuffleValueSelectorConfig(changeMoveSelectorConfig.getValueSelectorConfig()); + shuffleValueSelectorConfig(Objects.requireNonNull(changeMoveSelectorConfig.getValueSelectorConfig())); case CartesianProductMoveSelectorConfig cartesianProductMoveSelectorConfig -> { for (var innerMoveSelectorConfig : Objects .requireNonNull(cartesianProductMoveSelectorConfig.getMoveSelectorList())) { @@ -303,7 +331,7 @@ private static void shuffleEntityPlacerConfig(HeuristicConfigPolicy< .formatted(innerMoveSelectorConfig, ChangeMoveSelectorConfig.class.getSimpleName())); } - shuffleValueSelectorConfig(changeMoveSelectorConfig.getValueSelectorConfig()); + shuffleValueSelectorConfig(Objects.requireNonNull(changeMoveSelectorConfig.getValueSelectorConfig())); } } default -> @@ -344,22 +372,18 @@ private static void shuffleValueSelectorConfig(ValueSelectorConfig valueSelector * The method creates a local search phase based on Diversified Late Acceptance and customized diminished termination. */ private static Phase buildLocalSearchPhase(HeuristicConfigPolicy solverConfigPolicy, - @Nullable LocalSearchPhaseConfig localSearchPhaseConfig, SolverTermination solverTermination, - BestSolutionRecaller bestSolutionRecaller, boolean overconstrained, boolean isListVariable) { - var updatedLocalSearchPhaseConfig = localSearchPhaseConfig; + @Nullable EvolutionaryLocalSearchConfig localSearchPhaseConfig, SolverTermination solverTermination, + BestSolutionRecaller bestSolutionRecaller, boolean isComplex, boolean isListVariable) { + var updatedLocalSearchPhaseConfig = localSearchPhaseConfig != null ? localSearchPhaseConfig.getLocalSearch() : null; if (updatedLocalSearchPhaseConfig == null) { updatedLocalSearchPhaseConfig = new LocalSearchPhaseConfig(); updatedLocalSearchPhaseConfig.setLocalSearchType(LocalSearchType.DIVERSIFIED_LATE_ACCEPTANCE); } if (updatedLocalSearchPhaseConfig.getTerminationConfig() == null) { var terminationConfig = new TerminationConfig(); - if (overconstrained) { - terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() - .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(20L)); - } else { - terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() - .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(1L)); - } + var windowTime = isComplex ? 20L : 1L; + terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() + .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(windowTime)); updatedLocalSearchPhaseConfig.setTerminationConfig(terminationConfig); } var clearNearbyClass = updatedLocalSearchPhaseConfig.getMoveSelectorConfig() == null; @@ -416,10 +440,27 @@ private static void loadMoveSelectorConfig(HeuristicConfigPolicy void loadMoveSelectorConfig(HeuristicConfigPolicy Phase buildSwapStarPhase(HeuristicConfigPolicy solverConfigPolicy, - SolverTermination solverTermination, boolean enableSwapStar) { - if (enableSwapStar && solverConfigPolicy.getNearbyDistanceMeterClass() != null) { + private static @Nullable Phase buildRefinmentPhase( + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + boolean isListVariable) { + if (isListVariable && solverConfigPolicy.getNearbyDistanceMeterClass() != null) { var entityClass = solverConfigPolicy.getSolutionDescriptor().getListVariableDescriptor().getEntityDescriptor() .getEntityClass(); var originalEntitySelectorConfig = new EntitySelectorConfig() @@ -475,7 +518,7 @@ private static Phase buildSwapStarPhase(HeuristicConfigPo * @param phase the phase to be configured * @return a phase that disables the best solution updates and run the same logic as the inner phase. */ - private static Phase disableBestSolutionUpdate(Phase phase) { + private static @Nullable Phase disableBestSolutionUpdate(@Nullable Phase phase) { if (phase == null) { return null; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java index 3e12eff70c3..493b56aef32 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java @@ -13,17 +13,26 @@ private Utils() { /** * Generate a cut-point and ensure that the expected number of planning values is included in the interval. */ - public static int[] generateIndexes(RandomGenerator workingRandom, int size, double inheritanceRate) { - var minSize = (int) (size * inheritanceRate); - if (minSize == 0) { - return generateIndexes(workingRandom, size); + public static int[] generateIndexes(RandomGenerator workingRandom, int size, double inheritanceRate, boolean ensureSize) { + if (ensureSize) { + var minSize = (int) (size * inheritanceRate); + if (minSize == 0) { + return generateIndexes(workingRandom, size); + } + var maxStart = size - minSize + 1; + var start = workingRandom.nextInt(0, maxStart); + var minEnd = start + minSize - 1; + var maxEnd = start == 0 ? size - 1 : size; + var end = start == maxStart - 1 ? size - 1 : workingRandom.nextInt(minEnd, maxEnd); + return new int[] { start, end }; + } else { + var start = workingRandom.nextInt(size); + // An inheritance rate of 95% means no more than 5% of the solution can be ruined. + // Some experiments have shown that a higher rate is more effective for overconstrained models + var maxSize = size * (1 - inheritanceRate); + var end = Math.min((int) (start + maxSize), size - 1); + return new int[] { start, end }; } - var maxStart = size - minSize + 1; - var start = workingRandom.nextInt(0, maxStart); - var minEnd = start + minSize - 1; - var maxEnd = start == 0 ? size - 1 : size; - var end = start == maxStart - 1 ? size - 1 : workingRandom.nextInt(minEnd, maxEnd); - return new int[] { start, end }; } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java index 8f75c6d25fb..eaa645f7ba5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java @@ -38,7 +38,8 @@ public void updateBestSolution(EvolutionaryAlgorithmStepScope stepSco // it uses the solution state manager to save the individual state. // This method reads the values once and then assigns them to the current working solution, // requiring one additional read of the values. - var individualState = solutionStateManager.saveSolutionState(stepScope.getStepIndividual()); + var individualState = + solutionStateManager.saveSolutionState(stepScope.getScoreDirector(), stepScope.getStepIndividual()); solutionStateManager.restoreSolutionState(sharedPhaseScope.getScoreDirector(), individualState); var bestSolutionStepScope = new EvolutionaryAlgorithmStepScope<>(sharedPhaseScope, newIndividual); bestSolutionStepScope.setScore(newIndividual.getScore()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java index 6b4ab373da7..420073827ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/SolutionStateManager.java @@ -10,7 +10,7 @@ /** * Base contract for defining solution state managers used by the {@link EvolutionaryDecider evolutionary decider} to save and * restore states. - * + * * @param the solution type * @param the score type * @param the solution state type @@ -20,7 +20,7 @@ public interface SolutionStateManager, S State_ saveSolutionState(InnerScoreDirector scoreDirector, boolean saveAssigned); - State_ saveSolutionState(Individual individual); + State_ saveSolutionState(InnerScoreDirector scoreDirector, Individual individual); void restoreSolutionState(InnerScoreDirector scoreDirector, State_ stateToRestore); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionState.java new file mode 100644 index 00000000000..544542b7f01 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionState.java @@ -0,0 +1,24 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic; + +import java.util.List; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.score.director.InnerScore; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record BasicSolutionState>(Solution_ solution, List stateList, + InnerScore score) implements SolutionState { + + @Override + public Solution_ getSolution() { + return solution; + } + + @Override + public InnerScore getScore() { + return score; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionStateManager.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionStateManager.java new file mode 100644 index 00000000000..01a9f3d0bf1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionStateManager.java @@ -0,0 +1,114 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; + +/** + * Handles the saving and restoring of the working solution state for solutions using + * {@link BasicVariableDescriptor basic planning variables}. + *

+ * {@link SolutionStateManager#saveSolutionState} captures a snapshot of the current working solution by recording + * the value of every basic planning variable for every planning entity. + * An entity descriptor may declare multiple basic variables; each is captured as a separate {@link BasicValueState} + * entry keyed by its position in the entity's ordered list of basic variable descriptors given by + * {@link EntityDescriptor#getBasicVariableDescriptorList()}. + *

+ * {@link #restoreSolutionState} revert the working solution back to a previously saved snapshot by re-applying the + * saved variable values via change moves. + *

+ * When saving from an {@link Individual}, + * {@link ChromosomeEntry#index()} is interpreted as the index of the variable descriptor within the entity's list of + * basic variable descriptors. + */ +@NullMarked +public final class BasicSolutionStateManager> + implements SolutionStateManager> { + + @Override + public BasicSolutionState saveSolutionState(InnerScoreDirector scoreDirector, + boolean saveAssigned) { + var solution = scoreDirector.getWorkingSolution(); + var entityValueList = new ArrayList(); + for (var entityDescriptor : scoreDirector.getSolutionDescriptor().getGenuineEntityDescriptors()) { + var basicVarDescriptors = entityDescriptor.getBasicVariableDescriptorList(); + if (basicVarDescriptors.isEmpty()) { + continue; + } + for (var entity : entityDescriptor.extractEntities(solution)) { + for (var i = 0; i < basicVarDescriptors.size(); i++) { + var variableDescriptor = basicVarDescriptors.get(i); + var value = saveAssigned || scoreDirector.getMoveDirector().isPinned(entityDescriptor, entity) + ? variableDescriptor.getValue(entity) + : null; + entityValueList.add(new BasicValueState(entity, value, i)); + } + } + } + return new BasicSolutionState<>(solution, Collections.unmodifiableList(entityValueList), + scoreDirector.calculateScore()); + } + + @Override + public BasicSolutionState saveSolutionState(InnerScoreDirector scoreDirector, + Individual individual) { + var chromosome = individual.getChromosome(); + if (chromosome.length == 0) { + return new BasicSolutionState<>(individual.getSolution(), Collections.emptyList(), individual.getScore()); + } + // ChromosomeEntry.index encodes the variable descriptor index within the entity's basic variable list. + var entityValueList = Arrays.stream(chromosome) + .map(entry -> new BasicValueState(entry.entity(), entry.value(), entry.index())) + .toList(); + return new BasicSolutionState<>(individual.getSolution(), entityValueList, individual.getScore()); + } + + @Override + public void restoreSolutionState(InnerScoreDirector scoreDirector, + BasicSolutionState stateToRestore) { + if (stateToRestore.stateList().isEmpty()) { + return; + } + var stateList = stateToRestore.stateList(); + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var needRebase = stateToRestore.getSolution() != scoreDirector.getWorkingSolution(); + var moveList = new ArrayList>(stateList.size()); + EntityDescriptor entityDescriptor = null; + for (var stateEntry : stateList) { + if (entityDescriptor == null || entityDescriptor.getEntityClass() != stateEntry.entity().getClass()) { + entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(stateEntry.entity().getClass()); + } + var basicVarDescriptors = entityDescriptor.getBasicVariableDescriptorList(); + if (basicVarDescriptors.isEmpty()) { + continue; + } + var variableDescriptor = basicVarDescriptors.get(stateEntry.index()); + var rebasedEntity = stateEntry.entity(); + var rebasedValue = stateEntry.value(); + if (needRebase) { + rebasedEntity = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.entity())); + rebasedValue = stateEntry.value() != null + ? Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.value())) + : null; + } + moveList.add(Moves.change(variableDescriptor.getVariableMetaModel(), rebasedEntity, rebasedValue)); + } + if (!moveList.isEmpty()) { + var compositeMove = Moves.compose(moveList); + scoreDirector.getMoveDirector().execute(compositeMove); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicValueState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicValueState.java new file mode 100644 index 00000000000..2ef8ff2b63a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicValueState.java @@ -0,0 +1,20 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic; + +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Captures the state of one basic planning variable for one entity. + *

+ * {@link #index} is the position of the {@link BasicVariableDescriptor} in the entity descriptor's list of basic variable + * descriptors given by {@link EntityDescriptor#getBasicVariableDescriptorList()}. + *

+ * This index also matches the convention used by {@link ChromosomeEntry#index()} for basic variable individuals. + */ +@NullMarked +record BasicValueState(Object entity, @Nullable Object value, int index) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java index c14987b78e3..16c24dd9f86 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionState.java @@ -6,8 +6,8 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.score.director.InnerScore; -public record ListSolutionState>(Solution_ solution, - List assignedValueList, InnerScore score) implements SolutionState { +public record ListSolutionState>(Solution_ solution, List stateList, + InnerScore score) implements SolutionState { @Override public Solution_ getSolution() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java index 9cc987d94eb..37addca9abe 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java @@ -18,6 +18,8 @@ import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.builtin.Moves; +import org.jspecify.annotations.NullMarked; + /** * Handles the saving and restoring of the working solution state for solutions using a {@link ListVariableDescriptor list * variable}. @@ -31,6 +33,7 @@ * This class is used by the evolutionary algorithm to reset the working solution to a clean baseline before generating * new individuals. */ +@NullMarked public final class ListSolutionStateManager> implements SolutionStateManager> { @@ -65,7 +68,8 @@ public ListSolutionState saveSolutionState(InnerScoreDirector } @Override - public ListSolutionState saveSolutionState(Individual individual) { + public ListSolutionState saveSolutionState(InnerScoreDirector scoreDirector, + Individual individual) { var assignedValues = Arrays.stream(individual.getChromosome()) .map(chromosomeEntry -> new ListValueState(chromosomeEntry.value(), ElementPosition.of(chromosomeEntry.entity(), chromosomeEntry.index()))) @@ -85,15 +89,15 @@ public void restoreSolutionState(InnerScoreDirector scoreDire solution) - listVariableSupply.getUnassignedCount(); var needRebase = stateToRestore.getSolution() != solution; var moveList = unassignAll(listVariableMetaModel, listVariableDescriptor, listVariableSupply, solution, size); - for (var assignedValue : stateToRestore.assignedValueList()) { + for (var stateEntry : stateToRestore.stateList()) { if (needRebase) { - var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(assignedValue.value())); + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.value())); var rebasedEntity = - Objects.requireNonNull(scoreDirector.lookUpWorkingObject(assignedValue.positionInList().entity())); + Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.positionInList().entity())); moveList.add(Moves.assign(listVariableMetaModel, rebasedValue, rebasedEntity, - assignedValue.positionInList().index())); + stateEntry.positionInList().index())); } else { - moveList.add(Moves.assign(listVariableMetaModel, assignedValue.value(), assignedValue.positionInList())); + moveList.add(Moves.assign(listVariableMetaModel, stateEntry.value(), stateEntry.positionInList())); } } if (!moveList.isEmpty()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java index ac1f1fa0bb3..7a05aa3475c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java @@ -2,6 +2,9 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; +/** + * Captures the state of the list variable for a given entity. + */ record ListValueState(Object value, PositionInList positionInList) { int index() { return positionInList().index(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java new file mode 100644 index 00000000000..504ce976fc6 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java @@ -0,0 +1,84 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.basic; + +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.common.Utils.fixIndex; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.common.Utils.generateIndexes; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.applyPhases; +import static ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorker.updateScope; + +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +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.evolutionaryalgorithm.common.Utils; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverResult; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Implementation of the OX crossover strategy for basic planning variables. + *

+ * The solution is viewed as a flat array of (entity, variable-index) slots, ordered by entity iteration then variable index + * within {@link EntityDescriptor#getBasicVariableDescriptorList()}. + * A [start, end) cut-point interval is selected from the first parent's chromosome; slots in that interval inherit + * the first parent's values, and the remaining slots inherit the second parent's values. + *

+ * {@link Utils#fixIndex} ensures the cut never splits the basic variables of a single entity across parents. + */ +@NullMarked +public record BasicOXCrossover>(Phase localSearchPhase, + @Nullable Phase refinementPhase, double inheritanceRate) implements CrossoverStrategy { + + @Override + public CrossoverResult apply(CrossoverContext context) { + var phaseScope = context.phaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = phaseScope. getScoreDirector(); + generateOffspring(scoreDirector, context.firstIndividual(), context.secondIndividual(), + inheritanceRate, phaseScope.getWorkingRandom()); + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + return new CrossoverResult<>(scoreDirector.cloneSolution(solverScope.getBestSolution()), + solverScope.getBestScore(), + context.firstIndividual().getScore(), context.secondIndividual().getScore()); + } + + private static > void generateOffspring( + InnerScoreDirector scoreDirector, Individual firstIndividual, + Individual secondIndividual, double inheritanceRate, RandomGenerator workingRandom) { + var p1 = firstIndividual.getChromosome(); + var p2 = secondIndividual.getChromosome(); + var indexes = generateIndexes(workingRandom, p1.length, inheritanceRate, true); + var start = fixIndex(p1, indexes[0], true); + var end = fixIndex(p1, indexes[1], false); + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + inheritChromosome(scoreDirector, solutionDescriptor, p1, start, end); + inheritChromosome(scoreDirector, solutionDescriptor, p2, 0, start); + inheritChromosome(scoreDirector, solutionDescriptor, p2, end, p2.length); + } + + private static > void inheritChromosome( + InnerScoreDirector scoreDirector, SolutionDescriptor solutionDescriptor, + ChromosomeEntry[] chromosome, int from, int to) { + EntityDescriptor entityDescriptor = null; + for (var i = from; i < to; i++) { + var entry = chromosome[i]; + if (entityDescriptor == null || entityDescriptor.getEntityClass() != entry.entity().getClass()) { + entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(entry.entity().getClass()); + } + var varDescriptor = entityDescriptor.getBasicVariableDescriptorList().get(entry.index()); + var rebasedEntity = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(entry.entity())); + var rebasedValue = entry.value() != null ? scoreDirector.lookUpWorkingObject(entry.value()) : null; + scoreDirector.executeMove(Moves.change(varDescriptor.getVariableMetaModel(), rebasedEntity, rebasedValue)); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java index 90ee8fc500d..7c1275707ee 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java @@ -81,7 +81,7 @@ private static > void generateOffspring( ValueRangeManager valueRangeManager, Individual firstIndividual, Individual secondIndividual, double inheritanceRate, boolean applyBestFitFirstPhase, RandomGenerator workingRandom) { - var indexes = generateIndexes(workingRandom, firstIndividual.size(), inheritanceRate); + var indexes = generateIndexes(workingRandom, firstIndividual.size(), inheritanceRate, true); var start = fixIndex(firstIndividual.getChromosome(), indexes[0], true); var end = fixIndex(firstIndividual.getChromosome(), indexes[1], false); // Add the values from the first parent within the specified interval diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java index 3feabadb602..b107a91ca9f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -228,7 +228,7 @@ public Builder withLocalSearchPhase(Phase withSwapStarPhase(@Nullable Phase swapStarPhase) { + public Builder withRefinementPhase(@Nullable Phase swapStarPhase) { this.refinementPhase = swapStarPhase; return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java index 8b41b135e56..ebcac195550 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java @@ -117,6 +117,10 @@ private InternalIndividual addIndividualToList(Individual> implements Individual - permits ListVariableIndividual { + permits BasicVariableIndividual, ListVariableIndividual { protected final Solution_ solution; protected final InnerScore score; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/BasicVariableIndividual.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/BasicVariableIndividual.java new file mode 100644 index 00000000000..d54144304fa --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/BasicVariableIndividual.java @@ -0,0 +1,93 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; + +import java.util.ArrayList; +import java.util.Objects; + +import ai.timefold.solver.core.api.score.Score; +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.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Default representation of an individual for basic planning variables. + *

+ * Each {@link ChromosomeEntry} in the chromosome represents one (entity, variable) pair. + * {@link ChromosomeEntry#index()} is the position of the variable descriptor within + * {@link EntityDescriptor#getBasicVariableDescriptorList()}. + * + * @param the solution type + * @param the score type + */ +@NullMarked +public final class BasicVariableIndividual> + extends AbstractIndividual { + + private final ChromosomeEntry[] chromosome; + + public BasicVariableIndividual(InnerScoreDirector scoreDirector, Solution_ solution, + InnerScore score, @Nullable InnerScore firstParentScore, + @Nullable InnerScore secondParentScore) { + super(solution, score, firstParentScore, secondParentScore); + this.chromosome = load(scoreDirector.getSolutionDescriptor(), solution); + } + + private BasicVariableIndividual(Solution_ solution, InnerScore score, + InnerScore firstParentScore, InnerScore secondParentScore, + ChromosomeEntry[] chromosome) { + super(solution, score, firstParentScore, secondParentScore); + this.chromosome = chromosome; + } + + private static ChromosomeEntry[] load(SolutionDescriptor solutionDescriptor, Solution_ solution) { + var chromosomeList = new ArrayList(); + for (var entityDescriptor : solutionDescriptor.getGenuineEntityDescriptors()) { + var basicVarDescriptors = entityDescriptor.getBasicVariableDescriptorList(); + if (basicVarDescriptors.isEmpty()) { + continue; + } + for (var entity : entityDescriptor.extractEntities(solution)) { + for (var i = 0; i < basicVarDescriptors.size(); i++) { + chromosomeList.add(new ChromosomeEntry(entity, basicVarDescriptors.get(i).getValue(entity), i)); + } + } + } + return chromosomeList.toArray(ChromosomeEntry[]::new); + } + + @Override + public ChromosomeEntry[] getChromosome() { + return chromosome; + } + + @Override + public int size() { + return chromosome.length; + } + + @Override + public double diff(Individual otherIndividual) { + var other = (BasicVariableIndividual) otherIndividual; + var total = chromosome.length; + if (total == 0) { + return 0.0; + } + var diff = 0; + for (var i = 0; i < total; i++) { + if (!Objects.equals(chromosome[i].value(), other.chromosome[i].value())) { + diff++; + } + } + return (double) diff / (double) total; + } + + @Override + public Individual clone(InnerScoreDirector scoreDirector) { + var newSolution = scoreDirector.cloneSolution(solution); + var newChromosome = load(scoreDirector.getSolutionDescriptor(), newSolution); + return new BasicVariableIndividual<>(newSolution, score, firstParentScore, secondParentScore, newChromosome); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java index 39eafe925f6..f83a2ec8627 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java @@ -1,7 +1,8 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; @NullMarked -public record ChromosomeEntry(Object value, Object entity, int index) { +public record ChromosomeEntry(Object entity, @Nullable Object value, int index) { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java index c303b31d1ac..0e1b73e6b76 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java @@ -29,8 +29,9 @@ public final class ListVariableIndividual predecessorAndSuccessorMap; private final ChromosomeEntry[] chromosome; - public ListVariableIndividual(Solution_ solution, InnerScore score, @Nullable InnerScore firstParentScore, - @Nullable InnerScore secondParentScore, InnerScoreDirector scoreDirector) { + public ListVariableIndividual(InnerScoreDirector scoreDirector, Solution_ solution, + InnerScore score, @Nullable InnerScore firstParentScore, + @Nullable InnerScore secondParentScore) { super(solution, score, firstParentScore, secondParentScore); var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); this.planningIdAccessor = @@ -43,7 +44,7 @@ public ListVariableIndividual(Solution_ solution, InnerScore score, @Nul var size = (int) scoreDirector.getValueRangeManager().getProblemSizeStatistics().approximateValueCount(); this.predecessorAndSuccessorMap = HashMap.newHashMap(size); var chromosomeList = new ArrayList(size); - load(solution, listVariableDescriptor, chromosomeList, predecessorAndSuccessorMap, planningIdAccessor); + load(listVariableDescriptor, planningIdAccessor, solution, chromosomeList, predecessorAndSuccessorMap); this.chromosome = chromosomeList.toArray(ChromosomeEntry[]::new); } @@ -56,9 +57,9 @@ private ListVariableIndividual(Solution_ solution, InnerScore score, Inn this.chromosome = chromosome; } - private static void load(Solution_ solution, ListVariableDescriptor listVariableDescriptor, - List chromosomeList, Map predecessorAndSuccessorMap, - MemberAccessor planningIdAccessor) { + private static void load(ListVariableDescriptor listVariableDescriptor, + MemberAccessor planningIdAccessor, Solution_ solution, List chromosomeList, + Map predecessorAndSuccessorMap) { var allEntities = listVariableDescriptor.getEntityDescriptor().extractEntities(solution); for (var entity : allEntities) { var valueList = listVariableDescriptor.getValue(entity); @@ -71,7 +72,7 @@ private static void load(Solution_ solution, ListVariableDescriptor< for (var i = 0; i < size; i++) { var value = valueList.get(i); ids[i] = planningIdAccessor.executeGetter(value); - chromosomeList.add(new ChromosomeEntry(value, entity, i)); + chromosomeList.add(new ChromosomeEntry(entity, value, i)); } for (var i = 0; i < size; i++) { predecessorAndSuccessorMap.put(ids[i], @@ -120,8 +121,8 @@ public Individual clone(InnerScoreDirector var newSolution = scoreDirector.cloneSolution(solution); var newPredecessorAndSuccessorMap = HashMap. newHashMap(predecessorAndSuccessorMap.size()); var chromosomeList = new ArrayList(chromosome.length); - load(solution, scoreDirector.getSolutionDescriptor().getListVariableDescriptor(), chromosomeList, - newPredecessorAndSuccessorMap, planningIdAccessor); + load(scoreDirector.getSolutionDescriptor().getListVariableDescriptor(), planningIdAccessor, solution, chromosomeList, + newPredecessorAndSuccessorMap); var newChromosome = chromosomeList.toArray(ChromosomeEntry[]::new); return new ListVariableIndividual<>(newSolution, score, firstParentScore, secondParentScore, planningIdAccessor, newPredecessorAndSuccessorMap, newChromosome); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java index 7126668ed43..f451083b870 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java @@ -27,23 +27,19 @@ * The strategy can be used for both variable types, considering that the inner phases can handle each variable type. */ @NullMarked -public final class DefaultConstructionIndividualStrategy> - implements ConstructionIndividualStrategy { - - private final List> customPhaseIndividualCommandList; - private final Phase deterministicBestFitConstructionPhase; - private final Phase shuffledBestFitConstructionPhase; - private final Phase localSearchPhase; - private final @Nullable Phase refinementPhase; - private final IndividualBuilder individualBuilder; +public record DefaultConstructionIndividualStrategy>( + List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, + Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, + @Nullable Phase refinementPhase, + IndividualBuilder individualBuilder) implements ConstructionIndividualStrategy { public DefaultConstructionIndividualStrategy(List> customPhaseIndividualCommandList, - Phase deterministicBestFitConstructionPhase, Phase shuffledBestFitConstructionPhase, + Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, IndividualBuilder individualBuilder) { this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); - this.shuffledBestFitConstructionPhase = Objects.requireNonNull(shuffledBestFitConstructionPhase); + this.shuffledFirstFitConstructionPhase = Objects.requireNonNull(shuffledFirstFitConstructionPhase); this.localSearchPhase = Objects.requireNonNull(localSearchPhase); this.refinementPhase = refinementPhase; this.individualBuilder = Objects.requireNonNull(individualBuilder); @@ -74,6 +70,6 @@ private Phase getConstructionPhase(EvolutionaryAlgorithmStepScope + * When the population is empty the first individual is built using a deterministic best-fit construction phase, + * identical to {@link DefaultConstructionIndividualStrategy}. + * For every subsequent individual the strategy selects a random contiguous segment from the best individual's + * chromosome, sets those variables to {@code null} (ruin phase), and reinitialize them via a shuffled first-fit + * construction phase (recreate phase) before running local search. The segment boundaries are snapped to entity + * borders so that all basic variables belonging to the same entity are always ruined or kept together. + */ +@NullMarked +public record BasicRuinRecreateIndividualStrategy, State_ extends SolutionState>( + List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, + Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, + @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, + double inheritanceRate) implements ConstructionIndividualStrategy { + + public BasicRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, + Phase shuffledFirstFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, double inheritanceRate) { + this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); + this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); + this.shuffledFirstFitConstructionPhase = Objects.requireNonNull(shuffledFirstFitConstructionPhase); + this.localSearchPhase = Objects.requireNonNull(localSearchPhase); + this.refinementPhase = refinementPhase; + this.solutionStateManager = solutionStateManager; + this.individualBuilder = Objects.requireNonNull(individualBuilder); + this.inheritanceRate = inheritanceRate; + } + + @Override + public Individual apply(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + var solverScope = phaseScope.getSolverScope(); + var scoreDirector = solverScope. getScoreDirector(); + if (!customPhaseIndividualCommandList.isEmpty()) { + var commandContext = new DefaultPhaseCommandContext<>(stepScope.getMoveDirector(), + () -> phaseScope.getTermination().isPhaseTerminated(phaseScope)); + customPhaseIndividualCommandList.forEach(command -> command.changeWorkingSolution(commandContext)); + } + updateScope(phaseScope); + var population = phaseScope. getPopulation(); + if (population.getBestIndividual() == null) { + applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); + } else { + applyRuinRecreate(solverScope, scoreDirector, phaseScope, population); + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + } + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), + solverScope.getBestScore(), null, null, scoreDirector); + } + + void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, + EvolutionaryAlgorithmPhaseScope phaseScope, Population population) { + var bestIndividual = Objects.requireNonNull(population.getBestIndividual()); + var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); + solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); + applyRuinPhase(scoreDirector, solverScope.getWorkingRandom(), bestIndividual); + updateScope(phaseScope); + applyPhases(phaseScope, shuffledFirstFitConstructionPhase); + } + + private void applyRuinPhase(InnerScoreDirector scoreDirector, + RandomGenerator workingRandom, Individual bestIndividual) { + var chromosome = bestIndividual.getChromosome(); + var indexes = generateIndexes(workingRandom, chromosome.length, inheritanceRate, false); + var start = fixIndex(chromosome, indexes[0], true); + var end = fixIndex(chromosome, indexes[1], false); + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var moveList = new ArrayList>(end - start); + EntityDescriptor entityDescriptor = null; + for (var i = start; i < end; i++) { + var entry = chromosome[i]; + if (entityDescriptor == null || entityDescriptor.getEntityClass() != entry.entity().getClass()) { + entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(entry.entity().getClass()); + } + var rebasedEntity = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(entry.entity())); + if (scoreDirector.getMoveDirector().isPinned(entityDescriptor, rebasedEntity)) { + continue; + } + var variableDescriptor = entityDescriptor.getBasicVariableDescriptorList().get(entry.index()); + moveList.add(Moves.change(variableDescriptor.getVariableMetaModel(), rebasedEntity, null)); + } + if (!moveList.isEmpty()) { + scoreDirector.getMoveDirector().execute(Moves.compose(moveList)); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java index 7cf0f030fa7..7ac786f495b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -47,16 +47,12 @@ * all values belonging to the same entity are always ruined or kept together. */ @NullMarked -public final class ListRuinRecreateIndividualStrategy, State_ extends SolutionState> - implements ConstructionIndividualStrategy { - - private final List> customPhaseIndividualCommandList; - private final Phase deterministicBestFitConstructionPhase; - private final Phase localSearchPhase; - private final @Nullable Phase refinementPhase; - private final SolutionStateManager solutionStateManager; - private final IndividualBuilder individualBuilder; - private final double inheritanceRate; +public record ListRuinRecreateIndividualStrategy, State_ extends SolutionState>( + List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, + double inheritanceRate) implements ConstructionIndividualStrategy { public ListRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, @@ -100,7 +96,7 @@ public Individual apply(EvolutionaryAlgorithmStepScope solverScope, InnerScoreDirector scoreDirector, Population population) { var bestIndividual = Objects.requireNonNull(population.getBestIndividual()); - var bestSolutionState = solutionStateManager.saveSolutionState(bestIndividual); + var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); @@ -122,7 +118,7 @@ private List applyRuinPhase(InnerScoreDirector scoreD var start = fixIndex(bestIndividual.getChromosome(), indexes[0], true); var end = fixIndex(bestIndividual.getChromosome(), indexes[1], false); var chromosome = bestIndividual.getChromosome(); - var unassignMoveList = new ArrayList>(end - start); + var moveList = new ArrayList>(end - start); var ruinedValues = new ArrayList<>(end - start); for (var i = start; i < end; i++) { var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(chromosome[i].value())); @@ -130,13 +126,12 @@ private List applyRuinPhase(InnerScoreDirector scoreD continue; } var position = listVariableStateSupply.getElementPosition(rebasedValue).ensureAssigned(); - unassignMoveList.add(Moves.unassign(listVariableMetaModel, position)); + moveList.add(Moves.unassign(listVariableMetaModel, position)); ruinedValues.add(rebasedValue); } - Collections.reverse(unassignMoveList); - if (!unassignMoveList.isEmpty()) { - var compositeMove = Moves.compose(unassignMoveList); - scoreDirector.getMoveDirector().execute(compositeMove); + Collections.reverse(moveList); + if (!moveList.isEmpty()) { + scoreDirector.getMoveDirector().execute(Moves.compose(moveList)); } return ruinedValues; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java index 8b77a916f0f..affe789e290 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/termination/DiminishedReturnsTermination.java @@ -175,6 +175,7 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { @Override public void phaseEnded(AbstractPhaseScope phaseScope) { + isGracePeriodStarted = false; scoresByTime.clear(); } diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index d2023acda99..8f95caf71f6 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -1385,6 +1385,8 @@ + + @@ -1429,9 +1431,9 @@ - + - + @@ -1441,7 +1443,7 @@ - + @@ -1449,9 +1451,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseTest.java new file mode 100644 index 00000000000..04c55c7b027 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseTest.java @@ -0,0 +1,141 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashSet; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; +import ai.timefold.solver.core.config.solver.PreviewFeature; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.util.MutableLong; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testutil.AbstractMeterTest; +import ai.timefold.solver.core.testutil.PlannerTestUtils; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +class DefaultEvolutionaryAlgorithmPhaseTest extends AbstractMeterTest { + + @Test + void solveListVariable() { + var solverConfig = new SolverConfig() + .withPreviewFeature(PreviewFeature.EVOLUTIONARY_ALGORITHM) + .withSolutionClass(TestdataListSolution.class) + .withEntityClasses(TestdataListEntity.class, TestdataListValue.class) + .withEasyScoreCalculatorClass(TestingListSingleValueEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig().withBestScoreLimit("0")) + .withPhases(new EvolutionaryAlgorithmPhaseConfig()); + + var solution = TestdataListSolution.generateUninitializedSolution(3, 3); + solution = PlannerTestUtils.solve(solverConfig, solution, true); + assertThat(solution).isNotNull(); + } + + @Test + void solveBasicVariable() { + var solverConfig = new SolverConfig() + .withPreviewFeature(PreviewFeature.EVOLUTIONARY_ALGORITHM) + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withEasyScoreCalculatorClass(TestingSingleValueEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig().withBestScoreLimit("-3")) + .withPhases(new EvolutionaryAlgorithmPhaseConfig()); + + var solution = TestdataSolution.generateUninitializedSolution(3, 3); + solution = PlannerTestUtils.solve(solverConfig, solution, true); + assertThat(solution).isNotNull(); + } + + @Test + void solveMultiBasicVariable() { + var solverConfig = new SolverConfig() + .withPreviewFeature(PreviewFeature.EVOLUTIONARY_ALGORITHM) + .withSolutionClass(TestdataMultiVarSolution.class) + .withEntityClasses(TestdataMultiVarEntity.class) + .withEasyScoreCalculatorClass(TestingMultiVarEasyScoreCalculator.class) + .withTerminationConfig(new TerminationConfig().withBestScoreLimit("-6")) + .withPhases(new EvolutionaryAlgorithmPhaseConfig()); + + var solution = TestdataMultiVarSolution.generateUninitializedSolution(3, 3); + solution = PlannerTestUtils.solve(solverConfig, solution, true); + assertThat(solution).isNotNull(); + } + + public static final class TestingListSingleValueEasyScoreCalculator + implements EasyScoreCalculator { + public @NonNull SimpleScore calculateScore(@NonNull TestdataListSolution solution) { + var sum = new MutableLong(0); + solution.getEntityList().forEach(e -> { + int size = e.getValueList().size(); + if (size == 0) { + sum.increment(); + } else if (size > 1) { + double penalty = Math.pow(size - 1, 2); + sum.add((long) penalty); + } + }); + return SimpleScore.of(-sum.intValue()); + } + } + + public static final class TestingSingleValueEasyScoreCalculator + implements EasyScoreCalculator { + public @NonNull SimpleScore calculateScore(@NonNull TestdataSolution solution) { + var sum = new MutableLong(0); + var set = new HashSet(); + solution.getEntityList().forEach(e -> { + if (e.getValue() == null) { + sum.add(5L); + return; + } + if (set.contains(e.getValue())) { + sum.add(5L); + } + set.add(e.getValue()); + }); + sum.add(set.size()); + return SimpleScore.of(-sum.intValue()); + } + } + + public static final class TestingMultiVarEasyScoreCalculator + implements EasyScoreCalculator { + public @NonNull SimpleScore calculateScore(@NonNull TestdataMultiVarSolution solution) { + var sum = new MutableLong(0); + var primarySet = new HashSet(); + var secondarySet = new HashSet(); + solution.getMultiVarEntityList().forEach(e -> { + if (e.getPrimaryValue() == null) { + sum.add(5L); + } else { + if (primarySet.contains(e.getPrimaryValue())) { + sum.add(5L); + } + primarySet.add(e.getPrimaryValue()); + } + if (e.getSecondaryValue() == null) { + sum.add(5L); + } else { + if (secondarySet.contains(e.getSecondaryValue())) { + sum.add(5L); + } + secondarySet.add(e.getSecondaryValue()); + } + }); + sum.add(primarySet.size() + secondarySet.size()); + return SimpleScore.of(-sum.intValue()); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionDefaultStateManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionDefaultStateManagerTest.java new file mode 100644 index 00000000000..98c70e0324c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/basic/BasicSolutionDefaultStateManagerTest.java @@ -0,0 +1,403 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic; + +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarConstraintProvider; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testdomain.multivar.TestdataOtherValue; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; + +import org.junit.jupiter.api.Test; + +class BasicSolutionDefaultStateManagerTest { + + private InnerScoreDirector buildPinnedScoreDirector() { + var factory = new BavetConstraintStreamScoreDirectorFactory( + TestdataPinnedSolution.buildSolutionDescriptor(), + constraintFactory -> new Constraint[] { constraintFactory + .forEach(TestdataPinnedEntity.class).penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, + EnvironmentMode.FULL_ASSERT); + return new BavetConstraintStreamScoreDirector.Builder<>(factory) + .withLookUpEnabled(true) + .build(); + } + + private InnerScoreDirector buildMultiVarScoreDirector() { + var factory = new BavetConstraintStreamScoreDirectorFactory( + TestdataMultiVarSolution.buildSolutionDescriptor(), + new TestdataMultiVarConstraintProvider(), + EnvironmentMode.FULL_ASSERT); + return new BavetConstraintStreamScoreDirector.Builder<>(factory) + .withLookUpEnabled(true) + .build(); + } + + private InnerScoreDirector buildScoreDirector() { + var factory = new BavetConstraintStreamScoreDirectorFactory( + TestdataSolution.buildSolutionDescriptor(), + constraintFactory -> new Constraint[] { constraintFactory + .forEach(TestdataEntity.class).penalize(SimpleScore.ONE).asConstraint("Dummy constraint") }, + EnvironmentMode.FULL_ASSERT); + return new BavetConstraintStreamScoreDirector.Builder<>(factory) + .withLookUpEnabled(true) + .build(); + } + + @Test + void saveStateWithAllVariablesAssigned() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = mockScoreDirector(TestdataSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new BasicSolutionStateManager().saveSolutionState(scoreDirector, true); + + assertThat(state.stateList()) + .hasSize(2) + .extracting(BasicValueState::value) + .containsExactlyInAnyOrder(v1, v2); + } + + @Test + void saveStateWithNoVariablesAssigned() { + var v1 = new TestdataValue("v1"); + var e1 = new TestdataEntity("e1"); + var e2 = new TestdataEntity("e2"); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = mockScoreDirector(TestdataSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new BasicSolutionStateManager().saveSolutionState(scoreDirector, true); + + assertThat(state.stateList()) + .hasSize(2) + .extracting(BasicValueState::value) + .containsOnlyNulls(); + } + + @Test + void saveStateWithoutAssignedValues() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = mockScoreDirector(TestdataSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = new BasicSolutionStateManager().saveSolutionState(scoreDirector, false); + + assertThat(state.stateList()) + .hasSize(2) + .extracting(BasicValueState::value) + .containsOnlyNulls(); + } + + @Test + void restorePartialState() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var v3 = new TestdataValue("v3"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var manager = new BasicSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Change e1's value after the snapshot + var variableMetaModel = TestdataEntity.buildVariableDescriptorForValue().getVariableMetaModel(); + scoreDirector.executeMove(Moves.change(variableMetaModel, e1, v3)); + + assertThat(e1.getValue()).isSameAs(v3); + + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(e1.getValue()).isSameAs(v1); + assertThat(e2.getValue()).isSameAs(v2); + } + + @Test + void preserveState() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var manager = new BasicSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(e1.getValue()).isSameAs(v1); + assertThat(e2.getValue()).isSameAs(v2); + } + + @Test + void restoreStateWithRebase() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var manager = new BasicSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Clone the working solution to force rebasing + scoreDirector.setWorkingSolution(scoreDirector.cloneWorkingSolution()); + + manager.restoreSolutionState(scoreDirector, savedState); + + var restoredEntities = scoreDirector.getWorkingSolution().getEntityList(); + assertThat(restoredEntities.get(0).getValue().getCode()).isEqualTo("v1"); + assertThat(restoredEntities.get(1).getValue().getCode()).isEqualTo("v2"); + } + + @Test + @SuppressWarnings("unchecked") + void saveStateFromIndividualWithEmptyChromosome() { + var solution = new TestdataSolution(); + solution.setValueList(List.of()); + solution.setEntityList(List.of()); + + var individual = (Individual) mock(Individual.class); + doReturn(solution).when(individual).getSolution(); + doReturn(new ChromosomeEntry[0]).when(individual).getChromosome(); + var score = (InnerScore) mock(InnerScore.class); + doReturn(score).when(individual).getScore(); + + var scoreDirector = buildScoreDirector(); + + var state = new BasicSolutionStateManager().saveSolutionState(scoreDirector, individual); + + assertThat(state.getSolution()).isSameAs(solution); + assertThat(state.stateList()).isEmpty(); + assertThat(state.getScore()).isSameAs(score); + } + + @Test + @SuppressWarnings("unchecked") + void saveStateFromIndividualWithAssignedValues() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + + var solution = new TestdataSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var individual = (Individual) mock(Individual.class); + doReturn(solution).when(individual).getSolution(); + // index=0 means the first (and only) basic variable descriptor for TestdataEntity + doReturn(new ChromosomeEntry[] { + new ChromosomeEntry(e1, v1, 0), + new ChromosomeEntry(e2, v2, 0) + }).when(individual).getChromosome(); + var score = (InnerScore) mock(InnerScore.class); + doReturn(score).when(individual).getScore(); + + var scoreDirector = buildScoreDirector(); + + var state = new BasicSolutionStateManager().saveSolutionState(scoreDirector, individual); + + assertThat(state.getSolution()).isSameAs(solution); + assertThat(state.getScore()).isSameAs(score); + + var entityValueList = state.stateList(); + assertThat(entityValueList).hasSize(2); + + assertThat(entityValueList.get(0).entity()).isSameAs(e1); + assertThat(entityValueList.get(0).value()).isSameAs(v1); + assertThat(entityValueList.get(0).index()).isZero(); + + assertThat(entityValueList.get(1).entity()).isSameAs(e2); + assertThat(entityValueList.get(1).value()).isSameAs(v2); + assertThat(entityValueList.get(1).index()).isZero(); + } + + @Test + void savePartialStatePreservesPinnedValues() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var e1 = new TestdataPinnedEntity("e1", v1, true); + var e2 = new TestdataPinnedEntity("e2", v2, false); + + var solution = new TestdataPinnedSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = buildPinnedScoreDirector(); + scoreDirector.setWorkingSolution(solution); + + var state = new BasicSolutionStateManager() + .saveSolutionState(scoreDirector, false); + + assertThat(state.stateList()).hasSize(2); + // Pinned entity's value is saved even when saveAssigned=false + assertThat(state.stateList()).filteredOn(s -> s.entity() == e1) + .singleElement().extracting(BasicValueState::value).isSameAs(v1); + // Unpinned entity's value is not saved when saveAssigned=false + assertThat(state.stateList()).filteredOn(s -> s.entity() == e2) + .singleElement().extracting(BasicValueState::value).isNull(); + } + + @Test + void restorePartialStateWithPinnedEntities() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var v3 = new TestdataValue("v3"); + var e1 = new TestdataPinnedEntity("e1", v1, true); + var e2 = new TestdataPinnedEntity("e2", v2, false); + + var solution = new TestdataPinnedSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setEntityList(List.of(e1, e2)); + + var scoreDirector = buildPinnedScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var manager = new BasicSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Change the unpinned entity's value after the snapshot + var variableMetaModel = TestdataPinnedEntity.buildEntityDescriptor() + .getBasicVariableDescriptorList().get(0).getVariableMetaModel(); + scoreDirector.executeMove(Moves.change(variableMetaModel, e2, v3)); + assertThat(e2.getValue()).isSameAs(v3); + + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(e1.getValue()).isSameAs(v1); + assertThat(e2.getValue()).isSameAs(v2); + } + + @Test + void savePartialStateWithMultipleBasicVariables() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var ov1 = new TestdataOtherValue("ov1"); + var e1 = new TestdataMultiVarEntity("e1", v1, v2, ov1); + var e2 = new TestdataMultiVarEntity("e2", v2, v1, null); + + var solution = new TestdataMultiVarSolution(); + solution.setValueList(List.of(v1, v2)); + solution.setOtherValueList(List.of(ov1)); + solution.setMultiVarEntityList(List.of(e1, e2)); + + var scoreDirector = mockScoreDirector(TestdataMultiVarSolution.buildSolutionDescriptor(), true); + scoreDirector.setWorkingSolution(solution); + + var state = + new BasicSolutionStateManager().saveSolutionState(scoreDirector, true); + + // 2 entities × 3 basic variables each (primaryValue, secondaryValue, tertiaryValueAllowedUnassigned) + assertThat(state.stateList()).hasSize(6); + + var e1States = state.stateList().stream().filter(s -> s.entity() == e1).toList(); + assertThat(e1States).hasSize(3); + assertThat(e1States).extracting(BasicValueState::value).containsExactlyInAnyOrder(v1, v2, ov1); + + var e2States = state.stateList().stream().filter(s -> s.entity() == e2).toList(); + assertThat(e2States).hasSize(3); + assertThat(e2States).extracting(BasicValueState::value).containsExactlyInAnyOrder(v2, v1, null); + } + + @Test + void restorePartialStateWithMultipleBasicVariables() { + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var v3 = new TestdataValue("v3"); + var ov1 = new TestdataOtherValue("ov1"); + var ov2 = new TestdataOtherValue("ov2"); + var ov3 = new TestdataOtherValue("ov3"); + var e1 = new TestdataMultiVarEntity("e1", v1, v2, ov1); + var e2 = new TestdataMultiVarEntity("e2", v2, v1, ov2); + + var solution = new TestdataMultiVarSolution(); + solution.setValueList(List.of(v1, v2, v3)); + solution.setOtherValueList(List.of(ov1, ov2, ov3)); + solution.setMultiVarEntityList(List.of(e1, e2)); + + var scoreDirector = buildMultiVarScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var manager = new BasicSolutionStateManager(); + + var savedState = manager.saveSolutionState(scoreDirector, true); + + // Change primaryValue of e1 and secondaryValue of e2 after the snapshot + var primaryMeta = TestdataMultiVarEntity.buildVariableDescriptorForPrimaryValue().getVariableMetaModel(); + var secondaryMeta = TestdataMultiVarEntity.buildVariableDescriptorForSecondaryValue().getVariableMetaModel(); + var tertiaryMeta = TestdataMultiVarEntity.buildVariableDescriptorForTertiaryValue().getVariableMetaModel(); + scoreDirector.executeMove(Moves.change(primaryMeta, e1, v3)); + scoreDirector.executeMove(Moves.change(secondaryMeta, e2, v3)); + scoreDirector.executeMove(Moves.change(tertiaryMeta, e2, ov3)); + + assertThat(e1.getPrimaryValue()).isSameAs(v3); + assertThat(e2.getSecondaryValue()).isSameAs(v3); + assertThat(e2.getTertiaryValueAllowedUnassigned()).isSameAs(ov3); + + manager.restoreSolutionState(scoreDirector, savedState); + + assertThat(e1.getPrimaryValue()).isSameAs(v1); + assertThat(e1.getSecondaryValue()).isSameAs(v2); + assertThat(e1.getTertiaryValueAllowedUnassigned()).isSameAs(ov1); + assertThat(e2.getPrimaryValue()).isSameAs(v2); + assertThat(e2.getSecondaryValue()).isSameAs(v1); + assertThat(e2.getTertiaryValueAllowedUnassigned()).isSameAs(ov2); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java index 6916556f91b..33c6ba4bc23 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java @@ -77,7 +77,7 @@ void saveStateWithNoValuesAssigned() { var state = new ListSolutionStateManager() .saveSolutionState(scoreDirector, true); - assertThat(state.assignedValueList()).isEmpty(); + assertThat(state.stateList()).isEmpty(); } @Test @@ -100,7 +100,7 @@ void saveStateWithAllValuesAssigned() { var state = new ListSolutionStateManager() .saveSolutionState(scoreDirector, true); - assertThat(state.assignedValueList()) + assertThat(state.stateList()) .hasSize(3) .extracting(lv -> ((TestdataListValue) lv.value()).getCode()) .containsExactlyInAnyOrder("v1", "v2", "v3"); @@ -126,7 +126,7 @@ void saveStateWithoutAssignedValues() { var state = new ListSolutionStateManager() .saveSolutionState(scoreDirector, false); - assertThat(state.assignedValueList()).isEmpty(); + assertThat(state.stateList()).isEmpty(); } @Test @@ -150,7 +150,7 @@ void saveStateWithPartiallyAssigned() { var state = new ListSolutionStateManager() .saveSolutionState(scoreDirector, true); - assertThat(state.assignedValueList()) + assertThat(state.stateList()) .hasSize(2) .extracting(lv -> ((TestdataListValue) lv.value()).getCode()) .containsExactlyInAnyOrder("v1", "v2"); @@ -337,11 +337,13 @@ void saveStateFromIndividualWithEmptyChromosome() { var score = (InnerScore) mock(InnerScore.class); doReturn(score).when(individual).getScore(); + InnerScoreDirector scoreDirector = buildScoreDirector(false); + var state = new ListSolutionStateManager() - .saveSolutionState(individual); + .saveSolutionState(scoreDirector, individual); assertThat(state.getSolution()).isSameAs(solution); - assertThat(state.assignedValueList()).isEmpty(); + assertThat(state.stateList()).isEmpty(); assertThat(state.getScore()).isSameAs(score); } @@ -361,20 +363,22 @@ void saveStateFromIndividualWithAssignedValues() { var individual = (Individual) mock(Individual.class); doReturn(solution).when(individual).getSolution(); doReturn(new ChromosomeEntry[] { - new ChromosomeEntry(v1, a, 0), - new ChromosomeEntry(v2, a, 1), - new ChromosomeEntry(v3, b, 0) + new ChromosomeEntry(a, v1, 0), + new ChromosomeEntry(a, v2, 1), + new ChromosomeEntry(b, v3, 0) }).when(individual).getChromosome(); var score = (InnerScore) mock(InnerScore.class); doReturn(score).when(individual).getScore(); + InnerScoreDirector scoreDirector = buildScoreDirector(false); + var state = new ListSolutionStateManager() - .saveSolutionState(individual); + .saveSolutionState(scoreDirector, individual); assertThat(state.getSolution()).isSameAs(solution); assertThat(state.getScore()).isSameAs(score); - var assignedValues = state.assignedValueList(); + var assignedValues = state.stateList(); assertThat(assignedValues).hasSize(3); assertThat(assignedValues.get(0).value()).isSameAs(v1); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java new file mode 100644 index 00000000000..d3dc675bcf3 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java @@ -0,0 +1,274 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.basic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.MockablePhaseTermination; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testdomain.multivar.TestdataOtherValue; + +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalAnswers; + +class BasicOXCrossoverTest { + + private static InnerScoreDirector buildScoreDirector() { + var factory = new EasyScoreDirectorFactory<>(TestdataSolution.buildSolutionDescriptor(), + solution -> SimpleScore.of(0), EnvironmentMode.PHASE_ASSERT); + factory.setInitializingScoreTrend( + InitializingScoreTrend.buildUniformTrend(InitializingScoreTrendLevel.ONLY_DOWN, 1)); + var delegate = factory.createScoreDirectorBuilder() + .withLookUpEnabled(true) + .build(); + return mock(InnerScoreDirector.class, AdditionalAnswers.delegatesTo(delegate)); + } + + private static InnerScoreDirector buildMultiVarScoreDirector() { + var factory = new EasyScoreDirectorFactory<>(TestdataMultiVarSolution.buildSolutionDescriptor(), + solution -> SimpleScore.of(0), EnvironmentMode.PHASE_ASSERT); + factory.setInitializingScoreTrend( + InitializingScoreTrend.buildUniformTrend(InitializingScoreTrendLevel.ONLY_DOWN, 1)); + var delegate = factory.createScoreDirectorBuilder() + .withLookUpEnabled(true) + .build(); + return mock(InnerScoreDirector.class, AdditionalAnswers.delegatesTo(delegate)); + } + + private static ChromosomeEntry[] buildChromosome(TestdataSolution solution) { + var varDescriptors = TestdataEntity.buildEntityDescriptor().getBasicVariableDescriptorList(); + var chromosomeList = new ArrayList(); + for (var entity : solution.getEntityList()) { + for (var i = 0; i < varDescriptors.size(); i++) { + chromosomeList.add(new ChromosomeEntry(entity, varDescriptors.get(i).getValue(entity), i)); + } + } + return chromosomeList.toArray(ChromosomeEntry[]::new); + } + + private static ChromosomeEntry[] buildMultiVarChromosome(TestdataMultiVarSolution solution) { + var varDescriptors = TestdataMultiVarEntity.buildEntityDescriptor().getBasicVariableDescriptorList(); + var chromosomeList = new ArrayList(); + for (var entity : solution.getMultiVarEntityList()) { + for (var i = 0; i < varDescriptors.size(); i++) { + chromosomeList.add(new ChromosomeEntry(entity, varDescriptors.get(i).getValue(entity), i)); + } + } + return chromosomeList.toArray(ChromosomeEntry[]::new); + } + + @Test + @SuppressWarnings("unchecked") + void crossoverSingleVariable() { + // Working solution: 3 entities, 1 basic variable each. + // All values that appear in either parent must be present so lookUpWorkingObject can rebase them. + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var v3 = new TestdataValue("v3"); + var va1 = new TestdataValue("va1"); + var vb1 = new TestdataValue("vb1"); + var vc1 = new TestdataValue("vc1"); + var va2 = new TestdataValue("va2"); + var vb2 = new TestdataValue("vb2"); + var vc2 = new TestdataValue("vc2"); + + var e1 = new TestdataEntity("e1", v1); + var e2 = new TestdataEntity("e2", v2); + var e3 = new TestdataEntity("e3", v3); + + var workingSolution = new TestdataSolution(); + workingSolution.setValueList(new ArrayList<>(List.of(v1, v2, v3, va1, vb1, vc1, va2, vb2, vc2))); + workingSolution.setEntityList(new ArrayList<>(List.of(e1, e2, e3))); + + // First parent: e1=va1, e2=vb1, e3=vc1 + var p1e1 = new TestdataEntity("e1", va1); + var p1e2 = new TestdataEntity("e2", vb1); + var p1e3 = new TestdataEntity("e3", vc1); + var firstParent = new TestdataSolution(); + firstParent.setValueList(new ArrayList<>(List.of(va1, vb1, vc1))); + firstParent.setEntityList(new ArrayList<>(List.of(p1e1, p1e2, p1e3))); + + // Second parent: e1=va2, e2=vb2, e3=vc2 + var p2e1 = new TestdataEntity("e1", va2); + var p2e2 = new TestdataEntity("e2", vb2); + var p2e3 = new TestdataEntity("e3", vc2); + var secondParent = new TestdataSolution(); + secondParent.setValueList(new ArrayList<>(List.of(va2, vb2, vc2))); + secondParent.setEntityList(new ArrayList<>(List.of(p2e1, p2e2, p2e3))); + + var scoreDirector = buildScoreDirector(); + scoreDirector.setWorkingSolution(workingSolution); + scoreDirector.calculateScore(); + + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + doReturn(mock(AbstractSolver.class)).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(3); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(buildChromosome(firstParent)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(3); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(buildChromosome(secondParent)); + + // generateIndexes(size=3, rate=0): nextInt(3) → 1, 2 → [1, 2] + // fixIndex(p1, 1, true): target=e2, chromosome[0].entity()=e1 ≠ e2 → return 1 + // fixIndex(p1, 2, false): target=e3, index=3 → past end → return 3 + // Effective cut: P1 applies [1, 3) = {e2, e3}; P2 applies [0, 1) = {e1} + when(random.nextInt(3)).thenReturn(1, 2); + + var localSearchPhase = mock(Phase.class); + var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + var result = new BasicOXCrossover(localSearchPhase, null, 0).apply(context); + var offspring = result.solution(); + + assertThat(offspring.getEntityList().get(0).getValue().getCode()).isEqualTo("va2"); // e1 from P2 + assertThat(offspring.getEntityList().get(1).getValue().getCode()).isEqualTo("vb1"); // e2 from P1 + assertThat(offspring.getEntityList().get(2).getValue().getCode()).isEqualTo("vc1"); // e3 from P1 + } + + @Test + @SuppressWarnings("unchecked") + void crossoverMultipleVariables() { + // Working solution: 3 entities, each with 3 basic variables (primary, secondary, tertiary). + // All values from both parents are in the working solution's value list so lookUpWorkingObject works. + var p1e1Prim = new TestdataValue("p1e1Prim"); + var p1e1Sec = new TestdataValue("p1e1Sec"); + var p1e2Prim = new TestdataValue("p1e2Prim"); + var p1e2Sec = new TestdataValue("p1e2Sec"); + var p1e3Prim = new TestdataValue("p1e3Prim"); + var p1e3Sec = new TestdataValue("p1e3Sec"); + var p2e1Prim = new TestdataValue("p2e1Prim"); + var p2e1Sec = new TestdataValue("p2e1Sec"); + var p2e1Ter = new TestdataOtherValue("p2e1Ter"); + var p2e2Prim = new TestdataValue("p2e2Prim"); + var p2e2Sec = new TestdataValue("p2e2Sec"); + var p2e3Prim = new TestdataValue("p2e3Prim"); + var p2e3Sec = new TestdataValue("p2e3Sec"); + + // Working entities start with P1's values; tertiary is unassigned (null) + var e1 = new TestdataMultiVarEntity("e1", p1e1Prim, p1e1Sec, null); + var e2 = new TestdataMultiVarEntity("e2", p1e2Prim, p1e2Sec, null); + var e3 = new TestdataMultiVarEntity("e3", p1e3Prim, p1e3Sec, null); + + var workingSolution = new TestdataMultiVarSolution(); + workingSolution.setValueList(new ArrayList<>(List.of( + p1e1Prim, p1e1Sec, p1e2Prim, p1e2Sec, p1e3Prim, p1e3Sec, + p2e1Prim, p2e1Sec, p2e2Prim, p2e2Sec, p2e3Prim, p2e3Sec))); + workingSolution.setOtherValueList(new ArrayList<>(List.of(p2e1Ter))); + workingSolution.setMultiVarEntityList(new ArrayList<>(List.of(e1, e2, e3))); + + // First parent: e1=(p1e1Prim, p1e1Sec), e2=(p1e2Prim, p1e2Sec), e3=(p1e3Prim, p1e3Sec) + var fp1e1 = new TestdataMultiVarEntity("e1", p1e1Prim, p1e1Sec, null); + var fp1e2 = new TestdataMultiVarEntity("e2", p1e2Prim, p1e2Sec, null); + var fp1e3 = new TestdataMultiVarEntity("e3", p1e3Prim, p1e3Sec, null); + var firstParent = new TestdataMultiVarSolution(); + firstParent.setValueList(new ArrayList<>(List.of(p1e1Prim, p1e1Sec, p1e2Prim, p1e2Sec, p1e3Prim, p1e3Sec))); + firstParent.setOtherValueList(new ArrayList<>()); + firstParent.setMultiVarEntityList(new ArrayList<>(List.of(fp1e1, fp1e2, fp1e3))); + + // Second parent: e1=(p2e1Prim, p2e1Sec), e2=(p2e2Prim, p2e2Sec), e3=(p2e3Prim, p2e3Sec) + var fp2e1 = new TestdataMultiVarEntity("e1", p2e1Prim, p2e1Sec, p2e1Ter); + var fp2e2 = new TestdataMultiVarEntity("e2", p2e2Prim, p2e2Sec, null); + var fp2e3 = new TestdataMultiVarEntity("e3", p2e3Prim, p2e3Sec, null); + var secondParent = new TestdataMultiVarSolution(); + secondParent.setValueList(new ArrayList<>(List.of(p2e1Prim, p2e1Sec, p2e2Prim, p2e2Sec, p2e3Prim, p2e3Sec))); + secondParent.setOtherValueList(new ArrayList<>(List.of(p2e1Ter))); + secondParent.setMultiVarEntityList(new ArrayList<>(List.of(fp2e1, fp2e2, fp2e3))); + + var scoreDirector = buildMultiVarScoreDirector(); + scoreDirector.setWorkingSolution(workingSolution); + scoreDirector.calculateScore(); + + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(scoreDirector).when(phaseScope).getScoreDirector(); + var phaseTermination = mock(MockablePhaseTermination.class); + doReturn(phaseTermination).when(phaseScope).getTermination(); + doReturn(false).when(phaseTermination).isPhaseTerminated(phaseScope); + var random = mock(RandomGenerator.class); + doReturn(random).when(phaseScope).getWorkingRandom(); + var solverScope = mock(SolverScope.class); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(solverScope).when(phaseScope).getSolverScope(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + doReturn(scoreDirector.getWorkingSolution()).when(solverScope).getBestSolution(); + doReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)).when(solverScope).getBestScore(); + doReturn(mock(AbstractSolver.class)).when(solverScope).getSolver(); + + var firstIndividual = (Individual) mock(Individual.class); + when(firstIndividual.size()).thenReturn(9); + when(firstIndividual.getSolution()).thenReturn(firstParent); + when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(firstIndividual.getChromosome()).thenReturn(buildMultiVarChromosome(firstParent)); + + var secondIndividual = (Individual) mock(Individual.class); + when(secondIndividual.size()).thenReturn(9); + when(secondIndividual.getSolution()).thenReturn(secondParent); + when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); + when(secondIndividual.getChromosome()).thenReturn(buildMultiVarChromosome(secondParent)); + + // generateIndexes(size=9, rate=0): nextInt(9) → 3, 4 → [3, 4] + // fixIndex(p1, 3, true): target=e2 (first var of e2), chromosome[2].entity()=e1 ≠ e2 → return 3 + // fixIndex(p1, 4, false): target=e2 (second var of e2), scans forward: chromosome[5].entity()=e2 + // → continue; chromosome[6].entity()=e3 ≠ e2 → return 6 + // Effective cut: P1 applies [3, 6) = all 3 variables of e2; P2 applies [0, 3) + [6, 9) = e1 and e3 + when(random.nextInt(9)).thenReturn(3, 4); + + var localSearchPhase = mock(Phase.class); + var context = + new CrossoverContext(phaseScope, firstIndividual, secondIndividual); + var result = new BasicOXCrossover(localSearchPhase, null, 0).apply(context); + var offspring = result.solution(); + var entities = offspring.getMultiVarEntityList(); + + assertThat(entities.get(0).getPrimaryValue().getCode()).isEqualTo("p2e1Prim"); // e1 from P2 + assertThat(entities.get(0).getSecondaryValue().getCode()).isEqualTo("p2e1Sec"); + assertThat(entities.get(0).getTertiaryValueAllowedUnassigned().getCode()).isEqualTo("p2e1Ter"); + assertThat(entities.get(1).getPrimaryValue().getCode()).isEqualTo("p1e2Prim"); // e2 from P1 + assertThat(entities.get(1).getSecondaryValue().getCode()).isEqualTo("p1e2Sec"); + assertThat(entities.get(1).getTertiaryValueAllowedUnassigned()).isNull(); + assertThat(entities.get(2).getPrimaryValue().getCode()).isEqualTo("p2e3Prim"); // e3 from P2 + assertThat(entities.get(2).getSecondaryValue().getCode()).isEqualTo("p2e3Sec"); + assertThat(entities.get(2).getTertiaryValueAllowedUnassigned()).isNull(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java index 5d22d4a7831..6fa45a51001 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java @@ -135,7 +135,7 @@ void crossoverOneEntity() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -143,7 +143,7 @@ void crossoverOneEntity() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); // Cut [2, 5] → both within entity a → fixIndex snaps to [0, 10] → all P1 values @@ -252,7 +252,7 @@ void crossoverTwoEntities() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -260,7 +260,7 @@ void crossoverTwoEntities() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); // Cut [3, 7] → start mid-a (snaps to 0), end mid-b (snaps to 10) → all P1 @@ -372,7 +372,7 @@ void crossoverThreeEntities() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -380,7 +380,7 @@ void crossoverThreeEntities() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); // Cut [2, 8] → start mid-a (snaps to 0), end mid-c (snaps to 10) → all P1 @@ -492,7 +492,7 @@ void crossoverThreeEntitiesWithInheritanceRate() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -500,7 +500,7 @@ void crossoverThreeEntitiesWithInheritanceRate() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); // inheritanceRate=0.5, size=10 → minSize=5, maxStart=6 diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java index ea9ddbe99b8..7279568dd7e 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java @@ -140,7 +140,7 @@ void crossoverOneEntityFirstParent() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -148,7 +148,7 @@ void crossoverOneEntityFirstParent() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); @@ -242,7 +242,7 @@ void crossoverOneEntitySecondParent() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -250,7 +250,7 @@ void crossoverOneEntitySecondParent() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); @@ -353,7 +353,7 @@ void crossoverTwoEntities() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -361,7 +361,7 @@ void crossoverTwoEntities() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); @@ -471,7 +471,7 @@ void crossoverTwoEntitiesWithInheritanceRate() { when(firstIndividual.getSolution()).thenReturn(firstParent); when(firstIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(firstIndividual.getChromosome()).thenReturn(firstParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var secondIndividual = (Individual) mock(Individual.class); @@ -479,7 +479,7 @@ void crossoverTwoEntitiesWithInheritanceRate() { when(secondIndividual.getSolution()).thenReturn(secondParent); when(secondIndividual.getScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.ZERO)); when(secondIndividual.getChromosome()).thenReturn(secondParent.getEntityList().stream() - .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(v, e, 0))) + .flatMap(e -> e.getValueList().stream().map(v -> new ChromosomeEntry(e, v, 0))) .toArray(ChromosomeEntry[]::new)); var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java new file mode 100644 index 00000000000..a7e7f23db5e --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java @@ -0,0 +1,215 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.basic; + +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.phase.PhaseCommand; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class BasicRuinRecreateIndividualStrategyTest { + + @SuppressWarnings("unchecked") + private EvolutionaryAlgorithmStepScope prepareStepScope() { + var solverScope = mock(SolverScope.class); + var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); + doReturn(solverScope).when(phaseScope).getSolverScope(); + var solutionDescriptor = TestdataMultiVarSolution.buildSolutionDescriptor(); + var heuristicConfigPolicy = + new HeuristicConfigPolicy.Builder().withSolutionDescriptor(solutionDescriptor) + .build(); + var termination = (PhaseTermination) TerminationFactory + . create(new TerminationConfig().withStepCountLimit(1)) + .buildTermination(heuristicConfigPolicy); + doReturn(termination).when(phaseScope).getTermination(); + var stepScope = mock(EvolutionaryAlgorithmStepScope.class); + doReturn(phaseScope).when(stepScope).getPhaseScope(); + var population = mock(Population.class); + doReturn(population).when(phaseScope).getPopulation(); + var scoreDirector = mockScoreDirector(solutionDescriptor); + var problem = TestdataMultiVarSolution.generateUninitializedSolution(1, 1); + scoreDirector.setWorkingSolution(problem); + var score = scoreDirector.calculateScore(); + doReturn(scoreDirector).when(solverScope).getScoreDirector(); + doReturn(problem).when(solverScope).getBestSolution(); + doReturn(score).when(solverScope).getBestScore(); + doReturn(Clock.systemDefaultZone()).when(solverScope).getClock(); + var solver = mock(AbstractSolver.class); + doReturn(solver).when(solverScope).getSolver(); + return stepScope; + } + + @Test + @SuppressWarnings("unchecked") + void applyWithNoBestIndividual() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + var strategy = + new BasicRuinRecreateIndividualStrategy>( + Collections.emptyList(), deterministicPhase, shuffledPhase, localSearchPhase, null, + solutionStateManager, individualBuilder, 0.95); + + var generatedIndividual = strategy.apply(stepScope); + + assertThat(generatedIndividual).isNotNull(); + verify(deterministicPhase).solve(solverScope); + verify(localSearchPhase).solve(solverScope); + verify(shuffledPhase, never()).solve(any()); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithBestIndividual() { + var primaryValue = new TestdataValue("primary"); + var secondaryValue = new TestdataValue("secondary"); + var entity = new TestdataMultiVarEntity("e1"); + + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var scoreDirector = (InnerScoreDirector) solverScope.getScoreDirector(); + + doReturn(entity).when(scoreDirector).lookUpWorkingObject(entity); + doReturn(primaryValue).when(scoreDirector).lookUpWorkingObject(primaryValue); + doReturn(secondaryValue).when(scoreDirector).lookUpWorkingObject(secondaryValue); + doNothing().when(scoreDirector).executeMove(any()); + + var workingRandom = mock(Random.class); + when(workingRandom.nextInt(2)).thenReturn(0, 1); + doReturn(workingRandom).when(solverScope).getWorkingRandom(); + + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var solutionState = mock(SolutionState.class); + var bestIndividual = mock(Individual.class); + doReturn(new ChromosomeEntry[] { + new ChromosomeEntry(entity, primaryValue, 0), + new ChromosomeEntry(entity, secondaryValue, 1) + }).when(bestIndividual).getChromosome(); + doReturn(2).when(bestIndividual).size(); + doReturn(solutionState).when(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); + + var population = stepScope.getPhaseScope().getPopulation(); + doReturn(bestIndividual).when(population).getBestIndividual(); + + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + var strategy = + new BasicRuinRecreateIndividualStrategy>( + Collections.emptyList(), deterministicPhase, shuffledPhase, localSearchPhase, null, + solutionStateManager, individualBuilder, 0.95); + + var generatedIndividual = strategy.apply(stepScope); + + assertThat(generatedIndividual).isNotNull(); + verify(deterministicPhase, never()).solve(any()); + verify(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); + verify(solutionStateManager).restoreSolutionState(any(), any()); + verify(shuffledPhase).solve(solverScope); + verify(localSearchPhase).solve(solverScope); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithPhaseCommands() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + + var command = mock(PhaseCommand.class); + var strategy = + new BasicRuinRecreateIndividualStrategy>( + List.of(command), deterministicPhase, shuffledPhase, localSearchPhase, null, solutionStateManager, + individualBuilder, 0.95); + + strategy.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + } + + @Test + @SuppressWarnings("unchecked") + void applyWithRefinement() { + var stepScope = prepareStepScope(); + var solverScope = stepScope.getPhaseScope().getSolverScope(); + var deterministicPhase = mock(Phase.class); + var shuffledPhase = mock(Phase.class); + var localSearchPhase = mock(Phase.class); + var refinementPhase = mock(Phase.class); + var solutionStateManager = mock(SolutionStateManager.class); + var individualBuilder = mock(IndividualBuilder.class); + var individual = mock(Individual.class); + doReturn(individual).when(individualBuilder).build(any(), any(), any(), any(), any()); + + when(stepScope.getMoveDirector()).thenReturn(mock()); + when(stepScope.getWorkingRandom()).thenReturn(mock()); + + var command = mock(PhaseCommand.class); + var strategy = + new BasicRuinRecreateIndividualStrategy>( + List.of(command), deterministicPhase, shuffledPhase, localSearchPhase, refinementPhase, + solutionStateManager, individualBuilder, 0.95); + + strategy.apply(stepScope); + + var inOrder = Mockito.inOrder(command, deterministicPhase, refinementPhase); + inOrder.verify(command).changeWorkingSolution(any()); + inOrder.verify(deterministicPhase).solve(solverScope); + inOrder.verify(refinementPhase).solve(solverScope); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java index 681a71158ac..fb8010b473f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java @@ -130,11 +130,11 @@ void applyWithBestIndividual() { var solutionState = mock(SolutionState.class); var bestIndividual = mock(Individual.class); doReturn(new ChromosomeEntry[] { - new ChromosomeEntry(v1, a, 0), - new ChromosomeEntry(v2, a, 1) + new ChromosomeEntry(a, v1, 0), + new ChromosomeEntry(a, v2, 1) }).when(bestIndividual).getChromosome(); doReturn(2).when(bestIndividual).size(); - doReturn(solutionState).when(solutionStateManager).saveSolutionState(bestIndividual); + doReturn(solutionState).when(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); var population = stepScope.getPhaseScope().getPopulation(); doReturn(bestIndividual).when(population).getBestIndividual(); @@ -154,7 +154,7 @@ void applyWithBestIndividual() { // Ruin-recreate path: deterministic phase is skipped verify(deterministicPhase, never()).solve(any()); verify(localSearchPhase).solve(solverScope); - verify(solutionStateManager).saveSolutionState(bestIndividual); + verify(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); verify(solutionStateManager).restoreSolutionState(any(), any()); } diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarEntity.java index a0b4c5058a9..f271e9bc3dd 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarEntity.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarEntity.java @@ -25,6 +25,11 @@ public static BasicVariableDescriptor buildVariableDes .getGenuineVariableDescriptor("secondaryValue"); } + public static BasicVariableDescriptor buildVariableDescriptorForTertiaryValue() { + return (BasicVariableDescriptor) buildEntityDescriptor() + .getGenuineVariableDescriptor("tertiaryValueAllowedUnassigned"); + } + private TestdataValue primaryValue; private TestdataValue secondaryValue; diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 55bc485b9a2..97f7da2836c 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -2369,6 +2369,9 @@ + + + @@ -2435,10 +2438,10 @@ - + - + @@ -2453,7 +2456,7 @@ - + @@ -2465,10 +2468,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 21dc3026e8f10eb5e9c528b5a783a34cc4baa233 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 10 Jun 2026 10:53:12 -0300 Subject: [PATCH 5/8] feat: prepare for multithread implementation --- ...nfig.java => EvolutionaryAgentConfig.java} | 12 +- .../EvolutionaryAlgorithmPhaseConfig.java | 42 +- .../TimefoldSolverEnterpriseService.java | 20 +- .../DefaultEvolutionaryAlgorithmPhase.java | 8 +- ...aultEvolutionaryAlgorithmPhaseFactory.java | 119 +++-- .../DefaultBestSolutionUpdater.java | 46 +- .../common/phase/NoBestEventPhase.java | 88 ---- .../EvolutionaryAlgorithmPhaseScope.java | 22 +- .../scope/EvolutionaryAlgorithmStepScope.java | 27 +- .../AbstractHybridGeneticSearchDecider.java | 197 ++++++++ .../decider/EvolutionaryDecider.java | 2 + .../HybridGeneticSearchConfiguration.java | 37 -- .../decider/HybridGeneticSearchDecider.java | 198 ++------ .../decider/HybridGeneticSearchWorker.java | 98 ++-- .../HybridGeneticSearchWorkerContext.java | 16 + .../population/AbstractPopulation.java | 424 ++++++++++++++++++ .../population/DefaultPopulation.java | 420 +---------------- .../population/Population.java | 3 +- .../population/PopulationDiffMap.java | 2 +- ...DefaultConstructionIndividualStrategy.java | 2 +- .../BasicRuinRecreateIndividualStrategy.java | 13 +- .../ListRuinRecreateIndividualStrategy.java | 8 +- .../impl/heuristic/HeuristicConfigPolicy.java | 1 + .../score/director/AbstractScoreDirector.java | 31 +- .../solver/recaller/BestSolutionRecaller.java | 16 +- .../core/impl/solver/scope/SolverScope.java | 49 +- .../impl/solver/thread/ChildThreadType.java | 6 +- core/src/main/java/module-info.java | 2 +- core/src/main/resources/solver.xsd | 6 +- ...ultConstructionIndividualStrategyTest.java | 1 + ...sicRuinRecreateIndividualStrategyTest.java | 1 + ...istRuinRecreateIndividualStrategyTest.java | 1 + .../src/main/resources/benchmark.xsd | 7 +- 33 files changed, 1000 insertions(+), 925 deletions(-) rename core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/{EvolutionaryWorkerConfig.java => EvolutionaryAgentConfig.java} (85%) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAgentConfig.java similarity index 85% rename from core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java rename to core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAgentConfig.java index c047c0f32f5..7499fb5e1a3 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAgentConfig.java @@ -15,7 +15,7 @@ "localSearchConfig", }) @NullMarked -public class EvolutionaryWorkerConfig extends PhaseConfig { +public class EvolutionaryAgentConfig extends PhaseConfig { @Nullable private EvolutionaryIndividualGeneratorConfig individualGeneratorConfig = null; @@ -47,19 +47,19 @@ public void setLocalSearchConfig(@Nullable EvolutionaryLocalSearchConfig localSe // With methods // ************************************************************************ - public EvolutionaryWorkerConfig + public EvolutionaryAgentConfig withIndividualGeneratorConfig(EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { setIndividualGeneratorConfig(individualGeneratorConfig); return this; } - public EvolutionaryWorkerConfig withLocalSearchConfig(EvolutionaryLocalSearchConfig localSearchConfig) { + public EvolutionaryAgentConfig withLocalSearchConfig(EvolutionaryLocalSearchConfig localSearchConfig) { setLocalSearchConfig(localSearchConfig); return this; } @Override - public EvolutionaryWorkerConfig inherit(EvolutionaryWorkerConfig inheritedConfig) { + public EvolutionaryAgentConfig inherit(EvolutionaryAgentConfig inheritedConfig) { super.inherit(inheritedConfig); individualGeneratorConfig = ConfigUtils.inheritConfig(individualGeneratorConfig, inheritedConfig.getIndividualGeneratorConfig()); @@ -68,8 +68,8 @@ public EvolutionaryWorkerConfig inherit(EvolutionaryWorkerConfig inheritedConfig } @Override - public EvolutionaryWorkerConfig copyConfig() { - return new EvolutionaryWorkerConfig().inherit(this); + public EvolutionaryAgentConfig copyConfig() { + return new EvolutionaryAgentConfig().inherit(this); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java index b9a9dbf2ef1..b30ac9692d5 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java @@ -12,8 +12,9 @@ @XmlType(propOrder = { "complexProblem", + "agentCount", "populationConfig", - "workerConfig", + "evolutionaryAgentConfig", }) @NullMarked public class EvolutionaryAlgorithmPhaseConfig extends PhaseConfig { @@ -23,11 +24,14 @@ public class EvolutionaryAlgorithmPhaseConfig extends PhaseConfig> classVisitor) { if (populationConfig != null) { populationConfig.visitReferencedClasses(classVisitor); } - if (workerConfig != null) { - workerConfig.visitReferencedClasses(classVisitor); + if (evolutionaryAgentConfig != null) { + evolutionaryAgentConfig.visitReferencedClasses(classVisitor); } } } diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index a4ce5522bd1..f59dfa026ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -31,11 +31,8 @@ import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.declarative.TopologicalOrderGraph; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorkerContext; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.list.DestinationSelector; @@ -48,7 +45,6 @@ import ai.timefold.solver.core.impl.localsearch.decider.forager.LocalSearchForager; import ai.timefold.solver.core.impl.neighborhood.MoveRepository; import ai.timefold.solver.core.impl.partitionedsearch.PartitionedSearchPhase; -import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; @@ -181,13 +177,9 @@ PartitionedSearchPhase buildPartitionedSearch(int phaseIn BiFunction, SolverTermination, PhaseTermination> phaseTerminationFunction); , State_ extends SolutionState> - EvolutionaryDecider buildHybridGeneticSearch(int populationSize, int generationSize, - int eliteGroupSize, int populationRestartCount, - ConstructionIndividualStrategy constructionIndividualStrategy, - Phase localSearchPhase, Phase swapStarPhase, - CrossoverStrategy crossoverStrategy, - IndividualBuilder individualBuilder, - SolutionStateManager solutionInitializer, + EvolutionaryDecider buildHybridGeneticSearch(HeuristicConfigPolicy solverConfigPolicy, + int agentCount, int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount, + List> agentContextList, PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller); EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, @@ -236,7 +228,9 @@ enum Feature { "remove multistageMoveSelector and/or listMultistageMoveSelector from the solver configuration"), CONSTRAINT_PROFILING("Constraint profiling", "remove constraintStreamProfilingEnabled from the solver configuration"), SCORE_ANALYSIS("Score analysis", "do not use SolutionManager's analyze() method"), - RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"); + RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"), + EVOLUTIONARY_ALGORITHM("Evolutionary Algorithm", + "remove the agent count property from the evolutionary algorithm configuration"); private final String name; private final String workaround; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java index 17341a60612..1a9b85dcc1c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java @@ -106,8 +106,12 @@ public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { super.stepEnded(stepScope); evolutionaryDecider.stepEnded(stepScope); - var solver = stepScope.getPhaseScope().getSolverScope().getSolver(); - solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); + } + + @Override + public void solvingError(SolverScope solverScope, Exception exception) { + super.solvingError(solverScope, exception); + evolutionaryDecider.solvingError(solverScope, exception); } @NullMarked diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java index 9ba37388b33..acb7a40bfc1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -1,11 +1,13 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm; +import static ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService.Feature.EVOLUTIONARY_ALGORITHM; import static ai.timefold.solver.core.impl.AbstractFromConfigFactory.deduceEntityDescriptor; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; @@ -16,11 +18,11 @@ import ai.timefold.solver.core.config.constructionheuristic.placer.EntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAgentConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryIndividualGeneratorConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryLocalSearchConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryPopulationConfig; -import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryWorkerConfig; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionOrder; import ai.timefold.solver.core.config.heuristic.selector.common.nearby.NearbySelectionConfig; @@ -49,9 +51,9 @@ import ai.timefold.solver.core.config.solver.termination.DiminishedReturnsTerminationConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedEntityPlacerFactory; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase.NoBestEventPhase; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.basic.BasicSolutionStateManager; @@ -61,7 +63,7 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list.ListOXCrossover; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchDecider.Builder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.HybridGeneticSearchWorkerContext; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.BasicVariableIndividual; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ListVariableIndividual; @@ -72,6 +74,7 @@ import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelectorFactory; +import ai.timefold.solver.core.impl.phase.AbstractPhase; import ai.timefold.solver.core.impl.phase.AbstractPhaseFactory; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.PhaseFactory; @@ -105,9 +108,9 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean var generationSize = Objects.requireNonNullElse(populationConfig.getGenerationSize(), 20); var eliteGroupSize = Objects.requireNonNullElse(populationConfig.getEliteSolutionSize(), 10); var populationRestartCount = Objects.requireNonNullElse(populationConfig.getPopulationRestartCount(), 400); - var workerConfig = phaseConfig.getWorkerConfig(); + var workerConfig = phaseConfig.getEvolutionaryAgentConfig(); if (workerConfig == null) { - workerConfig = new EvolutionaryWorkerConfig(); + workerConfig = new EvolutionaryAgentConfig(); } var isListVariable = solverConfigPolicy.getSolutionDescriptor().hasListVariable(); var phaseTermination = buildPhaseTermination(solverConfigPolicy, solverTermination); @@ -119,10 +122,11 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean // This means that an individual will incorporate 95% of a parent's solution for crossover operations // or ruin only 5% of it when creating a new individual. boolean isComplex = phaseConfig.getComplexProblem() != null && phaseConfig.getComplexProblem(); + var agentCount = phaseConfig.getAgentCount() != null ? phaseConfig.getAgentCount() : 0; var evolutionaryDecider = buildEvolutionaryAlgorithmDecider(workerConfig, solverConfigPolicy, solverTermination, phaseTermination, - bestSolutionRecaller, isComplex, isListVariable, populationSize, generationSize, eliteGroupSize, - populationRestartCount); + bestSolutionRecaller, isComplex, isListVariable, agentCount, populationSize, generationSize, + eliteGroupSize, populationRestartCount); return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider, isComplex).build(); } @@ -132,24 +136,68 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean */ private static , State_ extends SolutionState> EvolutionaryDecider - buildEvolutionaryAlgorithmDecider(EvolutionaryWorkerConfig workerConfig, + buildEvolutionaryAlgorithmDecider(EvolutionaryAgentConfig workerConfig, HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller, - boolean isComplex, boolean isListVariable, int populationSize, int generationSize, int eliteGroupSize, - int populationRestartCount) { + boolean isComplex, boolean isListVariable, int agentCount, int populationSize, int generationSize, + int eliteGroupSize, int populationRestartCount) { IndividualBuilder individualBuilder = buildIndividualBuilder(isListVariable); SolutionStateManager solutionStateManager = buildSolutionStateManager(isListVariable); + var requiresEnterpriseService = agentCount > 0; + if (requiresEnterpriseService) { + if (solverConfigPolicy.getMoveThreadCount() != null) { + throw new IllegalStateException( + "The move thread count setting cannot be used in conjunction with the agent count."); + } + if (agentCount < 2) { + throw new IllegalStateException("The agent count must be at least 2."); + } + var agentContextList = new ArrayList>(agentCount); + for (var i = 0; i < agentCount; i++) { + agentContextList.add(buildAgentContext(workerConfig, solverConfigPolicy, solverTermination, + bestSolutionRecaller, solutionStateManager, individualBuilder, isComplex, isListVariable)); + } + return TimefoldSolverEnterpriseService.loadOrFail(EVOLUTIONARY_ALGORITHM) + .buildHybridGeneticSearch(solverConfigPolicy, agentCount, populationSize, generationSize, eliteGroupSize, + populationRestartCount, agentContextList, phaseTermination, bestSolutionRecaller); + } else { + var agentContext = buildAgentContext(workerConfig, solverConfigPolicy, solverTermination, + bestSolutionRecaller, solutionStateManager, individualBuilder, isComplex, isListVariable); + return new HybridGeneticSearchDecider.Builder() + .withLogIndentation(solverConfigPolicy.getLogIndentation()) + .withPopulationSize(populationSize) + .withGenerationSize(generationSize) + .withEliteSolutionSize(eliteGroupSize) + .withPopulationRestartCount(populationRestartCount) + .withConstructionIndividualStrategy(agentContext.constructionIndividualStrategy()) + .withLocalSearchPhase(agentContext.localSearchPhase()) + .withRefinementPhase(agentContext.refinementPhase()) + .withCrossoverStrategy(agentContext.crossoverStrategy()) + .withIndividualBuilder(individualBuilder) + .withSolutionStateManager(solutionStateManager) + .withPhaseTermination(phaseTermination) + .withBestSolutionRecaller(bestSolutionRecaller) + .build(); + } + } + + private static , State_ extends SolutionState> + HybridGeneticSearchWorkerContext buildAgentContext(EvolutionaryAgentConfig workerConfig, + HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, + BestSolutionRecaller bestSolutionRecaller, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, boolean isComplex, boolean isListVariable) { Phase deterministicBestFitConstructionPhase = - disableBestSolutionUpdate(buildDeterministicConstructionHeuristicPhase(solverConfigPolicy, + disableLogging(buildDeterministicConstructionHeuristicPhase(solverConfigPolicy, workerConfig.getIndividualGeneratorConfig(), solverTermination)); - Phase shuffledFirstFitConstructionPhase = disableBestSolutionUpdate( + Phase shuffledFirstFitConstructionPhase = disableLogging( buildShuffledConstructionHeuristicPhase(solverConfigPolicy, solverTermination, isListVariable)); Phase localSearchPhase = - disableBestSolutionUpdate(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), + disableLogging(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), solverTermination, bestSolutionRecaller, isComplex, isListVariable)); Phase refinmentPhase = - disableBestSolutionUpdate(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); + disableLogging(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); ConstructionIndividualStrategy constructionIndividualStrategy = buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, @@ -157,21 +205,8 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean CrossoverStrategy crossoverStrategy = buildCrossoverStrategy(workerConfig.getLocalSearchConfig(), localSearchPhase, refinmentPhase, isComplex, isListVariable); - - return new Builder>() - .withPopulationSize(populationSize) - .withGenerationSize(generationSize) - .withEliteSolutionSize(eliteGroupSize) - .withPopulationRestartCount(populationRestartCount) - .withConstructionIndividualStrategy(constructionIndividualStrategy) - .withLocalSearchPhase(localSearchPhase) - .withRefinementPhase(refinmentPhase) - .withCrossoverStrategy(crossoverStrategy) - .withIndividualBuilder(individualBuilder) - .withSolutionStateManager(solutionStateManager) - .withPhaseTermination(phaseTermination) - .withBestSolutionRecaller(bestSolutionRecaller) - .build(); + return new HybridGeneticSearchWorkerContext<>(constructionIndividualStrategy, localSearchPhase, refinmentPhase, + crossoverStrategy, individualBuilder, solutionStateManager); } @SuppressWarnings("unchecked") @@ -199,10 +234,8 @@ private static > CrossoverStrategy localSearchPhase, @Nullable Phase refinementPhase, boolean isComplex, boolean isListVariable) { - var inheritanceRate = isComplex ? 0.95 : 0.5; - if (localSearchConfig != null && localSearchConfig.getInheritanceRate() != null) { - inheritanceRate = localSearchConfig.getInheritanceRate(); - } + double inheritanceRate = Optional.ofNullable(localSearchConfig).map(EvolutionaryLocalSearchConfig::getInheritanceRate) + .orElse(isComplex ? 0.95 : 0.5); if (isListVariable) { return new ListOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, !isComplex); } else { @@ -245,16 +278,14 @@ private static Phase buildShuffledConstructionHeuristicPh private static , State_ extends SolutionState> ConstructionIndividualStrategy - buildConstructionIndividualPhase(EvolutionaryWorkerConfig workerConfig, + buildConstructionIndividualPhase(EvolutionaryAgentConfig workerConfig, @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, IndividualBuilder individualBuilder, boolean isComplex, boolean isListVariable) { - var inheritanceRate = isComplex ? 0.95 : 0.5; - if (individualGeneratorConfig != null && individualGeneratorConfig.getInheritanceRate() != null) { - inheritanceRate = individualGeneratorConfig.getInheritanceRate(); - } + double inheritanceRate = Optional.ofNullable(individualGeneratorConfig) + .map(EvolutionaryIndividualGeneratorConfig::getInheritanceRate).orElse(isComplex ? 0.95 : 0.5); List> customIndividualPhaseCommandList = buildPhaseCommandList(workerConfig, individualGeneratorConfig); if (isListVariable) { @@ -268,7 +299,7 @@ private static Phase buildShuffledConstructionHeuristicPh } } - private static List> buildPhaseCommandList(EvolutionaryWorkerConfig workerConfig, + private static List> buildPhaseCommandList(EvolutionaryAgentConfig workerConfig, @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { var customIndividualPhaseCommandList = Collections.> emptyList(); if (individualGeneratorConfig != null && individualGeneratorConfig.getCustomPhaseCommandClassList() != null) { @@ -513,15 +544,15 @@ private static void loadMoveSelectorConfig(HeuristicConfigPolicy @Nullable Phase disableBestSolutionUpdate(@Nullable Phase phase) { - if (phase == null) { - return null; + private static @Nullable Phase disableLogging(@Nullable Phase phase) { + if (phase instanceof AbstractPhase abstractPhase) { + abstractPhase.disableLogging(); } - return new NoBestEventPhase<>(phase); + return phase; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java index eaa645f7ba5..948b5414a65 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java @@ -10,28 +10,29 @@ import org.jspecify.annotations.NullMarked; +/** + * Single-threaded implementation of {@link BestSolutionUpdater}. + *

+ * When the step individual is the population's current best, this updater transfers that individual's + * state to the shared phase scope's score director and notifies the {@link BestSolutionRecaller}. + *

+ * Rather than calling {@code scoreDirector.setWorkingSolution}, which would trigger a full re-read of + * all planning and fact values, the transfer is performed through the {@link SolutionStateManager}: + * the individual's variable values are saved once from the worker's score director and then applied + * directly to the shared score director, requiring only one additional read pass. + */ @NullMarked -public final class DefaultBestSolutionUpdater, State_ extends SolutionState> - implements BestSolutionUpdater { - - private final EvolutionaryAlgorithmPhaseScope sharedPhaseScope; - private final BestSolutionRecaller sharedBestSolutionRecaller; - private final Population sharedPopulation; - private final SolutionStateManager solutionStateManager; - - public DefaultBestSolutionUpdater(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, - BestSolutionRecaller sharedBestSolutionRecaller, Population sharedPopulation, - SolutionStateManager solutionStateManager) { - this.sharedPhaseScope = sharedPhaseScope; - this.sharedBestSolutionRecaller = sharedBestSolutionRecaller; - this.sharedPopulation = sharedPopulation; - this.solutionStateManager = solutionStateManager; - } +public record DefaultBestSolutionUpdater, State_ extends SolutionState>( + EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + BestSolutionRecaller sharedBestSolutionRecaller, Population sharedPopulation, + SolutionStateManager solutionStateManager) + implements + BestSolutionUpdater { @Override public void updateBestSolution(EvolutionaryAlgorithmStepScope stepScope) { - var newIndividual = stepScope.getStepIndividual(); - if (sharedPopulation.getBestIndividual() == stepScope.getStepIndividual()) { + var newIndividual = stepScope. getStepIndividual(); + if (newIndividual != null && sharedPopulation.getBestIndividual() == stepScope.getStepIndividual()) { // The proposed approach avoids using `scoreDirector::setWorkingSolution` // to prevent the need to read all planning and fact values and recalculate statistics. // Instead, @@ -39,14 +40,15 @@ public void updateBestSolution(EvolutionaryAlgorithmStepScope stepSco // This method reads the values once and then assigns them to the current working solution, // requiring one additional read of the values. var individualState = - solutionStateManager.saveSolutionState(stepScope.getScoreDirector(), stepScope.getStepIndividual()); + solutionStateManager.saveSolutionState(stepScope.getScoreDirector(), newIndividual); solutionStateManager.restoreSolutionState(sharedPhaseScope.getScoreDirector(), individualState); var bestSolutionStepScope = new EvolutionaryAlgorithmStepScope<>(sharedPhaseScope, newIndividual); bestSolutionStepScope.setScore(newIndividual.getScore()); - var oldState = sharedBestSolutionRecaller.isEnableUpdateEvents(); - sharedBestSolutionRecaller.setEnableUpdateEvents(true); + // The shared scope has the flag for triggering the best solution events set to true sharedBestSolutionRecaller.processWorkingSolutionDuringStep(bestSolutionStepScope); - sharedBestSolutionRecaller.setEnableUpdateEvents(oldState); } + // The method is always triggered after an individual is added to the population, + // and it must be counted as a completed step + sharedPhaseScope.setLastCompletedStepScope(stepScope); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java deleted file mode 100644 index e8e06569a51..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/phase/NoBestEventPhase.java +++ /dev/null @@ -1,88 +0,0 @@ -package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase; - -import java.util.function.IntFunction; - -import ai.timefold.solver.core.api.solver.event.EventProducerId; -import ai.timefold.solver.core.impl.phase.AbstractPhase; -import ai.timefold.solver.core.impl.phase.Phase; -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; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; - -/** - * The phase receives an inner phase and disables any best solution events, - * which is required when running the inner phase in the evolutionary process. - * - * @param the solution type - */ -public final class NoBestEventPhase implements Phase { - private final Phase innerPhase; - private boolean previousState; - - public NoBestEventPhase(Phase innerPhase) { - this.innerPhase = innerPhase; - if (innerPhase instanceof AbstractPhase abstractPhase) { - abstractPhase.disableLogging(); - } - } - - @Override - public void addPhaseLifecycleListener(PhaseLifecycleListener phaseLifecycleListener) { - innerPhase.addPhaseLifecycleListener(phaseLifecycleListener); - } - - @Override - public void removePhaseLifecycleListener(PhaseLifecycleListener phaseLifecycleListener) { - innerPhase.removePhaseLifecycleListener(phaseLifecycleListener); - } - - @Override - public void solve(SolverScope solverScope) { - innerPhase.solve(solverScope); - } - - @Override - public IntFunction getEventProducerIdSupplier() { - return innerPhase.getEventProducerIdSupplier(); - } - - @Override - public void solvingStarted(SolverScope solverScope) { - var solver = solverScope.getSolver(); - if (solver != null) { - previousState = solver.getBestSolutionRecaller().isEnableUpdateEvents(); - solver.getBestSolutionRecaller().setEnableUpdateEvents(false); - } - innerPhase.solvingStarted(solverScope); - } - - @Override - public void solvingEnded(SolverScope solverScope) { - var solver = solverScope.getSolver(); - if (solver != null) { - solver.getBestSolutionRecaller().setEnableUpdateEvents(previousState); - } - innerPhase.solvingEnded(solverScope); - } - - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - innerPhase.phaseStarted(phaseScope); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - innerPhase.phaseEnded(phaseScope); - } - - @Override - public void stepStarted(AbstractStepScope stepScope) { - // Do nothing - } - - @Override - public void stepEnded(AbstractStepScope stepScope) { - // Do nothing - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java index 9600ee00319..728712d9d48 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java @@ -4,7 +4,6 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; -import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.solver.scope.SolverScope; public final class EvolutionaryAlgorithmPhaseScope extends AbstractPhaseScope { @@ -35,17 +34,16 @@ public void setPopulation(Population population) { this.population = population; } - public EvolutionaryAlgorithmPhaseScope copy(InnerScoreDirector scoreDirector) { - var solverScopeCopy = getSolverScope().copy(scoreDirector); - var copy = new EvolutionaryAlgorithmPhaseScope<>(solverScopeCopy, phaseIndex); - copy.startingSystemTimeMillis = startingSystemTimeMillis; - copy.startingScoreCalculationCount = startingScoreCalculationCount; - copy.startingMoveEvaluationCount = startingMoveEvaluationCount; - copy.startingScore = startingScore; - copy.setTermination(getTermination()); - copy.lastCompletedStepScope = lastCompletedStepScope; - copy.population = population; - return copy; + public EvolutionaryAlgorithmPhaseScope createChildThreadPhaseScope(SolverScope solveScope) { + var childThreadSPhaseScope = new EvolutionaryAlgorithmPhaseScope<>(solveScope, phaseIndex); + childThreadSPhaseScope.startingSystemTimeMillis = startingSystemTimeMillis; + childThreadSPhaseScope.startingScoreCalculationCount = startingScoreCalculationCount; + childThreadSPhaseScope.startingMoveEvaluationCount = startingMoveEvaluationCount; + childThreadSPhaseScope.startingScore = startingScore; + childThreadSPhaseScope.setTermination(getTermination()); + childThreadSPhaseScope.lastCompletedStepScope = lastCompletedStepScope; + childThreadSPhaseScope.population = population; + return childThreadSPhaseScope; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java index 63cf86f166c..9aaccb86008 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java @@ -1,32 +1,40 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked public final class EvolutionaryAlgorithmStepScope extends AbstractStepScope { private final EvolutionaryAlgorithmPhaseScope phaseScope; + @Nullable private Individual stepIndividual; + @Nullable + private Individual bestIndividual; public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope) { this(phaseScope, phaseScope.getNextStepIndex(), null); } public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope, - Individual stepIndividual) { + @Nullable Individual stepIndividual) { this(phaseScope, phaseScope.getNextStepIndex(), stepIndividual); } public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope, int stepIndex, - Individual stepIndividual) { + @Nullable Individual stepIndividual) { super(stepIndex); this.phaseScope = phaseScope; this.stepIndividual = stepIndividual; } @SuppressWarnings("unchecked") - public > Individual getStepIndividual() { + public @Nullable > Individual getStepIndividual() { return (Individual) stepIndividual; } @@ -34,6 +42,19 @@ public void setStepIndividual(Individual stepIndividual) { this.stepIndividual = stepIndividual; } + @SuppressWarnings("unchecked") + public @Nullable > Individual getBestIndividual() { + return (Individual) bestIndividual; + } + + public void setBestIndividual(@Nullable Individual bestIndividual) { + this.bestIndividual = bestIndividual; + } + + public > Population getPopulation() { + return phaseScope.getPopulation(); + } + @Override public EvolutionaryAlgorithmPhaseScope getPhaseScope() { return phaseScope; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java new file mode 100644 index 00000000000..2abbaf80374 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java @@ -0,0 +1,197 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.phase.Phase; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract class AbstractHybridGeneticSearchDecider> + implements EvolutionaryDecider { + + protected final String logIndentation; + protected final int populationSize; + protected final int generationSize; + protected final int eliteSolutionSize; + protected final int populationRestartCount; + protected final BestSolutionRecaller bestSolutionRecaller; + + private long lastBestIter; + + protected AbstractHybridGeneticSearchDecider(String logIndentation, int populationSize, int generationSize, + int eliteSolutionSize, int populationRestartCount, BestSolutionRecaller bestSolutionRecaller) { + this.logIndentation = logIndentation; + this.populationSize = populationSize; + this.generationSize = generationSize; + this.eliteSolutionSize = eliteSolutionSize; + this.populationRestartCount = populationRestartCount; + this.bestSolutionRecaller = bestSolutionRecaller; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + public abstract void restart(EvolutionaryAlgorithmStepScope stepScope, int size); + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void solvingStarted(SolverScope solverScope) { + // Do nothing + } + + @Override + public void solvingEnded(SolverScope solverScope) { + // Do nothing + } + + @Override + public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { + this.lastBestIter = 0; + } + + @Override + public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + // Do nothing + } + + @Override + public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + if (lastBestIter == 0) { + this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); + } else { + var size = populationSize - eliteSolutionSize; + var restart = + (phaseScope.getPopulation().getStatistics().individualCount() - lastBestIter) > populationRestartCount; + if (restart) { + restart(stepScope, size); + this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); + } + } + } + + @Override + public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { + // Do nothing + } + + public void solvingError(SolverScope solverScope, Exception exception) { + // Overridable by a subclass. + } + + @NullMarked + @SuppressWarnings("rawtypes") + public abstract static class AbstractBuilder, State_ extends SolutionState> { + + public String logIndentation; + public int populationSize; + public int generationSize; + public int eliteSolutionSize; + public int populationRestartCount; + @Nullable + public ConstructionIndividualStrategy constructionIndividualStrategy; + @Nullable + public Phase localSearchPhase; + @Nullable + public Phase refinementPhase; + @Nullable + public CrossoverStrategy crossoverStrategy; + @Nullable + public IndividualBuilder individualBuilder; + @Nullable + public SolutionStateManager solutionStateManager; + @Nullable + public PhaseTermination phaseTermination; + @Nullable + public BestSolutionRecaller bestSolutionRecaller; + + public AbstractBuilder withLogIndentation(String logIndentation) { + this.logIndentation = logIndentation; + return this; + } + + public AbstractBuilder withPopulationSize(int populationSize) { + this.populationSize = populationSize; + return this; + } + + public AbstractBuilder withGenerationSize(int generationSize) { + this.generationSize = generationSize; + return this; + } + + public AbstractBuilder withEliteSolutionSize(int eliteSolutionSize) { + this.eliteSolutionSize = eliteSolutionSize; + return this; + } + + public AbstractBuilder withPopulationRestartCount(int populationRestartCount) { + this.populationRestartCount = populationRestartCount; + return this; + } + + public AbstractBuilder + withConstructionIndividualStrategy( + ConstructionIndividualStrategy constructionIndividualStrategy) { + this.constructionIndividualStrategy = constructionIndividualStrategy; + return this; + } + + public AbstractBuilder withLocalSearchPhase(Phase localSearchPhase) { + this.localSearchPhase = localSearchPhase; + return this; + } + + public AbstractBuilder withRefinementPhase(@Nullable Phase swapStarPhase) { + this.refinementPhase = swapStarPhase; + return this; + } + + public AbstractBuilder + withCrossoverStrategy(CrossoverStrategy crossoverStrategy) { + this.crossoverStrategy = crossoverStrategy; + return this; + } + + public AbstractBuilder + withIndividualBuilder(IndividualBuilder individualBuilder) { + this.individualBuilder = individualBuilder; + return this; + } + + public AbstractBuilder + withSolutionStateManager(SolutionStateManager solutionInitializer) { + this.solutionStateManager = solutionInitializer; + return this; + } + + public AbstractBuilder + withPhaseTermination(PhaseTermination phaseTermination) { + this.phaseTermination = phaseTermination; + return this; + } + + public AbstractBuilder + withBestSolutionRecaller(BestSolutionRecaller bestSolutionRecaller) { + this.bestSolutionRecaller = bestSolutionRecaller; + return this; + } + + public abstract Type_ build(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java index 8ee46dda709..1656fdd1443 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java @@ -53,4 +53,6 @@ public interface EvolutionaryDecider> { void stepStarted(EvolutionaryAlgorithmStepScope abstractStepScope); void stepEnded(EvolutionaryAlgorithmStepScope abstractStepScope); + + void solvingError(SolverScope solverScope, Exception exception); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java deleted file mode 100644 index 2636978baab..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchConfiguration.java +++ /dev/null @@ -1,37 +0,0 @@ -package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; - -import java.util.Objects; - -import ai.timefold.solver.core.api.score.Score; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; -import ai.timefold.solver.core.impl.phase.Phase; -import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; -import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; - -import org.jspecify.annotations.Nullable; - -public record HybridGeneticSearchConfiguration, State_ extends SolutionState>( - int populationSize, int generationSize, int eliteSolutionSize, int populationRestartCount, - ConstructionIndividualStrategy constructionIndividualStrategy, Phase localSearchPhase, - @Nullable Phase refinementPhase, CrossoverStrategy crossoverStrategy, - IndividualBuilder individualBuilder, - SolutionStateManager solutionStateManager, PhaseTermination phaseTermination, - BestSolutionRecaller bestSolutionRecaller) { - - static , State_ extends SolutionState> - HybridGeneticSearchConfiguration - of(HybridGeneticSearchDecider.Builder builder) { - return new HybridGeneticSearchConfiguration<>(builder.populationSize, - builder.generationSize, builder.eliteSolutionSize, builder.populationRestartCount, - Objects.requireNonNull(builder.constructionIndividualStrategy), - Objects.requireNonNull(builder.localSearchPhase), - builder.refinementPhase, Objects.requireNonNull(builder.crossoverStrategy), - Objects.requireNonNull(builder.individualBuilder), Objects.requireNonNull(builder.solutionStateManager), - Objects.requireNonNull(builder.phaseTermination), - Objects.requireNonNull(builder.bestSolutionRecaller)); - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java index b107a91ca9f..4bec8a1827b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; +import static ai.timefold.solver.core.impl.solver.thread.ChildThreadType.EVOLUTIONARY_AGENT_THREAD; + import java.util.Objects; import ai.timefold.solver.core.api.score.Score; @@ -7,17 +9,8 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.DefaultPopulation; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; -import ai.timefold.solver.core.impl.phase.Phase; -import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; -import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; -import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -54,17 +47,19 @@ */ @NullMarked public final class HybridGeneticSearchDecider, State_ extends SolutionState> - implements EvolutionaryDecider { + extends AbstractHybridGeneticSearchDecider { - private final HybridGeneticSearchConfiguration configuration; + private final HybridGeneticSearchWorkerContext workerContext; @Nullable private HybridGeneticSearchWorker worker = null; - private long lastBestIter; - - public HybridGeneticSearchDecider(Builder builder) { - this.configuration = HybridGeneticSearchConfiguration.of(builder); + public HybridGeneticSearchDecider(AbstractBuilder builder) { + super(builder.logIndentation, builder.populationSize, builder.generationSize, builder.eliteSolutionSize, + builder.populationRestartCount, Objects.requireNonNull(builder.bestSolutionRecaller)); + this.workerContext = new HybridGeneticSearchWorkerContext<>(builder.constructionIndividualStrategy, + builder.localSearchPhase, builder.refinementPhase, builder.crossoverStrategy, builder.individualBuilder, + builder.solutionStateManager); } // ************************************************************************ @@ -73,19 +68,19 @@ public HybridGeneticSearchDecider(Builder builder) @Override public Population emptyPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { - return new DefaultPopulation<>(phaseScope.getWorkingRandom(), configuration.populationSize(), - configuration.generationSize(), configuration.eliteSolutionSize()); + return new DefaultPopulation<>(populationSize, generationSize, eliteSolutionSize); } @Override public void loadPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { var population = phaseScope. getPopulation(); var nonNullWorker = Objects.requireNonNull(worker); - while (population.size() < configuration.populationSize()) { + while (population.size() < populationSize) { if (phaseScope.getTermination().isPhaseTerminated(phaseScope)) { break; } - nonNullWorker.generateIndividual(phaseScope, individual -> population.addIndividual(individual, false)); + nonNullWorker.generateIndividual(phaseScope, population::getBestIndividual, + individual -> population.addIndividual(individual, false)); // Each individual produced contributes one additional movement. // Therefore, the movement speed will be the number of produced individuals divided by time. phaseScope.getSolverScope().addMoveEvaluationCount(1L); @@ -96,12 +91,12 @@ public void loadPopulation(EvolutionaryAlgorithmPhaseScope phaseScope public void evolvePopulation(EvolutionaryAlgorithmStepScope stepScope) { var phaseScope = stepScope.getPhaseScope(); var population = phaseScope. getPopulation(); - var firstIndividual = population.selectIndividual(); - var secondIndividual = population.selectIndividual(); + var firstIndividual = population.selectIndividual(stepScope.getWorkingRandom()); + var secondIndividual = population.selectIndividual(stepScope.getWorkingRandom()); var bailout = population.size(); while (bailout > 0 && (firstIndividual == secondIndividual || firstIndividual.getScore().equals(secondIndividual.getScore()))) { - secondIndividual = population.selectIndividual(); + secondIndividual = population.selectIndividual(stepScope.getWorkingRandom()); bailout--; } Objects.requireNonNull(worker).applyCrossover(stepScope, firstIndividual, secondIndividual, @@ -111,160 +106,49 @@ public void evolvePopulation(EvolutionaryAlgorithmStepScope stepScope phaseScope.getSolverScope().addMoveEvaluationCount(1L); } - // ************************************************************************ - // Lifecycle methods - // ************************************************************************ - @Override - public void solvingStarted(SolverScope solverScope) { - // Do nothing + public void restart(EvolutionaryAlgorithmStepScope stepScope, int size) { + // Each individual produced contributes one additional movement. + // Therefore, the movement speed will be the number of produced individuals divided by time. + var population = stepScope. getPopulation(); + Objects.requireNonNull(worker).restartPopulation(stepScope.getPhaseScope(), size, + population::getBestIndividual, + individual -> stepScope.getPhaseScope().getSolverScope().addMoveEvaluationCount(1L)); } - @Override - public void solvingEnded(SolverScope solverScope) { - // Do nothing - } + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ @Override public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { - var workerScoreDirector = - phaseScope. getScoreDirector().createChildThreadScoreDirector(ChildThreadType.MOVE_THREAD); - var workerSolutionUpdater = - new DefaultBestSolutionUpdater<>(phaseScope, configuration.bestSolutionRecaller(), phaseScope.getPopulation(), - configuration.solutionStateManager()); + super.phaseStarted(phaseScope); + var workerSolverScope = phaseScope.getSolverScope().createChildThreadSolverScope(EVOLUTIONARY_AGENT_THREAD); + var bestSolutionUpdater = new DefaultBestSolutionUpdater<>(phaseScope, bestSolutionRecaller, phaseScope.getPopulation(), + workerContext.solutionStateManager()); + var context = + new HybridGeneticSearchWorkerContext<>(workerContext.constructionIndividualStrategy(), + workerContext.localSearchPhase(), workerContext.refinementPhase(), workerContext.crossoverStrategy(), + workerContext.individualBuilder(), workerContext.solutionStateManager()); this.worker = - new HybridGeneticSearchWorker<>(configuration.constructionIndividualStrategy(), - configuration.localSearchPhase(), - configuration.refinementPhase(), configuration.crossoverStrategy(), configuration.individualBuilder(), - configuration.solutionStateManager(), workerSolutionUpdater, workerScoreDirector); + new HybridGeneticSearchWorker<>(context, bestSolutionUpdater, workerSolverScope); this.worker.phaseStarted(phaseScope); - this.lastBestIter = 0; } @Override public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + super.phaseEnded(phaseScope); Objects.requireNonNull(worker).phaseEnded(phaseScope); worker = null; } - @Override - public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { - var phaseScope = stepScope.getPhaseScope(); - if (lastBestIter == 0) { - this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); - } else { - var size = configuration.populationSize() - configuration.eliteSolutionSize(); - var restart = (phaseScope.getPopulation().getStatistics().individualCount() - lastBestIter) > configuration - .populationRestartCount(); - if (restart) { - // Each individual produced contributes one additional movement. - // Therefore, the movement speed will be the number of produced individuals divided by time. - Objects.requireNonNull(worker).restartPopulation(phaseScope, size, - individual -> stepScope.getPhaseScope().getSolverScope().addMoveEvaluationCount(1L)); - this.lastBestIter = phaseScope.getPopulation().getStatistics().individualCount(); - } - } - } - - @Override - public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { - // Do nothing - } - @NullMarked - @SuppressWarnings("rawtypes") - public static class Builder, State_ extends SolutionState, Type_ extends EvolutionaryDecider> { - - int populationSize; - int generationSize; - int eliteSolutionSize; - int populationRestartCount; - @Nullable - ConstructionIndividualStrategy constructionIndividualStrategy; - @Nullable - Phase localSearchPhase; - @Nullable - Phase refinementPhase; - @Nullable - CrossoverStrategy crossoverStrategy; - @Nullable - IndividualBuilder individualBuilder; - @Nullable - SolutionStateManager solutionStateManager; - @Nullable - PhaseTermination phaseTermination; - @Nullable - BestSolutionRecaller bestSolutionRecaller; - - public Builder withPopulationSize(int populationSize) { - this.populationSize = populationSize; - return this; - } - - public Builder withGenerationSize(int generationSize) { - this.generationSize = generationSize; - return this; - } - - public Builder withEliteSolutionSize(int eliteSolutionSize) { - this.eliteSolutionSize = eliteSolutionSize; - return this; - } - - public Builder withPopulationRestartCount(int populationRestartCount) { - this.populationRestartCount = populationRestartCount; - return this; - } - - public Builder - withConstructionIndividualStrategy( - ConstructionIndividualStrategy constructionIndividualStrategy) { - this.constructionIndividualStrategy = constructionIndividualStrategy; - return this; - } - - public Builder withLocalSearchPhase(Phase localSearchPhase) { - this.localSearchPhase = localSearchPhase; - return this; - } - - public Builder withRefinementPhase(@Nullable Phase swapStarPhase) { - this.refinementPhase = swapStarPhase; - return this; - } - - public Builder - withCrossoverStrategy(CrossoverStrategy crossoverStrategy) { - this.crossoverStrategy = crossoverStrategy; - return this; - } - - public Builder - withIndividualBuilder(IndividualBuilder individualBuilder) { - this.individualBuilder = individualBuilder; - return this; - } - - public Builder - withSolutionStateManager(SolutionStateManager solutionInitializer) { - this.solutionStateManager = solutionInitializer; - return this; - } - - public Builder withPhaseTermination(PhaseTermination phaseTermination) { - this.phaseTermination = phaseTermination; - return this; - } - - public Builder - withBestSolutionRecaller(BestSolutionRecaller bestSolutionRecaller) { - this.bestSolutionRecaller = bestSolutionRecaller; - return this; - } + public static class Builder, State_ extends SolutionState> + extends AbstractHybridGeneticSearchDecider.AbstractBuilder { @SuppressWarnings("unchecked") - public Type_ build() { - return (Type_) new HybridGeneticSearchDecider<>(this); + public HybridGeneticSearchDecider build() { + return new HybridGeneticSearchDecider<>(this); } } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java index 5b6a3cccdcc..6f714c0474b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java @@ -3,28 +3,27 @@ import java.util.ArrayList; import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Supplier; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution.BestSolutionUpdater; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; 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.solver.scope.SolverScope; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** - * Implementation of the foundational components of the HGS algorithm. Each worker has its own score director, allowing it to - * apply its logic for generating and refining new individuals without affecting the state of the root solver's scope during + * Implementation of the foundational components of the HGS algorithm. + * Each worker has its own solver scope, allowing it to + * apply its logic for generating and refining new individuals + * without affecting the state of the root solver's scope during * execution. *

* To ensure that the generation of individuals starts from a fresh phase scope, a copy of the scope is always created when @@ -41,31 +40,18 @@ @NullMarked public class HybridGeneticSearchWorker, State_ extends SolutionState> { - private final ConstructionIndividualStrategy constructionIndividualStrategy; - private final Phase localSearchPhase; - private final @Nullable Phase refinementPhase; - private final CrossoverStrategy crossoverStrategy; - private final IndividualBuilder individualBuilder; - private final SolutionStateManager solutionManager; + private final HybridGeneticSearchWorkerContext context; private final BestSolutionUpdater bestSolutionUpdater; - private final InnerScoreDirector ownScoreDirector; + private final SolverScope ownSolverScope; @Nullable private State_ initialState; - public HybridGeneticSearchWorker(ConstructionIndividualStrategy constructionIndividualStrategy, - Phase localSearchPhase, @Nullable Phase refinementPhase, - CrossoverStrategy crossoverStrategy, IndividualBuilder individualBuilder, - SolutionStateManager solutionManager, - BestSolutionUpdater bestSolutionUpdater, InnerScoreDirector ownScoreDirector) { - this.constructionIndividualStrategy = constructionIndividualStrategy; - this.localSearchPhase = localSearchPhase; - this.refinementPhase = refinementPhase; - this.crossoverStrategy = crossoverStrategy; - this.individualBuilder = individualBuilder; - this.solutionManager = solutionManager; + public HybridGeneticSearchWorker(HybridGeneticSearchWorkerContext context, + BestSolutionUpdater bestSolutionUpdater, SolverScope ownSolverScope) { + this.context = context; this.bestSolutionUpdater = bestSolutionUpdater; - this.ownScoreDirector = ownScoreDirector; + this.ownSolverScope = ownSolverScope; } // ************************************************************************ @@ -79,6 +65,7 @@ public HybridGeneticSearchWorker(ConstructionIndividualStrategy sharedPhaseScope, + Supplier<@Nullable Individual> bestSolutionSupplier, Consumer> individualConsumer) { if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { return; @@ -86,15 +73,18 @@ public void generateIndividual(EvolutionaryAlgorithmPhaseScope shared // The solver's working solution is restored to its initial state, and a separate phase scope is created. var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); var stepScope = new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope); - var newIndividual = constructionIndividualStrategy.apply(stepScope); + stepScope.setBestIndividual(bestSolutionSupplier.get()); + var newIndividual = context.constructionIndividualStrategy().apply(stepScope); var addIndividual = true; var oldScore = newIndividual.getScore(); if (!newIndividual.getScore().raw().isFeasible() && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { - individualConsumer.accept(newIndividual.clone(ownScoreDirector)); - applyPhases(restoredPhaseScope, localSearchPhase, refinementPhase); - if (newIndividual.getScore().compareTo(oldScore) == 0) { + var clonedIndividual = newIndividual.clone(ownSolverScope.getScoreDirector()); + individualConsumer.accept(clonedIndividual); + applyPhases(restoredPhaseScope, context.localSearchPhase(), context.refinementPhase()); + if (restoredPhaseScope. getBestScore().compareTo(oldScore) <= 0) { addIndividual = false; + newIndividual = clonedIndividual; } } if (addIndividual) { @@ -120,24 +110,34 @@ public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepS } var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); var crossoverContext = new CrossoverContext<>(restoredPhaseScope, firstIndividual, secondIndividual); - var offspringResult = crossoverStrategy.apply(crossoverContext); - var offspringIndividual = individualBuilder.build(offspringResult.solution(), offspringResult.score(), - offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownScoreDirector); + var offspringResult = context.crossoverStrategy().apply(crossoverContext); + var offspringIndividual = context.individualBuilder().build(offspringResult.solution(), offspringResult.score(), + offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownSolverScope.getScoreDirector()); var addIndividual = true; var oldScore = offspringIndividual.getScore(); if (!offspringIndividual.getScore().raw().isFeasible() && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { - individualConsumer.accept(offspringIndividual.clone(ownScoreDirector)); - applyPhases(restoredPhaseScope, localSearchPhase, refinementPhase); - if (offspringIndividual.getScore().compareTo(oldScore) == 0) { + individualConsumer.accept(offspringIndividual.clone(ownSolverScope.getScoreDirector())); + applyPhases(restoredPhaseScope, context.localSearchPhase(), context.refinementPhase()); + if (restoredPhaseScope. getBestScore().compareTo(oldScore) == 0) { addIndividual = false; } } if (addIndividual) { - individualConsumer.accept(offspringIndividual); + var otherOffspringIndividual = context.individualBuilder().build( + restoredPhaseScope.getSolverScope().getBestSolution(), restoredPhaseScope.getBestScore(), + offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownSolverScope.getScoreDirector()); + individualConsumer.accept(otherOffspringIndividual); + sharedStepScope.setStepIndividual(otherOffspringIndividual); + sharedStepScope.setScore(otherOffspringIndividual.getScore()); + bestSolutionUpdater + .updateBestSolution(new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope, otherOffspringIndividual)); + } else { + sharedStepScope.setStepIndividual(offspringIndividual); + sharedStepScope.setScore(offspringIndividual.getScore()); + bestSolutionUpdater + .updateBestSolution(new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope, offspringIndividual)); } - sharedStepScope.setStepIndividual(offspringIndividual); - sharedStepScope.setScore(offspringResult.score()); } /** @@ -146,12 +146,15 @@ public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepS * * @param state the state to be restored */ - private EvolutionaryAlgorithmPhaseScope restoreState(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + protected EvolutionaryAlgorithmPhaseScope restoreState( + EvolutionaryAlgorithmPhaseScope sharedPhaseScope, State_ state) { - solutionManager.restoreSolutionState(ownScoreDirector, state); - var restoredPhaseScope = sharedPhaseScope.copy(ownScoreDirector); + context.solutionStateManager().restoreSolutionState(ownSolverScope.getScoreDirector(), state); + var restoredPhaseScope = sharedPhaseScope.createChildThreadPhaseScope(ownSolverScope); restoredPhaseScope.getSolverScope().setBestScore(state.getScore()); restoredPhaseScope.getSolverScope().setBestSolutionTimeMillis(restoredPhaseScope.getSolverScope().getClock().millis()); + // The best solution events are disabled while the inner phases are being executed + restoredPhaseScope.getSolverScope().setTriggerBestSolutionEvent(false); return restoredPhaseScope; } @@ -162,13 +165,14 @@ private EvolutionaryAlgorithmPhaseScope restoreState(EvolutionaryAlgo * @param size the size of the population */ public void restartPopulation(EvolutionaryAlgorithmPhaseScope sharedPhaseScope, int size, + Supplier<@Nullable Individual> bestSolutionSupplier, Consumer> individualConsumer) { var individualList = new ArrayList>(size); while (individualList.size() < size) { if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { break; } - generateIndividual(sharedPhaseScope, individual -> { + generateIndividual(sharedPhaseScope, bestSolutionSupplier, individual -> { individualList.add(individual); individualConsumer.accept(individual); }); @@ -263,16 +267,16 @@ public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) // The cancellation of demand is disabled, so when a resource counter reaches zero, it is not removed. // This allows the algorithm // to function without recalculating resources such as nearby matrices and value range caches. - ownScoreDirector.getSupplyManager().disableDemandCancellation(); + this.ownSolverScope.getScoreDirector().getSupplyManager().disableDemandCancellation(); // A solution that has only pinned values assigned is preferred for generating new individuals - this.initialState = solutionManager.saveSolutionState(ownScoreDirector, false); + this.initialState = context.solutionStateManager().saveSolutionState(ownSolverScope.getScoreDirector(), false); } public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { // Enable the cancellation of demand again and cancel all to clean up the supply manager, // so it doesn't hold on to any resources. - ownScoreDirector.getSupplyManager().enableDemandCancellation(); - ownScoreDirector.getSupplyManager().cancelAll(); + ownSolverScope.getScoreDirector().getSupplyManager().enableDemandCancellation(); + ownSolverScope.getScoreDirector().getSupplyManager().cancelAll(); this.initialState = null; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java new file mode 100644 index 00000000000..733a3465c36 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java @@ -0,0 +1,16 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; +import ai.timefold.solver.core.impl.phase.Phase; + +public record HybridGeneticSearchWorkerContext, State_ extends SolutionState>( + ConstructionIndividualStrategy constructionIndividualStrategy, Phase localSearchPhase, + Phase refinementPhase, CrossoverStrategy crossoverStrategy, + IndividualBuilder individualBuilder, + SolutionStateManager solutionStateManager) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java new file mode 100644 index 00000000000..cfe41cc6141 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java @@ -0,0 +1,424 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for population implementations of the evolutionary algorithm. + *

+ * Manages the population of candidate solutions following the biased-fitness survival strategy + * described in the HGS (Hybrid Genetic Search) original article. + *

+ * Individuals are split into two subpopulations — feasible and infeasible, + * each kept sorted by score in descending order (best individual first). + * The combined capacity is {@code populationSize + generationSize} and once that threshold + * is reached, survival selection reduces the population back to {@code populationSize} + * by removing the least-fit individuals. + *

+ * Fitness is a composite measure that rewards both solution quality and + * contribution to population diversity. + * Lower fitness is better, + * and individuals whose solution is identical to all others (zero diversity) are always removed first. + *

+ * Selection uses binary tournament: two candidates are selected at random from + * the combined population, and the one with the lower fitness is returned. + *

+ * Restart preserves the {@code eliteSolutionSize} best individuals + * (feasible first, then infeasible) before clearing the population and seeding + * it with a new set of individuals. + */ +@NullMarked +public abstract class AbstractPopulation> implements Population { + + protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractPopulation.class); + + protected final int populationSize; + protected final int eliteSolutionSize; + protected final int maxSize; + protected final List> feasibleIndividualList; + protected final List> infeasibleIndividualList; + protected final PopulationDiffMap diffMap; + @Nullable + protected Individual bestIndividual = null; + protected long bestGeneration = 0L; + protected long bestIteration = 0L; + protected long generationCount = 0L; + protected long individualCount = 0L; + + protected boolean feasibleFitnessUpdated = false; + protected boolean infeasibleFitnessUpdated = false; + + protected AbstractPopulation(int populationSize, int generationSize, int eliteSolutionSize) { + this.populationSize = populationSize; + this.eliteSolutionSize = eliteSolutionSize; + this.maxSize = populationSize + generationSize; + this.feasibleIndividualList = new ArrayList<>(maxSize); + this.infeasibleIndividualList = new ArrayList<>(maxSize); + // The map can store at most maxSize elements from both lists + this.diffMap = new PopulationDiffMap<>(maxSize * 2); + } + + @Override + public void addIndividual(Individual individual, boolean enableSurvivalSelection) { + var internalIndividual = addIndividualToList(individual); + if (enableSurvivalSelection) { + // Calculate the difference between the new individual and each individual in the related list + var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; + computeDiff(internalIndividual, individualList); + // Analyze and apply the survival selection strategy + analyzeSubpopulationList(individualList); + } + } + + private InternalIndividual addIndividualToList(Individual individual) { + var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; + var pos = 0; + var internalIndividual = new InternalIndividual<>(individual); + if (!individualList.isEmpty()) { + // The list is kept sorted by score descending (best first). + // Comparator.reverseOrder() ensures the best scores are added to the beginning of the list + pos = Collections.binarySearch(individualList, internalIndividual, Comparator.reverseOrder()); + if (pos < 0) { + pos = -pos - 1; + } + } + individualList.add(pos, internalIndividual); + individualCount++; + if (individualList == feasibleIndividualList) { + feasibleFitnessUpdated = false; + } else { + infeasibleFitnessUpdated = false; + } + var newBestSolution = bestIndividual == null || internalIndividual.compareTo(bestIndividual) > 0; + if (newBestSolution) { + bestIndividual = individual; + bestGeneration = generationCount; + bestIteration = individualCount; + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Added individual iteration ({}), generation({}), feasible population size({}), infeasible population size({}), individual score ({}), first parent score ({}), second parent score ({}), best score ({}), best generation ({}), best iteration ({}), **new best solution({})", + individualCount, generationCount, feasibleIndividualList.size(), infeasibleIndividualList.size(), + individual.getScore().raw(), + individual.getFirstParentScore() != null ? individual.getFirstParentScore().raw() : "-", + individual.getSecondParentScore() != null ? individual.getSecondParentScore().raw() : "-", + bestIndividual.getScore().raw(), bestGeneration, bestIteration, newBestSolution); + } + return internalIndividual; + } + + @Override + public void restart(List> individuals) { + var eliteIndividuals = new ArrayList>(eliteSolutionSize); + var feasibleCount = feasibleIndividualList.size(); + if (feasibleCount >= eliteSolutionSize) { + eliteIndividuals.addAll(feasibleIndividualList.subList(0, eliteSolutionSize)); + } else { + eliteIndividuals.addAll(feasibleIndividualList); + var infeasibleEliteCount = Math.min(eliteSolutionSize - feasibleCount, infeasibleIndividualList.size()); + eliteIndividuals.addAll(infeasibleIndividualList.subList(0, infeasibleEliteCount)); + } + feasibleIndividualList.clear(); + infeasibleIndividualList.clear(); + diffMap.clear(); + eliteIndividuals.forEach(individual -> this.addIndividualToList(individual.innerIndividual)); + individuals.forEach(this::addIndividualToList); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Restarting population, size({}), generation({}), best generation({}), best iscore ({})", size(), + generationCount, bestGeneration, bestIndividual != null ? bestIndividual.getScore().raw() : "-"); + } + } + + @Override + public PopulationStatistics getStatistics() { + return new PopulationStatistics(generationCount, individualCount, bestGeneration, bestIteration); + } + + @Override + public int size() { + return feasibleIndividualList.size() + infeasibleIndividualList.size(); + } + + /** + * Calculates the difference between the given individual and all other individuals from the given list. + * + * @param individual the first individual + * @param individualList the list to be evaluated + */ + private void computeDiff(Individual individual, + List> individualList) { + for (var otherIndividual : individualList) { + if (individual == otherIndividual) { + continue; + } + var diff = individual.diff(otherIndividual.innerIndividual); + diffMap.addIndividualDiff(individual, otherIndividual, diff); + } + } + + /** + * The survival method removes the worst individual from the population until the population size is restored. + * This removal is based on the fitness of each individual, + * which is calculated according to their contribution to the diversity of the population. + * + * @param subpopulationList the population to be analyzed + */ + private void analyzeSubpopulationList(List> subpopulationList) { + if (subpopulationList.size() < maxSize) { + return; + } + // Remove extra individuals until the population is restored to populationSize. + // Fitness is computed once before the removal loop, + // and subsequent removals use the pre-computed ranks, + // which is a valid approximation since generationSize is small relative to populationSize. + var sizeToRemove = subpopulationList.size() - populationSize; + updateSubpopulationFitness(subpopulationList); + for (int i = 0; i < sizeToRemove; i++) { + // Find and remove the worst individual + var worstIndividualIndex = 0; + InternalIndividual worstIndividual = null; + // It means all other individuals from the subpopulation have the same solution + var hasWorstIndividualSameSolution = false; + for (var j = 0; j < subpopulationList.size(); j++) { + var otherIndividual = subpopulationList.get(j); + // The average value will be the sum of all diffs. + // If all other solutions have diff equal to zero, + // it means all individuals have the same solution + var hasSameSolution = averageDiff(otherIndividual, 1) == 0d; + // 1 - We select the individual if it has no diff and the current worst element has any diff + // 2 - We also select the individual if the fitness is higher, which means it is worse + if ((worstIndividual == null) || (hasSameSolution && !hasWorstIndividualSameSolution) + || (hasWorstIndividualSameSolution == hasSameSolution + && otherIndividual.getFitness() > worstIndividual.getFitness())) { + worstIndividualIndex = j; + worstIndividual = otherIndividual; + hasWorstIndividualSameSolution = hasSameSolution; + } + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Removed individual, position({}), individual fitness ({}), individual score ({}), best score ({})", + worstIndividualIndex, subpopulationList.get(worstIndividualIndex).getFitness(), + subpopulationList.get(worstIndividualIndex).getScore().raw(), + bestIndividual != null ? bestIndividual.getScore().raw() : "-"); + } + subpopulationList.remove(worstIndividualIndex); + diffMap.removeIndividualDiff(worstIndividual); + if (subpopulationList == feasibleIndividualList) { + feasibleFitnessUpdated = false; + } else { + infeasibleFitnessUpdated = false; + } + } + generationCount++; + if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "New generation ({}), Best generation ({}), Feasible population size ({}), Infeasible population size ({})", + generationCount, bestGeneration, feasibleIndividualList.size(), infeasibleIndividualList.size()); + } + + } + + /** + * The ranking method follows the logic proposed in the HGS article, + * using both solution quality and diversity contribution to estimate fitness. + */ + private void updateSubpopulationFitness(List> subpopulationList) { + var isFeasiblePopulation = subpopulationList == feasibleIndividualList; + if ((isFeasiblePopulation && feasibleFitnessUpdated) || (!isFeasiblePopulation && infeasibleFitnessUpdated)) { + return; + } + var subpopulationSize = subpopulationList.size(); + var avgDiffs = new double[subpopulationSize]; + for (var i = 0; i < subpopulationSize; i++) { + avgDiffs[i] = averageDiff(subpopulationList.get(i), eliteSolutionSize); + } + // sortedIndices[rank] = original index in subpopulationList, sorted by descending avgDiff + var sortedIndices = new Integer[subpopulationSize]; + for (var i = 0; i < subpopulationSize; i++) { + sortedIndices[i] = i; + } + // Rank according to the average diff and contribution to the diversity + Arrays.sort(sortedIndices, Comparator.comparingDouble(i -> -avgDiffs[i])); + if (subpopulationSize > 1) { + var rankRatio = 1.0 / (double) (subpopulationSize - 1); + var diversityWeight = (subpopulationSize >= eliteSolutionSize) + ? 1.0 - (double) eliteSolutionSize / (double) subpopulationSize + : 0.0; + for (var rank = 0; rank < subpopulationSize; rank++) { + int idx = sortedIndices[rank]; + // The list is already sorted by the score + var scoreRank = rank * rankRatio; + var diffRank = idx * rankRatio; + subpopulationList.get(idx).setFitness(diffRank + diversityWeight * scoreRank); + } + } + if (isFeasiblePopulation) { + feasibleFitnessUpdated = true; + } else { + infeasibleFitnessUpdated = true; + } + + } + + /** + * Calculates the average diff to the {@code size} nearest (most similar) individuals. + * + * @param individual the individual to be evaluated + * @param size the number of nearest individuals + * @return a double where a higher value reflects the greater average difference to nearest individuals + */ + private double averageDiff(Individual individual, int size) { + var individualDiffMap = diffMap.getIndividualDiffMap(individual); + var otherIndividualsCount = individualDiffMap.size(); + if (otherIndividualsCount == 0) { + return 0.0; + } + // Hot path for a size of one + if (size == 1) { + var min = Double.MAX_VALUE; + for (var diff : individualDiffMap.values()) { + if (diff < min) { + min = diff; + } + } + return min; + } + // All other individuals fit within the limit, so we can calculate the average without sorting + if (otherIndividualsCount <= size) { + var result = 0.d; + for (var diff : individualDiffMap.values()) { + result += diff; + } + return result / (double) otherIndividualsCount; + } + // Sort the individuals ascending and compute only k nearst ones + var diffs = new double[otherIndividualsCount]; + var i = 0; + for (var diff : individualDiffMap.values()) { + diffs[i++] = diff; + } + Arrays.sort(diffs); + var result = 0.d; + for (var j = 0; j < size; j++) { + result += diffs[j]; + } + return result / (double) size; + } + + @Override + public Individual selectIndividual(RandomGenerator workingRandom) { + var size = feasibleIndividualList.size() + infeasibleIndividualList.size(); + var firstIdx = workingRandom.nextInt(0, size); + var secondIdx = size > 1 ? workingRandom.nextInt(0, size - 1) : firstIdx; + if (size > 1 && secondIdx >= firstIdx) { + secondIdx++; + } + var firstIndividual = (firstIdx >= feasibleIndividualList.size()) + ? infeasibleIndividualList.get(firstIdx - feasibleIndividualList.size()) + : feasibleIndividualList.get(firstIdx); + var secondIndividual = (secondIdx >= feasibleIndividualList.size()) + ? infeasibleIndividualList.get(secondIdx - feasibleIndividualList.size()) + : feasibleIndividualList.get(secondIdx); + var updateFeasiblePopulation = firstIdx >= feasibleIndividualList.size() || secondIdx < feasibleIndividualList.size(); + var updateInfeasiblePopulation = + firstIdx >= feasibleIndividualList.size() || secondIdx >= feasibleIndividualList.size(); + if (updateFeasiblePopulation) { + updateSubpopulationFitness(feasibleIndividualList); + } + if (updateInfeasiblePopulation) { + updateSubpopulationFitness(infeasibleIndividualList); + } + return firstIndividual.getFitness() < secondIndividual.getFitness() ? firstIndividual : secondIndividual; + } + + @Nullable + @Override + public Individual getBestIndividual() { + return bestIndividual; + } + + protected static class InternalIndividual> + implements Individual { + + final Individual innerIndividual; + private double fitness; + + protected InternalIndividual(Individual innerIndividual) { + this.innerIndividual = innerIndividual; + } + + @Override + public Solution_ getSolution() { + return innerIndividual.getSolution(); + } + + @Override + public ChromosomeEntry[] getChromosome() { + return innerIndividual.getChromosome(); + } + + @Override + public int size() { + return innerIndividual.size(); + } + + @Override + public double diff(Individual otherIndividual) { + return innerIndividual.diff(otherIndividual); + } + + @Override + public boolean isFeasible() { + return innerIndividual.isFeasible(); + } + + @Override + public Individual clone(InnerScoreDirector scoreDirector) { + return innerIndividual.clone(scoreDirector); + } + + @Override + public InnerScore getFirstParentScore() { + return innerIndividual.getFirstParentScore(); + } + + @Override + public InnerScore getSecondParentScore() { + return innerIndividual.getSecondParentScore(); + } + + @Override + public InnerScore getScore() { + return innerIndividual.getScore(); + } + + @Override + public int compareTo(Individual o) { + return innerIndividual.compareTo(o); + } + + public double getFitness() { + return fitness; + } + + public void setFitness(double fitness) { + this.fitness = fitness; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java index ebcac195550..3ed7d8e70cc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java @@ -1,425 +1,19 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.random.RandomGenerator; - import ai.timefold.solver.core.api.score.Score; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.ChromosomeEntry; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; -import ai.timefold.solver.core.impl.score.director.InnerScore; -import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** - * Manages the population of candidate solutions for the evolutionary algorithm, - * following the biased-fitness survival strategy described in the HGS - * (Hybrid Genetic Search) original article. - *

- * Individuals are split into two subpopulations — feasible and infeasible, - * each kept sorted by score in descending order (best individual first). - * The combined capacity is {@code populationSize + generationSize} and once that threshold - * is reached, survival selection reduces the population back to {@code populationSize} - * by removing the least-fit individuals. - *

- * Fitness is a composite measure that rewards both solution quality and - * contribution to population diversity. - * Lower fitness is better, - * and individuals whose solution is identical to all others (zero diversity) are always removed first. - *

- * Selection uses binary tournament: two candidates are selected at random from - * the combined population, and the one with the lower fitness is returned. - *

- * Restart preserves the {@code eliteSolutionSize} best individuals - * (feasible first, then infeasible) before clearing the population and seeding - * it with a new set of individuals. + * Single-threaded implementation of {@link Population}. + * + * @see AbstractPopulation */ @NullMarked -public final class DefaultPopulation> implements Population { - - private static final Logger LOGGER = LoggerFactory.getLogger(DefaultPopulation.class); - private final RandomGenerator workingRandom; - private final int populationSize; - private final int eliteSolutionSize; - private final int maxSize; - private final List> feasibleIndividualList; - private final List> infeasibleIndividualList; - private final PopulationDiffMap diffMap; - @Nullable - private Individual bestIndividual = null; - private long bestGeneration = 0L; - private long bestIteration = 0L; - private long generationCount = 0L; - private long individualCount = 0L; - - private boolean feasibleFitnessUpdated = false; - private boolean infeasibleFitnessUpdated = false; - - public DefaultPopulation(RandomGenerator workingRandom, int populationSize, int generationSize, int eliteSolutionSize) { - this.workingRandom = workingRandom; - this.populationSize = populationSize; - this.eliteSolutionSize = eliteSolutionSize; - this.maxSize = populationSize + generationSize; - this.feasibleIndividualList = new ArrayList<>(maxSize); - this.infeasibleIndividualList = new ArrayList<>(maxSize); - // The map can store at most maxSize elements from both lists - this.diffMap = new PopulationDiffMap<>(maxSize * 2); - } - - @Override - public void addIndividual(Individual individual, boolean enableSurvivalSelection) { - var internalIndividual = addIndividualToList(individual); - if (enableSurvivalSelection) { - // Calculate the difference between the new individual and each individual in the related list - var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; - computeDiff(internalIndividual, individualList); - // Analyze and apply the survival selection strategy - analyzeSubpopulationList(individualList); - } - } - - private InternalIndividual addIndividualToList(Individual individual) { - var individualList = individual.isFeasible() ? feasibleIndividualList : infeasibleIndividualList; - var pos = 0; - var internalIndividual = new InternalIndividual<>(individual); - if (!individualList.isEmpty()) { - // The list is kept sorted by score descending (best first). - // Comparator.reverseOrder() ensures the best scores are added to the beginning of the list - pos = Collections.binarySearch(individualList, internalIndividual, Comparator.reverseOrder()); - if (pos < 0) { - pos = -pos - 1; - } - } - individualList.add(pos, internalIndividual); - individualCount++; - if (individualList == feasibleIndividualList) { - feasibleFitnessUpdated = false; - } else { - infeasibleFitnessUpdated = false; - } - if (LOGGER.isDebugEnabled()) { - LOGGER.debug( - "Added individual iteration ({}), generation({}), feasible population size({}), infeasible population size({}), individual score ({}), first parent score ({}), second parent score ({}), best score ({}), best generation ({}), best iteration ({})", - individualCount, generationCount, feasibleIndividualList.size(), infeasibleIndividualList.size(), - individual.getScore().raw(), - individual.getFirstParentScore() != null ? individual.getFirstParentScore().raw() : "-", - individual.getSecondParentScore() != null ? individual.getSecondParentScore().raw() : "-", - bestIndividual != null ? bestIndividual.getScore().raw() : "-", bestGeneration, bestIteration); - } - if (bestIndividual == null || internalIndividual.compareTo(bestIndividual) > 0) { - bestIndividual = individual; - bestGeneration = generationCount; - bestIteration = individualCount; - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("New best individual, iteration ({}), generation({}), score ({})", bestIteration, bestGeneration, - bestIndividual.getScore().raw()); - } - } - return internalIndividual; - } - - @Override - public void restart(List> individuals) { - var eliteIndividuals = new ArrayList>(eliteSolutionSize); - var feasibleCount = feasibleIndividualList.size(); - if (feasibleCount >= eliteSolutionSize) { - eliteIndividuals.addAll(feasibleIndividualList.subList(0, eliteSolutionSize)); - } else { - eliteIndividuals.addAll(feasibleIndividualList); - var infeasibleEliteCount = Math.min(eliteSolutionSize - feasibleCount, infeasibleIndividualList.size()); - eliteIndividuals.addAll(infeasibleIndividualList.subList(0, infeasibleEliteCount)); - } - feasibleIndividualList.clear(); - infeasibleIndividualList.clear(); - diffMap.clear(); - eliteIndividuals.forEach(individual -> this.addIndividualToList(individual.innerIndividual)); - individuals.forEach(this::addIndividualToList); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Restarting population, size({}), generation({}), best generation({}), best iscore ({})", size(), - generationCount, bestGeneration, bestIndividual != null ? bestIndividual.getScore().raw() : "-"); - } - } - - @Override - public PopulationStatistics getStatistics() { - return new PopulationStatistics(generationCount, individualCount, bestGeneration, bestIteration); - } - - @Override - public int size() { - return feasibleIndividualList.size() + infeasibleIndividualList.size(); - } - - /** - * Calculates the difference between the given individual and all other individuals from the given list. - * - * @param individual the first individual - * @param individualList the list to be evaluated - */ - private void computeDiff(Individual individual, - List> individualList) { - for (var otherIndividual : individualList) { - if (individual == otherIndividual) { - continue; - } - var diff = individual.diff(otherIndividual.innerIndividual); - diffMap.addIndividualDiff(individual, otherIndividual, diff); - } - } - - /** - * The survival method removes the worst individual from the population until the population size is restored. - * This removal is based on the fitness of each individual, - * which is calculated according to their contribution to the diversity of the population. - * - * @param subpopulationList the population to be analyzed - */ - private void analyzeSubpopulationList(List> subpopulationList) { - if (subpopulationList.size() < maxSize) { - return; - } - // Remove extra individuals until the population is restored to populationSize. - // Fitness is computed once before the removal loop, - // and subsequent removals use the pre-computed ranks, - // which is a valid approximation since generationSize is small relative to populationSize. - var sizeToRemove = subpopulationList.size() - populationSize; - updateSubpopulationFitness(subpopulationList); - for (int i = 0; i < sizeToRemove; i++) { - // Find and remove the worst individual - var worstIndividualIndex = 0; - InternalIndividual worstIndividual = null; - // It means all other individuals from the subpopulation have the same solution - var hasWorstIndividualSameSolution = false; - for (var j = 0; j < subpopulationList.size(); j++) { - var otherIndividual = subpopulationList.get(j); - // The average value will be the sum of all diffs. - // If all other solutions have diff equal to zero, - // it means all individuals have the same solution - var hasSameSolution = averageDiff(otherIndividual, 1) == 0d; - // 1 - We select the individual if it has no diff and the current worst element has any diff - // 2 - We also select the individual if the fitness is higher, which means it is worse - if ((worstIndividual == null) || (hasSameSolution && !hasWorstIndividualSameSolution) - || (hasWorstIndividualSameSolution == hasSameSolution - && otherIndividual.getFitness() > worstIndividual.getFitness())) { - worstIndividualIndex = j; - worstIndividual = otherIndividual; - hasWorstIndividualSameSolution = hasSameSolution; - } - } - if (LOGGER.isDebugEnabled()) { - LOGGER.debug( - "Removed individual, position({}), individual fitness ({}), individual score ({}), best score ({})", - worstIndividualIndex, subpopulationList.get(worstIndividualIndex).getFitness(), - subpopulationList.get(worstIndividualIndex).getScore().raw(), - bestIndividual != null ? bestIndividual.getScore().raw() : "-"); - } - subpopulationList.remove(worstIndividualIndex); - diffMap.removeIndividualDiff(worstIndividual); - if (subpopulationList == feasibleIndividualList) { - feasibleFitnessUpdated = false; - } else { - infeasibleFitnessUpdated = false; - } - } - generationCount++; - if (LOGGER.isDebugEnabled()) { - LOGGER.debug( - "New generation ({}), Best generation ({}), Feasible population size ({}), Infeasible population size ({})", - generationCount, bestGeneration, feasibleIndividualList.size(), infeasibleIndividualList.size()); - } - - } - - /** - * The ranking method follows the logic proposed in the HGS article, - * using both solution quality and diversity contribution to estimate fitness. - */ - private void updateSubpopulationFitness(List> subpopulationList) { - var isFeasiblePopulation = subpopulationList == feasibleIndividualList; - if ((isFeasiblePopulation && feasibleFitnessUpdated) || (!isFeasiblePopulation && infeasibleFitnessUpdated)) { - return; - } - var subpopulationSize = subpopulationList.size(); - var avgDiffs = new double[subpopulationSize]; - for (var i = 0; i < subpopulationSize; i++) { - avgDiffs[i] = averageDiff(subpopulationList.get(i), eliteSolutionSize); - } - // sortedIndices[rank] = original index in subpopulationList, sorted by descending avgDiff - var sortedIndices = new Integer[subpopulationSize]; - for (var i = 0; i < subpopulationSize; i++) { - sortedIndices[i] = i; - } - // Rank according to the average diff and contribution to the diversity - Arrays.sort(sortedIndices, Comparator.comparingDouble(i -> -avgDiffs[i])); - if (subpopulationSize > 1) { - var rankRatio = 1.0 / (double) (subpopulationSize - 1); - var diversityWeight = (subpopulationSize >= eliteSolutionSize) - ? 1.0 - (double) eliteSolutionSize / (double) subpopulationSize - : 0.0; - for (var rank = 0; rank < subpopulationSize; rank++) { - int idx = sortedIndices[rank]; - // The list is already sorted by the score - var scoreRank = rank * rankRatio; - var diffRank = idx * rankRatio; - subpopulationList.get(idx).setFitness(diffRank + diversityWeight * scoreRank); - } - } - if (isFeasiblePopulation) { - feasibleFitnessUpdated = true; - } else { - infeasibleFitnessUpdated = true; - } - - } - - /** - * Calculates the average diff to the {@code size} nearest (most similar) individuals. - * - * @param individual the individual to be evaluated - * @param size the number of nearest individuals - * @return a double where a higher value reflects the greater average difference to nearest individuals - */ - private double averageDiff(Individual individual, int size) { - var individualDiffMap = diffMap.getIndividualDiffMap(individual); - var otherIndividualsCount = individualDiffMap.size(); - if (otherIndividualsCount == 0) { - return 0.0; - } - // Hot path for a size of one - if (size == 1) { - var min = Double.MAX_VALUE; - for (var diff : individualDiffMap.values()) { - if (diff < min) { - min = diff; - } - } - return min; - } - // All other individuals fit within the limit, so we can calculate the average without sorting - if (otherIndividualsCount <= size) { - var result = 0.d; - for (var diff : individualDiffMap.values()) { - result += diff; - } - return result / (double) otherIndividualsCount; - } - // Sort the individuals ascending and compute only k nearst ones - var diffs = new double[otherIndividualsCount]; - var i = 0; - for (var diff : individualDiffMap.values()) { - diffs[i++] = diff; - } - Arrays.sort(diffs); - var result = 0.d; - for (var j = 0; j < size; j++) { - result += diffs[j]; - } - return result / (double) size; - } - - @Override - public Individual selectIndividual() { - var size = feasibleIndividualList.size() + infeasibleIndividualList.size(); - var firstIdx = workingRandom.nextInt(0, size); - var secondIdx = size > 1 ? workingRandom.nextInt(0, size - 1) : firstIdx; - if (size > 1 && secondIdx >= firstIdx) { - secondIdx++; - } - var firstIndividual = (firstIdx >= feasibleIndividualList.size()) - ? infeasibleIndividualList.get(firstIdx - feasibleIndividualList.size()) - : feasibleIndividualList.get(firstIdx); - var secondIndividual = (secondIdx >= feasibleIndividualList.size()) - ? infeasibleIndividualList.get(secondIdx - feasibleIndividualList.size()) - : feasibleIndividualList.get(secondIdx); - var updateFeasiblePopulation = firstIdx >= feasibleIndividualList.size() || secondIdx < feasibleIndividualList.size(); - var updateInfeasiblePopulation = - firstIdx >= feasibleIndividualList.size() || secondIdx >= feasibleIndividualList.size(); - if (updateFeasiblePopulation) { - updateSubpopulationFitness(feasibleIndividualList); - } - if (updateInfeasiblePopulation) { - updateSubpopulationFitness(infeasibleIndividualList); - } - return firstIndividual.getFitness() < secondIndividual.getFitness() ? firstIndividual : secondIndividual; - } - - @Override - public @Nullable Individual getBestIndividual() { - return bestIndividual; - } - - private static class InternalIndividual> implements Individual { - - private final Individual innerIndividual; - private double fitness; - - private InternalIndividual(Individual innerIndividual) { - this.innerIndividual = innerIndividual; - } - - @Override - public Solution_ getSolution() { - return innerIndividual.getSolution(); - } - - @Override - public ChromosomeEntry[] getChromosome() { - return innerIndividual.getChromosome(); - } - - @Override - public int size() { - return innerIndividual.size(); - } - - @Override - public double diff(Individual otherIndividual) { - return innerIndividual.diff(otherIndividual); - } - - @Override - public boolean isFeasible() { - return innerIndividual.isFeasible(); - } - - @Override - public Individual clone(InnerScoreDirector scoreDirector) { - return innerIndividual.clone(scoreDirector); - } - - @Override - public InnerScore getFirstParentScore() { - return innerIndividual.getFirstParentScore(); - } - - @Override - public InnerScore getSecondParentScore() { - return innerIndividual.getSecondParentScore(); - } - - @Override - public InnerScore getScore() { - return innerIndividual.getScore(); - } - - @Override - public int compareTo(Individual o) { - return innerIndividual.compareTo(o); - } - - public double getFitness() { - return fitness; - } +public final class DefaultPopulation> + extends AbstractPopulation { - public void setFitness(double fitness) { - this.fitness = fitness; - } + public DefaultPopulation(int populationSize, int generationSize, int eliteSolutionSize) { + super(populationSize, generationSize, eliteSolutionSize); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java index ff35eb80e44..1461934f0d9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; import java.util.List; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.evolutionaryalgorithm.decider.EvolutionaryDecider; @@ -45,7 +46,7 @@ public interface Population> { * * @return an individual from the population. */ - Individual selectIndividual(); + Individual selectIndividual(RandomGenerator workingRandom); /** * Recreate the population with new individuals. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java index 84aba09ea62..e531d3f7166 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/PopulationDiffMap.java @@ -10,7 +10,7 @@ /** * Stores information about individual differences, which are used for survival selection methods. */ -final class PopulationDiffMap> { +public final class PopulationDiffMap> { private final int size; private final Map, Map, Double>> individualMap; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java index f451083b870..cca7bfacbb1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java @@ -65,7 +65,7 @@ public Individual apply(EvolutionaryAlgorithmStepScope getConstructionPhase(EvolutionaryAlgorithmStepScope stepScope) { - if (stepScope.getPhaseScope().getPopulation().getBestIndividual() == null) { + if (stepScope.getBestIndividual() == null) { // The deterministic phase is used only once as its behavior always returns the same solution. // The shuffled phase is expected to shuffle the selector and produce different solutions. return deterministicBestFitConstructionPhase; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java index d53497694ac..304b9c269b3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java @@ -17,7 +17,6 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; @@ -78,21 +77,19 @@ public Individual apply(EvolutionaryAlgorithmStepScope command.changeWorkingSolution(commandContext)); } updateScope(phaseScope); - var population = phaseScope. getPopulation(); - if (population.getBestIndividual() == null) { + if (stepScope.getBestIndividual() == null) { applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); } else { - applyRuinRecreate(solverScope, scoreDirector, phaseScope, population); + applyRuinRecreate(solverScope, scoreDirector, phaseScope, Objects.requireNonNull(stepScope.getBestIndividual())); updateScope(phaseScope); applyPhases(phaseScope, localSearchPhase, refinementPhase); } - return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), - solverScope.getBestScore(), null, null, scoreDirector); + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + null, null, scoreDirector); } void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, - EvolutionaryAlgorithmPhaseScope phaseScope, Population population) { - var bestIndividual = Objects.requireNonNull(population.getBestIndividual()); + EvolutionaryAlgorithmPhaseScope phaseScope, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); applyRuinPhase(scoreDirector, solverScope.getWorkingRandom(), bestIndividual); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java index 7ac786f495b..050f28a4506 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -18,7 +18,6 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; @@ -82,10 +81,10 @@ public Individual apply(EvolutionaryAlgorithmStepScope getPopulation(); - if (population.getBestIndividual() == null) { + if (stepScope.getBestIndividual() == null) { applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); } else { - applyRuinRecreate(solverScope, scoreDirector, population); + applyRuinRecreate(solverScope, scoreDirector, Objects.requireNonNull(stepScope.getBestIndividual())); updateScope(stepScope.getPhaseScope()); applyPhases(phaseScope, localSearchPhase, refinementPhase); } @@ -94,8 +93,7 @@ public Individual apply(EvolutionaryAlgorithmStepScope solverScope, InnerScoreDirector scoreDirector, - Population population) { - var bestIndividual = Objects.requireNonNull(population.getBestIndividual()); + Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); 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 1525651062d..d09db5a8594 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 @@ -230,6 +230,7 @@ public ThreadFactory buildThreadFactory(ChildThreadType childThreadType) { var threadPrefix = switch (childThreadType) { case MOVE_THREAD -> "MoveThread"; case PART_THREAD -> "PartThread"; + case EVOLUTIONARY_AGENT_THREAD -> "EvolutionaryThread"; }; return new DefaultSolverThreadFactory(threadPrefix); } 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 710456be2b9..079ff22840b 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 @@ -439,20 +439,23 @@ 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 ChildThreadType.PART_THREAD -> { + var childThreadScoreDirector = + scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + // ScoreCalculationCountTermination takes into account previous phases + // but the calculationCount of partitions is maxed, not summed. + childThreadScoreDirector.calculationCount = calculationCount; + return childThreadScoreDirector; + } + case ChildThreadType.EVOLUTIONARY_AGENT_THREAD, ChildThreadType.MOVE_THREAD -> { + var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) + .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); + return childThreadScoreDirector; + } + default -> throw new IllegalStateException("The childThreadType (" + childThreadType + ") is not implemented."); } } 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 523c2e3df98..714bed940eb 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 @@ -22,8 +22,6 @@ public class BestSolutionRecaller extends PhaseLifecycleListenerAdapt protected boolean assertShadowVariablesAreNotStale = false; protected boolean assertBestScoreIsUnmodified = false; - protected boolean enableUpdateEvents = true; - protected SolverEventSupport solverEventSupport; public void setAssertInitialScoreFromScratch(boolean assertInitialScoreFromScratch) { @@ -42,14 +40,6 @@ public void setSolverEventSupport(SolverEventSupport solverEventSuppo this.solverEventSupport = solverEventSupport; } - public boolean isEnableUpdateEvents() { - return enableUpdateEvents; - } - - public void setEnableUpdateEvents(boolean enableUpdateEvents) { - this.enableUpdateEvents = enableUpdateEvents; - } - // ************************************************************************ // Worker methods // ************************************************************************ @@ -132,7 +122,7 @@ public > void processWorkingSolutionDuringMove(Inne public void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope) { updateBestSolutionWithoutFiring(solverScope); - if (enableUpdateEvents) { + if (solverScope.isTriggerBestSolutionEvent()) { solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); } } @@ -140,7 +130,7 @@ public void updateBestSolutionAndFire(SolverScope solverScope, Abstra public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, EventProducerId eventProducerId) { updateBestSolutionWithoutFiring(solverScope); - if (solverScope.isBestSolutionInitialized() && enableUpdateEvents) { + if (solverScope.isBestSolutionInitialized() && solverScope.isTriggerBestSolutionEvent()) { solverEventSupport.fireBestSolutionChanged(solverScope, eventProducerId, solverScope.getBestSolution()); } } @@ -148,7 +138,7 @@ public void updateBestSolutionAndFireIfInitialized(SolverScope solver private void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope, InnerScore bestScore, Solution_ bestSolution) { updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution); - if (enableUpdateEvents) { + if (solverScope.isTriggerBestSolutionEvent()) { solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); } } 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 e1a67d7ec6b..25d1d65bcd9 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 @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.solver.scope; +import static ai.timefold.solver.core.impl.solver.thread.ChildThreadType.EVOLUTIONARY_AGENT_THREAD; import static ai.timefold.solver.core.impl.util.MathUtils.getSpeed; import java.time.Clock; @@ -77,6 +78,8 @@ public class SolverScope { */ private final Map moveEvaluationCountPerTypeMap = new ConcurrentHashMap<>(); + private boolean triggerBestSolutionEvent = true; + private static AtomicLong resetAtomicLongTimeMillis(AtomicLong atomicLong) { atomicLong.set(-1); return atomicLong; @@ -264,6 +267,14 @@ public Map getMoveEvaluationCountPerType() { return moveEvaluationCountPerTypeMap; } + public boolean isTriggerBestSolutionEvent() { + return triggerBestSolutionEvent; + } + + public void setTriggerBestSolutionEvent(boolean triggerBestSolutionEvent) { + this.triggerBestSolutionEvent = triggerBestSolutionEvent; + } + // ************************************************************************ // Calculated methods // ************************************************************************ @@ -347,28 +358,26 @@ public void setInitialSolution(Solution_ initialSolution) { } public SolverScope createChildThreadSolverScope(ChildThreadType childThreadType) { - var childThreadScoreDirector = scoreDirector.createChildThreadScoreDirector(childThreadType); - return copy(childThreadScoreDirector); - } - - public > SolverScope copy(InnerScoreDirector newScoreDirector) { - var copy = new SolverScope(clock); - copy.solver = solver; - copy.scoreDirector = newScoreDirector; - copy.bestSolution.set(null); - copy.bestScore.set(null); - copy.monitoringTags = monitoringTags; - copy.solverMetricSet = solverMetricSet; - copy.startingSolverCount = startingSolverCount; + SolverScope childThreadSolverScope = new SolverScope<>(clock); + childThreadSolverScope.bestSolution.set(null); + childThreadSolverScope.bestScore.set(null); + childThreadSolverScope.monitoringTags = monitoringTags; + childThreadSolverScope.solverMetricSet = solverMetricSet; + childThreadSolverScope.startingSolverCount = startingSolverCount; // Experiments show that this trick to attain reproducibility doesn't break uniform distribution var delegatingRandom = (DelegatingSplittableRandomGenerator) workingRandom; - copy.workingRandom = new DelegatingSplittableRandomGenerator(delegatingRandom.getSeed(), delegatingRandom.split()); - copy.startingSystemTimeMillis.set(startingSystemTimeMillis.get()); - resetAtomicLongTimeMillis(copy.endingSystemTimeMillis); - copy.startingInitializedScore = null; - copy.bestSolutionTimeMillis = null; - copy.problemSizeStatistics.set(problemSizeStatistics.get()); - return copy; + childThreadSolverScope.workingRandom = + new DelegatingSplittableRandomGenerator(delegatingRandom.getSeed(), delegatingRandom.split()); + childThreadSolverScope.scoreDirector = scoreDirector.createChildThreadScoreDirector(childThreadType); + childThreadSolverScope.startingSystemTimeMillis.set(startingSystemTimeMillis.get()); + resetAtomicLongTimeMillis(childThreadSolverScope.endingSystemTimeMillis); + childThreadSolverScope.startingInitializedScore = null; + childThreadSolverScope.bestSolutionTimeMillis = null; + if (childThreadType == EVOLUTIONARY_AGENT_THREAD) { + childThreadSolverScope.solver = solver; + childThreadSolverScope.problemSizeStatistics.set(problemSizeStatistics.get()); + } + return childThreadSolverScope; } public void initializeYielding() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/thread/ChildThreadType.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/thread/ChildThreadType.java index 4d7ec2dd27f..efa91ca8768 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/thread/ChildThreadType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/thread/ChildThreadType.java @@ -10,5 +10,9 @@ public enum ChildThreadType { /** * Used by multithreaded incremental solving. */ - MOVE_THREAD; + MOVE_THREAD, + /** + * Used by multithreaded evolutionary algorithm. + */ + EVOLUTIONARY_AGENT_THREAD; } diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index ae8a99162fb..09b7be2139b 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -88,6 +88,7 @@ exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution; exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.phase; exports ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population; exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual; exports ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator; @@ -213,7 +214,6 @@ exports ai.timefold.solver.core.impl.neighborhood to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.partitionedsearch to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.evolutionaryalgorithm to ai.timefold.solver.enterprise.core; - exports ai.timefold.solver.core.impl.evolutionaryalgorithm.decider to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.phase to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.score.stream.bavet to ai.timefold.solver.enterprise.core; exports ai.timefold.solver.core.impl.score.stream.bavet.uni to ai.timefold.solver.enterprise.core; diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index 8f95caf71f6..21a21828212 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -1387,9 +1387,11 @@ + + - + @@ -1423,7 +1425,7 @@ - + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java index 5ea4112f224..30c9dea078f 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java @@ -86,6 +86,7 @@ void apply() { var bestIndividual = mock(Individual.class); var population = stepScope.getPhaseScope().getPopulation(); doReturn(bestIndividual).when(population).getBestIndividual(); + doReturn(bestIndividual).when(stepScope).getBestIndividual(); constructionPhase.apply(stepScope); verify(deterministicPhase).solve(solverScope); verify(shuffledPhase).solve(solverScope); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java index a7e7f23db5e..35ed9de7f20 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java @@ -130,6 +130,7 @@ void applyWithBestIndividual() { }).when(bestIndividual).getChromosome(); doReturn(2).when(bestIndividual).size(); doReturn(solutionState).when(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); + doReturn(bestIndividual).when(stepScope).getBestIndividual(); var population = stepScope.getPhaseScope().getPopulation(); doReturn(bestIndividual).when(population).getBestIndividual(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java index fb8010b473f..fa7c285c88e 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java @@ -135,6 +135,7 @@ void applyWithBestIndividual() { }).when(bestIndividual).getChromosome(); doReturn(2).when(bestIndividual).size(); doReturn(solutionState).when(solutionStateManager).saveSolutionState(scoreDirector, bestIndividual); + doReturn(bestIndividual).when(stepScope).getBestIndividual(); var population = stepScope.getPhaseScope().getPopulation(); doReturn(bestIndividual).when(population).getBestIndividual(); diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 97f7da2836c..3171ef6db3b 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -2372,10 +2372,13 @@ + + + - + @@ -2426,7 +2429,7 @@ - + From 28c496660d4ba0986cde68148cb1bea44e786f5c Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 11 Jun 2026 08:27:10 -0300 Subject: [PATCH 6/8] chore: add optimization profiles --- .../EvolutionaryAlgorithmPhaseConfig.java | 59 ++------ ...EvolutionaryIndividualGeneratorConfig.java | 18 --- .../EvolutionaryLocalSearchConfig.java | 18 --- ...fig.java => EvolutionaryWorkerConfig.java} | 30 +++- .../TimefoldSolverEnterpriseService.java | 6 +- .../DefaultEvolutionaryAlgorithmPhase.java | 10 +- ...aultEvolutionaryAlgorithmPhaseFactory.java | 140 ++++++++---------- .../crossover/CrossoverStrategy.java | 8 +- .../crossover/basic/BasicOXCrossover.java | 10 ++ .../crossover/list/ListMixedCrossover.java | 32 ---- .../crossover/list/ListOXCrossover.java | 10 ++ .../crossover/list/ListRXCrossover.java | 10 ++ .../AbstractHybridGeneticSearchDecider.java | 50 +------ .../decider/HybridGeneticSearchDecider.java | 35 +++-- .../decider/HybridGeneticSearchWorker.java | 129 +++++++++++----- .../HybridGeneticSearchWorkerContext.java | 21 ++- .../ConstructionIndividualStrategy.java | 8 +- ...DefaultConstructionIndividualStrategy.java | 10 ++ .../BasicRuinRecreateIndividualStrategy.java | 10 ++ .../ListRuinRecreateIndividualStrategy.java | 10 ++ core/src/main/resources/solver.xsd | 14 +- .../src/main/resources/benchmark.xsd | 19 +-- 22 files changed, 321 insertions(+), 336 deletions(-) rename core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/{EvolutionaryAgentConfig.java => EvolutionaryWorkerConfig.java} (72%) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java index b30ac9692d5..fc381bc42a2 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java @@ -11,48 +11,24 @@ import org.jspecify.annotations.Nullable; @XmlType(propOrder = { - "complexProblem", - "agentCount", "populationConfig", - "evolutionaryAgentConfig", + "workerConfig", }) @NullMarked public class EvolutionaryAlgorithmPhaseConfig extends PhaseConfig { public static final String XML_ELEMENT_NAME = "evolutionaryAlgorithm"; - @Nullable - private Boolean complexProblem; - - @Nullable - private Integer agentCount; - @Nullable private EvolutionaryPopulationConfig populationConfig = null; @Nullable - private EvolutionaryAgentConfig evolutionaryAgentConfig = null; + private EvolutionaryWorkerConfig workerConfig = null; // ************************************************************************ // Constructors and simple getters/setters // ************************************************************************ - public @Nullable Boolean getComplexProblem() { - return complexProblem; - } - - public void setComplexProblem(@Nullable Boolean complexProblem) { - this.complexProblem = complexProblem; - } - - public @Nullable Integer getAgentCount() { - return agentCount; - } - - public void setAgentCount(@Nullable Integer agentCount) { - this.agentCount = agentCount; - } - public @Nullable EvolutionaryPopulationConfig getPopulationConfig() { return populationConfig; } @@ -61,46 +37,33 @@ public void setPopulationConfig(@Nullable EvolutionaryPopulationConfig populatio this.populationConfig = populationConfig; } - public @Nullable EvolutionaryAgentConfig getEvolutionaryAgentConfig() { - return evolutionaryAgentConfig; + public @Nullable EvolutionaryWorkerConfig getWorkerConfig() { + return workerConfig; } - public void setEvolutionaryAgentConfig(@Nullable EvolutionaryAgentConfig evolutionaryAgentConfig) { - this.evolutionaryAgentConfig = evolutionaryAgentConfig; + public void setWorkerConfig(@Nullable EvolutionaryWorkerConfig workerConfig) { + this.workerConfig = workerConfig; } // ************************************************************************ // With methods // ************************************************************************ - public EvolutionaryAlgorithmPhaseConfig withComplexProblem(Boolean complexProblem) { - setComplexProblem(complexProblem); - return this; - } - - public EvolutionaryAlgorithmPhaseConfig withAgentCount(Integer agentCount) { - setAgentCount(agentCount); - return this; - } - public EvolutionaryAlgorithmPhaseConfig withPopulationConfig(EvolutionaryPopulationConfig populationConfig) { setPopulationConfig(populationConfig); return this; } - public EvolutionaryAlgorithmPhaseConfig withEvolutionaryAgentConfig(EvolutionaryAgentConfig evolutionaryAgentConfig) { - setEvolutionaryAgentConfig(evolutionaryAgentConfig); + public EvolutionaryAlgorithmPhaseConfig withWorkerConfig(EvolutionaryWorkerConfig workerConfig) { + setWorkerConfig(workerConfig); return this; } @Override public EvolutionaryAlgorithmPhaseConfig inherit(EvolutionaryAlgorithmPhaseConfig inheritedConfig) { super.inherit(inheritedConfig); - complexProblem = ConfigUtils.inheritOverwritableProperty(complexProblem, inheritedConfig.getComplexProblem()); - agentCount = ConfigUtils.inheritOverwritableProperty(agentCount, inheritedConfig.getAgentCount()); populationConfig = ConfigUtils.inheritConfig(populationConfig, inheritedConfig.getPopulationConfig()); - evolutionaryAgentConfig = - ConfigUtils.inheritConfig(evolutionaryAgentConfig, inheritedConfig.getEvolutionaryAgentConfig()); + workerConfig = ConfigUtils.inheritConfig(workerConfig, inheritedConfig.getWorkerConfig()); return this; } @@ -114,8 +77,8 @@ public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { if (populationConfig != null) { populationConfig.visitReferencedClasses(classVisitor); } - if (evolutionaryAgentConfig != null) { - evolutionaryAgentConfig.visitReferencedClasses(classVisitor); + if (workerConfig != null) { + workerConfig.visitReferencedClasses(classVisitor); } } } diff --git a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java index c58a825a0b5..b0b83f6668b 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java @@ -18,7 +18,6 @@ import org.jspecify.annotations.Nullable; @XmlType(propOrder = { - "inheritanceRate", "customPhaseCommandClassList", "customProperties", "constructionHeuristic" @@ -26,9 +25,6 @@ @NullMarked public class EvolutionaryIndividualGeneratorConfig extends PhaseConfig { - @Nullable - private Double inheritanceRate = null; - @XmlElement(name = "customPhaseCommandClass") @Nullable private List> customPhaseCommandClassList = null; @@ -44,14 +40,6 @@ public class EvolutionaryIndividualGeneratorConfig extends PhaseConfig> getCustomPhaseCommandClassList() { return customPhaseCommandClassList; } @@ -81,11 +69,6 @@ public void setConstructionHeuristic(@Nullable ConstructionHeuristicPhaseConfig // With methods // ************************************************************************ - public EvolutionaryIndividualGeneratorConfig withInheritanceRate(@Nullable Double inheritanceRate) { - setInheritanceRate(inheritanceRate); - return this; - } - public EvolutionaryIndividualGeneratorConfig withCustomPhaseCommandClassList( List> customPhaseCommandClassList) { setCustomPhaseCommandClassList(customPhaseCommandClassList); @@ -106,7 +89,6 @@ public EvolutionaryIndividualGeneratorConfig withCustomProperties(Map { - @Nullable - private Double inheritanceRate = null; - @Nullable private LocalSearchPhaseConfig localSearch = null; @@ -28,14 +24,6 @@ public class EvolutionaryLocalSearchConfig extends PhaseConfig { +public class EvolutionaryWorkerConfig extends PhaseConfig { + + @Nullable + private Double exploratoryRate; @Nullable private EvolutionaryIndividualGeneratorConfig individualGeneratorConfig = null; @@ -27,6 +31,14 @@ public class EvolutionaryAgentConfig extends PhaseConfig PartitionedSearchPhase buildPartitionedSearch(int phaseIn , State_ extends SolutionState> EvolutionaryDecider buildHybridGeneticSearch(HeuristicConfigPolicy solverConfigPolicy, - int agentCount, int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount, - List> agentContextList, + int workerCount, int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount, + List> workerContextList, PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller); EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, @@ -230,7 +230,7 @@ enum Feature { SCORE_ANALYSIS("Score analysis", "do not use SolutionManager's analyze() method"), RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"), EVOLUTIONARY_ALGORITHM("Evolutionary Algorithm", - "remove the agent count property from the evolutionary algorithm configuration"); + "remove the worker count property from the evolutionary algorithm configuration"); private final String name; private final String workaround; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java index 1a9b85dcc1c..c5a68ea464c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java @@ -18,12 +18,10 @@ public final class DefaultEvolutionaryAlgorithmPhase extends Abstract implements EvolutionaryAlgorithmPhase, EvolutionaryAlgorithmPhaseLifecycleListener { private final EvolutionaryDecider evolutionaryDecider; - private final boolean isComplex; public DefaultEvolutionaryAlgorithmPhase(Builder builder) { super(builder); this.evolutionaryDecider = builder.evolutionaryDecider; - this.isComplex = builder.isComplex; } // ************************************************************************ @@ -90,10 +88,10 @@ public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { phaseScope.endingNow(); var statistics = phaseScope.getPopulation().getStatistics(); logger.info( - "Evolutionary Algorithm phase ({}) ended: time spent ({}), best score ({}), best generation ({}), best iteration ({}), generation total ({}), iteration total ({}), overconstrained ({}).", + "Evolutionary Algorithm phase ({}) ended: time spent ({}), best score ({}), best generation ({}), best iteration ({}), generation total ({}), iteration total ({}).", phaseScope.getPhaseIndex(), phaseScope.calculateSolverTimeMillisSpentUpToNow(), phaseScope.getBestScore().raw(), statistics.bestGeneration(), statistics.bestIteration(), statistics.generationCount(), - statistics.individualCount(), isComplex); + statistics.individualCount()); } @Override @@ -118,13 +116,11 @@ public void solvingError(SolverScope solverScope, Exception exception public static class Builder extends AbstractPhaseBuilder { private final EvolutionaryDecider evolutionaryDecider; - private final boolean isComplex; public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, - EvolutionaryDecider evolutionaryDecider, boolean isComplex) { + EvolutionaryDecider evolutionaryDecider) { super(phaseIndex, logIndentation, phaseTermination); this.evolutionaryDecider = evolutionaryDecider; - this.isComplex = isComplex; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java index acb7a40bfc1..dc3674f881e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -1,13 +1,11 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm; -import static ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService.Feature.EVOLUTIONARY_ALGORITHM; import static ai.timefold.solver.core.impl.AbstractFromConfigFactory.deduceEntityDescriptor; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.Optional; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; @@ -18,11 +16,11 @@ import ai.timefold.solver.core.config.constructionheuristic.placer.EntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig; import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig; -import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAgentConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryAlgorithmPhaseConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryIndividualGeneratorConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryLocalSearchConfig; import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryPopulationConfig; +import ai.timefold.solver.core.config.evolutionaryalgorithm.EvolutionaryWorkerConfig; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionOrder; import ai.timefold.solver.core.config.heuristic.selector.common.nearby.NearbySelectionConfig; @@ -51,7 +49,6 @@ import ai.timefold.solver.core.config.solver.termination.DiminishedReturnsTerminationConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.config.util.ConfigUtils; -import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhaseFactory; import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedEntityPlacerFactory; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; @@ -104,31 +101,31 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean if (populationConfig == null) { populationConfig = new EvolutionaryPopulationConfig(); } + // The default population settings are based on the original HGS article var populationSize = Objects.requireNonNullElse(populationConfig.getPopulationSize(), 40); var generationSize = Objects.requireNonNullElse(populationConfig.getGenerationSize(), 20); var eliteGroupSize = Objects.requireNonNullElse(populationConfig.getEliteSolutionSize(), 10); var populationRestartCount = Objects.requireNonNullElse(populationConfig.getPopulationRestartCount(), 400); - var workerConfig = phaseConfig.getEvolutionaryAgentConfig(); + var workerConfig = phaseConfig.getWorkerConfig(); if (workerConfig == null) { - workerConfig = new EvolutionaryAgentConfig(); + workerConfig = new EvolutionaryWorkerConfig(); } var isListVariable = solverConfigPolicy.getSolutionDescriptor().hasListVariable(); var phaseTermination = buildPhaseTermination(solverConfigPolicy, solverTermination); // Research has shown - // that simpler models perform better in operations with a higher perturbation rate. + // that simpler problems perform better in operations with a higher perturbation rate. // Conversely, - // complex models that work with complex datasets tend + // models that work with complex datasets tend // to be more effective with smaller perturbation rates, such as an inheritance rate of at least 95%. // This means that an individual will incorporate 95% of a parent's solution for crossover operations // or ruin only 5% of it when creating a new individual. - boolean isComplex = phaseConfig.getComplexProblem() != null && phaseConfig.getComplexProblem(); - var agentCount = phaseConfig.getAgentCount() != null ? phaseConfig.getAgentCount() : 0; + var conservativeInheritanceRate = 0.95; + var exploratoryInheritanceRate = 0.5; var evolutionaryDecider = buildEvolutionaryAlgorithmDecider(workerConfig, solverConfigPolicy, solverTermination, phaseTermination, - bestSolutionRecaller, isComplex, isListVariable, agentCount, populationSize, generationSize, - eliteGroupSize, populationRestartCount); - return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider, - isComplex).build(); + bestSolutionRecaller, conservativeInheritanceRate, exploratoryInheritanceRate, isListVariable, + populationSize, generationSize, eliteGroupSize, populationRestartCount); + return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider).build(); } /** @@ -136,77 +133,73 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean */ private static , State_ extends SolutionState> EvolutionaryDecider - buildEvolutionaryAlgorithmDecider(EvolutionaryAgentConfig workerConfig, + buildEvolutionaryAlgorithmDecider(EvolutionaryWorkerConfig workerConfig, HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller, - boolean isComplex, boolean isListVariable, int agentCount, int populationSize, int generationSize, - int eliteGroupSize, int populationRestartCount) { + double exploratoryInheritanceRate, double conservativeInheritanceRate, boolean isListVariable, + int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount) { IndividualBuilder individualBuilder = buildIndividualBuilder(isListVariable); SolutionStateManager solutionStateManager = buildSolutionStateManager(isListVariable); - var requiresEnterpriseService = agentCount > 0; - if (requiresEnterpriseService) { - if (solverConfigPolicy.getMoveThreadCount() != null) { - throw new IllegalStateException( - "The move thread count setting cannot be used in conjunction with the agent count."); - } - if (agentCount < 2) { - throw new IllegalStateException("The agent count must be at least 2."); - } - var agentContextList = new ArrayList>(agentCount); - for (var i = 0; i < agentCount; i++) { - agentContextList.add(buildAgentContext(workerConfig, solverConfigPolicy, solverTermination, - bestSolutionRecaller, solutionStateManager, individualBuilder, isComplex, isListVariable)); - } - return TimefoldSolverEnterpriseService.loadOrFail(EVOLUTIONARY_ALGORITHM) - .buildHybridGeneticSearch(solverConfigPolicy, agentCount, populationSize, generationSize, eliteGroupSize, - populationRestartCount, agentContextList, phaseTermination, bestSolutionRecaller); - } else { - var agentContext = buildAgentContext(workerConfig, solverConfigPolicy, solverTermination, - bestSolutionRecaller, solutionStateManager, individualBuilder, isComplex, isListVariable); - return new HybridGeneticSearchDecider.Builder() - .withLogIndentation(solverConfigPolicy.getLogIndentation()) - .withPopulationSize(populationSize) - .withGenerationSize(generationSize) - .withEliteSolutionSize(eliteGroupSize) - .withPopulationRestartCount(populationRestartCount) - .withConstructionIndividualStrategy(agentContext.constructionIndividualStrategy()) - .withLocalSearchPhase(agentContext.localSearchPhase()) - .withRefinementPhase(agentContext.refinementPhase()) - .withCrossoverStrategy(agentContext.crossoverStrategy()) - .withIndividualBuilder(individualBuilder) - .withSolutionStateManager(solutionStateManager) - .withPhaseTermination(phaseTermination) - .withBestSolutionRecaller(bestSolutionRecaller) - .build(); - } + var workerContext = buildWorkerContext(workerConfig, solverConfigPolicy, solverTermination, bestSolutionRecaller, + solutionStateManager, individualBuilder, isListVariable, exploratoryInheritanceRate, + conservativeInheritanceRate); + return new HybridGeneticSearchDecider.Builder() + .withLogIndentation(solverConfigPolicy.getLogIndentation()) + .withPopulationSize(populationSize) + .withGenerationSize(generationSize) + .withEliteSolutionSize(eliteGroupSize) + .withPopulationRestartCount(populationRestartCount) + .withContext(workerContext) + .withPhaseTermination(phaseTermination) + .withBestSolutionRecaller(bestSolutionRecaller) + .build(); } private static , State_ extends SolutionState> - HybridGeneticSearchWorkerContext buildAgentContext(EvolutionaryAgentConfig workerConfig, - HeuristicConfigPolicy solverConfigPolicy, SolverTermination solverTermination, - BestSolutionRecaller bestSolutionRecaller, + HybridGeneticSearchWorkerContext + buildWorkerContext(EvolutionaryWorkerConfig workerConfig, HeuristicConfigPolicy solverConfigPolicy, + SolverTermination solverTermination, BestSolutionRecaller bestSolutionRecaller, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, boolean isComplex, boolean isListVariable) { + IndividualBuilder individualBuilder, boolean isListVariable, + double exploratoryInheritanceRate, double conservativeInheritanceRate) { Phase deterministicBestFitConstructionPhase = disableLogging(buildDeterministicConstructionHeuristicPhase(solverConfigPolicy, workerConfig.getIndividualGeneratorConfig(), solverTermination)); Phase shuffledFirstFitConstructionPhase = disableLogging( buildShuffledConstructionHeuristicPhase(solverConfigPolicy, solverTermination, isListVariable)); - Phase localSearchPhase = + Phase fasterLocalSearchPhase = disableLogging(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), - solverTermination, bestSolutionRecaller, isComplex, isListVariable)); + solverTermination, bestSolutionRecaller, false, isListVariable)); + Phase regularLocalSearchPhase = + disableLogging(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), + solverTermination, bestSolutionRecaller, true, isListVariable)); Phase refinmentPhase = disableLogging(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); - ConstructionIndividualStrategy constructionIndividualStrategy = + + ConstructionIndividualStrategy exploratoryConstructionIndividualStrategy = buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), - deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, - refinmentPhase, solutionStateManager, individualBuilder, isComplex, isListVariable); - CrossoverStrategy crossoverStrategy = - buildCrossoverStrategy(workerConfig.getLocalSearchConfig(), localSearchPhase, refinmentPhase, isComplex, + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, fasterLocalSearchPhase, + refinmentPhase, solutionStateManager, individualBuilder, exploratoryInheritanceRate, isListVariable); + ConstructionIndividualStrategy conservativeConstructionIndividualStrategy = + buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, regularLocalSearchPhase, + refinmentPhase, solutionStateManager, individualBuilder, conservativeInheritanceRate, isListVariable); + + CrossoverStrategy exploratoryCrossoverStrategy = + buildCrossoverStrategy(fasterLocalSearchPhase, refinmentPhase, false, exploratoryInheritanceRate, isListVariable); - return new HybridGeneticSearchWorkerContext<>(constructionIndividualStrategy, localSearchPhase, refinmentPhase, - crossoverStrategy, individualBuilder, solutionStateManager); + CrossoverStrategy conservativeCrossoverStrategy = buildCrossoverStrategy(regularLocalSearchPhase, + refinmentPhase, true, conservativeInheritanceRate, isListVariable); + + if (workerConfig.getExploratoryRate() != null && (workerConfig.getExploratoryRate() < 0.0 + || workerConfig.getExploratoryRate() > 1.0)) { + throw new IllegalArgumentException("Exploratory rate must be between 0.0 and 1.0"); + } + var exploratoryRate = workerConfig.getExploratoryRate() == null ? -1 : workerConfig.getExploratoryRate(); + return new HybridGeneticSearchWorkerContext<>(exploratoryRate, exploratoryConstructionIndividualStrategy, + conservativeConstructionIndividualStrategy, exploratoryCrossoverStrategy, conservativeCrossoverStrategy, + individualBuilder, solutionStateManager); } @SuppressWarnings("unchecked") @@ -231,11 +224,8 @@ SolutionStateManager buildSolutionStateManager(boolea } private static > CrossoverStrategy buildCrossoverStrategy( - @Nullable EvolutionaryLocalSearchConfig localSearchConfig, Phase localSearchPhase, - @Nullable Phase refinementPhase, boolean isComplex, - boolean isListVariable) { - double inheritanceRate = Optional.ofNullable(localSearchConfig).map(EvolutionaryLocalSearchConfig::getInheritanceRate) - .orElse(isComplex ? 0.95 : 0.5); + Phase localSearchPhase, @Nullable Phase refinementPhase, boolean isComplex, + double inheritanceRate, boolean isListVariable) { if (isListVariable) { return new ListOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, !isComplex); } else { @@ -278,14 +268,12 @@ private static Phase buildShuffledConstructionHeuristicPh private static , State_ extends SolutionState> ConstructionIndividualStrategy - buildConstructionIndividualPhase(EvolutionaryAgentConfig workerConfig, + buildConstructionIndividualPhase(EvolutionaryWorkerConfig workerConfig, @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, boolean isComplex, boolean isListVariable) { - double inheritanceRate = Optional.ofNullable(individualGeneratorConfig) - .map(EvolutionaryIndividualGeneratorConfig::getInheritanceRate).orElse(isComplex ? 0.95 : 0.5); + IndividualBuilder individualBuilder, double inheritanceRate, boolean isListVariable) { List> customIndividualPhaseCommandList = buildPhaseCommandList(workerConfig, individualGeneratorConfig); if (isListVariable) { @@ -299,7 +287,7 @@ private static Phase buildShuffledConstructionHeuristicPh } } - private static List> buildPhaseCommandList(EvolutionaryAgentConfig workerConfig, + private static List> buildPhaseCommandList(EvolutionaryWorkerConfig workerConfig, @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { var customIndividualPhaseCommandList = Collections.> emptyList(); if (individualGeneratorConfig != null && individualGeneratorConfig.getCustomPhaseCommandClassList() != null) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java index 7fd4dbce464..8196e92227a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java @@ -1,15 +1,21 @@ package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.impl.phase.Phase; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Base contract for defining crossover operations. */ -@FunctionalInterface @NullMarked public interface CrossoverStrategy> { CrossoverResult apply(CrossoverContext context); + + Phase getLocalSearchPhase(); + + @Nullable + Phase getRefinementPhase(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java index 504ce976fc6..1c217b86e99 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java @@ -52,6 +52,16 @@ public CrossoverResult apply(CrossoverContext getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + private static > void generateOffspring( InnerScoreDirector scoreDirector, Individual firstIndividual, Individual secondIndividual, double inheritanceRate, RandomGenerator workingRandom) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java deleted file mode 100644 index 94146e8ea8f..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListMixedCrossover.java +++ /dev/null @@ -1,32 +0,0 @@ -package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; - -import java.util.Objects; - -import ai.timefold.solver.core.api.score.Score; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverResult; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public final class ListMixedCrossover> implements CrossoverStrategy { - - private final CrossoverStrategy firstStrategy; - private final CrossoverStrategy secondStrategy; - - public ListMixedCrossover(CrossoverStrategy firstStrategy, - CrossoverStrategy secondStrategy) { - this.firstStrategy = Objects.requireNonNull(firstStrategy); - this.secondStrategy = Objects.requireNonNull(secondStrategy); - } - - @Override - public CrossoverResult apply(CrossoverContext context) { - if (context.phaseScope().getWorkingRandom().nextBoolean()) { - return firstStrategy.apply(context); - } else { - return secondStrategy.apply(context); - } - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java index 7c1275707ee..3365ef9f47e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java @@ -73,6 +73,16 @@ public CrossoverResult apply(CrossoverContext getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + private static > void generateOffspring( InnerScoreDirector scoreDirector, ListVariableStateSupply listVariableStateSupply, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java index e88c600688c..5191932b780 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java @@ -75,6 +75,16 @@ public CrossoverResult apply(CrossoverContext getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + private static > void generateOffspring( InnerScoreDirector scoreDirector, ListVariableStateSupply listVariableStateSupply, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java index 2abbaf80374..5cedcf82a90 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java @@ -4,11 +4,6 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmPhaseScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionStateManager; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; -import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; -import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; @@ -104,17 +99,7 @@ public abstract static class AbstractBuilder constructionIndividualStrategy; - @Nullable - public Phase localSearchPhase; - @Nullable - public Phase refinementPhase; - @Nullable - public CrossoverStrategy crossoverStrategy; - @Nullable - public IndividualBuilder individualBuilder; - @Nullable - public SolutionStateManager solutionStateManager; + public HybridGeneticSearchWorkerContext context; @Nullable public PhaseTermination phaseTermination; @Nullable @@ -146,37 +131,8 @@ public AbstractBuilder withPopulationRestartCount(int } public AbstractBuilder - withConstructionIndividualStrategy( - ConstructionIndividualStrategy constructionIndividualStrategy) { - this.constructionIndividualStrategy = constructionIndividualStrategy; - return this; - } - - public AbstractBuilder withLocalSearchPhase(Phase localSearchPhase) { - this.localSearchPhase = localSearchPhase; - return this; - } - - public AbstractBuilder withRefinementPhase(@Nullable Phase swapStarPhase) { - this.refinementPhase = swapStarPhase; - return this; - } - - public AbstractBuilder - withCrossoverStrategy(CrossoverStrategy crossoverStrategy) { - this.crossoverStrategy = crossoverStrategy; - return this; - } - - public AbstractBuilder - withIndividualBuilder(IndividualBuilder individualBuilder) { - this.individualBuilder = individualBuilder; - return this; - } - - public AbstractBuilder - withSolutionStateManager(SolutionStateManager solutionInitializer) { - this.solutionStateManager = solutionInitializer; + withContext(HybridGeneticSearchWorkerContext workerContext) { + this.context = workerContext; return this; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java index 4bec8a1827b..4fad9fae901 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -49,7 +49,7 @@ public final class HybridGeneticSearchDecider, State_ extends SolutionState> extends AbstractHybridGeneticSearchDecider { - private final HybridGeneticSearchWorkerContext workerContext; + private final HybridGeneticSearchWorkerContext context; @Nullable private HybridGeneticSearchWorker worker = null; @@ -57,9 +57,7 @@ public final class HybridGeneticSearchDecider builder) { super(builder.logIndentation, builder.populationSize, builder.generationSize, builder.eliteSolutionSize, builder.populationRestartCount, Objects.requireNonNull(builder.bestSolutionRecaller)); - this.workerContext = new HybridGeneticSearchWorkerContext<>(builder.constructionIndividualStrategy, - builder.localSearchPhase, builder.refinementPhase, builder.crossoverStrategy, builder.individualBuilder, - builder.solutionStateManager); + this.context = Objects.requireNonNull(builder.context); } // ************************************************************************ @@ -125,13 +123,28 @@ public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) super.phaseStarted(phaseScope); var workerSolverScope = phaseScope.getSolverScope().createChildThreadSolverScope(EVOLUTIONARY_AGENT_THREAD); var bestSolutionUpdater = new DefaultBestSolutionUpdater<>(phaseScope, bestSolutionRecaller, phaseScope.getPopulation(), - workerContext.solutionStateManager()); - var context = - new HybridGeneticSearchWorkerContext<>(workerContext.constructionIndividualStrategy(), - workerContext.localSearchPhase(), workerContext.refinementPhase(), workerContext.crossoverStrategy(), - workerContext.individualBuilder(), workerContext.solutionStateManager()); - this.worker = - new HybridGeneticSearchWorker<>(context, bestSolutionUpdater, workerSolverScope); + context.solutionStateManager()); + // The proposed HGS implementation can use two optimization profiles: + // one exploratory with a higher perturbation rate and another conservative approach with a lower perturbation rate. + // Experiments with academic instances have shown some benefits + // from using the conservative profile for problems + // that begin with 50 planning entities and 200 planning values. + // We use the scale metric to measure complexity, a value that is already computed by the solver. + // While this metric is not flawless, + // it helps prioritize the conservative profile when addressing complex problems. + // Specifically, + // this occurs + // when the combination of entities and values results in a scale metric + // that is greater than or equal to the one observed in the experiments: 427. + var exploratoryRate = context.exploratoryRate(); + if (exploratoryRate == -1) { // Not set by the user + var scaleLog = phaseScope.getSolverScope().getScoreDirector().getValueRangeManager().getProblemSizeStatistics() + .approximateProblemSizeLog(); + // It is expected that the conservative profile will be called 90% of the time for complex problems + exploratoryRate = scaleLog < 427.0 ? 0.9 : 0.1; + } + this.worker = new HybridGeneticSearchWorker<>(HybridGeneticSearchWorkerContext.of(exploratoryRate, context), + bestSolutionUpdater, workerSolverScope.getWorkingRandom(), workerSolverScope); this.worker.phaseStarted(phaseScope); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java index 6f714c0474b..278b6ced68f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.bestsolution.BestSolutionUpdater; @@ -11,7 +12,9 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.SolutionState; import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverContext; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; import ai.timefold.solver.core.impl.solver.scope.SolverScope; @@ -42,15 +45,18 @@ public class HybridGeneticSearchWorker, private final HybridGeneticSearchWorkerContext context; private final BestSolutionUpdater bestSolutionUpdater; + private final RandomGenerator workingRandom; private final SolverScope ownSolverScope; @Nullable private State_ initialState; public HybridGeneticSearchWorker(HybridGeneticSearchWorkerContext context, - BestSolutionUpdater bestSolutionUpdater, SolverScope ownSolverScope) { + BestSolutionUpdater bestSolutionUpdater, RandomGenerator workingRandom, + SolverScope ownSolverScope) { this.context = context; this.bestSolutionUpdater = bestSolutionUpdater; + this.workingRandom = workingRandom; this.ownSolverScope = ownSolverScope; } @@ -74,14 +80,16 @@ public void generateIndividual(EvolutionaryAlgorithmPhaseScope shared var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); var stepScope = new EvolutionaryAlgorithmStepScope<>(restoredPhaseScope); stepScope.setBestIndividual(bestSolutionSupplier.get()); - var newIndividual = context.constructionIndividualStrategy().apply(stepScope); + var constructionStrategy = pickConstructionIndividualStrategy(); + var newIndividual = constructionStrategy.apply(stepScope); var addIndividual = true; var oldScore = newIndividual.getScore(); if (!newIndividual.getScore().raw().isFeasible() && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { var clonedIndividual = newIndividual.clone(ownSolverScope.getScoreDirector()); individualConsumer.accept(clonedIndividual); - applyPhases(restoredPhaseScope, context.localSearchPhase(), context.refinementPhase()); + applyPhases(restoredPhaseScope, constructionStrategy.getLocalSearchPhase(), + constructionStrategy.getRefinementPhase()); if (restoredPhaseScope. getBestScore().compareTo(oldScore) <= 0) { addIndividual = false; newIndividual = clonedIndividual; @@ -110,7 +118,8 @@ public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepS } var restoredPhaseScope = restoreState(sharedPhaseScope, Objects.requireNonNull(initialState)); var crossoverContext = new CrossoverContext<>(restoredPhaseScope, firstIndividual, secondIndividual); - var offspringResult = context.crossoverStrategy().apply(crossoverContext); + var crossoverStrategy = pickCrossoverStrategy(); + var offspringResult = crossoverStrategy.apply(crossoverContext); var offspringIndividual = context.individualBuilder().build(offspringResult.solution(), offspringResult.score(), offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownSolverScope.getScoreDirector()); var addIndividual = true; @@ -118,7 +127,7 @@ public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepS if (!offspringIndividual.getScore().raw().isFeasible() && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { individualConsumer.accept(offspringIndividual.clone(ownSolverScope.getScoreDirector())); - applyPhases(restoredPhaseScope, context.localSearchPhase(), context.refinementPhase()); + applyPhases(restoredPhaseScope, crossoverStrategy.getLocalSearchPhase(), crossoverStrategy.getRefinementPhase()); if (restoredPhaseScope. getBestScore().compareTo(oldScore) == 0) { addIndividual = false; } @@ -201,48 +210,15 @@ public static void applyPhases(AbstractPhaseScope phaseSc var solverScope = phaseScope.getSolverScope(); switch (phases.length) { case 1: { - if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[0].solvingStarted(solverScope); - phases[0].solve(solverScope); - phases[0].solvingEnded(solverScope); + applyPhases(phaseScope, phases[0]); break; } case 2: { - if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[0].solvingStarted(solverScope); - phases[0].solve(solverScope); - phases[0].solvingEnded(solverScope); - if (phases[1] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[1].solvingStarted(solverScope); - phases[1].solve(solverScope); - phases[1].solvingEnded(solverScope); + applyPhases(phaseScope, phases[0], phases[1]); break; } case 3: { - if (phases[0] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[0].solvingStarted(solverScope); - phases[0].solve(solverScope); - phases[0].solvingEnded(solverScope); - if (phases[1] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[1].solvingStarted(solverScope); - phases[1].solve(solverScope); - phases[1].solvingEnded(solverScope); - if (phases[2] == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { - break; - } - phases[2].solvingStarted(solverScope); - phases[2].solve(solverScope); - phases[2].solvingEnded(solverScope); + applyPhases(phaseScope, phases[0], phases[1], phases[2]); break; } default: { @@ -259,6 +235,77 @@ public static void applyPhases(AbstractPhaseScope phaseSc } } + private static void applyPhases(AbstractPhaseScope phaseScope, + @Nullable Phase phase) { + var solverScope = phaseScope.getSolverScope(); + if (phase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + phase.solvingStarted(solverScope); + phase.solve(solverScope); + phase.solvingEnded(solverScope); + } + + public static void applyPhases(AbstractPhaseScope phaseScope, @Nullable Phase firstPhase, + @Nullable Phase secondPhase) { + var solverScope = phaseScope.getSolverScope(); + if (firstPhase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + firstPhase.solvingStarted(solverScope); + firstPhase.solve(solverScope); + firstPhase.solvingEnded(solverScope); + if (secondPhase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + secondPhase.solvingStarted(solverScope); + secondPhase.solve(solverScope); + secondPhase.solvingEnded(solverScope); + } + + public static void applyPhases(AbstractPhaseScope phaseScope, @Nullable Phase firstPhase, + @Nullable Phase secondPhase, @Nullable Phase thirdPhase) { + var solverScope = phaseScope.getSolverScope(); + if (firstPhase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + firstPhase.solvingStarted(solverScope); + firstPhase.solve(solverScope); + firstPhase.solvingEnded(solverScope); + if (secondPhase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + secondPhase.solvingStarted(solverScope); + secondPhase.solve(solverScope); + secondPhase.solvingEnded(solverScope); + if (thirdPhase == null || phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + return; + } + thirdPhase.solvingStarted(solverScope); + thirdPhase.solve(solverScope); + thirdPhase.solvingEnded(solverScope); + } + + private ConstructionIndividualStrategy pickConstructionIndividualStrategy() { + if (workingRandom.nextDouble(1) < context.exploratoryRate()) { + return context.exploratoryConstructionIndividualStrategy(); + } else { + return context.conservativeConstructionIndividualStrategy(); + } + } + + private CrossoverStrategy pickCrossoverStrategy() { + if (workingRandom.nextDouble(1) < context.exploratoryRate()) { + return context.exploratoryCrossoverStrategy(); + } else { + return context.conservativeCrossoverStrategy(); + } + } + + protected RandomGenerator getWorkingRandom() { + return workingRandom; + } + // ************************************************************************ // Lifecycle methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java index 733a3465c36..80f8df3276c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java @@ -6,11 +6,26 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.CrossoverStrategy; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.IndividualBuilder; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; -import ai.timefold.solver.core.impl.phase.Phase; +import org.jspecify.annotations.NullMarked; + +@NullMarked public record HybridGeneticSearchWorkerContext, State_ extends SolutionState>( - ConstructionIndividualStrategy constructionIndividualStrategy, Phase localSearchPhase, - Phase refinementPhase, CrossoverStrategy crossoverStrategy, + double exploratoryRate, + ConstructionIndividualStrategy exploratoryConstructionIndividualStrategy, + ConstructionIndividualStrategy conservativeConstructionIndividualStrategy, + CrossoverStrategy exploratoryCrossoverStrategy, + CrossoverStrategy conservativeCrossoverStrategy, IndividualBuilder individualBuilder, SolutionStateManager solutionStateManager) { + + public static , State_ extends SolutionState, Solution_> + HybridGeneticSearchWorkerContext + of(double exploratoryRate, HybridGeneticSearchWorkerContext otherWorkerContext) { + return new HybridGeneticSearchWorkerContext<>(exploratoryRate, + otherWorkerContext.exploratoryConstructionIndividualStrategy, + otherWorkerContext.conservativeConstructionIndividualStrategy, otherWorkerContext.exploratoryCrossoverStrategy, + otherWorkerContext.conservativeCrossoverStrategy, otherWorkerContext.individualBuilder, + otherWorkerContext.solutionStateManager); + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java index e153b8b1d2d..a2c2e566983 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java @@ -3,15 +3,21 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.evolutionaryalgorithm.common.scope.EvolutionaryAlgorithmStepScope; import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.Individual; +import ai.timefold.solver.core.impl.phase.Phase; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Base contract for defining individuals' creation operations. */ @NullMarked -@FunctionalInterface public interface ConstructionIndividualStrategy> { Individual apply(EvolutionaryAlgorithmStepScope stepScope); + + Phase getLocalSearchPhase(); + + @Nullable + Phase getRefinementPhase(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java index cca7bfacbb1..10fc421d5be 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java @@ -64,6 +64,16 @@ public Individual apply(EvolutionaryAlgorithmStepScope getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + private Phase getConstructionPhase(EvolutionaryAlgorithmStepScope stepScope) { if (stepScope.getBestIndividual() == null) { // The deterministic phase is used only once as its behavior always returns the same solution. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java index 304b9c269b3..7d262dc7726 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java @@ -88,6 +88,16 @@ public Individual apply(EvolutionaryAlgorithmStepScope getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, EvolutionaryAlgorithmPhaseScope phaseScope, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java index 050f28a4506..f7e7b728b07 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -92,6 +92,16 @@ public Individual apply(EvolutionaryAlgorithmStepScope getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index 21a21828212..42afc4e0a54 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -1385,13 +1385,9 @@ - - - - - + @@ -1425,7 +1421,7 @@ - + @@ -1433,6 +1429,8 @@ + + @@ -1453,8 +1451,6 @@ - - @@ -1477,8 +1473,6 @@ - - diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 3171ef6db3b..5996e8f5845 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -2369,16 +2369,10 @@ - - - - - - - + @@ -2429,7 +2423,7 @@ - + @@ -2441,6 +2435,9 @@ + + + @@ -2471,9 +2468,6 @@ - - - @@ -2507,9 +2501,6 @@ - - - From 104579de0ad7d92bf58e6b85fa4f6bc1ca3ef5f4 Mon Sep 17 00:00:00 2001 From: Fred Date: Thu, 11 Jun 2026 10:21:38 -0300 Subject: [PATCH 7/8] chore: improve reproducibility --- ...aultEvolutionaryAlgorithmPhaseFactory.java | 31 +++++++++++-------- .../crossover/basic/BasicOXCrossover.java | 6 ++-- .../crossover/list/AbstractListCrossover.java | 7 +++-- .../crossover/list/ListOXCrossover.java | 6 ++-- .../crossover/list/ListRXCrossover.java | 8 ++--- .../decider/HybridGeneticSearchDecider.java | 4 ++- .../decider/HybridGeneticSearchWorker.java | 21 +++++-------- .../population/AbstractPopulation.java | 26 ++++++---------- .../BasicRuinRecreateIndividualStrategy.java | 14 ++++----- .../ListRuinRecreateIndividualStrategy.java | 18 +++++------ .../crossover/basic/BasicOXCrossoverTest.java | 5 +-- .../crossover/list/ListOXCrossoverTest.java | 8 ++--- .../crossover/list/ListRXCrossoverTest.java | 8 ++--- ...sicRuinRecreateIndividualStrategyTest.java | 10 +++--- ...istRuinRecreateIndividualStrategyTest.java | 10 +++--- 15 files changed, 92 insertions(+), 90 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java index dc3674f881e..0a6c6df27b1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -75,6 +75,7 @@ import ai.timefold.solver.core.impl.phase.AbstractPhaseFactory; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.PhaseFactory; +import ai.timefold.solver.core.impl.solver.random.DelegatingSplittableRandomGenerator; import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; import ai.timefold.solver.core.impl.solver.termination.SolverTermination; @@ -178,19 +179,19 @@ public EvolutionaryAlgorithmPhase buildPhase(int phaseIndex, boolean disableLogging(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); ConstructionIndividualStrategy exploratoryConstructionIndividualStrategy = - buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), + buildConstructionIndividualPhase(solverConfigPolicy, workerConfig, workerConfig.getIndividualGeneratorConfig(), deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, fasterLocalSearchPhase, refinmentPhase, solutionStateManager, individualBuilder, exploratoryInheritanceRate, isListVariable); ConstructionIndividualStrategy conservativeConstructionIndividualStrategy = - buildConstructionIndividualPhase(workerConfig, workerConfig.getIndividualGeneratorConfig(), + buildConstructionIndividualPhase(solverConfigPolicy, workerConfig, workerConfig.getIndividualGeneratorConfig(), deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, regularLocalSearchPhase, refinmentPhase, solutionStateManager, individualBuilder, conservativeInheritanceRate, isListVariable); CrossoverStrategy exploratoryCrossoverStrategy = - buildCrossoverStrategy(fasterLocalSearchPhase, refinmentPhase, false, exploratoryInheritanceRate, - isListVariable); - CrossoverStrategy conservativeCrossoverStrategy = buildCrossoverStrategy(regularLocalSearchPhase, - refinmentPhase, true, conservativeInheritanceRate, isListVariable); + buildCrossoverStrategy(solverConfigPolicy, fasterLocalSearchPhase, refinmentPhase, false, + exploratoryInheritanceRate, isListVariable); + CrossoverStrategy conservativeCrossoverStrategy = buildCrossoverStrategy(solverConfigPolicy, + regularLocalSearchPhase, refinmentPhase, true, conservativeInheritanceRate, isListVariable); if (workerConfig.getExploratoryRate() != null && (workerConfig.getExploratoryRate() < 0.0 || workerConfig.getExploratoryRate() > 1.0)) { @@ -224,12 +225,14 @@ SolutionStateManager buildSolutionStateManager(boolea } private static > CrossoverStrategy buildCrossoverStrategy( - Phase localSearchPhase, @Nullable Phase refinementPhase, boolean isComplex, - double inheritanceRate, boolean isListVariable) { + HeuristicConfigPolicy solverConfigPolicy, Phase localSearchPhase, + @Nullable Phase refinementPhase, boolean isComplex, double inheritanceRate, boolean isListVariable) { + var mainRandomGenerator = (DelegatingSplittableRandomGenerator) solverConfigPolicy.getRandom(); if (isListVariable) { - return new ListOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, !isComplex); + return new ListOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, !isComplex, + mainRandomGenerator.split()); } else { - return new BasicOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate); + return new BasicOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, mainRandomGenerator.split()); } } @@ -268,7 +271,8 @@ private static Phase buildShuffledConstructionHeuristicPh private static , State_ extends SolutionState> ConstructionIndividualStrategy - buildConstructionIndividualPhase(EvolutionaryWorkerConfig workerConfig, + buildConstructionIndividualPhase(HeuristicConfigPolicy solverConfigPolicy, + EvolutionaryWorkerConfig workerConfig, @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, @@ -276,14 +280,15 @@ private static Phase buildShuffledConstructionHeuristicPh IndividualBuilder individualBuilder, double inheritanceRate, boolean isListVariable) { List> customIndividualPhaseCommandList = buildPhaseCommandList(workerConfig, individualGeneratorConfig); + var mainRandomGenerator = (DelegatingSplittableRandomGenerator) solverConfigPolicy.getRandom(); if (isListVariable) { return new ListRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase, solutionStateManager, - individualBuilder, inheritanceRate); + individualBuilder, inheritanceRate, mainRandomGenerator.split()); } else { return new BasicRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, refinementPhase, - solutionStateManager, individualBuilder, inheritanceRate); + solutionStateManager, individualBuilder, inheritanceRate, mainRandomGenerator.split()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java index 1c217b86e99..af297613bdf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java @@ -36,15 +36,15 @@ */ @NullMarked public record BasicOXCrossover>(Phase localSearchPhase, - @Nullable Phase refinementPhase, double inheritanceRate) implements CrossoverStrategy { + @Nullable Phase refinementPhase, double inheritanceRate, + RandomGenerator workingRandom) implements CrossoverStrategy { @Override public CrossoverResult apply(CrossoverContext context) { var phaseScope = context.phaseScope(); var solverScope = phaseScope.getSolverScope(); var scoreDirector = phaseScope. getScoreDirector(); - generateOffspring(scoreDirector, context.firstIndividual(), context.secondIndividual(), - inheritanceRate, phaseScope.getWorkingRandom()); + generateOffspring(scoreDirector, context.firstIndividual(), context.secondIndividual(), inheritanceRate, workingRandom); updateScope(phaseScope); applyPhases(phaseScope, localSearchPhase, refinementPhase); return new CrossoverResult<>(scoreDirector.cloneSolution(solverScope.getBestSolution()), diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java index 17d997c4bc9..8bc14109258 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java @@ -2,6 +2,7 @@ import java.util.Objects; import java.util.Set; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; @@ -27,12 +28,14 @@ public abstract sealed class AbstractListCrossover localSearchPhase; final @Nullable Phase refinementPhase; final double inheritanceRate; + final RandomGenerator workingRandom; - AbstractListCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, - double inheritanceRate) { + AbstractListCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, double inheritanceRate, + RandomGenerator workingRandom) { this.localSearchPhase = localSearchPhase; this.refinementPhase = refinementPhase; this.inheritanceRate = inheritanceRate; + this.workingRandom = workingRandom; } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java index 3365ef9f47e..4e39c057d0b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java @@ -45,8 +45,8 @@ public final class ListOXCrossover> private final boolean applyBestFitFirstPhase; public ListOXCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, - double inheritanceRate, boolean applyBestFitFirstPhase) { - super(localSearchPhase, refinementPhase, inheritanceRate); + double inheritanceRate, boolean applyBestFitFirstPhase, RandomGenerator workingRandom) { + super(localSearchPhase, refinementPhase, inheritanceRate, workingRandom); this.applyBestFitFirstPhase = applyBestFitFirstPhase; } @@ -63,7 +63,7 @@ public CrossoverResult apply(CrossoverContext> private final boolean applyBestFitFirstPhase; public ListRXCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, - double inheritanceRage, boolean applyBestFitFirstPhase) { - super(localSearchPhase, refinementPhase, inheritanceRage); + double inheritanceRage, boolean applyBestFitFirstPhase, RandomGenerator randomGenerator) { + super(localSearchPhase, refinementPhase, inheritanceRage, randomGenerator); this.applyBestFitFirstPhase = applyBestFitFirstPhase; } @@ -64,8 +64,8 @@ public CrossoverResult apply(CrossoverContext phaseScope) exploratoryRate = scaleLog < 427.0 ? 0.9 : 0.1; } this.worker = new HybridGeneticSearchWorker<>(HybridGeneticSearchWorkerContext.of(exploratoryRate, context), - bestSolutionUpdater, workerSolverScope.getWorkingRandom(), workerSolverScope); + bestSolutionUpdater, (DelegatingSplittableRandomGenerator) workerSolverScope.getWorkingRandom(), + workerSolverScope); this.worker.phaseStarted(phaseScope); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java index 278b6ced68f..5385b4c1326 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java @@ -17,6 +17,7 @@ import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.ConstructionIndividualStrategy; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.solver.random.DelegatingSplittableRandomGenerator; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import org.jspecify.annotations.NullMarked; @@ -45,18 +46,18 @@ public class HybridGeneticSearchWorker, private final HybridGeneticSearchWorkerContext context; private final BestSolutionUpdater bestSolutionUpdater; - private final RandomGenerator workingRandom; + private final RandomGenerator workerRandom; private final SolverScope ownSolverScope; @Nullable private State_ initialState; public HybridGeneticSearchWorker(HybridGeneticSearchWorkerContext context, - BestSolutionUpdater bestSolutionUpdater, RandomGenerator workingRandom, + BestSolutionUpdater bestSolutionUpdater, DelegatingSplittableRandomGenerator workingRandom, SolverScope ownSolverScope) { this.context = context; this.bestSolutionUpdater = bestSolutionUpdater; - this.workingRandom = workingRandom; + this.workerRandom = workingRandom.split(); this.ownSolverScope = ownSolverScope; } @@ -84,8 +85,7 @@ public void generateIndividual(EvolutionaryAlgorithmPhaseScope shared var newIndividual = constructionStrategy.apply(stepScope); var addIndividual = true; var oldScore = newIndividual.getScore(); - if (!newIndividual.getScore().raw().isFeasible() - && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { + if (!newIndividual.getScore().raw().isFeasible() && workerRandom.nextBoolean()) { var clonedIndividual = newIndividual.clone(ownSolverScope.getScoreDirector()); individualConsumer.accept(clonedIndividual); applyPhases(restoredPhaseScope, constructionStrategy.getLocalSearchPhase(), @@ -124,8 +124,7 @@ public void applyCrossover(EvolutionaryAlgorithmStepScope sharedStepS offspringResult.firstParentScore(), offspringResult.secondParentScore(), ownSolverScope.getScoreDirector()); var addIndividual = true; var oldScore = offspringIndividual.getScore(); - if (!offspringIndividual.getScore().raw().isFeasible() - && restoredPhaseScope.getSolverScope().getWorkingRandom().nextBoolean()) { + if (!offspringIndividual.getScore().raw().isFeasible() && workerRandom.nextBoolean()) { individualConsumer.accept(offspringIndividual.clone(ownSolverScope.getScoreDirector())); applyPhases(restoredPhaseScope, crossoverStrategy.getLocalSearchPhase(), crossoverStrategy.getRefinementPhase()); if (restoredPhaseScope. getBestScore().compareTo(oldScore) == 0) { @@ -287,7 +286,7 @@ public static void applyPhases(AbstractPhaseScope phaseSc } private ConstructionIndividualStrategy pickConstructionIndividualStrategy() { - if (workingRandom.nextDouble(1) < context.exploratoryRate()) { + if (workerRandom.nextDouble(1) < context.exploratoryRate()) { return context.exploratoryConstructionIndividualStrategy(); } else { return context.conservativeConstructionIndividualStrategy(); @@ -295,17 +294,13 @@ private ConstructionIndividualStrategy pickConstructionIndivi } private CrossoverStrategy pickCrossoverStrategy() { - if (workingRandom.nextDouble(1) < context.exploratoryRate()) { + if (workerRandom.nextDouble(1) < context.exploratoryRate()) { return context.exploratoryCrossoverStrategy(); } else { return context.conservativeCrossoverStrategy(); } } - protected RandomGenerator getWorkingRandom() { - return workingRandom; - } - // ************************************************************************ // Lifecycle methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java index cfe41cc6141..a95c1dbcd8c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java @@ -289,31 +289,25 @@ private double averageDiff(Individual individual, int size) { if (otherIndividualsCount == 0) { return 0.0; } + // Load and sort the diffs for deterministic floating-point summation + var diffs = new double[otherIndividualsCount]; + var i = 0; + for (var diff : individualDiffMap.values()) { + diffs[i++] = diff; + } + Arrays.sort(diffs); // Hot path for a size of one if (size == 1) { - var min = Double.MAX_VALUE; - for (var diff : individualDiffMap.values()) { - if (diff < min) { - min = diff; - } - } - return min; + return diffs[0]; } - // All other individuals fit within the limit, so we can calculate the average without sorting if (otherIndividualsCount <= size) { var result = 0.d; - for (var diff : individualDiffMap.values()) { + for (var diff : diffs) { result += diff; } return result / (double) otherIndividualsCount; } - // Sort the individuals ascending and compute only k nearst ones - var diffs = new double[otherIndividualsCount]; - var i = 0; - for (var diff : individualDiffMap.values()) { - diffs[i++] = diff; - } - Arrays.sort(diffs); + // Compute only k nearest ones var result = 0.d; for (var j = 0; j < size; j++) { result += diffs[j]; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java index 7d262dc7726..d9bca6a94d4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java @@ -24,7 +24,6 @@ import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.custom.DefaultPhaseCommandContext; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.builtin.Moves; @@ -47,15 +46,15 @@ public record BasicRuinRecreateIndividualStrategy> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, - double inheritanceRate) implements ConstructionIndividualStrategy { + IndividualBuilder individualBuilder, double inheritanceRate, + RandomGenerator workingRandom) implements ConstructionIndividualStrategy { public BasicRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, double inheritanceRate) { + IndividualBuilder individualBuilder, double inheritanceRate, RandomGenerator workingRandom) { this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); this.shuffledFirstFitConstructionPhase = Objects.requireNonNull(shuffledFirstFitConstructionPhase); @@ -64,6 +63,7 @@ public BasicRuinRecreateIndividualStrategy(List> customP this.solutionStateManager = solutionStateManager; this.individualBuilder = Objects.requireNonNull(individualBuilder); this.inheritanceRate = inheritanceRate; + this.workingRandom = Objects.requireNonNull(workingRandom); } @Override @@ -80,7 +80,7 @@ public Individual apply(EvolutionaryAlgorithmStepScope getLocalSearchPhase() { return refinementPhase; } - void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, + void applyRuinRecreate(InnerScoreDirector scoreDirector, EvolutionaryAlgorithmPhaseScope phaseScope, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); - applyRuinPhase(scoreDirector, solverScope.getWorkingRandom(), bestIndividual); + applyRuinPhase(scoreDirector, workingRandom, bestIndividual); updateScope(phaseScope); applyPhases(phaseScope, shuffledFirstFitConstructionPhase); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java index f7e7b728b07..830b9a0c139 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -26,7 +26,6 @@ import ai.timefold.solver.core.impl.phase.custom.DefaultPhaseCommandContext; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.ValueRangeManager; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.builtin.Moves; @@ -51,12 +50,12 @@ public record ListRuinRecreateIndividualStrategy localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, IndividualBuilder individualBuilder, - double inheritanceRate) implements ConstructionIndividualStrategy { + double inheritanceRate, RandomGenerator workingRandom) implements ConstructionIndividualStrategy { public ListRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, - IndividualBuilder individualBuilder, double inheritanceRate) { + IndividualBuilder individualBuilder, double inheritanceRate, RandomGenerator workingRandom) { this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); this.localSearchPhase = Objects.requireNonNull(localSearchPhase); @@ -64,6 +63,7 @@ public ListRuinRecreateIndividualStrategy(List> customPh this.solutionStateManager = solutionStateManager; this.individualBuilder = Objects.requireNonNull(individualBuilder); this.inheritanceRate = inheritanceRate; + this.workingRandom = Objects.requireNonNull(workingRandom); } @Override @@ -80,11 +80,10 @@ public Individual apply(EvolutionaryAlgorithmStepScope getPopulation(); if (stepScope.getBestIndividual() == null) { applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); } else { - applyRuinRecreate(solverScope, scoreDirector, Objects.requireNonNull(stepScope.getBestIndividual())); + applyRuinRecreate(scoreDirector, Objects.requireNonNull(stepScope.getBestIndividual())); updateScope(stepScope.getPhaseScope()); applyPhases(phaseScope, localSearchPhase, refinementPhase); } @@ -102,17 +101,16 @@ public Phase getLocalSearchPhase() { return refinementPhase; } - void applyRuinRecreate(SolverScope solverScope, InnerScoreDirector scoreDirector, - Individual bestIndividual) { + void applyRuinRecreate(InnerScoreDirector scoreDirector, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); var valueRangeManager = scoreDirector.getValueRangeManager(); try (var listVariableStateSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { - var ruinedValues = applyRuinPhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, - solverScope.getWorkingRandom(), bestIndividual); - Collections.shuffle(ruinedValues, solverScope.getWorkingRandom()); + var ruinedValues = applyRuinPhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, workingRandom, + bestIndividual); + Collections.shuffle(ruinedValues, workingRandom); applyRecreatePhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, listVariableDescriptor, valueRangeManager, ruinedValues); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java index d3dc675bcf3..152d123b5fe 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java @@ -159,7 +159,7 @@ void crossoverSingleVariable() { var localSearchPhase = mock(Phase.class); var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); - var result = new BasicOXCrossover(localSearchPhase, null, 0).apply(context); + var result = new BasicOXCrossover(localSearchPhase, null, 0, random).apply(context); var offspring = result.solution(); assertThat(offspring.getEntityList().get(0).getValue().getCode()).isEqualTo("va2"); // e1 from P2 @@ -257,7 +257,8 @@ void crossoverMultipleVariables() { var localSearchPhase = mock(Phase.class); var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); - var result = new BasicOXCrossover(localSearchPhase, null, 0).apply(context); + var result = + new BasicOXCrossover(localSearchPhase, null, 0, random).apply(context); var offspring = result.solution(); var entities = offspring.getMultiVarEntityList(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java index 6fa45a51001..c63bcbe6d22 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossoverTest.java @@ -152,7 +152,7 @@ void crossoverOneEntity() { var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); // No inheritance rate var result = - new ListOXCrossover(localSearchPhase, null, 0, false).apply(context); + new ListOXCrossover(localSearchPhase, null, 0, false, random).apply(context); var offspring = result.solution(); // a inherit all P1 values with the same position from the parent @@ -269,7 +269,7 @@ void crossoverTwoEntities() { var localSearchPhase = mock(Phase.class); // No inheritance rate var result = - new ListOXCrossover(localSearchPhase, null, 0, false) + new ListOXCrossover(localSearchPhase, null, 0, false, random) .apply(context); var offspring = result.solution(); @@ -389,7 +389,7 @@ void crossoverThreeEntities() { var localSearchPhase = mock(Phase.class); // No inheritance rate var result = - new ListOXCrossover(localSearchPhase, null, 0, false) + new ListOXCrossover(localSearchPhase, null, 0, false, random) .apply(context); var offspring = result.solution(); @@ -511,7 +511,7 @@ void crossoverThreeEntitiesWithInheritanceRate() { var context = new CrossoverContext(phaseScope, firstIndividual, secondIndividual); var localSearchPhase = mock(Phase.class); var result = - new ListOXCrossover(localSearchPhase, null, 0.5, false) + new ListOXCrossover(localSearchPhase, null, 0.5, false, random) .apply(context); var offspring = result.solution(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java index 7279568dd7e..01eeeeb2c53 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossoverTest.java @@ -154,7 +154,7 @@ void crossoverOneEntityFirstParent() { var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); var localSearchPhase = mock(Phase.class); var result = new ListRXCrossover( - localSearchPhase, null, 1.0, false).apply(context); + localSearchPhase, null, 1.0, false, random).apply(context); var offspring = result.solution(); // inheritanceRate=1.0 → all entities pick P1; Phase 1 appends in order; Phase 2 finds nothing @@ -256,7 +256,7 @@ void crossoverOneEntitySecondParent() { var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); var localSearchPhase = mock(Phase.class); var result = new ListRXCrossover( - localSearchPhase, null, 0, false).apply(context); + localSearchPhase, null, 0, false, random).apply(context); var offspring = result.solution(); // inheritanceRate=0 → all entities pick P2; Phase 1 appends in order; Phase 2 finds nothing @@ -367,7 +367,7 @@ void crossoverTwoEntities() { var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); var localSearchPhase = mock(Phase.class); var result = new ListRXCrossover( - localSearchPhase, null, 0, false).apply(context); + localSearchPhase, null, 0, false, random).apply(context); var offspring = result.solution(); assertThat(offspring.getEntityList().get(0).getValueList()) @@ -485,7 +485,7 @@ void crossoverTwoEntitiesWithInheritanceRate() { var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); var localSearchPhase = mock(Phase.class); var result = new ListRXCrossover( - localSearchPhase, null, 0.5, false).apply(context); + localSearchPhase, null, 0.5, false, random).apply(context); var offspring = result.solution(); // a: Phase 1 [v1,v2,v3] from P1; Phase 2 prepends v4, v6, v7 → [v7,v6,v4,v1,v2,v3] diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java index 35ed9de7f20..4f89a9d6729 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java @@ -45,6 +45,8 @@ class BasicRuinRecreateIndividualStrategyTest { @SuppressWarnings("unchecked") private EvolutionaryAlgorithmStepScope prepareStepScope() { var solverScope = mock(SolverScope.class); + var randomGenerator = new Random(0); + doReturn(randomGenerator).when(solverScope).getWorkingRandom(); var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); doReturn(solverScope).when(phaseScope).getSolverScope(); var solutionDescriptor = TestdataMultiVarSolution.buildSolutionDescriptor(); @@ -88,7 +90,7 @@ void applyWithNoBestIndividual() { var strategy = new BasicRuinRecreateIndividualStrategy>( Collections.emptyList(), deterministicPhase, shuffledPhase, localSearchPhase, null, - solutionStateManager, individualBuilder, 0.95); + solutionStateManager, individualBuilder, 0.95, solverScope.getWorkingRandom()); var generatedIndividual = strategy.apply(stepScope); @@ -142,7 +144,7 @@ void applyWithBestIndividual() { var strategy = new BasicRuinRecreateIndividualStrategy>( Collections.emptyList(), deterministicPhase, shuffledPhase, localSearchPhase, null, - solutionStateManager, individualBuilder, 0.95); + solutionStateManager, individualBuilder, 0.95, solverScope.getWorkingRandom()); var generatedIndividual = strategy.apply(stepScope); @@ -174,7 +176,7 @@ void applyWithPhaseCommands() { var strategy = new BasicRuinRecreateIndividualStrategy>( List.of(command), deterministicPhase, shuffledPhase, localSearchPhase, null, solutionStateManager, - individualBuilder, 0.95); + individualBuilder, 0.95, solverScope.getWorkingRandom()); strategy.apply(stepScope); @@ -204,7 +206,7 @@ void applyWithRefinement() { var strategy = new BasicRuinRecreateIndividualStrategy>( List.of(command), deterministicPhase, shuffledPhase, localSearchPhase, refinementPhase, - solutionStateManager, individualBuilder, 0.95); + solutionStateManager, individualBuilder, 0.95, solverScope.getWorkingRandom()); strategy.apply(stepScope); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java index fa7c285c88e..55895ece343 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java @@ -47,6 +47,8 @@ class ListRuinRecreateIndividualStrategyTest { @SuppressWarnings("unchecked") private EvolutionaryAlgorithmStepScope prepareStepScope() { var solverScope = mock(SolverScope.class); + var randomGenerator = new Random(0); + doReturn(randomGenerator).when(solverScope).getWorkingRandom(); var phaseScope = mock(EvolutionaryAlgorithmPhaseScope.class); doReturn(solverScope).when(phaseScope).getSolverScope(); var solutionDescriptor = TestdataListSolution.buildSolutionDescriptor(); @@ -88,7 +90,7 @@ void applyWithNoBestIndividual() { var strategy = new ListRuinRecreateIndividualStrategy>( Collections.emptyList(), deterministicPhase, localSearchPhase, null, solutionStateManager, - individualBuilder, 0.95); + individualBuilder, 0.95, solverScope.getWorkingRandom()); var generatedIndividual = strategy.apply(stepScope); @@ -147,7 +149,7 @@ void applyWithBestIndividual() { var strategy = new ListRuinRecreateIndividualStrategy>( Collections.emptyList(), deterministicPhase, localSearchPhase, null, solutionStateManager, - individualBuilder, 0.95); + individualBuilder, 0.95, solverScope.getWorkingRandom()); var generatedIndividual = strategy.apply(stepScope); @@ -178,7 +180,7 @@ void applyWithPhaseCommands() { var strategy = new ListRuinRecreateIndividualStrategy>( List.of(command), deterministicPhase, localSearchPhase, null, solutionStateManager, individualBuilder, - 0.95); + 0.95, solverScope.getWorkingRandom()); strategy.apply(stepScope); @@ -207,7 +209,7 @@ void applyWithRefinement() { var strategy = new ListRuinRecreateIndividualStrategy>( List.of(command), deterministicPhase, localSearchPhase, refinementPhase, solutionStateManager, - individualBuilder, 0.95); + individualBuilder, 0.95, solverScope.getWorkingRandom()); strategy.apply(stepScope); From c241be99f6088ca5c6a83d6917de70e536e1d757 Mon Sep 17 00:00:00 2001 From: Fred Date: Wed, 17 Jun 2026 10:26:46 -0300 Subject: [PATCH 8/8] chore: minor improvements --- .../DefaultEvolutionaryAlgorithmPhaseFactory.java | 4 ++-- .../decider/HybridGeneticSearchDecider.java | 4 ++-- .../generator/basic/BasicRuinRecreateIndividualStrategy.java | 4 ++++ .../generator/list/ListRuinRecreateIndividualStrategy.java | 4 ++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java index 0a6c6df27b1..c786419222b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -405,9 +405,9 @@ private static Phase buildLocalSearchPhase(HeuristicConfi } if (updatedLocalSearchPhaseConfig.getTerminationConfig() == null) { var terminationConfig = new TerminationConfig(); - var windowTime = isComplex ? 20L : 1L; + var windowTime = isComplex ? 30L : 1L; terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() - .withMinimumImprovementRatio(0.01).withSlidingWindowSeconds(windowTime)); + .withMinimumImprovementRatio(0.001).withSlidingWindowSeconds(windowTime)); updatedLocalSearchPhaseConfig.setTerminationConfig(terminationConfig); } var clearNearbyClass = updatedLocalSearchPhaseConfig.getMoveSelectorConfig() == null; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java index 50c4110320d..faf03b3dcc0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -141,8 +141,8 @@ public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) if (exploratoryRate == -1) { // Not set by the user var scaleLog = phaseScope.getSolverScope().getScoreDirector().getValueRangeManager().getProblemSizeStatistics() .approximateProblemSizeLog(); - // It is expected that the conservative profile will be called 90% of the time for complex problems - exploratoryRate = scaleLog < 427.0 ? 0.9 : 0.1; + // It is expected that the conservative profile will be called 95% of the time for complex problems + exploratoryRate = scaleLog < 427.0 ? 0.95 : 0.05; } this.worker = new HybridGeneticSearchWorker<>(HybridGeneticSearchWorkerContext.of(exploratoryRate, context), bestSolutionUpdater, (DelegatingSplittableRandomGenerator) workerSolverScope.getWorkingRandom(), diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java index d9bca6a94d4..e86da132fee 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java @@ -102,6 +102,10 @@ void applyRuinRecreate(InnerScoreDirector scoreDirector, EvolutionaryAlgorithmPhaseScope phaseScope, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); + if (workingRandom.nextBoolean()) { + // The method can also maintain the current best solution and restart the search from it + return; + } applyRuinPhase(scoreDirector, workingRandom, bestIndividual); updateScope(phaseScope); applyPhases(phaseScope, shuffledFirstFitConstructionPhase); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java index 830b9a0c139..05eb7212333 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -104,6 +104,10 @@ public Phase getLocalSearchPhase() { void applyRuinRecreate(InnerScoreDirector scoreDirector, Individual bestIndividual) { var bestSolutionState = solutionStateManager.saveSolutionState(scoreDirector, bestIndividual); solutionStateManager.restoreSolutionState(scoreDirector, bestSolutionState); + if (workingRandom.nextBoolean()) { + // The method can also maintain the current best solution and restart the search from it + return; + } var listVariableDescriptor = Objects.requireNonNull(scoreDirector.getSolutionDescriptor().getListVariableDescriptor()); var listVariableMetaModel = listVariableDescriptor.getVariableMetaModel(); var valueRangeManager = scoreDirector.getValueRangeManager();