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/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..fc381bc42a2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryAlgorithmPhaseConfig.java @@ -0,0 +1,84 @@ +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 workerConfig) { + setWorkerConfig(workerConfig); + 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/EvolutionaryIndividualGeneratorConfig.java b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java new file mode 100644 index 00000000000..b0b83f6668b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryIndividualGeneratorConfig.java @@ -0,0 +1,113 @@ +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.constructionheuristic.ConstructionHeuristicPhaseConfig; +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", + "constructionHeuristic" +}) +@NullMarked +public class EvolutionaryIndividualGeneratorConfig extends PhaseConfig { + + @XmlElement(name = "customPhaseCommandClass") + @Nullable + private List> customPhaseCommandClassList = null; + + @XmlJavaTypeAdapter(JaxbCustomPropertiesAdapter.class) + @Nullable + private Map customProperties = null; + + @Nullable + private ConstructionHeuristicPhaseConfig constructionHeuristic = 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; + } + + public @Nullable ConstructionHeuristicPhaseConfig getConstructionHeuristic() { + return constructionHeuristic; + } + + public void setConstructionHeuristic(@Nullable ConstructionHeuristicPhaseConfig constructionHeuristic) { + this.constructionHeuristic = constructionHeuristic; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryIndividualGeneratorConfig withCustomPhaseCommandClassList( + List> customPhaseCommandClassList) { + setCustomPhaseCommandClassList(customPhaseCommandClassList); + return this; + } + + public EvolutionaryIndividualGeneratorConfig withCustomProperties(Map customProperties) { + setCustomProperties(customProperties); + return this; + } + + public EvolutionaryIndividualGeneratorConfig + withConstructionHeuristic(@Nullable ConstructionHeuristicPhaseConfig constructionHeuristic) { + setConstructionHeuristic(constructionHeuristic); + return this; + } + + @Override + public EvolutionaryIndividualGeneratorConfig inherit(EvolutionaryIndividualGeneratorConfig inheritedConfig) { + super.inherit(inheritedConfig); + customPhaseCommandClassList = ConfigUtils.inheritMergeableListProperty(customPhaseCommandClassList, + inheritedConfig.getCustomPhaseCommandClassList()); + customProperties = ConfigUtils.inheritMergeableMapProperty(customProperties, inheritedConfig.getCustomProperties()); + constructionHeuristic = ConfigUtils.inheritConfig(constructionHeuristic, inheritedConfig.getConstructionHeuristic()); + return this; + } + + @Override + public EvolutionaryIndividualGeneratorConfig copyConfig() { + return new EvolutionaryIndividualGeneratorConfig().inherit(this); + } + + @Override + 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..3c3796e0029 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryLocalSearchConfig.java @@ -0,0 +1,62 @@ +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 = { + "localSearch", +}) +@NullMarked +public class EvolutionaryLocalSearchConfig extends PhaseConfig { + + @Nullable + private LocalSearchPhaseConfig localSearch = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable LocalSearchPhaseConfig getLocalSearch() { + return localSearch; + } + + public void setLocalSearch(@Nullable LocalSearchPhaseConfig localSearch) { + this.localSearch = localSearch; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryLocalSearchConfig withLocalSearch(LocalSearchPhaseConfig localSearch) { + setLocalSearch(localSearch); + return this; + } + + @Override + public EvolutionaryLocalSearchConfig inherit(EvolutionaryLocalSearchConfig inheritedConfig) { + super.inherit(inheritedConfig); + 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/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..eb1a57a62c0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/config/evolutionaryalgorithm/EvolutionaryWorkerConfig.java @@ -0,0 +1,102 @@ +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 = { + "exploratoryRate", + "individualGeneratorConfig", + "localSearchConfig", +}) +@NullMarked +public class EvolutionaryWorkerConfig extends PhaseConfig { + + @Nullable + private Double exploratoryRate; + + @Nullable + private EvolutionaryIndividualGeneratorConfig individualGeneratorConfig = null; + + @Nullable + private EvolutionaryLocalSearchConfig localSearchConfig = null; + + // ************************************************************************ + // Constructors and simple getters/setters + // ************************************************************************ + + public @Nullable Double getExploratoryRate() { + return exploratoryRate; + } + + public void setExploratoryRate(@Nullable Double exploratoryRate) { + this.exploratoryRate = exploratoryRate; + } + + public @Nullable EvolutionaryIndividualGeneratorConfig getIndividualGeneratorConfig() { + return individualGeneratorConfig; + } + + public void setIndividualGeneratorConfig(@Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { + this.individualGeneratorConfig = individualGeneratorConfig; + } + + public @Nullable EvolutionaryLocalSearchConfig getLocalSearchConfig() { + return localSearchConfig; + } + + public void setLocalSearchConfig(@Nullable EvolutionaryLocalSearchConfig localSearchConfig) { + this.localSearchConfig = localSearchConfig; + } + + // ************************************************************************ + // With methods + // ************************************************************************ + + public EvolutionaryWorkerConfig withExploratoryRate(Double exploratoryRate) { + setExploratoryRate(exploratoryRate); + return this; + } + + public EvolutionaryWorkerConfig + withIndividualGeneratorConfig(EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { + setIndividualGeneratorConfig(individualGeneratorConfig); + return this; + } + + public EvolutionaryWorkerConfig withLocalSearchConfig(EvolutionaryLocalSearchConfig localSearchConfig) { + setLocalSearchConfig(localSearchConfig); + return this; + } + + @Override + public EvolutionaryWorkerConfig inherit(EvolutionaryWorkerConfig inheritedConfig) { + super.inherit(inheritedConfig); + exploratoryRate = ConfigUtils.inheritOverwritableProperty(exploratoryRate, inheritedConfig.getExploratoryRate()); + individualGeneratorConfig = + ConfigUtils.inheritConfig(individualGeneratorConfig, inheritedConfig.getIndividualGeneratorConfig()); + localSearchConfig = ConfigUtils.inheritConfig(localSearchConfig, inheritedConfig.getLocalSearchConfig()); + return this; + } + + @Override + public EvolutionaryWorkerConfig copyConfig() { + return new EvolutionaryWorkerConfig().inherit(this); + } + + @Override + public void visitReferencedClasses(Consumer<@Nullable Class> classVisitor) { + if (individualGeneratorConfig != null) { + individualGeneratorConfig.visitReferencedClasses(classVisitor); + } + if (localSearchConfig != null) { + localSearchConfig.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/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..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 @@ -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"; @@ -84,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; } @@ -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..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 @@ -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"; @@ -78,10 +83,9 @@ 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.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/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..b7216977037 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,13 @@ 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.decider.EvolutionaryDecider; +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; 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; @@ -47,6 +49,7 @@ 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 +176,12 @@ PartitionedSearchPhase buildPartitionedSearch(int phaseIn SolverTermination solverTermination, BiFunction, SolverTermination, PhaseTermination> phaseTerminationFunction); + , State_ extends SolutionState> + EvolutionaryDecider buildHybridGeneticSearch(HeuristicConfigPolicy solverConfigPolicy, + int workerCount, int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount, + List> workerContextList, + PhaseTermination phaseTermination, BestSolutionRecaller bestSolutionRecaller); + EntitySelector applyNearbySelection(EntitySelectorConfig entitySelectorConfig, HeuristicConfigPolicy configPolicy, NearbySelectionConfig nearbySelectionConfig, SelectionCacheType minimumCacheType, SelectionOrder resolvedSelectionOrder, @@ -184,7 +193,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, @@ -219,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 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/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/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/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/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/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java new file mode 100644 index 00000000000..c5a68ea464c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhase.java @@ -0,0 +1,131 @@ +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; + +import org.jspecify.annotations.NullMarked; + +public final class DefaultEvolutionaryAlgorithmPhase extends AbstractPhase + implements EvolutionaryAlgorithmPhase, EvolutionaryAlgorithmPhaseLifecycleListener { + + private final EvolutionaryDecider evolutionaryDecider; + + public DefaultEvolutionaryAlgorithmPhase(Builder builder) { + super(builder); + this.evolutionaryDecider = builder.evolutionaryDecider; + } + + // ************************************************************************ + // 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 ({}).", + phaseScope.getPhaseIndex(), phaseScope.calculateSolverTimeMillisSpentUpToNow(), phaseScope.getBestScore().raw(), + statistics.bestGeneration(), statistics.bestIteration(), statistics.generationCount(), + statistics.individualCount()); + } + + @Override + public void stepStarted(EvolutionaryAlgorithmStepScope stepScope) { + super.stepStarted(stepScope); + evolutionaryDecider.stepStarted(stepScope); + } + + @Override + public void stepEnded(EvolutionaryAlgorithmStepScope stepScope) { + super.stepEnded(stepScope); + evolutionaryDecider.stepEnded(stepScope); + } + + @Override + public void solvingError(SolverScope solverScope, Exception exception) { + super.solvingError(solverScope, exception); + evolutionaryDecider.solvingError(solverScope, exception); + } + + @NullMarked + public static class Builder extends AbstractPhaseBuilder { + + private final EvolutionaryDecider evolutionaryDecider; + + public Builder(int phaseIndex, String logIndentation, PhaseTermination phaseTermination, + EvolutionaryDecider evolutionaryDecider) { + super(phaseIndex, logIndentation, phaseTermination); + this.evolutionaryDecider = evolutionaryDecider; + } + + @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..c786419222b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/DefaultEvolutionaryAlgorithmPhaseFactory.java @@ -0,0 +1,551 @@ +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.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.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; +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.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.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; +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; +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; +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; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +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."); + } + var populationConfig = phaseConfig.getPopulationConfig(); + 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.getWorkerConfig(); + if (workerConfig == null) { + workerConfig = new EvolutionaryWorkerConfig(); + } + var isListVariable = solverConfigPolicy.getSolutionDescriptor().hasListVariable(); + var phaseTermination = buildPhaseTermination(solverConfigPolicy, solverTermination); + // Research has shown + // that simpler problems perform better in operations with a higher perturbation rate. + // Conversely, + // 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 conservativeInheritanceRate = 0.95; + var exploratoryInheritanceRate = 0.5; + var evolutionaryDecider = + buildEvolutionaryAlgorithmDecider(workerConfig, solverConfigPolicy, solverTermination, phaseTermination, + bestSolutionRecaller, conservativeInheritanceRate, exploratoryInheritanceRate, isListVariable, + populationSize, generationSize, eliteGroupSize, populationRestartCount); + return new DefaultEvolutionaryAlgorithmPhase.Builder<>(phaseIndex, "", phaseTermination, evolutionaryDecider).build(); + } + + /** + * 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, + double exploratoryInheritanceRate, double conservativeInheritanceRate, boolean isListVariable, + int populationSize, int generationSize, int eliteGroupSize, int populationRestartCount) { + + IndividualBuilder individualBuilder = buildIndividualBuilder(isListVariable); + SolutionStateManager solutionStateManager = buildSolutionStateManager(isListVariable); + 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 + buildWorkerContext(EvolutionaryWorkerConfig workerConfig, HeuristicConfigPolicy solverConfigPolicy, + SolverTermination solverTermination, BestSolutionRecaller bestSolutionRecaller, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, boolean isListVariable, + double exploratoryInheritanceRate, double conservativeInheritanceRate) { + Phase deterministicBestFitConstructionPhase = + disableLogging(buildDeterministicConstructionHeuristicPhase(solverConfigPolicy, + workerConfig.getIndividualGeneratorConfig(), solverTermination)); + Phase shuffledFirstFitConstructionPhase = disableLogging( + buildShuffledConstructionHeuristicPhase(solverConfigPolicy, solverTermination, isListVariable)); + Phase fasterLocalSearchPhase = + disableLogging(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), + solverTermination, bestSolutionRecaller, false, isListVariable)); + Phase regularLocalSearchPhase = + disableLogging(buildLocalSearchPhase(solverConfigPolicy, workerConfig.getLocalSearchConfig(), + solverTermination, bestSolutionRecaller, true, isListVariable)); + Phase refinmentPhase = + disableLogging(buildRefinmentPhase(solverConfigPolicy, solverTermination, isListVariable)); + + ConstructionIndividualStrategy exploratoryConstructionIndividualStrategy = + buildConstructionIndividualPhase(solverConfigPolicy, workerConfig, workerConfig.getIndividualGeneratorConfig(), + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, fasterLocalSearchPhase, + refinmentPhase, solutionStateManager, individualBuilder, exploratoryInheritanceRate, isListVariable); + ConstructionIndividualStrategy conservativeConstructionIndividualStrategy = + buildConstructionIndividualPhase(solverConfigPolicy, workerConfig, workerConfig.getIndividualGeneratorConfig(), + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, regularLocalSearchPhase, + refinmentPhase, solutionStateManager, individualBuilder, conservativeInheritanceRate, isListVariable); + + CrossoverStrategy exploratoryCrossoverStrategy = + 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)) { + 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") + private static , State_ extends SolutionState> + SolutionStateManager buildSolutionStateManager(boolean isListVariable) { + if (isListVariable) { + return (SolutionStateManager) new ListSolutionStateManager<>(); + } else { + return (SolutionStateManager) new BasicSolutionStateManager<>(); + } + } + + private static > IndividualBuilder + buildIndividualBuilder(boolean isListVariable) { + 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); + } + } + + private static > CrossoverStrategy buildCrossoverStrategy( + 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, + mainRandomGenerator.split()); + } else { + return new BasicOXCrossover<>(localSearchPhase, refinementPhase, inheritanceRate, mainRandomGenerator.split()); + } + } + + private static Phase buildDeterministicConstructionHeuristicPhase( + HeuristicConfigPolicy solverConfigPolicy, + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, + SolverTermination solverTermination) { + var constructionHeuristicPhaseConfig = new ConstructionHeuristicPhaseConfig(); + if (individualGeneratorConfig != null && individualGeneratorConfig.getConstructionHeuristic() != null) { + constructionHeuristicPhaseConfig = individualGeneratorConfig.getConstructionHeuristic(); + } + var constructionConfigPolicy = solverConfigPolicy.cloneBuilder() + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .build(); + return PhaseFactory. create(constructionHeuristicPhaseConfig).buildPhase(0, false, + 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(HeuristicConfigPolicy solverConfigPolicy, + EvolutionaryWorkerConfig workerConfig, + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig, + Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + 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, mainRandomGenerator.split()); + } else { + return new BasicRuinRecreateIndividualStrategy<>(customIndividualPhaseCommandList, + deterministicBestFitConstructionPhase, shuffledFirstFitConstructionPhase, localSearchPhase, refinementPhase, + solutionStateManager, individualBuilder, inheritanceRate, mainRandomGenerator.split()); + } + } + + private static List> buildPhaseCommandList(EvolutionaryWorkerConfig workerConfig, + @Nullable EvolutionaryIndividualGeneratorConfig individualGeneratorConfig) { + var customIndividualPhaseCommandList = Collections.> emptyList(); + if (individualGeneratorConfig != null && individualGeneratorConfig.getCustomPhaseCommandClassList() != null) { + customIndividualPhaseCommandList = + 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). + 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", + individualGeneratorConfig.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(Objects.requireNonNull(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(Objects.requireNonNull(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 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(); + var windowTime = isComplex ? 30L : 1L; + terminationConfig.setDiminishedReturnsConfig(new DiminishedReturnsTerminationConfig() + .withMinimumImprovementRatio(0.001).withSlidingWindowSeconds(windowTime)); + 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()); + var solutionDescriptor = solverConfigPolicy.getSolutionDescriptor(); + // The pillar movement should include additional information when multiple variables are involved + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + for (var variableDescriptor : entityDescriptor.getBasicVariableDescriptorList()) { + moveList.add(new PillarChangeMoveSelectorConfig() + .withValueSelectorConfig(new ValueSelectorConfig() + .withVariableName(variableDescriptor.getVariableName())) + .withPillarSelectorConfig(new PillarSelectorConfig() + .withEntitySelectorConfig( + new EntitySelectorConfig().withEntityClass(entityDescriptor.getEntityClass())) + .withMinimumSubPillarSize(2) + .withMaximumSubPillarSize(2))); + } + moveList.add( + new PillarSwapMoveSelectorConfig() + .withPillarSelectorConfig(new PillarSelectorConfig() + .withEntitySelectorConfig(new EntitySelectorConfig() + .withEntityClass(entityDescriptor.getEntityClass())) + .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 for list variables as outlined in the HGS article. + */ + 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() + .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 logging of the 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 @Nullable Phase disableLogging(@Nullable Phase phase) { + if (phase instanceof AbstractPhase abstractPhase) { + abstractPhase.disableLogging(); + } + return 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..493b56aef32 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/Utils.java @@ -0,0 +1,83 @@ +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, 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 }; + } + } + + /** + * 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..948b5414a65 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/bestsolution/DefaultBestSolutionUpdater.java @@ -0,0 +1,54 @@ +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; + +/** + * 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 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 (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, + // 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.getScoreDirector(), newIndividual); + solutionStateManager.restoreSolutionState(sharedPhaseScope.getScoreDirector(), individualState); + var bestSolutionStepScope = new EvolutionaryAlgorithmStepScope<>(sharedPhaseScope, newIndividual); + bestSolutionStepScope.setScore(newIndividual.getScore()); + // The shared scope has the flag for triggering the best solution events set to true + sharedBestSolutionRecaller.processWorkingSolutionDuringStep(bestSolutionStepScope); + } + // 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/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/scope/EvolutionaryAlgorithmPhaseScope.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java new file mode 100644 index 00000000000..728712d9d48 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmPhaseScope.java @@ -0,0 +1,49 @@ +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.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 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 new file mode 100644 index 00000000000..9aaccb86008 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/scope/EvolutionaryAlgorithmStepScope.java @@ -0,0 +1,68 @@ +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, + @Nullable Individual stepIndividual) { + this(phaseScope, phaseScope.getNextStepIndex(), stepIndividual); + } + + public EvolutionaryAlgorithmStepScope(EvolutionaryAlgorithmPhaseScope phaseScope, int stepIndex, + @Nullable Individual stepIndividual) { + super(stepIndex); + this.phaseScope = phaseScope; + this.stepIndividual = stepIndividual; + } + + @SuppressWarnings("unchecked") + public @Nullable > Individual getStepIndividual() { + return (Individual) stepIndividual; + } + + 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; + } + + @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..420073827ed --- /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(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 new file mode 100644 index 00000000000..16c24dd9f86 --- /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 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/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..37addca9abe --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManager.java @@ -0,0 +1,130 @@ +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; + +import org.jspecify.annotations.NullMarked; + +/** + * 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. + */ +@NullMarked +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(InnerScoreDirector scoreDirector, + 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 stateEntry : stateToRestore.stateList()) { + if (needRebase) { + var rebasedValue = Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.value())); + var rebasedEntity = + Objects.requireNonNull(scoreDirector.lookUpWorkingObject(stateEntry.positionInList().entity())); + moveList.add(Moves.assign(listVariableMetaModel, rebasedValue, rebasedEntity, + stateEntry.positionInList().index())); + } else { + moveList.add(Moves.assign(listVariableMetaModel, stateEntry.value(), stateEntry.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..7a05aa3475c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListValueState.java @@ -0,0 +1,12 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.common.state.list; + +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/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..8196e92227a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/CrossoverStrategy.java @@ -0,0 +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. + */ +@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 new file mode 100644 index 00000000000..af297613bdf --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossover.java @@ -0,0 +1,94 @@ +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, + 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, workingRandom); + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + return new CrossoverResult<>(scoreDirector.cloneSolution(solverScope.getBestSolution()), + solverScope.getBestScore(), + context.firstIndividual().getScore(), context.secondIndividual().getScore()); + } + + @Override + public Phase getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + + 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/AbstractListCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java new file mode 100644 index 00000000000..8bc14109258 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/AbstractListCrossover.java @@ -0,0 +1,112 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover.list; + +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.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; + final RandomGenerator workingRandom; + + AbstractListCrossover(Phase localSearchPhase, @Nullable Phase refinementPhase, double inheritanceRate, + RandomGenerator workingRandom) { + this.localSearchPhase = localSearchPhase; + this.refinementPhase = refinementPhase; + this.inheritanceRate = inheritanceRate; + this.workingRandom = workingRandom; + } + + /** + * 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/ListOXCrossover.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java new file mode 100644 index 00000000000..4e39c057d0b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListOXCrossover.java @@ -0,0 +1,133 @@ +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, RandomGenerator workingRandom) { + super(localSearchPhase, refinementPhase, inheritanceRate, workingRandom); + 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, workingRandom); + // 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()); + } + } + + @Override + public Phase getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + + 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, 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 + 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..9207cc645a5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/list/ListRXCrossover.java @@ -0,0 +1,130 @@ +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, RandomGenerator randomGenerator) { + super(localSearchPhase, refinementPhase, inheritanceRage, randomGenerator); + 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(), workingRandom, 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()); + } + } + + @Override + public Phase getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + + 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/AbstractHybridGeneticSearchDecider.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java new file mode 100644 index 00000000000..5cedcf82a90 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/AbstractHybridGeneticSearchDecider.java @@ -0,0 +1,153 @@ +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.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 HybridGeneticSearchWorkerContext context; + @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 + withContext(HybridGeneticSearchWorkerContext workerContext) { + this.context = workerContext; + 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 new file mode 100644 index 00000000000..1656fdd1443 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/EvolutionaryDecider.java @@ -0,0 +1,58 @@ +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); + + void solvingError(SolverScope solverScope, Exception exception); +} 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..faf03b3dcc0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchDecider.java @@ -0,0 +1,169 @@ +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; +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.population.DefaultPopulation; +import ai.timefold.solver.core.impl.evolutionaryalgorithm.population.Population; +import ai.timefold.solver.core.impl.solver.random.DelegatingSplittableRandomGenerator; + +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> + extends AbstractHybridGeneticSearchDecider { + + private final HybridGeneticSearchWorkerContext context; + + @Nullable + private HybridGeneticSearchWorker worker = null; + + public HybridGeneticSearchDecider(AbstractBuilder builder) { + super(builder.logIndentation, builder.populationSize, builder.generationSize, builder.eliteSolutionSize, + builder.populationRestartCount, Objects.requireNonNull(builder.bestSolutionRecaller)); + this.context = Objects.requireNonNull(builder.context); + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public Population emptyPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { + return new DefaultPopulation<>(populationSize, generationSize, eliteSolutionSize); + } + + @Override + public void loadPopulation(EvolutionaryAlgorithmPhaseScope phaseScope) { + var population = phaseScope. getPopulation(); + var nonNullWorker = Objects.requireNonNull(worker); + while (population.size() < populationSize) { + if (phaseScope.getTermination().isPhaseTerminated(phaseScope)) { + break; + } + 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); + } + } + + @Override + public void evolvePopulation(EvolutionaryAlgorithmStepScope stepScope) { + var phaseScope = stepScope.getPhaseScope(); + var population = phaseScope. getPopulation(); + 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(stepScope.getWorkingRandom()); + 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); + } + + @Override + 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)); + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void phaseStarted(EvolutionaryAlgorithmPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + var workerSolverScope = phaseScope.getSolverScope().createChildThreadSolverScope(EVOLUTIONARY_AGENT_THREAD); + var bestSolutionUpdater = new DefaultBestSolutionUpdater<>(phaseScope, bestSolutionRecaller, phaseScope.getPopulation(), + 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 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(), + workerSolverScope); + this.worker.phaseStarted(phaseScope); + } + + @Override + public void phaseEnded(EvolutionaryAlgorithmPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + Objects.requireNonNull(worker).phaseEnded(phaseScope); + worker = null; + } + + @NullMarked + public static class Builder, State_ extends SolutionState> + extends AbstractHybridGeneticSearchDecider.AbstractBuilder { + + @SuppressWarnings("unchecked") + 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 new file mode 100644 index 00000000000..5385b4c1326 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorker.java @@ -0,0 +1,324 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.decider; + +import java.util.ArrayList; +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; +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.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.random.DelegatingSplittableRandomGenerator; +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 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 + * 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 HybridGeneticSearchWorkerContext context; + private final BestSolutionUpdater bestSolutionUpdater; + private final RandomGenerator workerRandom; + private final SolverScope ownSolverScope; + + @Nullable + private State_ initialState; + + public HybridGeneticSearchWorker(HybridGeneticSearchWorkerContext context, + BestSolutionUpdater bestSolutionUpdater, DelegatingSplittableRandomGenerator workingRandom, + SolverScope ownSolverScope) { + this.context = context; + this.bestSolutionUpdater = bestSolutionUpdater; + this.workerRandom = workingRandom.split(); + this.ownSolverScope = ownSolverScope; + } + + // ************************************************************************ + // 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, + Supplier<@Nullable Individual> bestSolutionSupplier, + 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); + stepScope.setBestIndividual(bestSolutionSupplier.get()); + var constructionStrategy = pickConstructionIndividualStrategy(); + var newIndividual = constructionStrategy.apply(stepScope); + var addIndividual = true; + var oldScore = newIndividual.getScore(); + if (!newIndividual.getScore().raw().isFeasible() && workerRandom.nextBoolean()) { + var clonedIndividual = newIndividual.clone(ownSolverScope.getScoreDirector()); + individualConsumer.accept(clonedIndividual); + applyPhases(restoredPhaseScope, constructionStrategy.getLocalSearchPhase(), + constructionStrategy.getRefinementPhase()); + if (restoredPhaseScope. getBestScore().compareTo(oldScore) <= 0) { + addIndividual = false; + newIndividual = clonedIndividual; + } + } + 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 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; + var oldScore = offspringIndividual.getScore(); + 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) { + addIndividual = false; + } + } + if (addIndividual) { + 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)); + } + } + + /** + * 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 + */ + protected EvolutionaryAlgorithmPhaseScope restoreState( + EvolutionaryAlgorithmPhaseScope sharedPhaseScope, + State_ state) { + 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; + } + + /** + * 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, + Supplier<@Nullable Individual> bestSolutionSupplier, + Consumer> individualConsumer) { + var individualList = new ArrayList>(size); + while (individualList.size() < size) { + if (sharedPhaseScope.getTermination().isPhaseTerminated(sharedPhaseScope)) { + break; + } + generateIndividual(sharedPhaseScope, bestSolutionSupplier, 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: { + applyPhases(phaseScope, phases[0]); + break; + } + case 2: { + applyPhases(phaseScope, phases[0], phases[1]); + break; + } + case 3: { + applyPhases(phaseScope, phases[0], phases[1], phases[2]); + 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); + } + } + } + } + + 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 (workerRandom.nextDouble(1) < context.exploratoryRate()) { + return context.exploratoryConstructionIndividualStrategy(); + } else { + return context.conservativeConstructionIndividualStrategy(); + } + } + + private CrossoverStrategy pickCrossoverStrategy() { + if (workerRandom.nextDouble(1) < context.exploratoryRate()) { + return context.exploratoryCrossoverStrategy(); + } else { + return context.conservativeCrossoverStrategy(); + } + } + + // ************************************************************************ + // 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. + this.ownSolverScope.getScoreDirector().getSupplyManager().disableDemandCancellation(); + // A solution that has only pinned values assigned is preferred for generating new individuals + 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. + 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..80f8df3276c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/decider/HybridGeneticSearchWorkerContext.java @@ -0,0 +1,31 @@ +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 org.jspecify.annotations.NullMarked; + +@NullMarked +public record HybridGeneticSearchWorkerContext, State_ extends SolutionState>( + 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/AbstractPopulation.java b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java new file mode 100644 index 00000000000..a95c1dbcd8c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/AbstractPopulation.java @@ -0,0 +1,418 @@ +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; + } + // 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) { + return diffs[0]; + } + if (otherIndividualsCount <= size) { + var result = 0.d; + for (var diff : diffs) { + result += diff; + } + return result / (double) otherIndividualsCount; + } + // Compute only k nearest ones + 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 new file mode 100644 index 00000000000..3ed7d8e70cc --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/DefaultPopulation.java @@ -0,0 +1,19 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population; + +import ai.timefold.solver.core.api.score.Score; + +import org.jspecify.annotations.NullMarked; + +/** + * Single-threaded implementation of {@link Population}. + * + * @see AbstractPopulation + */ +@NullMarked +public final class DefaultPopulation> + extends AbstractPopulation { + + 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 new file mode 100644 index 00000000000..1461934f0d9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/Population.java @@ -0,0 +1,71 @@ +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; +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(RandomGenerator workingRandom); + + /** + * 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..e531d3f7166 --- /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. + */ +public 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..0afbe7e7eba --- /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 BasicVariableIndividual, 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/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 new file mode 100644 index 00000000000..f83a2ec8627 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ChromosomeEntry.java @@ -0,0 +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 entity, @Nullable Object value, 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..0e1b73e6b76 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/ListVariableIndividual.java @@ -0,0 +1,134 @@ +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(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 = + 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(listVariableDescriptor, planningIdAccessor, solution, chromosomeList, predecessorAndSuccessorMap); + 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(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); + 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(entity, value, 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(scoreDirector.getSolutionDescriptor().getListVariableDescriptor(), planningIdAccessor, solution, chromosomeList, + newPredecessorAndSuccessorMap); + 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..a2c2e566983 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/ConstructionIndividualStrategy.java @@ -0,0 +1,23 @@ +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 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 +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 new file mode 100644 index 00000000000..10fc421d5be --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategy.java @@ -0,0 +1,85 @@ +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 record DefaultConstructionIndividualStrategy>( + List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, + Phase shuffledFirstFitConstructionPhase, Phase localSearchPhase, + @Nullable Phase refinementPhase, + IndividualBuilder individualBuilder) implements ConstructionIndividualStrategy { + + public DefaultConstructionIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, Phase shuffledFirstFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + IndividualBuilder individualBuilder) { + this.customPhaseIndividualCommandList = Objects.requireNonNull(customPhaseIndividualCommandList); + this.deterministicBestFitConstructionPhase = Objects.requireNonNull(deterministicBestFitConstructionPhase); + this.shuffledFirstFitConstructionPhase = Objects.requireNonNull(shuffledFirstFitConstructionPhase); + 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); + } + + @Override + public Phase 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. + // The shuffled phase is expected to shuffle the selector and produce different solutions. + return deterministicBestFitConstructionPhase; + } + return shuffledFirstFitConstructionPhase; + } +} 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 new file mode 100644 index 00000000000..e86da132fee --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategy.java @@ -0,0 +1,139 @@ +package ai.timefold.solver.core.impl.evolutionaryalgorithm.population.individual.generator.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.ArrayList; +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.entity.descriptor.EntityDescriptor; +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.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.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'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, + RandomGenerator workingRandom) implements ConstructionIndividualStrategy { + + public BasicRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, + Phase shuffledFirstFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, double inheritanceRate, RandomGenerator workingRandom) { + 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; + this.workingRandom = Objects.requireNonNull(workingRandom); + } + + @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); + if (stepScope.getBestIndividual() == null) { + applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); + } else { + applyRuinRecreate(scoreDirector, phaseScope, Objects.requireNonNull(stepScope.getBestIndividual())); + updateScope(phaseScope); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + } + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + null, null, scoreDirector); + } + + @Override + public Phase getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + + 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); + } + + 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 new file mode 100644 index 00000000000..05eb7212333 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategy.java @@ -0,0 +1,168 @@ +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.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.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 record ListRuinRecreateIndividualStrategy, State_ extends SolutionState>( + List> customPhaseIndividualCommandList, Phase deterministicBestFitConstructionPhase, + Phase localSearchPhase, @Nullable Phase refinementPhase, + SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, + double inheritanceRate, RandomGenerator workingRandom) implements ConstructionIndividualStrategy { + + public ListRuinRecreateIndividualStrategy(List> customPhaseIndividualCommandList, + Phase deterministicBestFitConstructionPhase, Phase localSearchPhase, + @Nullable Phase refinementPhase, SolutionStateManager solutionStateManager, + IndividualBuilder individualBuilder, double inheritanceRate, RandomGenerator workingRandom) { + 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; + this.workingRandom = Objects.requireNonNull(workingRandom); + } + + @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 + if (stepScope.getBestIndividual() == null) { + applyPhases(phaseScope, deterministicBestFitConstructionPhase, localSearchPhase, refinementPhase); + } else { + applyRuinRecreate(scoreDirector, Objects.requireNonNull(stepScope.getBestIndividual())); + updateScope(stepScope.getPhaseScope()); + applyPhases(phaseScope, localSearchPhase, refinementPhase); + } + return individualBuilder.build(scoreDirector.cloneSolution(solverScope.getBestSolution()), solverScope.getBestScore(), + null, null, scoreDirector); + } + + @Override + public Phase getLocalSearchPhase() { + return localSearchPhase; + } + + @Override + public @Nullable Phase getRefinementPhase() { + return refinementPhase; + } + + 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(); + try (var listVariableStateSupply = scoreDirector.getListVariableStateSupply(listVariableDescriptor)) { + var ruinedValues = applyRuinPhase(scoreDirector, listVariableStateSupply, listVariableMetaModel, workingRandom, + bestIndividual); + Collections.shuffle(ruinedValues, workingRandom); + 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 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())); + if (listVariableStateSupply.isPinned(rebasedValue)) { + continue; + } + var position = listVariableStateSupply.getElementPosition(rebasedValue).ensureAssigned(); + moveList.add(Moves.unassign(listVariableMetaModel, position)); + ruinedValues.add(rebasedValue); + } + Collections.reverse(moveList); + if (!moveList.isEmpty()) { + scoreDirector.getMoveDirector().execute(Moves.compose(moveList)); + } + 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..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 @@ -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) @@ -222,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); } @@ -261,6 +270,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 +292,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 +323,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/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/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/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/localsearch/DefaultLocalSearchPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhase.java index c3bfe0dbd95..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 @@ -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(), @@ -149,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(""" @@ -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/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/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/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/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 91f645962b5..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 @@ -122,22 +122,25 @@ public > void processWorkingSolutionDuringMove(Inne public void updateBestSolutionAndFire(SolverScope solverScope, AbstractPhaseScope phaseScope) { updateBestSolutionWithoutFiring(solverScope); - solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); + if (solverScope.isTriggerBestSolutionEvent()) { + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), solverScope.getBestSolution()); + } } public void updateBestSolutionAndFireIfInitialized(SolverScope solverScope, EventProducerId eventProducerId) { updateBestSolutionWithoutFiring(solverScope); - if (solverScope.isBestSolutionInitialized()) { + if (solverScope.isBestSolutionInitialized() && solverScope.isTriggerBestSolutionEvent()) { 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 (solverScope.isTriggerBestSolutionEvent()) { + solverEventSupport.fireBestSolutionChanged(solverScope, phaseScope.getPhaseId(), bestSolution); + } } @SuppressWarnings({ "unchecked", "rawtypes" }) 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..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 // ************************************************************************ @@ -362,6 +373,10 @@ public SolverScope createChildThreadSolverScope(ChildThreadType child resetAtomicLongTimeMillis(childThreadSolverScope.endingSystemTimeMillis); childThreadSolverScope.startingInitializedScore = null; childThreadSolverScope.bestSolutionTimeMillis = null; + if (childThreadType == EVOLUTIONARY_AGENT_THREAD) { + childThreadSolverScope.solver = solver; + childThreadSolverScope.problemSizeStatistics.set(problemSizeStatistics.get()); + } return childThreadSolverScope; } 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/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/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 6ab448f37a6..09b7be2139b 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,14 @@ 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.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; + exports ai.timefold.solver.core.impl.evolutionaryalgorithm.crossover; // explicit exports to other modules exports ai.timefold.solver.core.impl.constructionheuristic.event to @@ -135,6 +144,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 +213,7 @@ 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.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..42afc4e0a54 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -47,6 +47,8 @@ + + @@ -1375,6 +1377,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1462,6 +1570,8 @@ + + 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/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/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 new file mode 100644 index 00000000000..33c6ba4bc23 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/common/state/list/ListSolutionStateManagerTest.java @@ -0,0 +1,433 @@ +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.stateList()).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.stateList()) + .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.stateList()).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.stateList()) + .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(); + + InnerScoreDirector scoreDirector = buildScoreDirector(false); + + var state = new ListSolutionStateManager() + .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 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(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(scoreDirector, individual); + + assertThat(state.getSolution()).isSameAs(solution); + assertThat(state.getScore()).isSameAs(score); + + var assignedValues = state.stateList(); + 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/basic/BasicOXCrossoverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java new file mode 100644 index 00000000000..152d123b5fe --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/crossover/basic/BasicOXCrossoverTest.java @@ -0,0 +1,275 @@ +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, random).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, random).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 new file mode 100644 index 00000000000..c63bcbe6d22 --- /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(e, v, 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(e, v, 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, random).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(e, v, 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(e, v, 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, random) + .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(e, v, 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(e, v, 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, random) + .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(e, v, 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(e, v, 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, random) + .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..01eeeeb2c53 --- /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(e, v, 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(e, v, 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, random).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(e, v, 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(e, v, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + 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 + 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(e, v, 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(e, v, 0))) + .toArray(ChromosomeEntry[]::new)); + + var context = new CrossoverContext<>(phaseScope, firstIndividual, secondIndividual); + var localSearchPhase = mock(Phase.class); + var result = new ListRXCrossover( + localSearchPhase, null, 0, false, random).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(e, v, 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(e, v, 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, 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] + 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..30c9dea078f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/DefaultConstructionIndividualStrategyTest.java @@ -0,0 +1,142 @@ +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(); + doReturn(bestIndividual).when(stepScope).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/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..4f89a9d6729 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/basic/BasicRuinRecreateIndividualStrategyTest.java @@ -0,0 +1,218 @@ +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 randomGenerator = new Random(0); + doReturn(randomGenerator).when(solverScope).getWorkingRandom(); + 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, solverScope.getWorkingRandom()); + + 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); + doReturn(bestIndividual).when(stepScope).getBestIndividual(); + + 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, solverScope.getWorkingRandom()); + + 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, solverScope.getWorkingRandom()); + + 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, solverScope.getWorkingRandom()); + + 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 new file mode 100644 index 00000000000..55895ece343 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/evolutionaryalgorithm/population/individual/generator/list/ListRuinRecreateIndividualStrategyTest.java @@ -0,0 +1,221 @@ +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 randomGenerator = new Random(0); + doReturn(randomGenerator).when(solverScope).getWorkingRandom(); + 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, solverScope.getWorkingRandom()); + + 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(a, v1, 0), + new ChromosomeEntry(a, v2, 1) + }).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(); + + 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, solverScope.getWorkingRandom()); + + 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(scoreDirector, 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, solverScope.getWorkingRandom()); + + 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, solverScope.getWorkingRandom()); + + 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/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"); 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/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..5996e8f5845 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -371,6 +371,9 @@ + + + @@ -2354,6 +2357,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2421,6 +2583,9 @@ + + +