diff --git a/benchmark/src/main/resources/benchmark.xsd b/benchmark/src/main/resources/benchmark.xsd
index 6bb3594d055..7fb34bd4308 100644
--- a/benchmark/src/main/resources/benchmark.xsd
+++ b/benchmark/src/main/resources/benchmark.xsd
@@ -674,7 +674,7 @@
-
+
diff --git a/core/src/build/revapi-differences.json b/core/src/build/revapi-differences.json
index 21d6761d887..47a22bfe78f 100644
--- a/core/src/build/revapi-differences.json
+++ b/core/src/build/revapi-differences.json
@@ -331,6 +331,23 @@
"new": "class ai.timefold.solver.core.api.score.buildin.simplelong.SimpleLongScore",
"annotation": "@org.jspecify.annotations.NullMarked",
"justification": "@NonNull replaced by @NullMarked"
+ },
+ {
+ "ignore": true,
+ "code": "java.field.removed",
+ "old": "field ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig.entityPlacerConfig",
+ "justification": "New CH configuration with multiple placers"
+ },
+ {
+ "ignore": true,
+ "code": "java.annotation.attributeValueChanged",
+ "old": "class ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig",
+ "new": "class ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig",
+ "annotationType": "jakarta.xml.bind.annotation.XmlType",
+ "attribute": "propOrder",
+ "oldValue": "{\"constructionHeuristicType\", \"entitySorterManner\", \"valueSorterManner\", \"entityPlacerConfig\", \"moveSelectorConfigList\", \"foragerConfig\"}",
+ "newValue": "{\"constructionHeuristicType\", \"entitySorterManner\", \"valueSorterManner\", \"entityPlacerConfigList\", \"moveSelectorConfigList\", \"foragerConfig\"}",
+ "justification": "New CH configuration with multiple placers"
}
]
}
diff --git a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/ConstructionHeuristicPhaseConfig.java b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/ConstructionHeuristicPhaseConfig.java
index e8a46a4d9b5..fca788048fb 100644
--- a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/ConstructionHeuristicPhaseConfig.java
+++ b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/ConstructionHeuristicPhaseConfig.java
@@ -1,5 +1,6 @@
package ai.timefold.solver.core.config.constructionheuristic;
+import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
@@ -36,7 +37,7 @@
"constructionHeuristicType",
"entitySorterManner",
"valueSorterManner",
- "entityPlacerConfig",
+ "entityPlacerConfigList",
"moveSelectorConfigList",
"foragerConfig"
})
@@ -56,9 +57,9 @@ public class ConstructionHeuristicPhaseConfig extends PhaseConfig entityPlacerConfigList = null;
- /** Simpler alternative for {@link #entityPlacerConfig}. */
+ /** Simpler alternative for {@link #entityPlacerConfigList}. */
@XmlElements({
@XmlElement(name = CartesianProductMoveSelectorConfig.XML_ELEMENT_NAME,
type = CartesianProductMoveSelectorConfig.class),
@@ -110,12 +111,35 @@ public void setValueSorterManner(@Nullable ValueSorterManner valueSorterManner)
this.valueSorterManner = valueSorterManner;
}
- public @Nullable EntityPlacerConfig getEntityPlacerConfig() {
- return entityPlacerConfig;
+ public List getEntityPlacerConfigList() {
+ return entityPlacerConfigList;
+ }
+
+ public void setEntityPlacerConfigList(List entityPlacerConfigList) {
+ this.entityPlacerConfigList = entityPlacerConfigList;
}
- public void setEntityPlacerConfig(@Nullable EntityPlacerConfig entityPlacerConfig) {
- this.entityPlacerConfig = entityPlacerConfig;
+ /**
+ * @deprecated Use {@link #setEntityPlacerConfigList(List)}} instead.
+ */
+ @Deprecated(forRemoval = true, since = "1.22.0")
+ public void setEntityPlacerConfig(EntityPlacerConfig entityPlacerConfig) {
+ setEntityPlacerConfigList(List.of(entityPlacerConfig));
+ }
+
+ /**
+ * @deprecated Use {@link #getEntityPlacerConfigList()} instead.
+ */
+ @Deprecated(forRemoval = true, since = "1.22.0")
+ public @Nullable EntityPlacerConfig getEntityPlacerConfig() {
+ if (entityPlacerConfigList == null || entityPlacerConfigList.isEmpty()) {
+ return null;
+ }
+ if (entityPlacerConfigList.size() > 1) {
+ throw new IllegalStateException(
+ "Returning a single entity placer configuration is not possible. Maybe use getEntityPlacerConfigList instead.");
+ }
+ return entityPlacerConfigList.get(0);
}
public @Nullable List<@NonNull MoveSelectorConfig> getMoveSelectorConfigList() {
@@ -154,8 +178,19 @@ public void setForagerConfig(@Nullable ConstructionHeuristicForagerConfig forage
return this;
}
- public @NonNull ConstructionHeuristicPhaseConfig withEntityPlacerConfig(@NonNull EntityPlacerConfig> entityPlacerConfig) {
- this.entityPlacerConfig = entityPlacerConfig;
+ public @NonNull ConstructionHeuristicPhaseConfig
+ withEntityPlacerConfigList(@NonNull EntityPlacerConfig>... entityPlacerConfig) {
+ setEntityPlacerConfigList(Arrays.asList(entityPlacerConfig));
+ return this;
+ }
+
+ /**
+ * @deprecated use {@link #withEntityPlacerConfigList(EntityPlacerConfig[])} instead.
+ */
+ @Deprecated(forRemoval = true, since = "1.22.0")
+ public @NonNull ConstructionHeuristicPhaseConfig
+ withEntityPlacerConfig(@NonNull EntityPlacerConfig entityPlacerConfig) {
+ setEntityPlacerConfigList(List.of(entityPlacerConfig));
return this;
}
@@ -180,8 +215,8 @@ public void setForagerConfig(@Nullable ConstructionHeuristicForagerConfig forage
inheritedConfig.getEntitySorterManner());
valueSorterManner = ConfigUtils.inheritOverwritableProperty(valueSorterManner,
inheritedConfig.getValueSorterManner());
- setEntityPlacerConfig(ConfigUtils.inheritOverwritableProperty(
- getEntityPlacerConfig(), inheritedConfig.getEntityPlacerConfig()));
+ entityPlacerConfigList = ConfigUtils.inheritMergeableListConfig(
+ entityPlacerConfigList, inheritedConfig.getEntityPlacerConfigList());
moveSelectorConfigList = ConfigUtils.inheritMergeableListConfig(
moveSelectorConfigList, inheritedConfig.getMoveSelectorConfigList());
foragerConfig = ConfigUtils.inheritConfig(foragerConfig, inheritedConfig.getForagerConfig());
@@ -198,8 +233,8 @@ public void visitReferencedClasses(@NonNull Consumer> classVisitor) {
if (terminationConfig != null) {
terminationConfig.visitReferencedClasses(classVisitor);
}
- if (entityPlacerConfig != null) {
- entityPlacerConfig.visitReferencedClasses(classVisitor);
+ if (entityPlacerConfigList != null) {
+ entityPlacerConfigList.forEach(entityPlacerConfig -> entityPlacerConfig.visitReferencedClasses(classVisitor));
}
if (moveSelectorConfigList != null) {
moveSelectorConfigList.forEach(ms -> ms.visitReferencedClasses(classVisitor));
diff --git a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedEntityPlacerConfig.java b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedEntityPlacerConfig.java
index 2e4a19bc8c9..b9996f53a5b 100644
--- a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedEntityPlacerConfig.java
+++ b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedEntityPlacerConfig.java
@@ -32,6 +32,8 @@
})
public class QueuedEntityPlacerConfig extends EntityPlacerConfig {
+ public static final String XML_ELEMENT_NAME = "queuedEntityPlacer";
+
@XmlElement(name = "entitySelector")
protected EntitySelectorConfig entitySelectorConfig = null;
diff --git a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedValuePlacerConfig.java b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedValuePlacerConfig.java
index 3ff0ce62728..791790e72f5 100644
--- a/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedValuePlacerConfig.java
+++ b/core/src/main/java/ai/timefold/solver/core/config/constructionheuristic/placer/QueuedValuePlacerConfig.java
@@ -31,6 +31,8 @@
})
public class QueuedValuePlacerConfig extends EntityPlacerConfig {
+ public static final String XML_ELEMENT_NAME = "queuedValuePlacer";
+
protected Class> entityClass = null;
@XmlElement(name = "valueSelector")
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 73c5e5a9ac9..ff691c260a8 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
@@ -3,7 +3,6 @@
import java.util.Collection;
import java.util.List;
import java.util.Objects;
-import java.util.stream.Collectors;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.config.AbstractConfig;
@@ -19,7 +18,7 @@ public abstract class AbstractFromConfigFactory getEntityDescriptorForClass(SolutionDescript
Class> entityClass) {
EntityDescriptor entityDescriptor = solutionDescriptor.getEntityDescriptorStrict(entityClass);
if (entityDescriptor == null) {
- throw new IllegalArgumentException("The config (" + config
- + ") has an entityClass (" + entityClass + ") that is not a known planning entity.\n"
- + "Check your solver configuration. If that class (" + entityClass.getSimpleName()
- + ") is not in the entityClassSet (" + solutionDescriptor.getEntityClassSet()
- + "), check your @" + PlanningSolution.class.getSimpleName()
- + " implementation's annotated methods too.");
+ throw new IllegalArgumentException(
+ """
+ The config (%s) has an entityClass (%s) that is not a known planning entity.
+ Check your solver configuration. If that class (%s) is not in the entityClassSet (%s), check your @%s implementation's annotated methods too."""
+ .formatted(config, entityClass, entityClass.getSimpleName(), solutionDescriptor.getEntityClassSet(),
+ PlanningSolution.class.getSimpleName()));
}
return entityDescriptor;
}
@@ -68,10 +67,23 @@ private EntityDescriptor getEntityDescriptorForClass(SolutionDescript
protected EntityDescriptor getTheOnlyEntityDescriptor(SolutionDescriptor solutionDescriptor) {
Collection> entityDescriptors = solutionDescriptor.getGenuineEntityDescriptors();
if (entityDescriptors.size() != 1) {
- throw new IllegalArgumentException("The config (" + config
- + ") has no entityClass configured and because there are multiple in the entityClassSet ("
- + solutionDescriptor.getEntityClassSet()
- + "), it cannot be deduced automatically.");
+ throw new IllegalArgumentException(
+ "The config (%s) has no entityClass configured and because there are multiple in the entityClassSet (%s), it cannot be deduced automatically."
+ .formatted(config, solutionDescriptor.getEntityClassSet()));
+ }
+ return entityDescriptors.iterator().next();
+ }
+
+ protected EntityDescriptor
+ getTheOnlyEntityDescriptorWithBasicVariables(SolutionDescriptor solutionDescriptor) {
+ Collection> entityDescriptors = solutionDescriptor.getGenuineEntityDescriptors()
+ .stream()
+ .filter(EntityDescriptor::hasAnyGenuineBasicVariables)
+ .toList();
+ if (entityDescriptors.size() != 1) {
+ throw new IllegalArgumentException(
+ "The config (%s) has no entityClass configured and because there are multiple in the entityClassSet (%s) defining basic variables, it cannot be deduced automatically."
+ .formatted(config, solutionDescriptor.getEntityClassSet()));
}
return entityDescriptors.iterator().next();
}
@@ -87,11 +99,11 @@ protected GenuineVariableDescriptor getVariableDescriptorForName(Enti
String variableName) {
GenuineVariableDescriptor variableDescriptor = entityDescriptor.getGenuineVariableDescriptor(variableName);
if (variableDescriptor == null) {
- throw new IllegalArgumentException("The config (" + config
- + ") has a variableName (" + variableName
- + ") which is not a valid planning variable on entityClass ("
- + entityDescriptor.getEntityClass() + ").\n"
- + entityDescriptor.buildInvalidVariableNameExceptionMessage(variableName));
+ throw new IllegalArgumentException(
+ """
+ The config (%s) has a variableName (%s) which is not a valid planning variable on entityClass (%s).
+ %s""".formatted(config, variableName, entityDescriptor.getEntityClass(),
+ entityDescriptor.buildInvalidVariableNameExceptionMessage(variableName)));
}
return variableDescriptor;
}
@@ -100,11 +112,10 @@ protected GenuineVariableDescriptor getTheOnlyVariableDescriptor(Enti
List> variableDescriptorList =
entityDescriptor.getGenuineVariableDescriptorList();
if (variableDescriptorList.size() != 1) {
- throw new IllegalArgumentException("The config (" + config
- + ") has no configured variableName for entityClass (" + entityDescriptor.getEntityClass()
- + ") and because there are multiple variableNames ("
- + entityDescriptor.getGenuineVariableNameSet()
- + "), it cannot be deduced automatically.");
+ throw new IllegalArgumentException(
+ "The config (%s) has no configured variableName for entityClass (%s) and because there are multiple variableNames (%s), it cannot be deduced automatically."
+ .formatted(config, entityDescriptor.getEntityClass(),
+ entityDescriptor.getGenuineVariableNameSet()));
}
return variableDescriptorList.iterator().next();
}
@@ -122,10 +133,10 @@ protected List> deduceVariableDescriptorLis
.map(variableNameInclude -> variableDescriptorList.stream()
.filter(variableDescriptor -> variableDescriptor.getVariableName().equals(variableNameInclude))
.findFirst()
- .orElseThrow(() -> new IllegalArgumentException("The config (" + config
- + ") has a variableNameInclude (" + variableNameInclude
- + ") which does not exist in the entity (" + entityDescriptor.getEntityClass()
- + ")'s variableDescriptorList (" + variableDescriptorList + ").")))
- .collect(Collectors.toList());
+ .orElseThrow(() -> new IllegalArgumentException(
+ "The config (%s) has a variableNameInclude (%s) which does not exist in the entity (%s)'s variableDescriptorList (%s)."
+ .formatted(config, variableNameInclude, entityDescriptor.getEntityClass(),
+ variableDescriptorList))))
+ .toList();
}
}
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 797fa7f9834..e16e8477b9c 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
@@ -57,15 +57,14 @@ public void solve(SolverScope solverScope) {
phaseStarted(phaseScope);
var solutionDescriptor = solverScope.getSolutionDescriptor();
- var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor();
- var hasListVariable = listVariableDescriptor != null;
+ var hasListVariable = solutionDescriptor.hasListVariable();
var maxStepCount = -1;
if (hasListVariable) {
// In case of list variable with support for unassigned values, the placer will iterate indefinitely.
// (When it exhausts all values, it will start over from the beginning.)
// To prevent that, we need to limit the number of steps to the number of unassigned values.
var workingSolution = phaseScope.getWorkingSolution();
- maxStepCount = listVariableDescriptor.countUnassigned(workingSolution);
+ maxStepCount = solutionDescriptor.getListVariableDescriptor().countUnassigned(workingSolution);
}
TerminationStatus earlyTerminationStatus = null;
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 9fdfb4cda3d..9f561892594 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
@@ -1,5 +1,6 @@
package ai.timefold.solver.core.impl.constructionheuristic;
+import java.util.ArrayList;
import java.util.Objects;
import java.util.Optional;
@@ -28,6 +29,7 @@
import ai.timefold.solver.core.impl.constructionheuristic.placer.PooledEntityPlacerFactory;
import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedEntityPlacerFactory;
import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedValuePlacerFactory;
+import ai.timefold.solver.core.impl.constructionheuristic.placer.internal.QueuedMultiplePlacerConfig;
import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
@@ -85,10 +87,38 @@ public ConstructionHeuristicPhase buildPhase(int phaseIndex, boolean
}
private Optional> getValidEntityPlacerConfig() {
- var entityPlacerConfig = phaseConfig.getEntityPlacerConfig();
- if (entityPlacerConfig == null) {
+ if (phaseConfig.getEntityPlacerConfigList() == null || phaseConfig.getEntityPlacerConfigList().isEmpty()) {
return Optional.empty();
}
+
+ if (phaseConfig.getEntityPlacerConfigList().size() > 2) {
+ throw new IllegalArgumentException(
+ "The Construction Heuristic configuration (%s) only support a maximum of two entity placers."
+ .formatted(phaseConfig));
+ }
+ if (phaseConfig.getEntityPlacerConfigList().stream().anyMatch(PooledEntityPlacerConfig.class::isInstance)
+ && phaseConfig.getEntityPlacerConfigList().size() == 2) {
+ throw new IllegalArgumentException(
+ "The Construction Heuristic configuration (%s) does not support multiple configurations when using the pooled placer configuration %s."
+ .formatted(phaseConfig, PooledEntityPlacerConfig.class.getSimpleName()));
+ }
+ if (phaseConfig.getEntityPlacerConfigList().stream().map(EntityPlacerConfig::getClass).distinct().count() == 1
+ && phaseConfig.getEntityPlacerConfigList().size() == 2) {
+ var message = "The Construction Heuristic configuration (%s) cannot contain duplicate placer configurations."
+ .formatted(phaseConfig);
+ if (phaseConfig.getEntityPlacerConfigList().get(0) instanceof QueuedEntityPlacerConfig) {
+ throw new IllegalArgumentException("""
+ %s
+ Maybe define multiple move selectors if there are more than one basic variables.""".formatted(message));
+ }
+ throw new IllegalArgumentException(message);
+ }
+
+ var entityPlacerConfig = phaseConfig.getEntityPlacerConfigList().get(0);
+ if (phaseConfig.getEntityPlacerConfigList().size() == 2) {
+ entityPlacerConfig = new QueuedMultiplePlacerConfig()
+ .withPlacerConfigList(phaseConfig.getEntityPlacerConfigList());
+ }
if (phaseConfig.getConstructionHeuristicType() != null) {
throw new IllegalArgumentException(
"The constructionHeuristicType (%s) must not be configured if the entityPlacerConfig (%s) is explicitly configured."
@@ -100,14 +130,32 @@ private Optional> getValidEntityPlacerConfig() {
"The moveSelectorConfigList (%s) cannot be configured if the entityPlacerConfig (%s) is explicitly configured."
.formatted(moveSelectorConfigList, entityPlacerConfig));
}
+
return Optional.of(entityPlacerConfig);
}
+ @SuppressWarnings("rawtypes")
private EntityPlacerConfig> buildDefaultEntityPlacerConfig(HeuristicConfigPolicy configPolicy,
ConstructionHeuristicType constructionHeuristicType) {
- return findValidListVariableDescriptor(configPolicy.getSolutionDescriptor())
- .map(listVariableDescriptor -> buildListVariableQueuedValuePlacerConfig(configPolicy, listVariableDescriptor))
- .orElseGet(() -> buildUnfoldedEntityPlacerConfig(configPolicy, constructionHeuristicType));
+ var listVariableDescriptor = findValidListVariableDescriptor(configPolicy.getSolutionDescriptor()).orElse(null);
+ if (configPolicy.getSolutionDescriptor().hasBothBasicAndListVariables()) {
+ if (listVariableDescriptor == null) {
+ throw new IllegalStateException("Impossible state: the list variable descriptor is null.");
+ }
+ var placerConfigList = new ArrayList();
+ // Generate the default configuration for the list variable
+ placerConfigList.add(buildListVariableQueuedValuePlacerConfig(configPolicy, listVariableDescriptor));
+ // Generate a single config for the basic variable(s)
+ // When multiple basic variables are defined, a Cartesian product is created
+ placerConfigList.add(buildUnfoldedEntityPlacerConfig(configPolicy, constructionHeuristicType));
+ return new QueuedMultiplePlacerConfig().withPlacerConfigList(placerConfigList);
+ } else {
+ if (listVariableDescriptor != null) {
+ return buildListVariableQueuedValuePlacerConfig(configPolicy, listVariableDescriptor);
+ } else {
+ return buildUnfoldedEntityPlacerConfig(configPolicy, constructionHeuristicType);
+ }
+ }
}
private Optional>
@@ -117,7 +165,6 @@ private EntityPlacerConfig> buildDefaultEntityPlacerConfig(HeuristicConfigPoli
return Optional.empty();
}
failIfConfigured(phaseConfig.getConstructionHeuristicType(), "constructionHeuristicType");
- failIfConfigured(phaseConfig.getEntityPlacerConfig(), "entityPlacerConfig");
failIfConfigured(phaseConfig.getMoveSelectorConfigList(), "moveSelectorConfigList");
return Optional.of(listVariableDescriptor);
}
@@ -144,7 +191,8 @@ public static EntityPlacerConfig buildListVariableQueuedValuePlacerConfig(Heuris
}
// Prepare replaying ValueSelector config.
var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
- .withMimicSelectorRef(mimicSelectorId);
+ .withMimicSelectorRef(mimicSelectorId)
+ .withVariableName(variableDescriptor.getVariableName());
// ListChangeMoveSelector uses the replaying ValueSelector.
var listChangeMoveSelectorConfig = new ListChangeMoveSelectorConfig()
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacerFactory.java
index f7039e2a60f..97837552cd9 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacerFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacerFactory.java
@@ -4,17 +4,21 @@
import ai.timefold.solver.core.config.constructionheuristic.placer.PooledEntityPlacerConfig;
import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig;
import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig;
+import ai.timefold.solver.core.impl.constructionheuristic.placer.internal.QueuedMultiplePlacerConfig;
import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
public interface EntityPlacerFactory {
+ @SuppressWarnings({ "rawtypes", "unchecked" })
static EntityPlacerFactory create(EntityPlacerConfig> entityPlacerConfig) {
- if (PooledEntityPlacerConfig.class.isAssignableFrom(entityPlacerConfig.getClass())) {
- return new PooledEntityPlacerFactory<>((PooledEntityPlacerConfig) entityPlacerConfig);
- } else if (QueuedEntityPlacerConfig.class.isAssignableFrom(entityPlacerConfig.getClass())) {
- return new QueuedEntityPlacerFactory<>((QueuedEntityPlacerConfig) entityPlacerConfig);
- } else if (QueuedValuePlacerConfig.class.isAssignableFrom(entityPlacerConfig.getClass())) {
- return new QueuedValuePlacerFactory<>((QueuedValuePlacerConfig) entityPlacerConfig);
+ if (entityPlacerConfig instanceof PooledEntityPlacerConfig pooledEntityPlacerConfig) {
+ return new PooledEntityPlacerFactory<>(pooledEntityPlacerConfig);
+ } else if (entityPlacerConfig instanceof QueuedEntityPlacerConfig queuedEntityPlacerConfig) {
+ return new QueuedEntityPlacerFactory<>(queuedEntityPlacerConfig);
+ } else if (entityPlacerConfig instanceof QueuedValuePlacerConfig queuedValuePlacerConfig) {
+ return new QueuedValuePlacerFactory<>(queuedValuePlacerConfig);
+ } else if (entityPlacerConfig instanceof QueuedMultiplePlacerConfig queuedMultiplePlacerConfig) {
+ return new QueuedMultiplePlacerFactory<>(queuedMultiplePlacerConfig);
} else {
throw new IllegalArgumentException(String.format("Unknown %s type: (%s).",
EntityPlacerConfig.class.getSimpleName(), entityPlacerConfig.getClass().getName()));
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 d013a1ec8b3..5fc5e40793e 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
@@ -87,7 +87,9 @@ public QueuedEntityPlacer buildEntityPlacer(HeuristicConfigPolicy !variableDescriptor.isListVariable())
+ .toList();
var subMoveSelectorConfigList = new ArrayList(variableDescriptorList.size());
for (var variableDescriptor : variableDescriptorList) {
subMoveSelectorConfigList
@@ -104,7 +106,7 @@ public QueuedEntityPlacer buildEntityPlacer(HeuristicConfigPolicy configPolicy) {
var entitySelectorConfig = config.getEntitySelectorConfig();
if (entitySelectorConfig == null) {
- var entityDescriptor = getTheOnlyEntityDescriptor(configPolicy.getSolutionDescriptor());
+ var entityDescriptor = getTheOnlyEntityDescriptorWithBasicVariables(configPolicy.getSolutionDescriptor());
entitySelectorConfig = getDefaultEntitySelectorConfigForEntity(configPolicy, entityDescriptor);
} else {
// The default phase configuration generates the entity selector config without an updated version of the configuration policy.
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacer.java
new file mode 100644
index 00000000000..8c051816b1a
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacer.java
@@ -0,0 +1,249 @@
+package ai.timefold.solver.core.impl.constructionheuristic.placer;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
+import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter;
+import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator;
+import ai.timefold.solver.core.impl.heuristic.selector.move.composite.CartesianProductMoveSelector;
+import ai.timefold.solver.core.impl.move.generic.CompositeMove;
+import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener;
+import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope;
+import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope;
+import ai.timefold.solver.core.impl.solver.scope.SolverScope;
+import ai.timefold.solver.core.preview.api.move.Move;
+
+public class QueuedMultiplePlacer extends AbstractEntityPlacer
+ implements EntityPlacer {
+
+ private final List> queuedPlacerList;
+
+ public QueuedMultiplePlacer(EntityPlacerFactory factory,
+ HeuristicConfigPolicy configPolicy, List> queuedPlacerList) {
+ super(factory, configPolicy);
+ this.queuedPlacerList = queuedPlacerList;
+ this.queuedPlacerList.forEach(queuedPlacer -> phaseLifecycleSupport.addEventListener(queuedPlacer));
+
+ }
+
+ @Override
+ public EntityPlacer rebuildWithFilter(SelectionFilter filter) {
+ var filteredQueuedPlacerList = queuedPlacerList.stream()
+ .map(placer -> placer.rebuildWithFilter(filter))
+ .toList();
+ return new QueuedMultiplePlacer<>(factory, configPolicy, filteredQueuedPlacerList);
+ }
+
+ @Override
+ public Iterator> iterator() {
+ var iterator = new MultipleQueuedPlacingIterator(queuedPlacerList);
+ phaseLifecycleSupport.addEventListener(iterator);
+ return iterator;
+ }
+
+ private class MultipleQueuedPlacingIterator extends UpcomingSelectionIterator>
+ implements PhaseLifecycleListener {
+
+ private final List> queuedPlacerList;
+ private Iterator>[] moveIterators;
+ private Iterator>[] placementIterators;
+ private Move[] previousMove;
+ private Move cachedMove = null;
+
+ private MultipleQueuedPlacingIterator(List> queuedPlacerList) {
+ // We expect only the QueuedValuePlacer and a QueuedEntityPlacer
+ var assertSize = queuedPlacerList.size() == 2;
+ var assertQueuedValuePlacer =
+ queuedPlacerList.stream().anyMatch(QueuedValuePlacer.class::isInstance);
+ var assertQueuedEntityPlacer =
+ queuedPlacerList.stream().anyMatch(QueuedEntityPlacer.class::isInstance);
+ if (!assertSize || !assertQueuedValuePlacer || !assertQueuedEntityPlacer) {
+ throw new IllegalArgumentException(
+ "Impossible state: the queued placer list must consist exclusively of a QueuedValuePlacer and a QueuedEntityPlacer.");
+ }
+ this.queuedPlacerList = new ArrayList<>();
+ // We make sure that the QueuedEntityPlacer is added first
+ this.queuedPlacerList.addAll(queuedPlacerList.stream().filter(QueuedValuePlacer.class::isInstance).toList());
+ this.queuedPlacerList.addAll(queuedPlacerList.stream().filter(QueuedEntityPlacer.class::isInstance).toList());
+ reset();
+ }
+
+ /**
+ * The method uses a strategy similar to {@link CartesianProductMoveSelector},
+ * but it uses placer iterators instead.
+ */
+ private Move nextMove() {
+ if (cachedMove != null) {
+ return cachedMove;
+ }
+ var childSize = moveIterators.length;
+ int index;
+ Move[] move = new Move[childSize];
+ if (previousMove == null) {
+ index = -1;
+ } else {
+ index = consumeNextMove(move, previousMove);
+ if (index == -1) {
+ // No more moves
+ return null;
+ }
+ }
+ var updatedMove = updateNextIterators(index, move);
+ if (updatedMove == null) {
+ // We stop if one of the placement iterators has no next placement
+ return null;
+ }
+ previousMove = updatedMove;
+ cachedMove = CompositeMove.buildMove(updatedMove);
+ return cachedMove;
+ }
+
+ /**
+ * Go through the registered iterators and check for any available moves.
+ *
+ * @param move the move array to be loaded
+ * @param previousMove the previous moves
+ * @return the last index of the iterator that still has available moves; otherwise, the function returns -1
+ * when all iterators have no more moves available.
+ */
+ private int consumeNextMove(Move>[] move, Move[] previousMove) {
+ var index = move.length - 1;
+ // Look for the first iterator that still has available moves to generate
+ while (index >= 0) {
+ var moveIterator = moveIterators[index];
+ if (moveIterator.hasNext()) {
+ break;
+ }
+ // Check if there are more placements available in the QueuedEntityPlacer
+ if (index == 1) {
+ var placementIterator = placementIterators[index];
+ if (placementIterator.hasNext()) {
+ moveIterators[index] = placementIterator.next().iterator();
+ continue;
+ } else {
+ // Reset the iterator in case the previous placerIterator still has more placements
+ placementIterators[index] = queuedPlacerList.get(index).iterator();
+ }
+ }
+ index--;
+ }
+ if (index < 0) {
+ return -1;
+ }
+ // Copy the previous move until the next one generated
+ System.arraycopy(previousMove, 0, move, 0, index);
+ // Generate and set the new move
+ move[index] = moveIterators[index].next();
+ return index;
+ }
+
+ /**
+ * Update the move list and recreate all move iterators starting from #lastValidIteratorIndex.
+ *
+ * @param lastValidIteratorIndex the index of the last iterator that generated a valid move
+ * @param move the move array to be loaded
+ */
+ private Move[] updateNextIterators(int lastValidIteratorIndex, Move>[] move) {
+ var childSize = moveIterators.length;
+ var updatedMove = new Move[childSize];
+ System.arraycopy(move, 0, updatedMove, 0, childSize);
+ for (int i = lastValidIteratorIndex + 1; i < childSize; i++) {
+ var placementIterator = placementIterators[i];
+ Move next;
+ if (!placementIterator.hasNext()) {
+ return null;
+ } else {
+ var moveIterator = placementIterator.next().iterator();
+ moveIterators[i] = moveIterator;
+ next = moveIterator.next();
+ }
+ updatedMove[i] = next;
+ }
+ return updatedMove;
+ }
+
+ private void clearCache() {
+ this.cachedMove = null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private void reset() {
+ if (moveIterators == null) {
+ moveIterators = new Iterator[queuedPlacerList.size()];
+ Arrays.fill(moveIterators, null);
+ placementIterators = new Iterator[queuedPlacerList.size()];
+ for (var i = 0; i < queuedPlacerList.size(); i++) {
+ var placement = queuedPlacerList.get(i);
+ placementIterators[i] = placement.iterator();
+ }
+ } else {
+ Arrays.fill(moveIterators, null);
+ // We need to reset of the QueuedEntityPlacer or there will be no more moves for the basic variables
+ placementIterators[1] = queuedPlacerList.get(1).iterator();
+ }
+ previousMove = null;
+ }
+
+ @Override
+ protected Placement createUpcomingSelection() {
+ var nextMove = nextMove();
+ if (nextMove == null) {
+ return noUpcomingSelection();
+ }
+ return new Placement<>(new PlacementToMoveAdapterIterator(this));
+ }
+
+ @Override
+ public void phaseStarted(AbstractPhaseScope phaseScope) {
+ // Ignore
+ }
+
+ @Override
+ public void stepStarted(AbstractStepScope stepScope) {
+ // Ignore
+ }
+
+ @Override
+ public void stepEnded(AbstractStepScope stepScope) {
+ reset();
+ }
+
+ @Override
+ public void phaseEnded(AbstractPhaseScope phaseScope) {
+ // Ignore
+ }
+
+ @Override
+ public void solvingStarted(SolverScope solverScope) {
+ // Ignore
+ }
+
+ @Override
+ public void solvingEnded(SolverScope solverScope) {
+ // Ignore
+ }
+ }
+
+ private class PlacementToMoveAdapterIterator implements Iterator> {
+ private final MultipleQueuedPlacingIterator iterator;
+
+ private PlacementToMoveAdapterIterator(MultipleQueuedPlacingIterator iterator) {
+ this.iterator = iterator;
+ }
+
+ @Override
+ public boolean hasNext() {
+ return iterator.nextMove() != null;
+ }
+
+ @Override
+ public Move next() {
+ var move = iterator.nextMove();
+ iterator.clearCache();
+ return move;
+ }
+ }
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacerFactory.java
new file mode 100644
index 00000000000..6a08c8a08de
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedMultiplePlacerFactory.java
@@ -0,0 +1,24 @@
+package ai.timefold.solver.core.impl.constructionheuristic.placer;
+
+import java.util.ArrayList;
+
+import ai.timefold.solver.core.impl.constructionheuristic.placer.internal.QueuedMultiplePlacerConfig;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
+
+public final class QueuedMultiplePlacerFactory
+ extends AbstractEntityPlacerFactory {
+
+ public QueuedMultiplePlacerFactory(QueuedMultiplePlacerConfig config) {
+ super(config);
+ }
+
+ @Override
+ public EntityPlacer buildEntityPlacer(HeuristicConfigPolicy configPolicy) {
+ var queuedPlacerList = new ArrayList>();
+ for (var placerConfig : config.getPlacerConfigList()) {
+ var placer = EntityPlacerFactory. create(placerConfig).buildEntityPlacer(configPolicy);
+ queuedPlacerList.add(placer);
+ }
+ return new QueuedMultiplePlacer<>(this, configPolicy, queuedPlacerList);
+ }
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/internal/QueuedMultiplePlacerConfig.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/internal/QueuedMultiplePlacerConfig.java
new file mode 100644
index 00000000000..a2d597e98f3
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/internal/QueuedMultiplePlacerConfig.java
@@ -0,0 +1,57 @@
+package ai.timefold.solver.core.impl.constructionheuristic.placer.internal;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import ai.timefold.solver.core.config.constructionheuristic.placer.EntityPlacerConfig;
+import ai.timefold.solver.core.config.util.ConfigUtils;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+public class QueuedMultiplePlacerConfig extends EntityPlacerConfig {
+
+ protected List placerConfigList = null;
+
+ public List getPlacerConfigList() {
+ return placerConfigList;
+ }
+
+ public void setPlacerConfigList(List placerConfigList) {
+ this.placerConfigList = placerConfigList;
+ }
+
+ // ************************************************************************
+ // With methods
+ // ************************************************************************
+
+ public @NonNull QueuedMultiplePlacerConfig
+ withPlacerConfigList(@NonNull List<@NonNull EntityPlacerConfig> placerConfigList) {
+ setPlacerConfigList(placerConfigList);
+ return this;
+ }
+
+ // ************************************************************************
+ // Worker methods
+ // ************************************************************************
+
+ @Override
+ public @NonNull QueuedMultiplePlacerConfig
+ inherit(@NonNull QueuedMultiplePlacerConfig inheritedConfig) {
+ placerConfigList =
+ ConfigUtils.inheritMergeableListConfig(placerConfigList, inheritedConfig.getPlacerConfigList());
+ return this;
+ }
+
+ @Override
+ public @NonNull QueuedMultiplePlacerConfig copyConfig() {
+ return new QueuedMultiplePlacerConfig().inherit(this);
+ }
+
+ @Override
+ public void visitReferencedClasses(@NonNull Consumer<@Nullable Class>> classVisitor) {
+ if (placerConfigList != null) {
+ placerConfigList.forEach(placer -> placer.visitReferencedClasses(classVisitor));
+ }
+ }
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java
index af00c37ffbf..e8b5c2a5404 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java
@@ -699,6 +699,24 @@ public boolean hasAnyGenuineVariables() {
return !effectiveGenuineVariableDescriptorMap.isEmpty();
}
+ public boolean hasAnyGenuineBasicVariables() {
+ if (!isGenuine()) {
+ return false;
+ }
+ return getDeclaredGenuineVariableDescriptors().stream()
+ .anyMatch(descriptor -> !descriptor.isListVariable());
+ }
+
+ public boolean hasAnyGenuineChainedVariables() {
+ if (!isGenuine()) {
+ return false;
+ }
+ return getDeclaredGenuineVariableDescriptors().stream()
+ .filter(descriptor -> descriptor instanceof BasicVariableDescriptor)
+ .map(descriptor -> (BasicVariableDescriptor) descriptor)
+ .anyMatch(BasicVariableDescriptor::isChained);
+ }
+
public boolean hasAnyGenuineListVariables() {
if (!isGenuine()) {
return false;
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java
index 2ab6ec4333f..08f28243c35 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java
@@ -727,11 +727,13 @@ private void validateListVariableDescriptors() {
var listVariableDescriptor = listVariableDescriptorList.get(0);
var listVariableEntityDescriptor = listVariableDescriptor.getEntityDescriptor();
- if (listVariableEntityDescriptor.getGenuineVariableDescriptorList().size() > 1) {
+ // We will not support chained and list variables at the same entity,
+ // and the validation can be removed once we discontinue support for chained variables.
+ if (hasChainedVariable()) {
var basicVariableDescriptorList = new ArrayList<>(listVariableEntityDescriptor.getGenuineVariableDescriptorList());
basicVariableDescriptorList.remove(listVariableDescriptor);
throw new UnsupportedOperationException(
- "Combining basic variables (%s) with list variables (%s) on a single planning entity (%s) is not supported."
+ "Combining chained variables (%s) with list variables (%s) on a single planning entity (%s) is not supported."
.formatted(basicVariableDescriptorList, listVariableDescriptor,
listVariableDescriptor.getEntityDescriptor().getEntityClass().getCanonicalName()));
}
@@ -877,6 +879,22 @@ public PlanningSolutionMetaModel getMetaModel() {
return planningSolutionMetaModel;
}
+ public boolean hasBasicVariable() {
+ return getGenuineEntityDescriptors().stream().anyMatch(EntityDescriptor::hasAnyGenuineBasicVariables);
+ }
+
+ public boolean hasChainedVariable() {
+ return getGenuineEntityDescriptors().stream().anyMatch(EntityDescriptor::hasAnyGenuineChainedVariables);
+ }
+
+ public boolean hasListVariable() {
+ return getListVariableDescriptor() != null;
+ }
+
+ public boolean hasBothBasicAndListVariables() {
+ return hasBasicVariable() && hasListVariable();
+ }
+
/**
* @deprecated {@link ConstraintConfiguration} was replaced by {@link ConstraintWeightOverrides}.
*/
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/anchor/AnchorShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/anchor/AnchorShadowVariableDescriptor.java
index e462b74a075..d87db428198 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/anchor/AnchorShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/anchor/AnchorShadowVariableDescriptor.java
@@ -92,4 +92,9 @@ public Iterable> buildVariableListeners(S
sourceVariableDescriptor).toCollection();
}
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
+
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java
index 87b0d1daca9..f61fd950810 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java
@@ -165,6 +165,11 @@ public Iterable> buildVariableListeners(S
throw new UnsupportedOperationException("Cascade update element generates no listeners.");
}
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
+
private record ShadowVariableTarget(EntityDescriptor entityDescriptor,
MemberAccessor variableMemberAccessor) {
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/CustomShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/CustomShadowVariableDescriptor.java
index 82e50c88c03..27094a7b5ef 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/CustomShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/CustomShadowVariableDescriptor.java
@@ -148,4 +148,9 @@ public Iterable> buildVariableListeners(S
return new VariableListenerWithSources<>(variableListener, classListEntry.getValue());
}).collect(Collectors.toList());
}
+
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/LegacyCustomShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/LegacyCustomShadowVariableDescriptor.java
index fbe233de51f..116efe879dc 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/LegacyCustomShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/LegacyCustomShadowVariableDescriptor.java
@@ -231,4 +231,9 @@ public Iterable> buildVariableListeners(S
return new VariableListenerWithSources<>(variableListener, sourceVariableDescriptorList).toCollection();
}
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
+
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/PiggybackShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/PiggybackShadowVariableDescriptor.java
index eb6424f2e15..e5e9c2158fa 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/PiggybackShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/custom/PiggybackShadowVariableDescriptor.java
@@ -110,4 +110,9 @@ public Iterable> buildVariableListeners(S
throw new UnsupportedOperationException("The piggybackShadowVariableDescriptor (" + this
+ ") cannot build a variable listener.");
}
+
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java
index 158037333d0..c01e650956b 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java
@@ -113,6 +113,11 @@ public void linkVariableDescriptors(DescriptorPolicy descriptorPolicy) {
}
}
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
+
public MemberAccessor getMemberAccessor() {
return variableMemberAccessor;
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ShadowVariableLoopedVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ShadowVariableLoopedVariableDescriptor.java
index ea98ec66bb2..18921a03851 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ShadowVariableLoopedVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ShadowVariableLoopedVariableDescriptor.java
@@ -61,4 +61,9 @@ public Iterable> buildVariableListeners(S
public void linkVariableDescriptors(DescriptorPolicy descriptorPolicy) {
// no action needed
}
+
+ @Override
+ public boolean isListVariableSource() {
+ return false;
+ }
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ShadowVariableDescriptor.java
index 56de9ed1f2f..121f5d8994f 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ShadowVariableDescriptor.java
@@ -64,6 +64,11 @@ public boolean hasVariableListener() {
return true;
}
+ /**
+ * return true if the source variable is a list variable; otherwise, return false.
+ */
+ public abstract boolean isListVariableSource();
+
/**
* @param supplyManager never null
* @return never null
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/index/IndexShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/index/IndexShadowVariableDescriptor.java
index b07a895260d..618f1b2c76a 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/index/IndexShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/index/IndexShadowVariableDescriptor.java
@@ -114,4 +114,9 @@ public Iterable> buildVariableListeners(S
public Integer getValue(Object entity) {
return super.getValue(entity);
}
+
+ @Override
+ public boolean isListVariableSource() {
+ return true;
+ }
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java
index aa58a5e6d38..d29ca6f62ed 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java
@@ -17,6 +17,7 @@
import ai.timefold.solver.core.impl.domain.policy.DescriptorPolicy;
import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply;
import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor;
+import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor;
import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor;
import ai.timefold.solver.core.impl.domain.variable.listener.VariableListenerWithSources;
@@ -182,4 +183,8 @@ private AbstractVariableListener buildVariableListener() {
}
}
+ @Override
+ public boolean isListVariableSource() {
+ return sourceVariableDescriptor instanceof ListVariableDescriptor;
+ }
}
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 65f6c3a36ae..44b3c4dcf82 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
@@ -112,14 +112,21 @@ public void linkVariableListeners() {
.flatMap(Collection::stream)
.filter(ShadowVariableDescriptor::hasVariableListener)
.sorted(Comparator.comparingInt(ShadowVariableDescriptor::getGlobalShadowOrder))
- .forEach(d -> {
+ .forEach(descriptor -> {
// All information about elements in all shadow variables is tracked in a centralized place.
- // Therefore all list-related shadow variables need to be connected to that centralized place.
+ // Therefore, all list-related shadow variables need to be connected to that centralized place.
// Shadow variables which are not related to a list variable are processed normally.
if (listVariableStateSupply == null) {
- processShadowVariableDescriptorWithoutListVariable(d);
+ processShadowVariableDescriptorWithoutListVariable(descriptor);
} else {
- processShadowVariableDescriptorWithListVariable(d, listVariableStateSupply);
+ // When multiple variable types are used,
+ // the shadow variable process needs to account for each variable
+ // and process them according to their types.
+ if (descriptor.isListVariableSource()) {
+ processShadowVariableDescriptorWithListVariable(descriptor, listVariableStateSupply);
+ } else {
+ processShadowVariableDescriptorWithoutListVariable(descriptor);
+ }
}
});
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java
index fb7efe38168..e8782be6247 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java
@@ -97,4 +97,9 @@ public Demand> getProvidedDemand() {
throw new UnsupportedOperationException("Impossible state: Handled by %s."
.formatted(ListVariableStateSupply.class.getSimpleName()));
}
+
+ @Override
+ public boolean isListVariableSource() {
+ return true;
+ }
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorFactory.java
index 9c915c34734..7a3fc874114 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListChangeMoveSelectorFactory.java
@@ -12,6 +12,7 @@
import ai.timefold.solver.core.config.heuristic.selector.move.MoveSelectorConfig;
import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig;
import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig;
+import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor;
import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor;
import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
@@ -71,8 +72,18 @@ protected MoveSelectorConfig> buildUnfoldedMoveSelectorConfig(HeuristicConfigP
: EntitySelectorFactory. create(destinationEntitySelectorConfig)
.extractEntityDescriptor(configPolicy);
var entityDescriptors =
- onlyEntityDescriptor == null ? configPolicy.getSolutionDescriptor().getGenuineEntityDescriptors()
+ onlyEntityDescriptor == null ? configPolicy.getSolutionDescriptor().getGenuineEntityDescriptors().stream()
+ // We need to filter the entity that defines the list variable
+ .filter(EntityDescriptor::hasAnyGenuineListVariables)
+ .toList()
: Collections.singletonList(onlyEntityDescriptor);
+
+ if (entityDescriptors.isEmpty()) {
+ throw new IllegalArgumentException(
+ "The listChangeMoveSelector (%s) cannot unfold because there are no planning list variables."
+ .formatted(config));
+ }
+
if (entityDescriptors.size() > 1) {
throw new IllegalArgumentException("""
The listChangeMoveSelector (%s) cannot unfold when there are multiple entities (%s).
@@ -123,11 +134,6 @@ The listChangeMoveSelector (%s) is configured to use a planning variable (%s), \
.map(variableDescriptor -> ((ListVariableDescriptor) variableDescriptor))
.toList());
}
- if (variableDescriptorList.isEmpty()) {
- throw new IllegalArgumentException(
- "The listChangeMoveSelector (%s) cannot unfold because there are no planning list variables."
- .formatted(config));
- }
if (variableDescriptorList.size() > 1) {
throw new IllegalArgumentException(
"The listChangeMoveSelector (%s) cannot unfold because there are multiple planning list variables."
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelectorFactory.java
index fb186201405..b9386ddabd9 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelectorFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveSelectorFactory.java
@@ -42,7 +42,7 @@ protected MoveSelector buildBaseMoveSelector(HeuristicConfigPolicy(valueSelector, listVariableDescriptor, constructionHeuristicPhaseBuilder,
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/ValueSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/ValueSelectorFactory.java
index 6de40c96620..480a215298e 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/ValueSelectorFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/ValueSelectorFactory.java
@@ -134,7 +134,6 @@ public ValueSelector buildValueSelector(HeuristicConfigPolicy buildMimicReplaying(HeuristicConfigPolicy configPolicy) {
if (config.getId() != null
- || config.getVariableName() != null
|| config.getCacheType() != null
|| config.getSelectionOrder() != null
|| config.getNearbySelectionConfig() != null
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java
index 6daa0a87c0e..4726ac294b0 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseFactory.java
@@ -68,7 +68,10 @@ public LocalSearchPhase buildPhase(int phaseIndex, boolean lastInitia
The solver configuration enabled both move selectors and Move Streams.
These are mutually exclusive features, please pick one or the other.""");
}
-
+ if (solverConfigPolicy.getSolutionDescriptor().hasBothBasicAndListVariables()) {
+ throw new UnsupportedOperationException(
+ "A mixed model using both basic and list variables is not supported yet.");
+ }
var phaseConfigPolicy = solverConfigPolicy.createPhaseConfigPolicy();
var phaseTermination = buildPhaseTermination(phaseConfigPolicy, solverTermination);
var decider = moveStreamsEnabled
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 2993b256210..935319ed64a 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
@@ -250,7 +250,7 @@ public List> buildPhaseList(HeuristicConfigPolicy co
.getDefaultEntitySelectorConfigForEntity(configPolicy, genuineEntityDescriptor));
}
- constructionHeuristicPhaseConfig.setEntityPlacerConfig(entityPlacerConfig);
+ constructionHeuristicPhaseConfig.setEntityPlacerConfigList(List.of(entityPlacerConfig));
phaseConfigList.add(constructionHeuristicPhaseConfig);
}
phaseConfigList.add(new LocalSearchPhaseConfig());
diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningSolutionMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningSolutionMetaModel.java
index a7fbcffb7dd..8de29a2d9e8 100644
--- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningSolutionMetaModel.java
+++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningSolutionMetaModel.java
@@ -84,5 +84,4 @@ default boolean hasEntity(Class> entityClass) {
}
return false;
}
-
}
diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd
index 74067637912..b3fe9064749 100644
--- a/core/src/main/resources/solver.xsd
+++ b/core/src/main/resources/solver.xsd
@@ -255,7 +255,7 @@
-
+
diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java
index dfa7e87909e..fb32a936fa3 100644
--- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java
@@ -31,10 +31,10 @@
import ai.timefold.solver.core.testdomain.list.shadowhistory.TestdataListSolutionWithShadowHistory;
import ai.timefold.solver.core.testdomain.list.shadowhistory.TestdataListValueWithShadowHistory;
import ai.timefold.solver.core.testdomain.list.shadowhistory.TestdataListWithShadowHistoryIncrementalScoreCalculator;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultivarIncrementalScoreCalculator;
-import ai.timefold.solver.core.testdomain.multivar.TestdataOtherValue;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultivarIncrementalScoreCalculator;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataOtherValue;
import ai.timefold.solver.core.testdomain.shadow.TestdataShadowedEntity;
import ai.timefold.solver.core.testdomain.shadow.TestdataShadowedIncrementalScoreCalculator;
import ai.timefold.solver.core.testdomain.shadow.TestdataShadowedSolution;
diff --git a/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java b/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java
index 77b8fbb2db0..d03a4f6ed55 100644
--- a/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/config/solver/SolverConfigTest.java
@@ -291,19 +291,6 @@ void entityWithTwoPlanningListVariables() {
.hasMessageContaining("secondListVariable");
}
- @Test
- void entityWithMixedBasicAndPlanningListVariables() {
- var solverConfig = new SolverConfig()
- .withSolutionClass(DummySolutionWithMixedSimpleAndListVariableEntity.class)
- .withEntityClasses(DummyEntityWithMixedSimpleAndListVariable.class)
- .withEasyScoreCalculatorClass(DummyRecordEasyScoreCalculator.class);
- assertThatThrownBy(() -> SolverFactory.create(solverConfig))
- .isExactlyInstanceOf(UnsupportedOperationException.class)
- .hasMessageContaining(DummyEntityWithMixedSimpleAndListVariable.class.getSimpleName())
- .hasMessageContaining("listVariable")
- .hasMessageContaining("basicVariable");
- }
-
@PlanningSolution
private record DummyRecordSolution(
@PlanningEntityCollectionProperty List entities,
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseTest.java
index e86fadf5dd0..cbc6bff7f2f 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhaseTest.java
@@ -2,6 +2,7 @@
import static ai.timefold.solver.core.testutil.PlannerAssert.assertCode;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.SoftAssertions.assertSoftly;
@@ -16,8 +17,15 @@
import ai.timefold.solver.core.api.solver.SolverFactory;
import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig;
import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicType;
+import ai.timefold.solver.core.config.constructionheuristic.placer.PooledEntityPlacerConfig;
+import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig;
+import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig;
+import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig;
+import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig;
+import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig;
import ai.timefold.solver.core.config.solver.monitoring.MonitoringConfig;
import ai.timefold.solver.core.config.solver.monitoring.SolverMetric;
+import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter;
import ai.timefold.solver.core.impl.solver.DefaultSolver;
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
@@ -27,10 +35,23 @@
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.TestdataListVarEasyScoreCalculator;
import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEasyScoreCalculator;
import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity;
import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution;
import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue;
+import ai.timefold.solver.core.testdomain.multivar.list.multientity.TestdataListMultiEntityEasyScoreCalculator;
+import ai.timefold.solver.core.testdomain.multivar.list.multientity.TestdataListMultiEntityFirstEntity;
+import ai.timefold.solver.core.testdomain.multivar.list.multientity.TestdataListMultiEntitySecondEntity;
+import ai.timefold.solver.core.testdomain.multivar.list.multientity.TestdataListMultiEntitySolution;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarEasyScoreCalculator;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarOtherValue;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarValue;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar.TestdataUnassignedListMultiVarEasyScoreCalculator;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar.TestdataUnassignedListMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar.TestdataUnassignedListMultiVarSolution;
import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedEntity;
import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution;
import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity;
@@ -342,4 +363,275 @@ void constructionHeuristicAllocateToValueFromQueue() {
.filter(e -> e.getValue() == null)).isEmpty();
}
+ @Test
+ void failWithExceededMultipleQueuedEntityPlacers() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(new QueuedEntityPlacerConfig(), new QueuedEntityPlacerConfig(),
+ new QueuedEntityPlacerConfig()));
+
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig, new TestdataSolution("s1")))
+ .hasMessageContaining(
+ "The Construction Heuristic configuration (ConstructionHeuristicPhaseConfig) only support a maximum of two entity placers.");
+ }
+
+ @Test
+ void failWithMultipleQueuedEntityPlacers() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(new QueuedEntityPlacerConfig(), new QueuedEntityPlacerConfig()));
+
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig, new TestdataSolution("s1")))
+ .hasMessageContaining(
+ "The Construction Heuristic configuration (ConstructionHeuristicPhaseConfig) cannot contain duplicate placer configurations.")
+ .hasMessageContaining("Maybe define multiple move selectors if there are more than one basic variables");
+
+ var solverConfig2 = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(new QueuedValuePlacerConfig(), new QueuedValuePlacerConfig()));
+
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig2, new TestdataSolution("s1")))
+ .hasMessageContaining(
+ "The Construction Heuristic configuration (ConstructionHeuristicPhaseConfig) cannot contain duplicate placer configurations.");
+ }
+
+ @Test
+ void failWithPooledEntityPlacers() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(new QueuedEntityPlacerConfig(), new PooledEntityPlacerConfig()));
+
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig, new TestdataSolution("s1")))
+ .hasMessageContaining(
+ "The Construction Heuristic configuration (ConstructionHeuristicPhaseConfig) does not support multiple configurations when using the pooled placer configuration PooledEntityPlacerConfig.");
+ }
+
+ @Test
+ void failMultiEntityWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiEntitySolution.class, TestdataListMultiEntityFirstEntity.class,
+ TestdataListMultiEntitySecondEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiEntitySolution.generateUninitializedSolution(2, 2, 2);
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem))
+ .hasMessageContaining("has no entityClass configured and because there are multiple in the entityClassSet")
+ .hasMessageContaining("it cannot be deduced automatically");
+ }
+
+ @Test
+ void failLocalSearchWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiVarSolution.class, TestdataListMultiVarEntity.class, TestdataListMultiVarValue.class,
+ TestdataListMultiVarOtherValue.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)),
+ new LocalSearchPhaseConfig())
+ .withEasyScoreCalculatorClass(TestdataListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem))
+ .hasMessageContaining("A mixed model using both basic and list variables is not supported yet.");
+ }
+
+ @Test
+ void solveWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiVarSolution.class, TestdataListMultiVarEntity.class, TestdataListMultiVarValue.class,
+ TestdataListMultiVarOtherValue.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getBasicValue() == null || e.getSecondBasicValue() == null || e.getValueList().isEmpty()))
+ .isEmpty();
+ }
+
+ @Test
+ void solvePinnedWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiVarSolution.class, TestdataListMultiVarEntity.class, TestdataListMultiVarValue.class,
+ TestdataListMultiVarOtherValue.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ // Pin the first entity
+ problem.getEntityList().get(0).setPinned(true);
+ problem.getEntityList().get(0).setPinnedIndex(0);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ // The first entity should remain unchanged
+ assertThat(solution.getEntityList().get(0).getBasicValue()).isNull();
+ assertThat(solution.getEntityList().get(0).getSecondBasicValue()).isNull();
+ assertThat(solution.getEntityList().get(0).getValueList()).isEmpty();
+ }
+
+ @Test
+ void solveUnassignedWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataUnassignedListMultiVarSolution.class, TestdataUnassignedListMultiVarEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataUnassignedListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataUnassignedListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ // Block values and make the basic and list variables unassigned
+ problem.getValueList().get(0).setBlocked(true);
+ problem.getValueList().get(1).setBlocked(true);
+ problem.getOtherValueList().get(0).setBlocked(true);
+ problem.getOtherValueList().get(1).setBlocked(true);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getBasicValue() == null))
+ .hasSize(2);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getSecondBasicValue() != null))
+ .hasSize(2);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getValueList().isEmpty()))
+ .hasSize(2);
+ }
+
+ @Test
+ void solvePinnedAndUnassignedWithListAndBasicVariables() {
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataUnassignedListMultiVarSolution.class, TestdataUnassignedListMultiVarEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataUnassignedListMultiVarEasyScoreCalculator.class);
+
+ // Pin the entire first entity
+ var problem = TestdataUnassignedListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ problem.getEntityList().get(0).setPinned(true);
+ problem.getEntityList().get(0).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setSecondBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setValueList(List.of(problem.getValueList().get(0)));
+ // Block values and make the basic and list variables unassigned
+ problem.getValueList().get(0).setBlocked(true);
+ problem.getValueList().get(1).setBlocked(true);
+ problem.getOtherValueList().get(0).setBlocked(true);
+ problem.getOtherValueList().get(1).setBlocked(true);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ // The first entity should remain unchanged
+ assertThat(solution.getEntityList().get(0).getBasicValue()).isNotNull();
+ assertThat(solution.getEntityList().get(0).getSecondBasicValue()).isNotNull();
+ assertThat(solution.getEntityList().get(0).getValueList()).hasSize(1);
+ assertThat(solution.getEntityList().get(1).getBasicValue()).isNull();
+ assertThat(solution.getEntityList().get(1).getSecondBasicValue()).isNotNull();
+ assertThat(solution.getEntityList().get(1).getValueList()).isEmpty();
+
+ // Pin partially the first entity list
+ problem = TestdataUnassignedListMultiVarSolution.generateUninitializedSolution(2, 4, 2);
+ problem.getEntityList().get(0).setPinnedIndex(2);
+ problem.getEntityList().get(0).setValueList(problem.getValueList().subList(1, 3));
+ // Block values and make the basic variable unassigned
+ problem.getOtherValueList().get(0).setBlocked(true);
+ problem.getOtherValueList().get(1).setBlocked(true);
+ solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().get(0).getBasicValue()).isNull();
+ assertThat(solution.getEntityList().get(0).getSecondBasicValue()).isNotNull();
+ // The pinning index fixed the values 1 and 2. The only remaining option is values are 0 and 3.
+ // The score is bigger when the list size is 3
+ assertThat(solution.getEntityList().get(0).getValueList()).hasSize(3);
+ assertThat(solution.getEntityList().get(0).getValueList())
+ .hasSameElementsAs(
+ List.of(problem.getValueList().get(1), problem.getValueList().get(2), problem.getValueList().get(0)));
+ assertThat(solution.getEntityList().get(1).getBasicValue()).isNull();
+ assertThat(solution.getEntityList().get(1).getSecondBasicValue()).isNotNull();
+ assertThat(solution.getEntityList().get(1).getValueList()).hasSize(1);
+ assertThat(solution.getEntityList().get(1).getValueList()).hasSameElementsAs(List.of(problem.getValueList().get(3)));
+ }
+
+ @Test
+ void solveCustomConfigurationMultiEntityWithListAndBasicVariables() {
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig))
+ .withEntityClass(TestdataListMultiEntityFirstEntity.class);
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiEntitySolution.class, TestdataListMultiEntityFirstEntity.class,
+ TestdataListMultiEntitySecondEntity.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(valuePlacerConfig, entityPlacerConfig)
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListMultiEntityEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiEntitySolution.generateUninitializedSolution(2, 2, 2);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getValueList().isEmpty()))
+ .isEmpty();
+ assertThat(solution.getOtherEntityList().stream()
+ .filter(e -> e.getBasicValue() == null || e.getSecondBasicValue() == null))
+ .isEmpty();
+ }
+
+ @Test
+ void solveCustomConfigurationWithListAndBasicVariables() {
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListMultiVarSolution.class, TestdataListMultiVarEntity.class, TestdataListMultiVarValue.class,
+ TestdataListMultiVarOtherValue.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(valuePlacerConfig, entityPlacerConfig)
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListMultiVarEasyScoreCalculator.class);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getBasicValue() == null || e.getSecondBasicValue() == null || e.getValueList().isEmpty()))
+ .isEmpty();
+ }
+
+ @Test
+ void solveCustomConfigurationWithListVariables() {
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+
+ var solverConfig = PlannerTestUtils.buildSolverConfig(
+ TestdataListSolution.class, TestdataListEntity.class, TestdataListValue.class)
+ .withPhases(new ConstructionHeuristicPhaseConfig()
+ .withEntityPlacerConfigList(valuePlacerConfig)
+ .withTerminationConfig(new TerminationConfig().withStepCountLimit(16)))
+ .withEasyScoreCalculatorClass(TestdataListVarEasyScoreCalculator.class);
+
+ var problem = TestdataListSolution.generateUninitializedSolution(2, 2);
+ var solution = PlannerTestUtils.solve(solverConfig, problem);
+ assertThat(solution.getEntityList().stream()
+ .filter(e -> e.getValueList().isEmpty()))
+ .isEmpty();
+ }
+
}
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 57b58585853..8b67c05d4ab 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
@@ -27,8 +27,8 @@
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
import ai.timefold.solver.core.testdomain.TestdataValue;
import ai.timefold.solver.core.testdomain.difficultyweight.TestdataDifficultyWeightSolution;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarSolution;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
index dcd93980d16..eb5fc2d56e7 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
@@ -33,8 +33,8 @@
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.basic.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarSolution;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedMultiplePlacerFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedMultiplePlacerFactoryTest.java
new file mode 100644
index 00000000000..c636c58a4e8
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedMultiplePlacerFactoryTest.java
@@ -0,0 +1,476 @@
+package ai.timefold.solver.core.impl.constructionheuristic.placer.entity;
+
+import static ai.timefold.solver.core.config.heuristic.selector.entity.EntitySorterManner.DECREASING_DIFFICULTY_IF_AVAILABLE;
+import static ai.timefold.solver.core.config.heuristic.selector.value.ValueSorterManner.INCREASING_STRENGTH_IF_AVAILABLE;
+import static ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel.ANY;
+import static ai.timefold.solver.core.config.solver.EnvironmentMode.PHASE_ASSERT;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Random;
+
+import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedEntityPlacerConfig;
+import ai.timefold.solver.core.config.constructionheuristic.placer.QueuedValuePlacerConfig;
+import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListChangeMoveSelectorConfig;
+import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig;
+import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel;
+import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacerFactory;
+import ai.timefold.solver.core.impl.constructionheuristic.placer.internal.QueuedMultiplePlacerConfig;
+import ai.timefold.solver.core.impl.domain.variable.listener.support.VariableListenerSupport;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
+import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope;
+import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope;
+import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
+import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend;
+import ai.timefold.solver.core.impl.solver.scope.SolverScope;
+import ai.timefold.solver.core.impl.util.MutableInt;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar.TestdataUnassignedListMultiVarSolution;
+
+import org.junit.jupiter.api.Test;
+
+class QueuedMultiplePlacerFactoryTest {
+
+ @Test
+ void testPlacersForConstructionHeuristic() {
+ var solutionDescriptor = TestdataListMultiVarSolution.buildSolutionDescriptor();
+ var configPolicy = new HeuristicConfigPolicy.Builder()
+ .withEnvironmentMode(PHASE_ASSERT)
+ .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { ANY }))
+ .withSolutionDescriptor(solutionDescriptor)
+ .withEntitySorterManner(DECREASING_DIFFICULTY_IF_AVAILABLE)
+ .withValueSorterManner(INCREASING_STRENGTH_IF_AVAILABLE)
+ .withReinitializeVariableFilterEnabled(true)
+ .withInitializedChainedValueFilterEnabled(true)
+ .withUnassignedValuesAllowed(true)
+ .withRandom(new Random(0))
+ .build();
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+ var placerConfig = new QueuedMultiplePlacerConfig()
+ .withPlacerConfigList(List.of(valuePlacerConfig, entityPlacerConfig));
+ var placer = EntityPlacerFactory. create(placerConfig).buildEntityPlacer(configPolicy);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ var solverScope = mock(SolverScope.class);
+ var scoreDirector = mock(InnerScoreDirector.class);
+ var random = new Random(0L);
+ when(solverScope.getScoreDirector()).thenReturn(scoreDirector);
+ when(solverScope.getWorkingRandom()).thenReturn(random);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor);
+
+ var supplyManager = VariableListenerSupport.create(scoreDirector);
+ when(scoreDirector.getSupplyManager()).thenReturn(supplyManager);
+ supplyManager.linkVariableListeners();
+ supplyManager.resetWorkingSolution();
+
+ placer.solvingStarted(solverScope);
+ var phaseScope = mock(AbstractPhaseScope.class);
+ when(phaseScope.getScoreDirector()).thenReturn(scoreDirector);
+ placer.phaseStarted(phaseScope);
+
+ var placerIterator = placer.iterator();
+
+ // Step 1
+ // 1 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 2 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 3 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 4 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 5 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 6 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 7 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 8 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 9 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 10 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 11 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 12 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 13 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 14 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 15 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 16 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ assertThat(placerIterator.hasNext()).isTrue();
+ var counter = new MutableInt();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(16);
+
+ // Accept the move -> 1 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(0).setValueList(List.of(problem.getValueList().get(0)));
+ problem.getEntityList().get(0).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ var stepScope = mock(AbstractStepScope.class);
+ placer.stepEnded(stepScope);
+
+ // Step 2
+ // 1 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 2 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 3 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 4 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 5 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 6 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 7 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 8 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 9 = Generated Value 1 -> Entity 0[1] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 10 = Generated Value 1 -> Entity 0[1] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 11 = Generated Value 1 -> Entity 0[1] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 12 = Generated Value 1 -> Entity 0[1] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ counter.setValue(0);
+ assertThat(placerIterator.hasNext()).isTrue();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(12);
+
+ // Accept the move = 5 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(1).setValueList(List.of(problem.getValueList().get(1)));
+ problem.getEntityList().get(1).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(1).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ placer.stepEnded(stepScope);
+ // No more placements
+ assertThat(placerIterator.hasNext()).isFalse();
+ }
+
+ @Test
+ void testPinnedPlacersForConstructionHeuristic() {
+ var solutionDescriptor = TestdataListMultiVarSolution.buildSolutionDescriptor();
+ var configPolicy = new HeuristicConfigPolicy.Builder()
+ .withEnvironmentMode(PHASE_ASSERT)
+ .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { ANY }))
+ .withSolutionDescriptor(solutionDescriptor)
+ .withEntitySorterManner(DECREASING_DIFFICULTY_IF_AVAILABLE)
+ .withValueSorterManner(INCREASING_STRENGTH_IF_AVAILABLE)
+ .withReinitializeVariableFilterEnabled(true)
+ .withInitializedChainedValueFilterEnabled(true)
+ .withUnassignedValuesAllowed(true)
+ .withRandom(new Random(0))
+ .build();
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+ var placerConfig = new QueuedMultiplePlacerConfig()
+ .withPlacerConfigList(List.of(valuePlacerConfig, entityPlacerConfig));
+ var placer = EntityPlacerFactory. create(placerConfig).buildEntityPlacer(configPolicy);
+
+ var problem = TestdataListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ // Pin the first entity
+ problem.getEntityList().get(0).setPinned(true);
+ problem.getEntityList().get(0).setPinnedIndex(2);
+ problem.getEntityList().get(0).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setSecondBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setValueList(List.of(problem.getValueList().get(0)));
+
+ var solverScope = mock(SolverScope.class);
+ var scoreDirector = mock(InnerScoreDirector.class);
+ var random = new Random(0L);
+ when(solverScope.getScoreDirector()).thenReturn(scoreDirector);
+ when(solverScope.getWorkingRandom()).thenReturn(random);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor);
+
+ var supplyManager = VariableListenerSupport.create(scoreDirector);
+ when(scoreDirector.getSupplyManager()).thenReturn(supplyManager);
+ supplyManager.linkVariableListeners();
+ supplyManager.resetWorkingSolution();
+
+ placer.solvingStarted(solverScope);
+ var phaseScope = mock(AbstractPhaseScope.class);
+ when(phaseScope.getScoreDirector()).thenReturn(scoreDirector);
+ placer.phaseStarted(phaseScope);
+
+ var placerIterator = placer.iterator();
+
+ // Step 1
+ // 1 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 2 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 3 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 4 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ assertThat(placerIterator.hasNext()).isTrue();
+ var counter = new MutableInt();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(4);
+
+ // Accept the move -> 1 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(1).setValueList(List.of(problem.getValueList().get(1)));
+ problem.getEntityList().get(1).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(1).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ var stepScope = mock(AbstractStepScope.class);
+ placer.stepEnded(stepScope);
+ assertThat(placerIterator.hasNext()).isFalse();
+ }
+
+ @Test
+ void testUnassignedPlacersForConstructionHeuristic() {
+ var solutionDescriptor = TestdataUnassignedListMultiVarSolution.buildSolutionDescriptor();
+ var configPolicy = new HeuristicConfigPolicy.Builder()
+ .withEnvironmentMode(PHASE_ASSERT)
+ .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { ANY }))
+ .withSolutionDescriptor(solutionDescriptor)
+ .withEntitySorterManner(DECREASING_DIFFICULTY_IF_AVAILABLE)
+ .withValueSorterManner(INCREASING_STRENGTH_IF_AVAILABLE)
+ .withReinitializeVariableFilterEnabled(true)
+ .withInitializedChainedValueFilterEnabled(true)
+ .withUnassignedValuesAllowed(true)
+ .withRandom(new Random(0))
+ .build();
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+ var placerConfig = new QueuedMultiplePlacerConfig()
+ .withPlacerConfigList(List.of(valuePlacerConfig, entityPlacerConfig));
+ var placer = EntityPlacerFactory. create(placerConfig)
+ .buildEntityPlacer(configPolicy);
+
+ var problem = TestdataUnassignedListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ var solverScope = mock(SolverScope.class);
+ var scoreDirector = mock(InnerScoreDirector.class);
+ var random = new Random(0L);
+ when(solverScope.getScoreDirector()).thenReturn(scoreDirector);
+ when(solverScope.getWorkingRandom()).thenReturn(random);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor);
+
+ var supplyManager = VariableListenerSupport.create(scoreDirector);
+ when(scoreDirector.getSupplyManager()).thenReturn(supplyManager);
+ supplyManager.linkVariableListeners();
+ supplyManager.resetWorkingSolution();
+
+ placer.solvingStarted(solverScope);
+ var phaseScope = mock(AbstractPhaseScope.class);
+ when(phaseScope.getScoreDirector()).thenReturn(scoreDirector);
+ placer.phaseStarted(phaseScope);
+
+ var placerIterator = placer.iterator();
+
+ // Step 1
+ // 1 = Generated Value 0 -> Entity 0[0] - Entity 0 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 2 = Generated Value 0 -> Entity 0[0] - Entity 0 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 3 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 4 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 5 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 6 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 7 = Generated Value 0 -> Entity 0[0] - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 8 = Generated Value 0 -> Entity 0[0] - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 9 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 10 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 11 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 12 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 13 = Generated Value 0 -> Entity 1[0] - Entity 0 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 14 = Generated Value 0 -> Entity 1[0] - Entity 0 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 15 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 16 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 17 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 18 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 19 = Generated Value 0 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 20 = Generated Value 0 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 21 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 22 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 23 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 24 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 25 = NoChange - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 26 = NoChange - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 27 = NoChange - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 28 = NoChange - Entity 0 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 29 = NoChange - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 30 = NoChange - Entity 0 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 31 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 32 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 33 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 34 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 35 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 36 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ assertThat(placerIterator.hasNext()).isTrue();
+ var counter = new MutableInt();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(36);
+
+ // Accept the move - 31 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(0).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ var stepScope = mock(AbstractStepScope.class);
+ placer.stepEnded(stepScope);
+
+ // Step 2
+ // 1 = Generated Value 1 -> Entity 0[0] - Entity 0 - null -> basicValue
+ // 2 = Generated Value 1 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue
+ // 3 = Generated Value 1 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue
+ // 4 = Generated Value 1 -> Entity 0[0] - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 5 = Generated Value 1 -> Entity 0[0] - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 6 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 7 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 8 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 9 = Generated Value 1 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 10 = Generated Value 1 -> Entity 1[0] - Entity 0 - null -> basicValue
+ // 11 = Generated Value 1 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue
+ // 12 = Generated Value 1 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue
+ // 13 = Generated Value 1 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 14 = Generated Value 1 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 15 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 16 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 17 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 18 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 19 = NoChange - Entity 0 - null -> basicValue
+ // 20 = NoChange - Entity 0 - Generated Other Value 0 -> basicValue
+ // 21 = NoChange - Entity 0 - Generated Other Value 1 -> basicValue
+ // 22 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 23 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 24 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 25 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 26 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 27 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ counter.setValue(0);
+ assertThat(placerIterator.hasNext()).isTrue();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(27);
+
+ // Accept the move - 22 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(1).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ placer.stepEnded(stepScope);
+ // 1 = Generated Value 0 -> Entity 0[0] - Entity 0 - null -> basicValue
+ // 2 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 0 -> basicValue
+ // 3 = Generated Value 0 -> Entity 0[0] - Entity 0 - Generated Other Value 1 -> basicValue
+ // 4 = Generated Value 0 -> Entity 0[0] - Entity 1 - null -> basicValue
+ // 5 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 0 -> basicValue
+ // 6 = Generated Value 0 -> Entity 0[0] - Entity 1 - Generated Other Value 1 -> basicValue
+ // 7 = Generated Value 0 -> Entity 1[0] - Entity 0 - null -> basicValue
+ // 8 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 0 -> basicValue
+ // 9 = Generated Value 0 -> Entity 1[0] - Entity 0 - Generated Other Value 1 -> basicValue
+ // 10 = Generated Value 0 -> Entity 1[0] - Entity 1 - null -> basicValue
+ // 11 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue
+ // 12 = Generated Value 0 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue
+ // 13 = NoChange - Entity 0 - null -> basicValue
+ // 14 = NoChange - Entity 0 - Generated Other Value 0 -> basicValue
+ // 15 = NoChange - Entity 0 - Generated Other Value 1 -> basicValue
+ // 16 = NoChange - Entity 1 - null -> basicValue
+ // 17 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue
+ // 18 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue
+ counter.setValue(0);
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(18);
+ }
+
+ @Test
+ void testPinnedUnassignedPlacersForConstructionHeuristic() {
+ var solutionDescriptor = TestdataUnassignedListMultiVarSolution.buildSolutionDescriptor();
+ var configPolicy = new HeuristicConfigPolicy.Builder()
+ .withEnvironmentMode(PHASE_ASSERT)
+ .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] { ANY }))
+ .withSolutionDescriptor(solutionDescriptor)
+ .withEntitySorterManner(DECREASING_DIFFICULTY_IF_AVAILABLE)
+ .withValueSorterManner(INCREASING_STRENGTH_IF_AVAILABLE)
+ .withReinitializeVariableFilterEnabled(true)
+ .withInitializedChainedValueFilterEnabled(true)
+ .withUnassignedValuesAllowed(true)
+ .withRandom(new Random(0))
+ .build();
+ var valueSelectorConfig = new ValueSelectorConfig("valueList")
+ .withId("valueList");
+ var mimicReplayingValueSelectorConfig = new ValueSelectorConfig()
+ .withMimicSelectorRef("valueList")
+ .withVariableName("valueList");
+ var valuePlacerConfig = new QueuedValuePlacerConfig()
+ .withValueSelectorConfig(valueSelectorConfig)
+ .withMoveSelectorConfig(new ListChangeMoveSelectorConfig()
+ .withValueSelectorConfig(mimicReplayingValueSelectorConfig));
+ var entityPlacerConfig = new QueuedEntityPlacerConfig();
+ var placerConfig = new QueuedMultiplePlacerConfig()
+ .withPlacerConfigList(List.of(valuePlacerConfig, entityPlacerConfig));
+ var placer = EntityPlacerFactory. create(placerConfig)
+ .buildEntityPlacer(configPolicy);
+
+ var problem = TestdataUnassignedListMultiVarSolution.generateUninitializedSolution(2, 2, 2);
+ // Pin the first entity
+ problem.getEntityList().get(0).setPinned(true);
+ problem.getEntityList().get(0).setPinnedIndex(2);
+ problem.getEntityList().get(0).setBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setSecondBasicValue(problem.getOtherValueList().get(0));
+ problem.getEntityList().get(0).setValueList(List.of(problem.getValueList().get(0)));
+
+ var solverScope = mock(SolverScope.class);
+ var scoreDirector = mock(InnerScoreDirector.class);
+ var random = new Random(0L);
+ when(solverScope.getScoreDirector()).thenReturn(scoreDirector);
+ when(solverScope.getWorkingRandom()).thenReturn(random);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getWorkingSolution()).thenReturn(problem);
+ when(scoreDirector.getSolutionDescriptor()).thenReturn(solutionDescriptor);
+
+ var supplyManager = VariableListenerSupport.create(scoreDirector);
+ when(scoreDirector.getSupplyManager()).thenReturn(supplyManager);
+ supplyManager.linkVariableListeners();
+ supplyManager.resetWorkingSolution();
+
+ placer.solvingStarted(solverScope);
+ var phaseScope = mock(AbstractPhaseScope.class);
+ when(phaseScope.getScoreDirector()).thenReturn(scoreDirector);
+ placer.phaseStarted(phaseScope);
+
+ var placerIterator = placer.iterator();
+ // Step 1
+ // 1 = Generated Value 1 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 2 = Generated Value 1 -> Entity 1[0] - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 3 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 4 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 5 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 6 = Generated Value 1 -> Entity 1[0] - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 7 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 8 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 9 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 10 = NoChange - Entity 1 - Generated Other Value 0 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ // 11 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 0 -> secondBasicValue
+ // 12 = NoChange - Entity 1 - Generated Other Value 1 -> basicValue - Generated Other Value 1 -> secondBasicValue
+ assertThat(placerIterator.hasNext()).isTrue();
+ var counter = new MutableInt();
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(12);
+
+ // Accept the move - 7 = NoChange - Entity 1 - null -> basicValue - Generated Other Value 0 -> secondBasicValue
+ problem.getEntityList().get(1).setSecondBasicValue(problem.getOtherValueList().get(0));
+ // Update all variables
+ supplyManager.resetWorkingSolution();
+ var stepScope = mock(AbstractStepScope.class);
+ placer.stepEnded(stepScope);
+ counter.setValue(0);
+ // 1 = Generated Value 1 -> Entity 1[0] - null -> secondBasicValue
+ // 2 = Generated Value 1 -> Entity 1[0] - Generated Other Value 0 -> secondBasicValue
+ // 3 = Generated Value 1 -> Entity 1[0] - Generated Other Value 1 -> secondBasicValue
+ // 4 = NoChange - null -> secondBasicValue
+ // 5 = NoChange - Generated Other Value 0 -> secondBasicValue
+ // 6 = NoChange - Generated Other Value 1 -> secondBasicValue
+ placerIterator.next().iterator().forEachRemaining(move -> counter.increment());
+ assertThat(counter.intValue()).isEqualTo(6);
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptorTest.java
index aa16673ab19..6bae5f871fb 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptorTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptorTest.java
@@ -33,6 +33,7 @@
import ai.timefold.solver.core.testdomain.invalid.constraintconfiguration.TestdataInvalidConfigurationSolution;
import ai.timefold.solver.core.testdomain.invalid.constraintweightoverrides.TestdataInvalidConstraintWeightOverridesSolution;
import ai.timefold.solver.core.testdomain.invalid.duplicateweightoverrides.TestdataDuplicateWeightConfigurationSolution;
+import ai.timefold.solver.core.testdomain.invalid.multivar.TestdataInvalidMultiVarSolution;
import ai.timefold.solver.core.testdomain.invalid.nosolution.TestdataNoSolution;
import ai.timefold.solver.core.testdomain.invalid.variablemap.TestdataMapConfigurationSolution;
import ai.timefold.solver.core.testdomain.list.TestdataListSolution;
@@ -641,4 +642,13 @@ void testBadFactCollection() {
assertThatCode(TestdataBadFactCollectionSolution::buildSolutionDescriptor)
.hasMessageContaining("that does not return a Collection or an array.");
}
+
+ @Test
+ void testBadChainedAndListModel() {
+ assertThatCode(TestdataInvalidMultiVarSolution::buildSolutionDescriptor)
+ .hasMessageContaining("Combining chained variables")
+ .hasMessageContaining("with list variables")
+ .hasMessageContaining("on a single planning entity")
+ .hasMessageContaining("is not supported");
+ }
}
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/composite/CartesianProductMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/composite/CartesianProductMoveSelectorTest.java
index f0cde221813..125cece9919 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/composite/CartesianProductMoveSelectorTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/composite/CartesianProductMoveSelectorTest.java
@@ -24,7 +24,7 @@
import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope;
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
import ai.timefold.solver.core.testdomain.TestdataValue;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveSelectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveSelectorFactoryTest.java
index 9597800e068..263b2e7af3c 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveSelectorFactoryTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveSelectorFactoryTest.java
@@ -24,7 +24,7 @@
import ai.timefold.solver.core.testdomain.list.TestdataListSolution;
import ai.timefold.solver.core.testdomain.multientity.TestdataHerdEntity;
import ai.timefold.solver.core.testdomain.multientity.TestdataMultiEntitySolution;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarSolution;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveTest.java
index 10158a9ceeb..565cda0bad6 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/ChangeMoveTest.java
@@ -13,8 +13,8 @@
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.TestdataOtherValue;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataOtherValue;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarChangeMoveTest.java
index 51224744b6b..0947f592cbd 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarChangeMoveTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarChangeMoveTest.java
@@ -15,7 +15,7 @@
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.basic.TestdataMultiVarEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarSwapMoveTest.java
index df0a1903580..051cf917448 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarSwapMoveTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/PillarSwapMoveTest.java
@@ -14,7 +14,7 @@
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.basic.TestdataMultiVarEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactoryTest.java
index e35b4df3894..f751942b036 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactoryTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactoryTest.java
@@ -21,7 +21,7 @@
import ai.timefold.solver.core.testdomain.multientity.TestdataHerdEntity;
import ai.timefold.solver.core.testdomain.multientity.TestdataLeadEntity;
import ai.timefold.solver.core.testdomain.multientity.TestdataMultiEntitySolution;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarSolution;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java
index 30355be02db..8f8dfd1af21 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java
@@ -14,8 +14,8 @@
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.TestdataOtherValue;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataOtherValue;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity;
import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/ReinitializeVariableValueSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/ReinitializeVariableValueSelectorTest.java
index c24b1d3ca82..01a3c36e8d0 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/ReinitializeVariableValueSelectorTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/ReinitializeVariableValueSelectorTest.java
@@ -17,7 +17,7 @@
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
import ai.timefold.solver.core.testdomain.TestdataEntity;
import ai.timefold.solver.core.testdomain.TestdataValue;
-import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarEntity;
+import ai.timefold.solver.core.testdomain.multivar.basic.TestdataMultiVarEntity;
import org.junit.jupiter.api.Test;
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java
index 79abd07d1c4..dc9c509434e 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java
@@ -22,7 +22,6 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
-import java.util.stream.Collectors;
import java.util.stream.IntStream;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
@@ -1023,7 +1022,7 @@ void solveRepeatedlyBasicVariable(SoftAssertions softly) {
final var entityCount = 5;
solution.setEntityList(IntStream.rangeClosed(1, entityCount)
.mapToObj(id -> new TestdataEntity("e" + id))
- .collect(Collectors.toList()));
+ .toList());
var score = SolutionManager.create(solverFactory).update(solution);
assertThat(score).isNotNull();
@@ -1199,9 +1198,9 @@ void solveWithMultipleChainedPlanningEntities() {
.withTerminationConfig(new TerminationConfig().withBestScoreLimit("0"))
.withPhases(
// Each planning entity class needs a separate CH phase.
- new ConstructionHeuristicPhaseConfig().withEntityPlacerConfig(new QueuedEntityPlacerConfig()
+ new ConstructionHeuristicPhaseConfig().withEntityPlacerConfigList(new QueuedEntityPlacerConfig()
.withEntitySelectorConfig(new EntitySelectorConfig(TestdataChainedBrownEntity.class))),
- new ConstructionHeuristicPhaseConfig().withEntityPlacerConfig(new QueuedEntityPlacerConfig()
+ new ConstructionHeuristicPhaseConfig().withEntityPlacerConfigList(new QueuedEntityPlacerConfig()
.withEntitySelectorConfig(new EntitySelectorConfig(TestdataChainedGreenEntity.class))),
new LocalSearchPhaseConfig().withMoveSelectorConfig(new UnionMoveSelectorConfig().withMoveSelectors(
new ChangeMoveSelectorConfig(),
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarEntity.java
new file mode 100644
index 00000000000..d918408745e
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarEntity.java
@@ -0,0 +1,42 @@
+package ai.timefold.solver.core.testdomain.invalid.multivar;
+
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariableGraphType;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+import ai.timefold.solver.core.testdomain.chained.TestdataChainedObject;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarValue;
+
+@PlanningEntity
+public class TestdataInvalidMultiVarEntity extends TestdataObject implements TestdataChainedObject {
+
+ @PlanningVariable(valueRangeProviderRefs = { "chainedEntityRange", "chainedAnchorRange" },
+ graphType = PlanningVariableGraphType.CHAINED)
+ private TestdataChainedObject chainedValue;
+
+ @PlanningListVariable(valueRangeProviderRefs = "valueRange")
+ private List valueList;
+
+ public TestdataInvalidMultiVarEntity(String code) {
+ super(code);
+ }
+
+ public TestdataChainedObject getChainedValue() {
+ return chainedValue;
+ }
+
+ public void setChainedValue(TestdataChainedObject chainedValue) {
+ this.chainedValue = chainedValue;
+ }
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarSolution.java
new file mode 100644
index 00000000000..4f303754154
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/invalid/multivar/TestdataInvalidMultiVarSolution.java
@@ -0,0 +1,65 @@
+package ai.timefold.solver.core.testdomain.invalid.multivar;
+
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
+import ai.timefold.solver.core.api.domain.solution.PlanningScore;
+import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
+import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
+import ai.timefold.solver.core.testdomain.chained.TestdataChainedAnchor;
+import ai.timefold.solver.core.testdomain.multivar.list.singleentity.TestdataListMultiVarValue;
+
+@PlanningSolution
+public class TestdataInvalidMultiVarSolution {
+
+ public static SolutionDescriptor buildSolutionDescriptor() {
+ return SolutionDescriptor.buildSolutionDescriptor(TestdataInvalidMultiVarSolution.class,
+ TestdataInvalidMultiVarEntity.class);
+ }
+
+ @ValueRangeProvider(id = "valueRange")
+ @PlanningEntityCollectionProperty
+ private List valueList;
+ @ValueRangeProvider(id = "chainedAnchorRange")
+ @PlanningEntityCollectionProperty
+ private List chainedAnchorList;
+ @PlanningEntityCollectionProperty
+ @ValueRangeProvider(id = "chainedEntityRange")
+ private List entityList;
+ @PlanningScore
+ private SimpleScore score;
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public List getChainedAnchorList() {
+ return chainedAnchorList;
+ }
+
+ public void setChainedAnchorList(List chainedAnchorList) {
+ this.chainedAnchorList = chainedAnchorList;
+ }
+
+ public List getEntityList() {
+ return entityList;
+ }
+
+ public void setEntityList(List entityList) {
+ this.entityList = entityList;
+ }
+
+ public SimpleScore getScore() {
+ return score;
+ }
+
+ public void setScore(SimpleScore score) {
+ this.score = score;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListVarEasyScoreCalculator.java b/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListVarEasyScoreCalculator.java
new file mode 100644
index 00000000000..61210b00ef4
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListVarEasyScoreCalculator.java
@@ -0,0 +1,23 @@
+package ai.timefold.solver.core.testdomain.list;
+
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
+
+import org.jspecify.annotations.NonNull;
+
+public class TestdataListVarEasyScoreCalculator implements EasyScoreCalculator {
+
+ @Override
+ public @NonNull SimpleScore calculateScore(@NonNull TestdataListSolution solution) {
+ int score = 0;
+ for (var entity : solution.getEntityList()) {
+ if (entity.getValueList().size() == 1) {
+ score += 2;
+ } else {
+ score += 1;
+ }
+ }
+ return SimpleScore.of(score);
+ }
+
+}
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/basic/TestdataMultiVarEntity.java
similarity index 97%
rename from core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarEntity.java
rename to core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultiVarEntity.java
index 7ddf763da91..61e9a3c74f7 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/basic/TestdataMultiVarEntity.java
@@ -1,4 +1,4 @@
-package ai.timefold.solver.core.testdomain.multivar;
+package ai.timefold.solver.core.testdomain.multivar.basic;
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultiVarSolution.java
similarity index 97%
rename from core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarSolution.java
rename to core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultiVarSolution.java
index 0c3db5b1978..53f44505d59 100644
--- a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultiVarSolution.java
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultiVarSolution.java
@@ -1,4 +1,4 @@
-package ai.timefold.solver.core.testdomain.multivar;
+package ai.timefold.solver.core.testdomain.multivar.basic;
import java.util.List;
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultivarIncrementalScoreCalculator.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultivarIncrementalScoreCalculator.java
similarity index 98%
rename from core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultivarIncrementalScoreCalculator.java
rename to core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultivarIncrementalScoreCalculator.java
index 3ed0430cc13..7c94edf2f32 100644
--- a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataMultivarIncrementalScoreCalculator.java
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataMultivarIncrementalScoreCalculator.java
@@ -1,4 +1,4 @@
-package ai.timefold.solver.core.testdomain.multivar;
+package ai.timefold.solver.core.testdomain.multivar.basic;
import java.util.Collection;
import java.util.Collections;
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataOtherValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataOtherValue.java
similarity index 79%
rename from core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataOtherValue.java
rename to core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataOtherValue.java
index 244c2f95c69..eb2b19a3694 100644
--- a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/TestdataOtherValue.java
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/basic/TestdataOtherValue.java
@@ -1,4 +1,4 @@
-package ai.timefold.solver.core.testdomain.multivar;
+package ai.timefold.solver.core.testdomain.multivar.basic;
import ai.timefold.solver.core.testdomain.TestdataObject;
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityEasyScoreCalculator.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityEasyScoreCalculator.java
new file mode 100644
index 00000000000..0501e762793
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityEasyScoreCalculator.java
@@ -0,0 +1,32 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
+
+import org.jspecify.annotations.NonNull;
+
+public class TestdataListMultiEntityEasyScoreCalculator
+ implements EasyScoreCalculator {
+
+ @Override
+ public @NonNull SimpleScore calculateScore(@NonNull TestdataListMultiEntitySolution solution) {
+ int score = 0;
+ for (var entity : solution.getEntityList()) {
+ if (entity.getValueList().size() == 1) {
+ score += 2;
+ } else {
+ score += 1;
+ }
+ }
+ for (var entity : solution.getOtherEntityList()) {
+ if (entity.getBasicValue() != null) {
+ score++;
+ }
+ if (entity.getSecondBasicValue() != null) {
+ score++;
+ }
+ }
+ return SimpleScore.of(score);
+ }
+
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstEntity.java
new file mode 100644
index 00000000000..389f500d2b8
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstEntity.java
@@ -0,0 +1,32 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiEntityFirstEntity extends TestdataObject {
+
+ @PlanningListVariable(valueRangeProviderRefs = "valueRange")
+ private List valueList;
+
+ public TestdataListMultiEntityFirstEntity() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiEntityFirstEntity(String code) {
+ super(code);
+ valueList = new ArrayList<>();
+ }
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstValue.java
new file mode 100644
index 00000000000..529874f6045
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntityFirstValue.java
@@ -0,0 +1,16 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiEntityFirstValue extends TestdataObject {
+
+ public TestdataListMultiEntityFirstValue() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiEntityFirstValue(String code) {
+ super(code);
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondEntity.java
new file mode 100644
index 00000000000..8f6acbb79f5
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondEntity.java
@@ -0,0 +1,39 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiEntitySecondEntity extends TestdataObject {
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange")
+ private TestdataListMultiEntitySecondValue basicValue;
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange")
+ private TestdataListMultiEntitySecondValue secondBasicValue;
+
+ public TestdataListMultiEntitySecondEntity() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiEntitySecondEntity(String code) {
+ super(code);
+ }
+
+ public TestdataListMultiEntitySecondValue getBasicValue() {
+ return basicValue;
+ }
+
+ public void setBasicValue(TestdataListMultiEntitySecondValue basicValue) {
+ this.basicValue = basicValue;
+ }
+
+ public TestdataListMultiEntitySecondValue getSecondBasicValue() {
+ return secondBasicValue;
+ }
+
+ public void setSecondBasicValue(TestdataListMultiEntitySecondValue secondBasicValue) {
+ this.secondBasicValue = secondBasicValue;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondValue.java
new file mode 100644
index 00000000000..caf87e234aa
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySecondValue.java
@@ -0,0 +1,16 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiEntitySecondValue extends TestdataObject {
+
+ public TestdataListMultiEntitySecondValue() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiEntitySecondValue(String code) {
+ super(code);
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySolution.java
new file mode 100644
index 00000000000..4edb6904c04
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/multientity/TestdataListMultiEntitySolution.java
@@ -0,0 +1,94 @@
+package ai.timefold.solver.core.testdomain.multivar.list.multientity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
+import ai.timefold.solver.core.api.domain.solution.PlanningScore;
+import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
+import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
+import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+
+@PlanningSolution
+public class TestdataListMultiEntitySolution {
+
+ public static TestdataListMultiEntitySolution generateUninitializedSolution(int entityListSize, int valueListSize,
+ int otherValueListSize) {
+ var solution = new TestdataListMultiEntitySolution();
+ var valueList = new ArrayList(valueListSize);
+ var otherValueList = new ArrayList(otherValueListSize);
+ for (int i = 0; i < valueListSize; i++) {
+ valueList.add(new TestdataListMultiEntityFirstValue("Generated Value " + i));
+ }
+ for (int i = 0; i < otherValueListSize; i++) {
+ otherValueList.add(new TestdataListMultiEntitySecondValue("Generated Other Value " + i));
+ }
+ solution.setValueList(valueList);
+ solution.setOtherValueList(otherValueList);
+ var entityList = new ArrayList(entityListSize);
+ var otherEntityList = new ArrayList(entityListSize);
+ for (int i = 0; i < entityListSize; i++) {
+ var entity = new TestdataListMultiEntityFirstEntity("Entity " + i);
+ entityList.add(entity);
+ var otherEntity = new TestdataListMultiEntitySecondEntity("Other Entity " + i);
+ otherEntityList.add(otherEntity);
+ }
+ solution.setEntityList(entityList);
+ solution.setOtherEntityList(otherEntityList);
+ return solution;
+ }
+
+ @ValueRangeProvider(id = "valueRange")
+ @ProblemFactCollectionProperty
+ private List valueList;
+ @ValueRangeProvider(id = "otherValueRange")
+ @ProblemFactCollectionProperty
+ private List otherValueList;
+ @PlanningEntityCollectionProperty
+ private List entityList;
+ @PlanningEntityCollectionProperty
+ private List otherEntityList;
+ @PlanningScore
+ private SimpleScore score;
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public List getOtherValueList() {
+ return otherValueList;
+ }
+
+ public void setOtherValueList(List otherValueList) {
+ this.otherValueList = otherValueList;
+ }
+
+ public List getEntityList() {
+ return entityList;
+ }
+
+ public void setEntityList(List entityList) {
+ this.entityList = entityList;
+ }
+
+ public List getOtherEntityList() {
+ return otherEntityList;
+ }
+
+ public void setOtherEntityList(List otherEntityList) {
+ this.otherEntityList = otherEntityList;
+ }
+
+ public SimpleScore getScore() {
+ return score;
+ }
+
+ public void setScore(SimpleScore score) {
+ this.score = score;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEasyScoreCalculator.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEasyScoreCalculator.java
new file mode 100644
index 00000000000..362ad084855
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEasyScoreCalculator.java
@@ -0,0 +1,29 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity;
+
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
+
+import org.jspecify.annotations.NonNull;
+
+public class TestdataListMultiVarEasyScoreCalculator implements EasyScoreCalculator {
+
+ @Override
+ public @NonNull SimpleScore calculateScore(@NonNull TestdataListMultiVarSolution solution) {
+ int score = 0;
+ for (var entity : solution.getEntityList()) {
+ if (entity.getBasicValue() != null) {
+ score++;
+ }
+ if (entity.getSecondBasicValue() != null) {
+ score++;
+ }
+ if (entity.getValueList().size() == 1) {
+ score += 2;
+ } else {
+ score += 1;
+ }
+ }
+ return SimpleScore.of(score);
+ }
+
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEntity.java
new file mode 100644
index 00000000000..4847ea19a28
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarEntity.java
@@ -0,0 +1,79 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.entity.PlanningPin;
+import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex;
+import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiVarEntity extends TestdataObject {
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange")
+ private TestdataListMultiVarOtherValue basicValue;
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange")
+ private TestdataListMultiVarOtherValue secondBasicValue;
+
+ @PlanningListVariable(valueRangeProviderRefs = "valueRange")
+ private List valueList;
+
+ @PlanningPin
+ private boolean pinned = false;
+
+ @PlanningPinToIndex
+ private int pinnedIndex = 0;
+
+ public TestdataListMultiVarEntity() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiVarEntity(String code) {
+ super(code);
+ valueList = new ArrayList<>();
+ }
+
+ public TestdataListMultiVarOtherValue getBasicValue() {
+ return basicValue;
+ }
+
+ public void setBasicValue(TestdataListMultiVarOtherValue basicValue) {
+ this.basicValue = basicValue;
+ }
+
+ public TestdataListMultiVarOtherValue getSecondBasicValue() {
+ return secondBasicValue;
+ }
+
+ public void setSecondBasicValue(TestdataListMultiVarOtherValue secondBasicValue) {
+ this.secondBasicValue = secondBasicValue;
+ }
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public boolean isPinned() {
+ return pinned;
+ }
+
+ public void setPinned(boolean pinned) {
+ this.pinned = pinned;
+ }
+
+ public int getPinnedIndex() {
+ return pinnedIndex;
+ }
+
+ public void setPinnedIndex(int pinnedIndex) {
+ this.pinnedIndex = pinnedIndex;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarOtherValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarOtherValue.java
new file mode 100644
index 00000000000..8740cdeaa62
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarOtherValue.java
@@ -0,0 +1,32 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiVarOtherValue extends TestdataObject {
+
+ @InverseRelationShadowVariable(sourceVariableName = "basicValue")
+ private List entityList;
+
+ public TestdataListMultiVarOtherValue() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiVarOtherValue(String code) {
+ super(code);
+ entityList = new ArrayList<>();
+ }
+
+ public List getEntityList() {
+ return entityList;
+ }
+
+ public void setEntityList(List entityList) {
+ this.entityList = entityList;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarSolution.java
new file mode 100644
index 00000000000..491a4d903b8
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarSolution.java
@@ -0,0 +1,86 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
+import ai.timefold.solver.core.api.domain.solution.PlanningScore;
+import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
+import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
+import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
+
+@PlanningSolution
+public class TestdataListMultiVarSolution {
+
+ public static SolutionDescriptor buildSolutionDescriptor() {
+ return SolutionDescriptor.buildSolutionDescriptor(TestdataListMultiVarSolution.class, TestdataListMultiVarEntity.class,
+ TestdataListMultiVarValue.class, TestdataListMultiVarOtherValue.class);
+ }
+
+ public static TestdataListMultiVarSolution generateUninitializedSolution(int entityListSize, int valueListSize,
+ int otherValueListSize) {
+ var solution = new TestdataListMultiVarSolution();
+ var valueList = new ArrayList(valueListSize);
+ var otherValueList = new ArrayList(otherValueListSize);
+ for (int i = 0; i < valueListSize; i++) {
+ valueList.add(new TestdataListMultiVarValue("Generated Value " + i));
+ }
+ for (int i = 0; i < otherValueListSize; i++) {
+ otherValueList.add(new TestdataListMultiVarOtherValue("Generated Other Value " + i));
+ }
+ solution.setValueList(valueList);
+ solution.setOtherValueList(otherValueList);
+ var entityList = new ArrayList(entityListSize);
+ for (int i = 0; i < entityListSize; i++) {
+ var entity = new TestdataListMultiVarEntity("Entity " + i);
+ entityList.add(entity);
+ }
+ solution.setEntityList(entityList);
+ return solution;
+ }
+
+ @ValueRangeProvider(id = "valueRange")
+ @ProblemFactCollectionProperty
+ private List valueList;
+ @ValueRangeProvider(id = "otherValueRange")
+ @ProblemFactCollectionProperty
+ private List otherValueList;
+ @PlanningEntityCollectionProperty
+ private List entityList;
+ @PlanningScore
+ private SimpleScore score;
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public List getOtherValueList() {
+ return otherValueList;
+ }
+
+ public void setOtherValueList(List otherValueList) {
+ this.otherValueList = otherValueList;
+ }
+
+ public List getEntityList() {
+ return entityList;
+ }
+
+ public void setEntityList(List entityList) {
+ this.entityList = entityList;
+ }
+
+ public SimpleScore getScore() {
+ return score;
+ }
+
+ public void setScore(SimpleScore score) {
+ this.score = score;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarValue.java
new file mode 100644
index 00000000000..aef299201e2
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/TestdataListMultiVarValue.java
@@ -0,0 +1,64 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.variable.IndexShadowVariable;
+import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable;
+import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable;
+import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataListMultiVarValue extends TestdataObject {
+
+ @InverseRelationShadowVariable(sourceVariableName = "valueList")
+ private TestdataListMultiVarEntity entity;
+
+ @PreviousElementShadowVariable(sourceVariableName = "valueList")
+ private TestdataListMultiVarValue previousElement;
+
+ @NextElementShadowVariable(sourceVariableName = "valueList")
+ private TestdataListMultiVarValue nextElement;
+
+ @IndexShadowVariable(sourceVariableName = "valueList")
+ private Integer index;
+
+ public TestdataListMultiVarValue() {
+ // Required for cloner
+ }
+
+ public TestdataListMultiVarValue(String code) {
+ super(code);
+ }
+
+ public TestdataListMultiVarEntity getEntity() {
+ return entity;
+ }
+
+ public void setEntity(TestdataListMultiVarEntity entity) {
+ this.entity = entity;
+ }
+
+ public TestdataListMultiVarValue getPreviousElement() {
+ return previousElement;
+ }
+
+ public void setPreviousElement(TestdataListMultiVarValue previousElement) {
+ this.previousElement = previousElement;
+ }
+
+ public TestdataListMultiVarValue getNextElement() {
+ return nextElement;
+ }
+
+ public void setNextElement(TestdataListMultiVarValue nextElement) {
+ this.nextElement = nextElement;
+ }
+
+ public Integer getIndex() {
+ return index;
+ }
+
+ public void setIndex(Integer index) {
+ this.index = index;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEasyScoreCalculator.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEasyScoreCalculator.java
new file mode 100644
index 00000000000..6fa8ab013d7
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEasyScoreCalculator.java
@@ -0,0 +1,34 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar;
+
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
+
+import org.jspecify.annotations.NonNull;
+
+public class TestdataUnassignedListMultiVarEasyScoreCalculator
+ implements EasyScoreCalculator {
+
+ @Override
+ public @NonNull SimpleScore calculateScore(@NonNull TestdataUnassignedListMultiVarSolution solution) {
+ int score = 0;
+ for (var entity : solution.getEntityList()) {
+ if (entity.getBasicValue() != null && !entity.getBasicValue().isBlocked()) {
+ score++;
+ } else if (entity.getBasicValue() != null) {
+ score -= 10;
+ }
+ if (entity.getSecondBasicValue() != null) {
+ score++;
+ }
+ if (entity.getValueList().stream().anyMatch(TestdataUnassignedListMultiVarValue::isBlocked)) {
+ score -= 10;
+ } else if (entity.getValueList().size() == 3) {
+ score += 2;
+ } else {
+ score++;
+ }
+ }
+ return SimpleScore.of(score);
+ }
+
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEntity.java
new file mode 100644
index 00000000000..8c698439bd4
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarEntity.java
@@ -0,0 +1,79 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
+import ai.timefold.solver.core.api.domain.entity.PlanningPin;
+import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex;
+import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+@PlanningEntity
+public class TestdataUnassignedListMultiVarEntity extends TestdataObject {
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange", allowsUnassigned = true)
+ private TestdataUnassignedListMultiVarOtherValue basicValue;
+
+ @PlanningVariable(valueRangeProviderRefs = "otherValueRange")
+ private TestdataUnassignedListMultiVarOtherValue secondBasicValue;
+
+ @PlanningListVariable(valueRangeProviderRefs = "valueRange", allowsUnassignedValues = true)
+ private List valueList;
+
+ @PlanningPin
+ private boolean pinned = false;
+
+ @PlanningPinToIndex
+ private int pinnedIndex = 0;
+
+ public TestdataUnassignedListMultiVarEntity() {
+ // Required for cloner
+ }
+
+ public TestdataUnassignedListMultiVarEntity(String code) {
+ super(code);
+ valueList = new ArrayList<>();
+ }
+
+ public TestdataUnassignedListMultiVarOtherValue getBasicValue() {
+ return basicValue;
+ }
+
+ public void setBasicValue(TestdataUnassignedListMultiVarOtherValue basicValue) {
+ this.basicValue = basicValue;
+ }
+
+ public TestdataUnassignedListMultiVarOtherValue getSecondBasicValue() {
+ return secondBasicValue;
+ }
+
+ public void setSecondBasicValue(TestdataUnassignedListMultiVarOtherValue secondBasicValue) {
+ this.secondBasicValue = secondBasicValue;
+ }
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public boolean isPinned() {
+ return pinned;
+ }
+
+ public void setPinned(boolean pinned) {
+ this.pinned = pinned;
+ }
+
+ public int getPinnedIndex() {
+ return pinnedIndex;
+ }
+
+ public void setPinnedIndex(int pinnedIndex) {
+ this.pinnedIndex = pinnedIndex;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarOtherValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarOtherValue.java
new file mode 100644
index 00000000000..34e5dfa88a5
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarOtherValue.java
@@ -0,0 +1,24 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar;
+
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+public class TestdataUnassignedListMultiVarOtherValue extends TestdataObject {
+
+ private boolean blocked = false;
+
+ public TestdataUnassignedListMultiVarOtherValue() {
+ // Required for cloner
+ }
+
+ public TestdataUnassignedListMultiVarOtherValue(String code) {
+ super(code);
+ }
+
+ public boolean isBlocked() {
+ return blocked;
+ }
+
+ public void setBlocked(boolean blocked) {
+ this.blocked = blocked;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarSolution.java
new file mode 100644
index 00000000000..ad9a8bcbb1f
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarSolution.java
@@ -0,0 +1,86 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
+import ai.timefold.solver.core.api.domain.solution.PlanningScore;
+import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
+import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
+import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
+import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore;
+import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
+
+@PlanningSolution
+public class TestdataUnassignedListMultiVarSolution {
+
+ public static SolutionDescriptor buildSolutionDescriptor() {
+ return SolutionDescriptor.buildSolutionDescriptor(TestdataUnassignedListMultiVarSolution.class,
+ TestdataUnassignedListMultiVarEntity.class);
+ }
+
+ public static TestdataUnassignedListMultiVarSolution generateUninitializedSolution(int entityListSize, int valueListSize,
+ int otherValueListSize) {
+ var solution = new TestdataUnassignedListMultiVarSolution();
+ var valueList = new ArrayList(valueListSize);
+ var otherValueList = new ArrayList(otherValueListSize);
+ for (int i = 0; i < valueListSize; i++) {
+ valueList.add(new TestdataUnassignedListMultiVarValue("Generated Value " + i));
+ }
+ for (int i = 0; i < otherValueListSize; i++) {
+ otherValueList.add(new TestdataUnassignedListMultiVarOtherValue("Generated Other Value " + i));
+ }
+ solution.setValueList(valueList);
+ solution.setOtherValueList(otherValueList);
+ var entityList = new ArrayList(entityListSize);
+ for (int i = 0; i < entityListSize; i++) {
+ var entity = new TestdataUnassignedListMultiVarEntity("Entity " + i);
+ entityList.add(entity);
+ }
+ solution.setEntityList(entityList);
+ return solution;
+ }
+
+ @ValueRangeProvider(id = "valueRange")
+ @ProblemFactCollectionProperty
+ private List valueList;
+ @ValueRangeProvider(id = "otherValueRange")
+ @ProblemFactCollectionProperty
+ private List otherValueList;
+ @PlanningEntityCollectionProperty
+ private List entityList;
+ @PlanningScore
+ private SimpleScore score;
+
+ public List getValueList() {
+ return valueList;
+ }
+
+ public void setValueList(List valueList) {
+ this.valueList = valueList;
+ }
+
+ public List getOtherValueList() {
+ return otherValueList;
+ }
+
+ public void setOtherValueList(List otherValueList) {
+ this.otherValueList = otherValueList;
+ }
+
+ public List getEntityList() {
+ return entityList;
+ }
+
+ public void setEntityList(List entityList) {
+ this.entityList = entityList;
+ }
+
+ public SimpleScore getScore() {
+ return score;
+ }
+
+ public void setScore(SimpleScore score) {
+ this.score = score;
+ }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarValue.java b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarValue.java
new file mode 100644
index 00000000000..a6a64c461ac
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/testdomain/multivar/list/singleentity/unassignedvar/TestdataUnassignedListMultiVarValue.java
@@ -0,0 +1,24 @@
+package ai.timefold.solver.core.testdomain.multivar.list.singleentity.unassignedvar;
+
+import ai.timefold.solver.core.testdomain.TestdataObject;
+
+public class TestdataUnassignedListMultiVarValue extends TestdataObject {
+
+ private boolean blocked = false;
+
+ public TestdataUnassignedListMultiVarValue() {
+ // Required for cloner
+ }
+
+ public TestdataUnassignedListMultiVarValue(String code) {
+ super(code);
+ }
+
+ public boolean isBlocked() {
+ return blocked;
+ }
+
+ public void setBlocked(boolean blocked) {
+ this.blocked = blocked;
+ }
+}