diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/solution/PlanningEntityCollectionProperty.java b/core/src/main/java/ai/timefold/solver/core/api/domain/solution/PlanningEntityCollectionProperty.java index 632e937d311..2fb7294fda4 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/solution/PlanningEntityCollectionProperty.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/solution/PlanningEntityCollectionProperty.java @@ -7,6 +7,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SortedSet; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.score.director.ScoreDirector; @@ -16,6 +19,9 @@ *

* Every element in the planning entity collection should have the {@link PlanningEntity} annotation. * Every element in the planning entity collection will be added to the {@link ScoreDirector}. + *

+ * For solver reproducibility, the collection must have a deterministic, stable iteration order. + * It is recommended to use a {@link List}, {@link LinkedHashSet} or {@link SortedSet}. */ @Target({ METHOD, FIELD }) @Retention(RUNTIME) diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/solution/ProblemFactCollectionProperty.java b/core/src/main/java/ai/timefold/solver/core/api/domain/solution/ProblemFactCollectionProperty.java index 50c1ced5a35..589dbe160d5 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/solution/ProblemFactCollectionProperty.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/solution/ProblemFactCollectionProperty.java @@ -7,6 +7,9 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SortedSet; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.score.stream.ConstraintFactory; @@ -21,6 +24,9 @@ *

* Do not annotate {@link PlanningEntity planning entities} as problem facts: * they are automatically available as facts for {@link ConstraintFactory#forEach(Class)}. + *

+ * For solver reproducibility, the collection must have a deterministic, stable iteration order. + * It is recommended to use a {@link List}, {@link LinkedHashSet} or {@link SortedSet}. * * @see ProblemFactProperty */ diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/valuerange/ValueRangeProvider.java b/core/src/main/java/ai/timefold/solver/core/api/domain/valuerange/ValueRangeProvider.java index 4ab61e1a629..6d75f595b49 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/valuerange/ValueRangeProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/valuerange/ValueRangeProvider.java @@ -7,18 +7,50 @@ import java.lang.annotation.Retention; import java.lang.annotation.Target; import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.SortedSet; +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.solver.SolverFactory; +import ai.timefold.solver.core.api.solver.change.ProblemChange; import org.jspecify.annotations.NonNull; /** * Provides the planning values that can be used for a {@link PlanningVariable}. + * *

* This is specified on a getter of a java bean property (or directly on a field) * which returns a {@link Collection} or {@link ValueRange}. * A {@link Collection} is implicitly converted to a {@link ValueRange}. + * For solver reproducibility, the collection must have a deterministic, stable iteration order. + * It is recommended to use a {@link List}, {@link LinkedHashSet} or {@link SortedSet}. + * + *

+ * Value ranges are not allowed to contain {@code null} values. + * The solver will automatically add a null to any range + * when {@link PlanningVariable#allowsUnassigned()} or {@link PlanningListVariable#allowsUnassignedValues()} is true. + * + *

+ * Value ranges are not allowed to contain multiple copies of the same object, + * as defined by {@code ==}. + * It is recommended that the value range never contains two objects + * that are equal according to {@link Object#equals(Object)}, + * but this is not enforced to not depend on user-defined {@link Object#equals(Object)} implementations. + * Having duplicates in a value range can lead to unexpected behavior, + * and skews selection probabilities in random selection algorithms. + * + *

+ * Value ranges are not allowed to change during solving. + * This is especially important for value ranges defined on {@link PlanningEntity}-annotated classes; + * these must never depend on any of that entity's variables, or on any other entity's variables. + * If you need to change a value range defined on an entity, + * trigger a {@link ProblemChange} for that entity or restart the solver with an updated planning solution. + * If you need to change a value range defined on a planning solution, + * restart the solver with a new planning solution. */ @Target({ METHOD, FIELD }) @Retention(RUNTIME) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/AbstractForEachUniNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/AbstractForEachUniNode.java index 46e02bded61..1b2ef60ef4f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/AbstractForEachUniNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/AbstractForEachUniNode.java @@ -12,6 +12,7 @@ import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Filtering nodes are expensive. @@ -38,7 +39,7 @@ protected AbstractForEachUniNode(Class forEachClass, TupleLifecycle(nextNodesTupleLifecycle); } - public void insert(A a) { + public void insert(@Nullable A a) { var tuple = new UniTuple<>(a, outputStoreSize); var old = tupleMap.put(a, tuple); if (old != null) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachFromSolutionUniNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachFromSolutionUniNode.java deleted file mode 100644 index 79d2d7b89b1..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachFromSolutionUniNode.java +++ /dev/null @@ -1,78 +0,0 @@ -package ai.timefold.solver.core.impl.bavet.uni; - -import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; -import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; -import ai.timefold.solver.core.impl.move.streams.FromSolutionValueCollectingFunction; - -import org.jspecify.annotations.NullMarked; - -/** - * Node that reads a property from a planning solution. - * Since anything directly on a solution is only allowed to change with a new working solution, - * this node has the following properties: - * - *

- * - * @param - * @param - */ -@NullMarked -public final class ForEachFromSolutionUniNode - extends ForEachIncludingUnassignedUniNode - implements AbstractForEachUniNode.InitializableForEachNode { - - private final FromSolutionValueCollectingFunction valueCollectingFunction; - - private boolean isInitialized = false; - - public ForEachFromSolutionUniNode(FromSolutionValueCollectingFunction valueCollectingFunction, - TupleLifecycle> nextNodesTupleLifecycle, int outputStoreSize) { - super(valueCollectingFunction.declaredClass(), nextNodesTupleLifecycle, outputStoreSize); - this.valueCollectingFunction = valueCollectingFunction; - } - - @Override - public void initialize(Solution_ workingSolution, SupplyManager supplyManager) { - if (this.isInitialized) { // Failsafe. - throw new IllegalStateException("Impossible state: initialize() has already been called on %s." - .formatted(this)); - } else { - this.isInitialized = true; - var valueRange = valueCollectingFunction.apply(workingSolution); - var valueIterator = valueRange.createOriginalIterator(); - while (valueIterator.hasNext()) { - var value = valueIterator.next(); - super.insert(value); - } - } - } - - @Override - public void insert(A a) { - throw new UnsupportedOperationException("Impossible state: direct insert is not supported on %s." - .formatted(this)); - } - - @Override - public void retract(A a) { - throw new UnsupportedOperationException("Impossible state: direct retract is not supported on %s." - .formatted(this)); - } - - @Override - public boolean supports(LifecycleOperation lifecycleOperation) { - return lifecycleOperation == LifecycleOperation.UPDATE; - } - - @Override - public void close() { - // No need to do anything; initialization doesn't perform anything that'd need cleanup. - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachIncludingUnassignedUniNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachIncludingUnassignedUniNode.java index f24ad89e920..3a3c85807bd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachIncludingUnassignedUniNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachIncludingUnassignedUniNode.java @@ -6,9 +6,8 @@ import org.jspecify.annotations.NullMarked; @NullMarked -public sealed class ForEachIncludingUnassignedUniNode - extends AbstractForEachUniNode - permits ForEachFromSolutionUniNode { +public final class ForEachIncludingUnassignedUniNode + extends AbstractForEachUniNode { public ForEachIncludingUnassignedUniNode(Class forEachClass, TupleLifecycle> nextNodesTupleLifecycle, int outputStoreSize) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java index 767aefbfac5..86c4f6b5799 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java @@ -14,7 +14,7 @@ public record DefaultPlanningListVariableMetaModel( ListVariableDescriptor variableDescriptor) implements PlanningListVariableMetaModel, - InnerVariableMetaModel { + InnerGenuineVariableMetaModel { @SuppressWarnings("unchecked") @Override @@ -27,6 +27,11 @@ public String name() { return variableDescriptor.getVariableName(); } + @Override + public boolean hasValueRangeOnEntity() { + return !variableDescriptor.isValueRangeEntityIndependent(); + } + @Override public boolean allowsUnassignedValues() { return false; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java index a8bff06919f..63ef122983e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java @@ -14,7 +14,7 @@ public record DefaultPlanningVariableMetaModel( BasicVariableDescriptor variableDescriptor) implements PlanningVariableMetaModel, - InnerVariableMetaModel { + InnerGenuineVariableMetaModel { @SuppressWarnings("unchecked") @Override @@ -27,6 +27,11 @@ public String name() { return variableDescriptor.getVariableName(); } + @Override + public boolean hasValueRangeOnEntity() { + return !variableDescriptor.isValueRangeEntityIndependent(); + } + @Override public boolean allowsUnassigned() { return variableDescriptor.allowsUnassigned(); @@ -58,4 +63,5 @@ public String toString() { return "Genuine Variable '%s %s.%s' (allowsUnassigned: %b, isChained: %b)" .formatted(type(), entity.getClass().getSimpleName(), name(), allowsUnassigned(), isChained()); } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerGenuineVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerGenuineVariableMetaModel.java new file mode 100644 index 00000000000..fc5802c8330 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerGenuineVariableMetaModel.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.core.impl.domain.solution.descriptor; + +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public sealed interface InnerGenuineVariableMetaModel + extends InnerVariableMetaModel + permits DefaultPlanningVariableMetaModel, DefaultPlanningListVariableMetaModel { + + @Override + GenuineVariableDescriptor variableDescriptor(); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerVariableMetaModel.java index 0f0897fcfa1..1630f4634f4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerVariableMetaModel.java @@ -6,7 +6,7 @@ @NullMarked public sealed interface InnerVariableMetaModel - permits DefaultPlanningVariableMetaModel, DefaultPlanningListVariableMetaModel, DefaultShadowVariableMetaModel { + permits InnerGenuineVariableMetaModel, DefaultShadowVariableMetaModel { VariableDescriptor variableDescriptor(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/ListValueRange.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/ListValueRange.java index 7621d8ce517..91c1b520385 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/ListValueRange.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/ListValueRange.java @@ -1,17 +1,26 @@ package ai.timefold.solver.core.impl.domain.valuerange.buildin.collection; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Random; +import java.util.Set; import ai.timefold.solver.core.impl.domain.valuerange.AbstractCountableValueRange; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.CachedListRandomIterator; +import ai.timefold.solver.core.impl.util.CollectionUtils; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +@NullMarked public final class ListValueRange extends AbstractCountableValueRange { + private static final int LIST_SIZE_LOOKUP_LIMIT = 10; // Selected arbitrarily. + private final List list; + private @Nullable Set lookupSet; // Initialized lazily for large lists. public ListValueRange(List list) { this.list = list; @@ -25,14 +34,26 @@ public long getSize() { @Override public T get(long index) { if (index > Integer.MAX_VALUE) { - throw new IndexOutOfBoundsException("The index (" + index + ") must fit in an int."); + throw new IndexOutOfBoundsException("The index (%d) must fit in an int." + .formatted(index)); } return list.get((int) index); } @Override - public boolean contains(T value) { - return list.contains(value); + public boolean contains(@Nullable T value) { + if (list.size() > LIST_SIZE_LOOKUP_LIMIT) { + if (lookupSet == null) { + lookupSet = Collections.newSetFromMap(CollectionUtils.newIdentityHashMap(list.size())); + lookupSet.addAll(list); + if (lookupSet.size() != list.size()) { + throw new IllegalStateException("The value range contains duplicate values: " + list); + } + } + return lookupSet.contains(value); + } else { // For small lists, sequential scanning is not a performance issue. + return list.contains(value); + } } @Override @@ -46,9 +67,8 @@ public boolean contains(T value) { } @Override - public String toString() { - // Formatting: interval (mathematics) ISO 31-11 - return list.isEmpty() ? "[]" : "[" + list.get(0) + "-" + list.get(list.size() - 1) + "]"; + public String toString() { // Formatting: interval (mathematics) ISO 31-11 + return list.isEmpty() ? "[]" : "[%s-%s]".formatted(list.get(0), list.get(list.size() - 1)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRange.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRange.java new file mode 100644 index 00000000000..0c5c0d88c45 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRange.java @@ -0,0 +1,75 @@ +package ai.timefold.solver.core.impl.domain.valuerange.buildin.collection; + +import java.util.Iterator; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.impl.domain.valuerange.AbstractCountableValueRange; +import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.CachedListRandomIterator; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class SetValueRange extends AbstractCountableValueRange { + + private static final int VALUES_TO_LIST_IN_TO_STRING = 3; + private static final String VALUE_DELIMITER = ", "; + + private final Set set; + private @Nullable List list; // To allow for random access; null until first access. + + public SetValueRange(Set set) { + this.set = set; + } + + @Override + public long getSize() { + return set.size(); + } + + @Override + public T get(long index) { + if (index > Integer.MAX_VALUE) { + throw new IndexOutOfBoundsException("The index (%d) must fit in an int." + .formatted(index)); + } + return getList().get((int) index); + } + + private List getList() { + if (list == null) { + list = List.copyOf(set); + } + return list; + } + + @Override + public boolean contains(@Nullable T value) { + return set.contains(value); + } + + @Override + public @NonNull Iterator createOriginalIterator() { + return set.iterator(); + } + + @Override + public @NonNull Iterator createRandomIterator(@NonNull Random workingRandom) { + return new CachedListRandomIterator<>(getList(), workingRandom); + } + + @Override + public String toString() { // Formatting: interval (mathematics) ISO 31-11, shorten long sets + var suffix = set.size() > VALUES_TO_LIST_IN_TO_STRING ? VALUE_DELIMITER + "...}" : "}"; + return set.isEmpty() ? "{}" + : set.stream() + .limit(VALUES_TO_LIST_IN_TO_STRING) + .map(Object::toString) + .collect(Collectors.joining(VALUE_DELIMITER, "{", suffix)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java index bc8350ba53d..3e72256e462 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java @@ -1,21 +1,25 @@ package ai.timefold.solver.core.impl.domain.valuerange.descriptor; import java.lang.reflect.Array; -import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; -import java.util.LinkedList; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.SortedSet; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.valuerange.CountableValueRange; import ai.timefold.solver.core.api.domain.valuerange.ValueRange; import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.config.util.ConfigUtils; -import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.valuerange.buildin.collection.ListValueRange; +import ai.timefold.solver.core.impl.domain.valuerange.buildin.collection.SetValueRange; import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; /** @@ -104,23 +108,35 @@ protected ValueRange readValueRange(Object bean) { .formatted(ValueRangeProvider.class.getSimpleName(), memberAccessor, bean, valueRangeObject)); } ValueRange valueRange; - if (collectionWrapping || arrayWrapping) { - List list = collectionWrapping ? transformCollectionToList((Collection) valueRangeObject) - : ReflectionHelper.transformArrayToList(valueRangeObject); - // Don't check the entire list for performance reasons, but do check common pitfalls - if (!list.isEmpty() && (list.get(0) == null || list.get(list.size() - 1) == null)) { - throw new IllegalStateException( - """ - The @%s-annotated member (%s) called on bean (%s) must not return a %s (%s) with an element that is null. - Maybe remove that null element from the dataset. - Maybe use @%s(allowsUnassigned = true) instead.""" - .formatted(ValueRangeProvider.class.getSimpleName(), - memberAccessor, bean, - collectionWrapping ? Collection.class.getSimpleName() : "array", - list, - PlanningVariable.class.getSimpleName())); - } + if (arrayWrapping) { + List list = transformArrayToList(valueRangeObject); + assertNullNotPresent(list, bean); valueRange = new ListValueRange<>(list); + } else if (collectionWrapping) { + var collection = (Collection) valueRangeObject; + if (collection instanceof Set set) { + if (set.contains(null)) { + throw new IllegalStateException(""" + The @%s-annotated member (%s) called on bean (%s) returns a Set (%s) with a null element. + Maybe remove that null element from the dataset. + Maybe use @%s(allowsUnassigned = true) or @%s(allowsUnassignedValues = true) instead.""" + .formatted(ValueRangeProvider.class.getSimpleName(), memberAccessor, bean, set, + PlanningVariable.class.getSimpleName(), PlanningListVariable.class.getSimpleName())); + } + if (collection instanceof SortedSet || collection instanceof LinkedHashSet) { + valueRange = new SetValueRange<>(set); + } else { + throw new IllegalStateException(""" + The @%s-annotated member (%s) called on bean (%s) returns a Set (%s) with undefined iteration order. + Maybe use SortedSet or LinkedHashSet to ensure solver reproducibility. + """ + .formatted(ValueRangeProvider.class.getSimpleName(), memberAccessor, bean, set.getClass())); + } + } else { + List list = transformCollectionToList(collection); + assertNullNotPresent(list, bean); + valueRange = new ListValueRange<>(list); + } } else { valueRange = (ValueRange) valueRangeObject; } @@ -134,6 +150,20 @@ protected ValueRange readValueRange(Object bean) { return valueRange; } + private void assertNullNotPresent(List list, Object bean) { + // Don't check the entire list for performance reasons, but do check common pitfalls + if (!list.isEmpty() && (list.get(0) == null || list.get(list.size() - 1) == null)) { + throw new IllegalStateException(""" + The @%s-annotated member (%s) called on bean (%s) must not return a %s (%s) with an element that is null. + Maybe remove that null element from the dataset. + Maybe remove that null element from the dataset. + Maybe use @%s(allowsUnassigned = true) or @%s(allowsUnassignedValues = true) instead.""" + .formatted(ValueRangeProvider.class.getSimpleName(), memberAccessor, bean, + collectionWrapping ? Collection.class.getSimpleName() : "array", list, + PlanningVariable.class.getSimpleName(), PlanningListVariable.class.getSimpleName())); + } + } + @SuppressWarnings("unchecked") protected long readValueRangeSize(Object bean) { var valueRangeObject = memberAccessor.executeGetter(bean); @@ -164,17 +194,25 @@ protected long readValueRangeSize(Object bean) { } private List transformCollectionToList(Collection collection) { - if (collection instanceof List list) { - if (collection instanceof LinkedList linkedList) { - // ValueRange.createRandomIterator(Random) and ValueRange.get(int) wouldn't be efficient. - return new ArrayList<>(linkedList); - } else { - return list; - } + if (collection.isEmpty()) { + return Collections.emptyList(); + } else if (collection instanceof List list) { + return list; } else { - // TODO If only ValueRange.createOriginalIterator() is used, cloning a Set to a List is a waste of time. - return new ArrayList<>(collection); + return List.copyOf(collection); + } + } + + @SuppressWarnings("unchecked") + public static List transformArrayToList(Object arrayObject) { + if (arrayObject == null) { + return Collections.emptyList(); + } + var array = (Value_[]) arrayObject; + if (array.length == 0) { + return Collections.emptyList(); } + return Arrays.asList(array); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractValueRangeDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractValueRangeDescriptor.java index 85ef518d418..c4226e0eabe 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractValueRangeDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractValueRangeDescriptor.java @@ -44,7 +44,7 @@ public boolean mightContainEntity() { protected ValueRange doNullInValueRangeWrapping(ValueRange valueRange) { if (addNullInValueRange) { - valueRange = new NullAllowingCountableValueRange<>((CountableValueRange) valueRange); + valueRange = new NullAllowingCountableValueRange<>((CountableValueRange) valueRange); } return valueRange; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java index 61aebc85623..a113ede5039 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java @@ -47,6 +47,8 @@ import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.preview.api.domain.metamodel.ShadowVariableMetaModel; +import org.jspecify.annotations.NullMarked; + /** * Utility class for updating shadow variables at entity level. */ @@ -85,7 +87,7 @@ public void updateShadowVariables(Solution_ solution) { .toArray(Class[]::new); var solutionDescriptor = SolutionDescriptor.buildSolutionDescriptor(enabledPreviewFeatures, solutionClass, entityClassArray); - try (var scoreDirector = new InternalScoreDirector<>(solutionDescriptor)) { + try (var scoreDirector = new InternalScoreDirector.Builder<>(solutionDescriptor).build()) { // When we have a solution, we can reuse the logic from VariableListenerSupport to update all variable types scoreDirector.setWorkingSolution(solution); scoreDirector.forceTriggerVariableListeners(); @@ -108,7 +110,8 @@ public void updateShadowVariables(Class solutionClass, throw new IllegalArgumentException( "Custom shadow variable descriptors are not supported (%s)".formatted(customShadowVariableDescriptorList)); } - var variableListenerSupport = VariableListenerSupport.create(new InternalScoreDirector<>(solutionDescriptor)); + var variableListenerSupport = + VariableListenerSupport.create(new InternalScoreDirector.Builder<>(solutionDescriptor).build()); var missingShadowVariableTypeList = variableListenerSupport.getSupportedShadowVariableTypes().stream() .filter(type -> !supportedShadowVariableTypes.contains(type)) .toList(); @@ -258,7 +261,8 @@ public void processCascadingVariable(Object... entities) { var cascadingVariableDescriptor = findShadowVariableDescriptor(entity.getClass(), CascadingUpdateShadowVariableDescriptor.class); if (cascadingVariableDescriptor != null) { - cascadingVariableDescriptor.update(new InternalScoreDirector<>(solutionDescriptor), entity); + cascadingVariableDescriptor.update(new InternalScoreDirector.Builder<>(solutionDescriptor).build(), + entity); } } } @@ -354,8 +358,8 @@ public InternalScoreDirectorFactory(SolutionDescriptor solutionDescri private static class InternalScoreDirector> extends AbstractScoreDirector> { - public InternalScoreDirector(SolutionDescriptor solutionDescriptor) { - super(new InternalScoreDirectorFactory<>(solutionDescriptor), false, DISABLED, false); + private InternalScoreDirector(Builder builder) { + super(builder); } @Override @@ -383,6 +387,26 @@ public Map> getIndictmentMap() { public boolean requiresFlushing() { throw new UnsupportedOperationException(); } + + @NullMarked + public static final class Builder> + extends + AbstractScoreDirectorBuilder, InternalScoreDirector.Builder> { + + public Builder(SolutionDescriptor solutionDescriptor) { + super(new InternalScoreDirectorFactory<>(solutionDescriptor)); + withConstraintMatchPolicy(DISABLED); + withLookUpEnabled(false); + withExpectShadowVariablesInCorrectState(false); + } + + @Override + public InternalScoreDirector build() { + return new InternalScoreDirector<>(this); + } + + } + } private record ShadowEntityVariable(String variableName, Collection values) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java index cca62fb1d9e..5fb4e85f533 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java @@ -28,6 +28,9 @@ public final class ListVariableDescriptor extends GenuineVariableDesc return list.contains(element); }; private final BiPredicate entityContainsPinnedValuePredicate = (value, entity) -> { + if (value == null) { + return false; // Null is never pinned. + } // Find an entity that has this value at a pinned position. var parentEntityDescriptor = getEntityDescriptor(); // The null here is safe, because PinningFilter is not supported with move streams diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveRepository.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveRepository.java index c19aa49e4d5..3b95539a29d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveRepository.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveRepository.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.move; import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; +import ai.timefold.solver.core.impl.move.director.MoveDirector; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener; import ai.timefold.solver.core.preview.api.move.Move; @@ -30,6 +31,6 @@ public sealed interface MoveRepository boolean isNeverEnding(); - void initialize(Solution_ workingSolution, SupplyManager supplyManager); + void initialize(MoveDirector workingMoveDirector, SupplyManager supplyManager); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveSelectorBasedMoveRepository.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveSelectorBasedMoveRepository.java index fd0d5acb7a9..35278c90309 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveSelectorBasedMoveRepository.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveSelectorBasedMoveRepository.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.heuristic.move.LegacyMoveAdapter; import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector; +import ai.timefold.solver.core.impl.move.director.MoveDirector; 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; @@ -30,7 +31,7 @@ public boolean isNeverEnding() { } @Override - public void initialize(Solution_ workingSolution, SupplyManager supplyManager) { + public void initialize(MoveDirector workingMoveDirector, SupplyManager supplyManager) { // No need to do anything. } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedMoveRepository.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedMoveRepository.java index 7278fdf820d..702e9c01dce 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedMoveRepository.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedMoveRepository.java @@ -6,6 +6,7 @@ import java.util.Random; import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; +import ai.timefold.solver.core.impl.move.director.MoveDirector; import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamFactory; import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamSession; import ai.timefold.solver.core.impl.move.streams.MoveIterable; @@ -43,11 +44,12 @@ public boolean isNeverEnding() { } @Override - public void initialize(Solution_ workingSolution, SupplyManager supplyManager) { + public void initialize(MoveDirector workingMoveDirector, SupplyManager supplyManager) { if (moveStreamSession != null) { throw new IllegalStateException("Impossible state: move repository initialized twice."); } - moveStreamSession = moveStreamFactory.createSession(workingSolution, supplyManager); + var workingSolution = workingMoveDirector.getScoreDirector().getWorkingSolution(); + moveStreamSession = moveStreamFactory.createSession(workingSolution, workingMoveDirector, supplyManager); moveStreamFactory.getSolutionDescriptor().visitAll(workingSolution, moveStreamSession::insert); moveStreamSession.settle(); moveIterable = moveProducer.getMoveIterable(moveStreamSession); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/PlacerBasedMoveRepository.java b/core/src/main/java/ai/timefold/solver/core/impl/move/PlacerBasedMoveRepository.java index fe3be6486ec..b7fac14b844 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/PlacerBasedMoveRepository.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/PlacerBasedMoveRepository.java @@ -7,6 +7,7 @@ import ai.timefold.solver.core.impl.constructionheuristic.placer.Placement; import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedValuePlacer; import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; +import ai.timefold.solver.core.impl.move.director.MoveDirector; 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; @@ -36,7 +37,7 @@ public boolean isNeverEnding() { } @Override - public void initialize(Solution_ workingSolution, SupplyManager supplyManager) { + public void initialize(MoveDirector workingMoveDirector, SupplyManager supplyManager) { placementIterator = placer.iterator(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/director/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/director/MoveDirector.java index bc41e61de62..9f5a5b67a3d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/director/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/director/MoveDirector.java @@ -7,6 +7,7 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningListVariableMetaModel; import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; +import ai.timefold.solver.core.impl.domain.solution.descriptor.InnerGenuineVariableMetaModel; 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.heuristic.move.LegacyMoveAdapter; @@ -15,6 +16,7 @@ import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; @@ -184,6 +186,13 @@ protected static ElementPosition getPositionOf(Inne .getElementPosition(value); } + @SuppressWarnings("unchecked") + @Override + public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value) { + return backingScoreDirector.isValueInRange((InnerGenuineVariableMetaModel) variableMetaModel, entity, value); + } + @Override public final @Nullable T rebase(@Nullable T problemFactOrPlanningEntity) { return externalScoreDirector.lookUpWorkingObject(problemFactOrPlanningEntity); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/BiMoveProducer.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/BiMoveProducer.java index 9184821b53a..899b919797b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/BiMoveProducer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/BiMoveProducer.java @@ -5,7 +5,6 @@ import java.util.Objects; import java.util.Random; import java.util.Set; -import java.util.function.BiPredicate; import java.util.function.Supplier; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; @@ -13,7 +12,9 @@ import ai.timefold.solver.core.impl.move.streams.dataset.AbstractDataset; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.BiMoveConstructor; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamSession; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.SolutionViewTriPredicate; import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -24,10 +25,10 @@ public final class BiMoveProducer implements InnerMoveProducer< private final AbstractDataset> aDataset; private final AbstractDataset> bDataset; private final BiMoveConstructor moveConstructor; - private final BiPredicate filter; + private final SolutionViewTriPredicate filter; public BiMoveProducer(AbstractDataset> aDataset, AbstractDataset> bDataset, - BiPredicate filter, BiMoveConstructor moveConstructor) { + SolutionViewTriPredicate filter, BiMoveConstructor moveConstructor) { this.aDataset = Objects.requireNonNull(aDataset); this.bDataset = Objects.requireNonNull(bDataset); this.filter = Objects.requireNonNull(filter); @@ -49,7 +50,7 @@ private final class BiMoveIterator implements Iterator> { private final IteratorSupplier aIteratorSupplier; private final IteratorSupplier bIteratorSupplier; - private final Solution_ solution; + private final SolutionView solutionView; // Fields required for iteration. private @Nullable Move nextMove; @@ -62,7 +63,7 @@ public BiMoveIterator(DefaultMoveStreamSession moveStreamSession) { this.aIteratorSupplier = aInstance::iterator; var bInstance = moveStreamSession.getDatasetInstance(bDataset); this.bIteratorSupplier = bInstance::iterator; - this.solution = moveStreamSession.getWorkingSolution(); + this.solutionView = moveStreamSession.getWorkingSolutionView(); } public BiMoveIterator(DefaultMoveStreamSession moveStreamSession, Random random) { @@ -70,7 +71,7 @@ public BiMoveIterator(DefaultMoveStreamSession moveStreamSession, Ran this.aIteratorSupplier = () -> aInstance.iterator(random); var bInstance = moveStreamSession.getDatasetInstance(bDataset); this.bIteratorSupplier = () -> bInstance.iterator(random); - this.solution = moveStreamSession.getWorkingSolution(); + this.solutionView = moveStreamSession.getWorkingSolutionView(); } @Override @@ -99,9 +100,9 @@ public boolean hasNext() { var currentB = bTuple.factA; // Check if this pair passes the filter... - if (filter.test(currentA, currentB)) { + if (filter.test(solutionView, currentA, currentB)) { // ... and create the next move. - nextMove = moveConstructor.apply(solution, currentA, currentB); + nextMove = moveConstructor.apply(solutionView, currentA, currentB); return true; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultBiMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultBiMoveStream.java index f17935f751f..edac594e2b4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultBiMoveStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultBiMoveStream.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.move.streams; import java.util.Objects; -import java.util.function.BiPredicate; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.move.streams.dataset.AbstractDataset; @@ -9,6 +8,7 @@ import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.BiMoveStream; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProducer; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.SolutionViewTriPredicate; import org.jspecify.annotations.NullMarked; @@ -17,11 +17,11 @@ public final class DefaultBiMoveStream implements BiMoveStream< private final InnerUniMoveStream leftMoveStream; private final AbstractDataset> rightDataset; - private final BiPredicate filter; + private final SolutionViewTriPredicate filter; public DefaultBiMoveStream(InnerUniMoveStream leftMoveStream, AbstractDataset> rightDataset, - BiPredicate filter) { + SolutionViewTriPredicate filter) { this.leftMoveStream = Objects.requireNonNull(leftMoveStream); this.rightDataset = Objects.requireNonNull(rightDataset); this.filter = Objects.requireNonNull(filter); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamFactory.java index c2fe441ced3..756ff542907 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamFactory.java @@ -1,9 +1,6 @@ package ai.timefold.solver.core.impl.move.streams; -import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningListVariableMetaModel; -import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.move.streams.dataset.AbstractUniDataStream; import ai.timefold.solver.core.impl.move.streams.dataset.DataStreamFactory; @@ -11,8 +8,7 @@ import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniMoveStream; -import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; @@ -28,68 +24,40 @@ public DefaultMoveStreamFactory(SolutionDescriptor solutionDescriptor this.datasetSessionFactory = new DatasetSessionFactory<>(dataStreamFactory); } - public DefaultMoveStreamSession createSession(Solution_ workingSolution, SupplyManager supplyManager) { + public DefaultMoveStreamSession createSession(Solution_ workingSolution, + SolutionView workingSolutionView, SupplyManager supplyManager) { var session = datasetSessionFactory.buildSession(); session.initialize(workingSolution, supplyManager); - return new DefaultMoveStreamSession<>(session, workingSolution); + return new DefaultMoveStreamSession<>(session, workingSolutionView); } @Override - public UniDataStream enumerate(Class sourceClass) { + public UniDataStream enumerate(Class sourceClass, boolean includeNull) { var entityDescriptor = getSolutionDescriptor().findEntityDescriptor(sourceClass); if (entityDescriptor == null) { // Not an entity, can't be pinned. - return dataStreamFactory.forEachNonDiscriminating(sourceClass); + return dataStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } if (entityDescriptor.isGenuine()) { // Genuine entity can be pinned. - return dataStreamFactory.forEachExcludingPinned(sourceClass); + return dataStreamFactory.forEachExcludingPinned(sourceClass, includeNull); } // From now on, we are testing a shadow entity. var listVariableDescriptor = getSolutionDescriptor().getListVariableDescriptor(); if (listVariableDescriptor == null) { // Can't be pinned when there are only basic variables. - return dataStreamFactory.forEachNonDiscriminating(sourceClass); + return dataStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } if (!listVariableDescriptor.supportsPinning()) { // The genuine entity does not support pinning. - return dataStreamFactory.forEachNonDiscriminating(sourceClass); + return dataStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } if (!listVariableDescriptor.acceptsValueType(sourceClass)) { // Can't be used as an element. - return dataStreamFactory.forEachNonDiscriminating(sourceClass); + return dataStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } // Finally a valid pin-supporting type. - return dataStreamFactory.forEachExcludingPinned(sourceClass); + return dataStreamFactory.forEachExcludingPinned(sourceClass, includeNull); } @Override - public UniDataStream enumerateIncludingPinned(Class sourceClass) { - return dataStreamFactory.forEachNonDiscriminating(sourceClass); - } - - @Override - public UniDataStream - enumeratePossibleValues(PlanningVariableMetaModel variableMetaModel) { - if (variableMetaModel.isChained()) { // Shouldn't have got this far. - throw new IllegalArgumentException("Impossible state: chained variable (%s)." - .formatted(variableMetaModel)); - } - var variableDescriptor = getVariableDescriptor(variableMetaModel); - var valueRangeDescriptor = variableDescriptor.getValueRangeDescriptor(); - if (variableDescriptor.isValueRangeEntityIndependent()) { - return dataStreamFactory.forEachFromSolution(new FromSolutionValueCollectingFunction<>(valueRangeDescriptor)); - } else { - throw new UnsupportedOperationException("Value range on entity is not yet supported."); - } - } - - private static GenuineVariableDescriptor - getVariableDescriptor(GenuineVariableMetaModel variableMetaModel) { - if (variableMetaModel instanceof DefaultPlanningVariableMetaModel planningVariableMetaModel) { - return planningVariableMetaModel.variableDescriptor(); - } else if (variableMetaModel instanceof DefaultPlanningListVariableMetaModel planningListVariableMetaModel) { - return planningListVariableMetaModel.variableDescriptor(); - } else { - throw new IllegalStateException( - "Impossible state: variable metamodel (%s) represents neither basic not list variable." - .formatted(variableMetaModel.getClass().getSimpleName())); - } + public UniDataStream enumerateIncludingPinned(Class sourceClass, boolean includeNull) { + return dataStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamSession.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamSession.java index a7979c8cdf5..039ddcf3cdf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamSession.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamSession.java @@ -7,6 +7,7 @@ import ai.timefold.solver.core.impl.move.streams.dataset.DatasetInstance; import ai.timefold.solver.core.impl.move.streams.dataset.DatasetSession; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamSession; +import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; @@ -15,11 +16,11 @@ public final class DefaultMoveStreamSession implements MoveStreamSession, AutoCloseable { private final DatasetSession datasetSession; - private final Solution_ workingSolution; + private final SolutionView workingSolutionView; - public DefaultMoveStreamSession(DatasetSession datasetSession, Solution_ workingSolution) { + public DefaultMoveStreamSession(DatasetSession datasetSession, SolutionView workingSolutionView) { this.datasetSession = Objects.requireNonNull(datasetSession); - this.workingSolution = Objects.requireNonNull(workingSolution); + this.workingSolutionView = Objects.requireNonNull(workingSolutionView); } public DatasetInstance @@ -43,8 +44,8 @@ public void settle() { datasetSession.settle(); } - public Solution_ getWorkingSolution() { - return workingSolution; + public SolutionView getWorkingSolutionView() { + return workingSolutionView; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultUniMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultUniMoveStream.java index e05fd233508..9ba975fe4dc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultUniMoveStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultUniMoveStream.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.impl.move.streams; import java.util.Objects; -import java.util.function.BiPredicate; import ai.timefold.solver.core.impl.move.streams.dataset.AbstractUniDataStream; import ai.timefold.solver.core.impl.move.streams.dataset.UniDataset; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.BiMoveStream; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.SolutionViewTriPredicate; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; import org.jspecify.annotations.NullMarked; @@ -23,7 +23,8 @@ public DefaultUniMoveStream(DefaultMoveStreamFactory moveStreamFactor } @Override - public BiMoveStream pick(UniDataStream uniDataStream, BiPredicate filter) { + public BiMoveStream pick(UniDataStream uniDataStream, + SolutionViewTriPredicate filter) { return new DefaultBiMoveStream<>(this, ((AbstractUniDataStream) uniDataStream).createDataset(), filter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/FromSolutionValueCollectingFunction.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/FromSolutionValueCollectingFunction.java deleted file mode 100644 index 858da9eec36..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/FromSolutionValueCollectingFunction.java +++ /dev/null @@ -1,37 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams; - -import java.util.function.Function; - -import ai.timefold.solver.core.api.domain.valuerange.CountableValueRange; -import ai.timefold.solver.core.api.domain.valuerange.ValueRange; -import ai.timefold.solver.core.impl.domain.valuerange.descriptor.ValueRangeDescriptor; - -public record FromSolutionValueCollectingFunction(ValueRangeDescriptor valueRangeDescriptor) - implements - Function> { - - @Override - public CountableValueRange apply(Solution_ solution) { - return ensureCountable(valueRangeDescriptor.extractValueRange(solution, null)); - } - - private static CountableValueRange ensureCountable(ValueRange valueRange) { - if (valueRange instanceof CountableValueRange countableValueRange) { - return countableValueRange; - } else { // Non-countable value ranges cannot be enumerated. - throw new UnsupportedOperationException("The value range (%s) is not countable." - .formatted(valueRange)); - } - } - - @SuppressWarnings("unchecked") - public Class declaredClass() { - return (Class) valueRangeDescriptor.getVariableDescriptor().getVariablePropertyType(); - } - - @Override - public String toString() { - return "FromSolution(%s)" - .formatted(valueRangeDescriptor.getVariableDescriptor()); - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractForEachDataStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractForEachDataStream.java index 7f0dd88e919..3bedea3784f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractForEachDataStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractForEachDataStream.java @@ -15,13 +15,16 @@ abstract sealed class AbstractForEachDataStream extends AbstractUniDataStream implements TupleSource - permits ForEachIncludingPinnedDataStream, ForEachExcludingPinnedDataStream, ForEachFromSolutionDataStream { + permits ForEachIncludingPinnedDataStream, ForEachExcludingPinnedDataStream { protected final Class forEachClass; + private final boolean shouldIncludeNull; - protected AbstractForEachDataStream(DataStreamFactory dataStreamFactory, Class forEachClass) { + protected AbstractForEachDataStream(DataStreamFactory dataStreamFactory, Class forEachClass, + boolean shouldIncludeNull) { super(dataStreamFactory, null); this.forEachClass = Objects.requireNonNull(forEachClass); + this.shouldIncludeNull = shouldIncludeNull; } @Override @@ -34,6 +37,9 @@ public final void buildNode(DataNodeBuildHelper buildHelper) { TupleLifecycle> tupleLifecycle = buildHelper.getAggregatedTupleLifecycle(childStreamList); var outputStoreSize = buildHelper.extractTupleStoreSize(this); var node = getNode(tupleLifecycle, outputStoreSize); + if (shouldIncludeNull) { + node.insert(null); + } buildHelper.addNode(node, this, null); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractUniDataStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractUniDataStream.java index 09f554cbd71..510a048fafd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractUniDataStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/AbstractUniDataStream.java @@ -31,7 +31,7 @@ public final UniDataStream filter(Predicate predicate) { @SafeVarargs @Override public final UniDataStream ifExists(Class otherClass, BiJoiner... joiners) { - return ifExists(dataStreamFactory.forEachNonDiscriminating(otherClass), joiners); + return ifExists(dataStreamFactory.forEachNonDiscriminating(otherClass, false), joiners); } @SafeVarargs @@ -43,7 +43,7 @@ public final UniDataStream ifExists(UniDataStream UniDataStream ifNotExists(Class otherClass, BiJoiner... joiners) { - return ifExistsOrNot(false, dataStreamFactory.forEachNonDiscriminating(otherClass), joiners); + return ifExistsOrNot(false, dataStreamFactory.forEachNonDiscriminating(otherClass, false), joiners); } @SafeVarargs diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/DataStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/DataStreamFactory.java index beb199ea827..4eea164d459 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/DataStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/DataStreamFactory.java @@ -9,7 +9,6 @@ import ai.timefold.solver.core.api.score.stream.Joiners; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.move.streams.FromSolutionValueCollectingFunction; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; import org.jspecify.annotations.NullMarked; @@ -24,19 +23,19 @@ public DataStreamFactory(SolutionDescriptor solutionDescriptor) { this.solutionDescriptor = solutionDescriptor; } - public UniDataStream forEachNonDiscriminating(Class sourceClass) { + public UniDataStream forEachNonDiscriminating(Class sourceClass, boolean includeNull) { assertValidForEachType(sourceClass); - return share(new ForEachIncludingPinnedDataStream<>(this, sourceClass)); + return share(new ForEachIncludingPinnedDataStream<>(this, sourceClass, includeNull)); } - public UniDataStream forEachExcludingPinned(Class sourceClass) { + public UniDataStream forEachExcludingPinned(Class sourceClass, boolean includeNull) { assertValidForEachType(sourceClass); var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); // We have a basic variable, or the sourceClass is not a valid type for a list variable value. // In that case, we use the standard exclusion logic. if (listVariableDescriptor == null || !listVariableDescriptor.acceptsValueType(sourceClass)) { - return share(new ForEachExcludingPinnedDataStream<>(this, - solutionDescriptor.getMetaModel().entity(sourceClass))); + return share(new ForEachExcludingPinnedDataStream<>(this, solutionDescriptor.getMetaModel().entity(sourceClass), + includeNull)); } // The sourceClass is a list variable value, therefore we need to specialize the exclusion logic. var parentEntityDescriptor = listVariableDescriptor.getEntityDescriptor(); @@ -44,18 +43,13 @@ public UniDataStream forEachExcludingPinned(Class sourceCla throw new UnsupportedOperationException("Impossible state: the list variable (%s) does not support pinning." .formatted(listVariableDescriptor.getVariableName())); } - var stream = forEachNonDiscriminating(sourceClass) + var stream = forEachNonDiscriminating(sourceClass, includeNull) .ifNotExists(parentEntityDescriptor.getEntityClass(), Joiners.filtering(listVariableDescriptor.getEntityContainsPinnedValuePredicate())); return share((AbstractUniDataStream) stream); } - public UniDataStream - forEachFromSolution(FromSolutionValueCollectingFunction valueCollectingFunction) { - return share(new ForEachFromSolutionDataStream<>(this, valueCollectingFunction)); - } - public void assertValidForEachType(Class fromType) { var problemFactOrEntityClassSet = solutionDescriptor.getProblemFactOrEntityClassSet(); /* diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachExcludingPinnedDataStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachExcludingPinnedDataStream.java index f8c32c0a8a9..471737381ba 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachExcludingPinnedDataStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachExcludingPinnedDataStream.java @@ -19,8 +19,8 @@ public final class ForEachExcludingPinnedDataStream private final PlanningEntityMetaModel entityMetaModel; public ForEachExcludingPinnedDataStream(DataStreamFactory dataStreamFactory, - PlanningEntityMetaModel entityMetaModel) { - super(dataStreamFactory, Objects.requireNonNull(entityMetaModel).type()); + PlanningEntityMetaModel entityMetaModel, boolean includeNull) { + super(dataStreamFactory, Objects.requireNonNull(entityMetaModel).type(), includeNull); this.entityMetaModel = entityMetaModel; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachFromSolutionDataStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachFromSolutionDataStream.java deleted file mode 100644 index 52c7b5767ab..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachFromSolutionDataStream.java +++ /dev/null @@ -1,49 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.dataset; - -import java.util.Objects; - -import ai.timefold.solver.core.impl.bavet.common.TupleSource; -import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; -import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.bavet.uni.AbstractForEachUniNode; -import ai.timefold.solver.core.impl.bavet.uni.ForEachFromSolutionUniNode; -import ai.timefold.solver.core.impl.move.streams.FromSolutionValueCollectingFunction; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public final class ForEachFromSolutionDataStream - extends AbstractForEachDataStream - implements TupleSource { - - private final FromSolutionValueCollectingFunction valueCollectingFunction; - - public ForEachFromSolutionDataStream(DataStreamFactory dataStreamFactory, - FromSolutionValueCollectingFunction valueCollectingFunction) { - super(dataStreamFactory, Objects.requireNonNull(valueCollectingFunction).declaredClass()); - this.valueCollectingFunction = valueCollectingFunction; - } - - @Override - protected AbstractForEachUniNode getNode(TupleLifecycle> tupleLifecycle, int outputStoreSize) { - return new ForEachFromSolutionUniNode<>(valueCollectingFunction, tupleLifecycle, outputStoreSize); - } - - @Override - public boolean equals(Object o) { - return o instanceof ForEachFromSolutionDataStream that && - Objects.equals(forEachClass, that.forEachClass) && - Objects.equals(valueCollectingFunction, that.valueCollectingFunction); - } - - @Override - public int hashCode() { - return Objects.hash(forEachClass, valueCollectingFunction); - } - - @Override - public String toString() { - return "ForEachFromSolution(" + valueCollectingFunction + ") with " + childStreamList.size() + " children"; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachIncludingPinnedDataStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachIncludingPinnedDataStream.java index 1cb5949f930..58e9d1ddde1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachIncludingPinnedDataStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachIncludingPinnedDataStream.java @@ -15,8 +15,9 @@ public final class ForEachIncludingPinnedDataStream extends AbstractForEachDataStream implements TupleSource { - public ForEachIncludingPinnedDataStream(DataStreamFactory dataStreamFactory, Class forEachClass) { - super(dataStreamFactory, forEachClass); + public ForEachIncludingPinnedDataStream(DataStreamFactory dataStreamFactory, Class forEachClass, + boolean includeNull) { + super(dataStreamFactory, forEachClass, includeNull); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/provider/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/provider/ChangeMoveProvider.java deleted file mode 100644 index e735bad8cfd..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/provider/ChangeMoveProvider.java +++ /dev/null @@ -1,80 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.generic.provider; - -import java.util.Objects; -import java.util.function.BiPredicate; -import java.util.function.Predicate; - -import ai.timefold.solver.core.api.domain.variable.PlanningVariable; -import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; -import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.move.streams.generic.move.ChangeMove; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProducer; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProvider; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -public class ChangeMoveProvider - implements MoveProvider { - - private final PlanningVariableMetaModel variableMetaModel; - private final Predicate<@Nullable Value_> valueFilter; - private final BiPredicate entityAndValueFilter; - - public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { - this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - var variableDescriptor = ((DefaultPlanningVariableMetaModel) variableMetaModel) - .variableDescriptor(); - this.valueFilter = variableMetaModel.allowsUnassigned() ? value -> true : Objects::nonNull; - this.entityAndValueFilter = (entity, value) -> variableDescriptor.getValue(entity) != value; - } - - @Override - public MoveProducer apply(MoveStreamFactory moveStreamFactory) { - var defaultMoveStreamFactory = (DefaultMoveStreamFactory) moveStreamFactory; - var entityStream = defaultMoveStreamFactory.enumerate(variableMetaModel.entity().type()) - .filter(this::acceptEntity); - var valueStream = defaultMoveStreamFactory.enumeratePossibleValues(variableMetaModel) - .filter(this::acceptValue); - return moveStreamFactory.pick(entityStream) - .pick(valueStream, this::acceptEntityValuePair) - .asMove((solution, entity, value) -> new ChangeMove<>(variableMetaModel, entity, value)); - } - - /** - * Determines whether the given entity should be accepted to produce a move. - * - * @param entity the entity to evaluate - * @return {@code true} if the entity is accepted, {@code false} otherwise; defaults to true. - */ - protected boolean acceptEntity(Entity_ entity) { - return true; - } - - /** - * Evaluates whether the given value (from the applicable value range) should be accepted to produce a move. - * - * @param value the value to evaluate - * @return {@code true} if the value is accepted, {@code false} otherwise; - * by default, it rejects null values if the {@link PlanningVariable#allowsUnassigned()} is false. - */ - protected boolean acceptValue(@Nullable Value_ value) { - return valueFilter.test(value); - } - - /** - * Determines whether a given entity and value pair should be accepted to produce a move. - * - * @param entity the entity to evaluate, already accepted by {@link #acceptEntity(Object)} - * @param value the value to evaluate, already accepted by {@link #acceptValue(Object)} - * @return {@code true} if the entity-value pair is accepted, {@code false} otherwise; - * by default, the pair is accepted if the entity's value is different from the value to evaluate. - */ - protected boolean acceptEntityValuePair(Entity_ entity, @Nullable Value_ value) { - return entityAndValueFilter.test(entity, value); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/AbstractMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/AbstractMove.java similarity index 98% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/AbstractMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/AbstractMove.java index 71ef8584f1e..6e3e2021280 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/AbstractMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/AbstractMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.ArrayList; import java.util.Collections; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChainedChangeMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChainedChangeMove.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChainedChangeMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChainedChangeMove.java index 9e687bd7d9a..46327165906 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChainedChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChainedChangeMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.Objects; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChangeMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChangeMove.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChangeMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChangeMove.java index b0105b4b553..e4a75c3976d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ChangeMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.Collection; import java.util.Collections; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListAssignMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListAssignMove.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListAssignMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListAssignMove.java index 4bd239de19d..2dc6af904b3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListAssignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListAssignMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.Collection; import java.util.List; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListChangeMove.java similarity index 99% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListChangeMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListChangeMove.java index ef2dd161ba9..2646c1e6929 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListChangeMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.Collection; import java.util.Collections; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListUnassignMove.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListUnassignMove.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListUnassignMove.java index 393a5f69b92..a3b3622da72 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/move/ListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/ListUnassignMove.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.move; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic; import java.util.Collection; import java.util.Collections; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java new file mode 100644 index 00000000000..0af886cfa9e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider; + +import java.util.Objects; + +import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamFactory; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.ChangeMove; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProducer; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProvider; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.SolutionViewTriPredicate; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class ChangeMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final SolutionViewTriPredicate entityValueFilter; + + public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.entityValueFilter = (solutionView, entity, value) -> { + Value_ oldValue = solutionView.getValue(variableMetaModel, entity); + if (Objects.equals(oldValue, value)) { + return false; + } + return solutionView.isValueInRange(variableMetaModel, entity, value); + }; + } + + @Override + public MoveProducer apply(MoveStreamFactory moveStreamFactory) { + var defaultMoveStreamFactory = (DefaultMoveStreamFactory) moveStreamFactory; + var entityStream = defaultMoveStreamFactory.enumerate(variableMetaModel.entity().type()); + // Together with the entityValueFilter, will iterate over all values + // and only include those that are valid for the entity based on its value range, + // regardless of whether the value range is on solution or on entity. + var valueStream = defaultMoveStreamFactory.enumerate(variableMetaModel.type(), true); + return moveStreamFactory.pick(entityStream) + .pick(valueStream, entityValueFilter) + .asMove((solution, entity, value) -> new ChangeMove<>(variableMetaModel, entity, value)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/package-info.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/package-info.java new file mode 100644 index 00000000000..a499da69770 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/package-info.java @@ -0,0 +1,5 @@ +/** + * Should eventually become {@code ai.timefold.solver.core.preview.api.move.streams}. + * TODO move this to the preview package, when we have the basic generic moves working + */ +package ai.timefold.solver.core.impl.move.streams.maybeapi; \ No newline at end of file diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/BiMoveConstructor.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/BiMoveConstructor.java index a221557e4f4..f291c92319c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/BiMoveConstructor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/BiMoveConstructor.java @@ -1,11 +1,12 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.stream; import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; @FunctionalInterface public non-sealed interface BiMoveConstructor extends MoveConstructor { - Move apply(Solution_ solution, A a, B b); + Move apply(SolutionView solution, A a, B b); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/MoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/MoveStreamFactory.java index 596d6d31ec0..26cfd645ec4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/MoveStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/MoveStreamFactory.java @@ -8,14 +8,19 @@ import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.score.stream.ConstraintStream; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import org.jspecify.annotations.NullMarked; @NullMarked public interface MoveStreamFactory { + /** + * As defined by {@link #enumerate(Class, boolean)} with includeNull set to false. + */ + default UniDataStream enumerate(Class sourceClass) { + return enumerate(sourceClass, false); + } + /** * Start a {@link ConstraintStream} of all instances of the sourceClass * that are known as {@link ProblemFactCollectionProperty problem facts} or {@link PlanningEntity planning entities}. @@ -34,12 +39,24 @@ public interface MoveStreamFactory { * This stream returns shadow entities regardless of whether they are assigned to any genuine entity. * They can easily be {@link UniDataStream#filter(Predicate) filtered out}. * + * @param sourceClass Facts and entities of this class will be enumerated. + * @param includeNull Whether to include null as a value in the stream. + * If true, the stream will contain a single null value. + * Useful when enumerating values for an entity variable, + * with the intent of allowing the variable to be unassigned. * @return A stream containing a tuple for each of the entities as described above. * @see PlanningPin An annotation to mark the entire entity as pinned. * @see PlanningPinToIndex An annotation to specify only a portion of {@link PlanningListVariable} is pinned. * @see #enumerateIncludingPinned(Class) Specialized method exists to automatically include pinned entities as well. */ - UniDataStream enumerate(Class sourceClass); + UniDataStream enumerate(Class sourceClass, boolean includeNull); + + /** + * As defined by {@link #enumerateIncludingPinned(Class, boolean)} with includeNull set to false. + */ + default UniDataStream enumerateIncludingPinned(Class sourceClass) { + return enumerateIncludingPinned(sourceClass, false); + } /** * Start a {@link ConstraintStream} of all instances of the sourceClass @@ -47,25 +64,14 @@ public interface MoveStreamFactory { * If the sourceClass is a genuine or shadow entity, * it returns instances regardless of their pinning status. * Otherwise as defined by {@link #enumerate(Class)}. + * + * @param sourceClass Facts and entities of this class will be enumerated. + * @param includeNull Whether to include null as a value in the stream. + * If true, the stream will contain a single null value. + * Useful when enumerating values for an entity variable, + * with the intent of allowing the variable to be unassigned. */ - UniDataStream enumerateIncludingPinned(Class sourceClass); - - /** - * Enumerate possible values for a given basic variable. - * If the variable allows unassigned values, the resulting stream will include a null value. - * - * @throws UnsupportedOperationException If the variable in question is a list variable, - * or if the basic variable is chained. - * @return data stream with all possible values of a given variable - * @see #enumeratePossiblePositions(PlanningListVariableMetaModel) For list variables, use a specialized method. - */ - UniDataStream - enumeratePossibleValues(PlanningVariableMetaModel variableMetaModel); - - default UniDataStream - enumeratePossiblePositions(PlanningListVariableMetaModel variableMetaModel) { - throw new UnsupportedOperationException(); // TODO - } + UniDataStream enumerateIncludingPinned(Class sourceClass, boolean includeNull); default UniMoveStream pick(Class clz) { return pick(enumerate(clz)); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/SolutionViewTriPredicate.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/SolutionViewTriPredicate.java new file mode 100644 index 00000000000..a01f53653e5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/SolutionViewTriPredicate.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.core.impl.move.streams.maybeapi.stream; + +import ai.timefold.solver.core.api.function.TriPredicate; +import ai.timefold.solver.core.preview.api.move.SolutionView; + +@FunctionalInterface +public interface SolutionViewTriPredicate + extends TriPredicate, A, B> { + + @SuppressWarnings("rawtypes") + SolutionViewTriPredicate TRUE = (solutionView, a, b) -> true; + + default SolutionViewTriPredicate and(SolutionViewTriPredicate other) { + return (solutionView, a, b) -> this.test(solutionView, a, b) && other.test(solutionView, a, b); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/UniMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/UniMoveStream.java index 453c1bd508e..4e66719562f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/UniMoveStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/UniMoveStream.java @@ -1,16 +1,18 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.stream; -import java.util.function.BiPredicate; - import org.jspecify.annotations.NullMarked; @NullMarked public interface UniMoveStream extends MoveStream { + @SuppressWarnings("unchecked") default BiMoveStream pick(UniDataStream uniDataStream) { - return pick(uniDataStream, (a, b) -> true); + return pick(uniDataStream, SolutionViewTriPredicate.TRUE); } - BiMoveStream pick(UniDataStream uniDataStream, BiPredicate filter); + // TODO Bring an API that works incrementally; + // The current implementation will scan the entire B stream for each A. + BiMoveStream pick(UniDataStream uniDataStream, + SolutionViewTriPredicate filter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index eb37224e721..360f37a482d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -23,6 +23,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.lookup.LookUpManager; +import ai.timefold.solver.core.impl.domain.solution.descriptor.InnerGenuineVariableMetaModel; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; @@ -81,21 +82,21 @@ public abstract class AbstractScoreDirector solutionTracker; // Null when tracking disabled. + + private final ValueRangeState valueRangeState; private final MoveDirector moveDirector = new MoveDirector<>(this); private @Nullable MoveRepository moveRepository; + private final ListVariableStateSupply listVariableStateSupply; // Null when no list variable. - // Null when no list variable - private final ListVariableStateSupply listVariableStateSupply; - - protected AbstractScoreDirector(Factory_ scoreDirectorFactory, boolean lookUpEnabled, - ConstraintMatchPolicy constraintMatchPolicy, boolean expectShadowVariablesInCorrectState) { + protected AbstractScoreDirector(AbstractScoreDirectorBuilder builder) { + var scoreDirectorFactory = builder.scoreDirectorFactory; var solutionDescriptor = scoreDirectorFactory.getSolutionDescriptor(); - this.lookUpEnabled = lookUpEnabled; + this.lookUpEnabled = builder.lookUpEnabled; this.lookUpManager = lookUpEnabled ? new LookUpManager(solutionDescriptor.getLookUpStrategyResolver()) : null; - this.constraintMatchPolicy = constraintMatchPolicy; - this.expectShadowVariablesInCorrectState = expectShadowVariablesInCorrectState; + this.constraintMatchPolicy = builder.constraintMatchPolicy; + this.expectShadowVariablesInCorrectState = builder.expectShadowVariablesInCorrectState; this.scoreDirectorFactory = scoreDirectorFactory; this.variableDescriptorCache = new VariableDescriptorCache<>(solutionDescriptor); this.variableListenerSupport = VariableListenerSupport.create(this); @@ -103,6 +104,7 @@ protected AbstractScoreDirector(Factory_ scoreDirectorFactory, boolean lookUpEna this.solutionTracker = scoreDirectorFactory.isTrackingWorkingSolution() ? new SolutionTracker<>(getSolutionDescriptor(), getSupplyManager()) : null; + this.valueRangeState = Objects.requireNonNull(builder.valueRangeState); var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); if (listVariableDescriptor == null) { this.listVariableStateSupply = null; @@ -217,6 +219,7 @@ public MoveDirector getMoveDirector() { */ protected void setWorkingSolution(Solution_ workingSolution, Consumer entityAndFactVisitor) { this.workingSolution = requireNonNull(workingSolution); + this.valueRangeState.resetWorkingSolution(workingSolution); var solutionDescriptor = getSolutionDescriptor(); /* @@ -251,7 +254,7 @@ protected void setWorkingSolution(Solution_ workingSolution, Consumer en workingGenuineEntityCount = initializationStatistics.genuineEntityCount(); variableListenerSupport.resetWorkingSolution(); if (moveRepository != null) { - moveRepository.initialize(workingSolution, getSupplyManager()); + moveRepository.initialize(moveDirector, getSupplyManager()); } } @@ -259,7 +262,7 @@ protected void setWorkingSolution(Solution_ workingSolution, Consumer en public void setMoveRepository(@Nullable MoveRepository moveRepository) { this.moveRepository = moveRepository; if (moveRepository != null) { - moveRepository.initialize(workingSolution, getSupplyManager()); + moveRepository.initialize(moveDirector, getSupplyManager()); } } @@ -313,6 +316,17 @@ protected void setWorkingEntityListDirty() { workingEntityListRevision++; } + @Override + public boolean isValueInRange(InnerGenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value) { + var valueRangeDescriptor = variableMetaModel.variableDescriptor().getValueRangeDescriptor(); + if (entity == null && !valueRangeDescriptor.isEntityIndependent()) { + throw new IllegalArgumentException("The entity must be provided when the value range (%s) is defined on an entity." + .formatted(valueRangeDescriptor)); + } + return valueRangeState.isInRange(valueRangeDescriptor, entity, value); + } + @Override public Solution_ cloneSolution(Solution_ originalSolution) { SolutionDescriptor solutionDescriptor = getSolutionDescriptor(); @@ -975,6 +989,8 @@ public abstract static class AbstractScoreDirectorBuilder valueRangeState = new ValueRangeState<>(); protected ConstraintMatchPolicy constraintMatchPolicy = ConstraintMatchPolicy.DISABLED; protected boolean lookUpEnabled = false; protected boolean expectShadowVariablesInCorrectState = true; @@ -983,6 +999,12 @@ protected AbstractScoreDirectorBuilder(Factory_ scoreDirectorFactory) { this.scoreDirectorFactory = Objects.requireNonNull(scoreDirectorFactory); } + @SuppressWarnings("unchecked") + public Builder_ withValueRangeState(ValueRangeState valueRangeState) { + this.valueRangeState = Objects.requireNonNull(valueRangeState); + return (Builder_) this; + } + @SuppressWarnings("unchecked") public Builder_ withConstraintMatchPolicy(ConstraintMatchPolicy constraintMatchPolicy) { this.constraintMatchPolicy = constraintMatchPolicy; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index a7c3d7b033c..726e13b5b30 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -26,6 +26,7 @@ import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy; import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.InnerGenuineVariableMetaModel; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; @@ -183,6 +184,9 @@ static > ConstraintAnalysis getConstraintAn boolean isWorkingSolutionInitialized(); + boolean isValueInRange(InnerGenuineVariableMetaModel variableDescriptor, + @Nullable Entity_ entity, @Nullable Value_ value); + /** * Some score directors keep a set of changes * that they only apply when {@link #calculateScore()} is called. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java new file mode 100644 index 00000000000..cb3baa6e7d6 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java @@ -0,0 +1,88 @@ +package ai.timefold.solver.core.impl.score.director; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.solver.change.ProblemChange; +import ai.timefold.solver.core.impl.domain.solution.descriptor.InnerGenuineVariableMetaModel; +import ai.timefold.solver.core.impl.domain.valuerange.descriptor.ValueRangeDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Caches value ranges for the current working solution, + * allowing to quickly check if a value is in range. + * Used by {@link AbstractScoreDirector#isValueInRange(InnerGenuineVariableMetaModel, Object, Object)}. + * + *

+ * The state is built on-demand as {@link #isInRange(ValueRangeDescriptor, Object, Object)} is called. + * It is not built in advance as it would be too expensive, and some code paths do not use it at all. + * + *

+ * Outside a {@link ProblemChange}, value ranges are not allowed to change. + * Call {@link #resetWorkingSolution(Object)} every time the working solution changes through a problem fact, + * so that all caches can be invalidated. + * + * @see ValueRange + * @see ValueRangeProvider + */ +@NullMarked +public final class ValueRangeState { + + private final Map, ValueRange> fromSolutionValueRangeMap = new IdentityHashMap<>(); + private final Map, ValueRange>> fromEntityValueRangeMap = + new IdentityHashMap<>(); + + private @Nullable Solution_ workingSolution; + + void resetWorkingSolution(Solution_ workingSolution) { + this.workingSolution = workingSolution; + fromSolutionValueRangeMap.clear(); + fromEntityValueRangeMap.clear(); + } + + public boolean isInRange(ValueRangeDescriptor valueRangeDescriptor, @Nullable Object entity, + @Nullable Object value) { + if (value == null) { + var variableDescriptor = valueRangeDescriptor.getVariableDescriptor(); + if (variableDescriptor instanceof BasicVariableDescriptor basicVariableDescriptor) { + return basicVariableDescriptor.allowsUnassigned(); + } else if (variableDescriptor instanceof ListVariableDescriptor listVariableDescriptor) { + return listVariableDescriptor.allowsUnassignedValues(); + } else { + throw new IllegalStateException("Impossible state: The variable descriptor (%s) is neither %s nor %s." + .formatted(variableDescriptor, BasicVariableDescriptor.class.getSimpleName(), + ListVariableDescriptor.class.getSimpleName())); + } + } + var solution = Objects.requireNonNull(workingSolution); + // Not using computeIfAbsent() here, as it would have allocated lambda instances on the hot path. + if (valueRangeDescriptor.isEntityIndependent()) { + var valueRange = fromSolutionValueRangeMap.get(valueRangeDescriptor); + if (valueRange == null) { + valueRange = valueRangeDescriptor.extractValueRange(solution, null); + fromSolutionValueRangeMap.put(valueRangeDescriptor, valueRange); + } + return valueRange.contains(value); + } else { + var valueRangeMap = fromEntityValueRangeMap.get(entity); + if (valueRangeMap == null) { + valueRangeMap = new IdentityHashMap<>(); + fromEntityValueRangeMap.put(Objects.requireNonNull(entity), valueRangeMap); + } + var valueRange = valueRangeMap.get(valueRangeDescriptor); + if (valueRange == null) { + valueRange = valueRangeDescriptor.extractValueRange(solution, entity); + valueRangeMap.put(valueRangeDescriptor, valueRange); + } + return valueRange.contains(value); + } + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java index dc4ce29a541..e5afb530ab3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java @@ -33,10 +33,10 @@ public final class EasyScoreDirector> private final EasyScoreCalculator easyScoreCalculator; - private EasyScoreDirector(EasyScoreDirectorFactory scoreDirectorFactory, boolean lookUpEnabled, - boolean expectShadowVariablesInCorrectState, EasyScoreCalculator easyScoreCalculator) { - super(scoreDirectorFactory, lookUpEnabled, ConstraintMatchPolicy.DISABLED, expectShadowVariablesInCorrectState); - this.easyScoreCalculator = Objects.requireNonNull(easyScoreCalculator); + private EasyScoreDirector(Builder builder) { + super(builder); + this.easyScoreCalculator = Objects.requireNonNull(builder.easyScoreCalculator, + "The easyScoreCalculator must not be null."); } public EasyScoreCalculator getEasyScoreCalculator() { @@ -96,6 +96,12 @@ public Builder(EasyScoreDirectorFactory scoreDirectorFactory) super(scoreDirectorFactory); } + @Override + public Builder withConstraintMatchPolicy(ConstraintMatchPolicy constraintMatchPolicy) { + // Override; easy can never support constraint matches. + return super.withConstraintMatchPolicy(ConstraintMatchPolicy.DISABLED); + } + public Builder withEasyScoreCalculator(EasyScoreCalculator easyScoreCalculator) { this.easyScoreCalculator = easyScoreCalculator; return this; @@ -103,8 +109,7 @@ public Builder withEasyScoreCalculator(EasyScoreCalculator build() { - return new EasyScoreDirector<>(scoreDirectorFactory, lookUpEnabled, expectShadowVariablesInCorrectState, - easyScoreCalculator); + return new EasyScoreDirector<>(this); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java index c39da549807..b492f774a9d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -40,12 +40,10 @@ public final class IncrementalScoreDirector incrementalScoreCalculator; - private IncrementalScoreDirector(IncrementalScoreDirectorFactory scoreDirectorFactory, - boolean lookUpEnabled, ConstraintMatchPolicy constraintMatchPolicy, boolean expectShadowVariablesInCorrectState, - IncrementalScoreCalculator incrementalScoreCalculator) { - super(scoreDirectorFactory, lookUpEnabled, determineCorrectPolicy(constraintMatchPolicy, incrementalScoreCalculator), - expectShadowVariablesInCorrectState); - this.incrementalScoreCalculator = Objects.requireNonNull(incrementalScoreCalculator); + private IncrementalScoreDirector(Builder builder) { + super(builder); + this.incrementalScoreCalculator = Objects.requireNonNull(builder.incrementalScoreCalculator, + "The incrementalScoreCalculator must not be null."); } private static ConstraintMatchPolicy determineCorrectPolicy(ConstraintMatchPolicy constraintMatchPolicy, @@ -267,13 +265,17 @@ public Builder(IncrementalScoreDirectorFactory scoreDirectorF public Builder withIncrementalScoreCalculator(IncrementalScoreCalculator incrementalScoreCalculator) { this.incrementalScoreCalculator = incrementalScoreCalculator; - return this; + return withConstraintMatchPolicy(constraintMatchPolicy); // Ensure the policy is correct for the calculator. + } + + @Override + public Builder withConstraintMatchPolicy(ConstraintMatchPolicy constraintMatchPolicy) { + return super.withConstraintMatchPolicy(determineCorrectPolicy(constraintMatchPolicy, incrementalScoreCalculator)); } @Override public IncrementalScoreDirector build() { - return new IncrementalScoreDirector<>(scoreDirectorFactory, lookUpEnabled, constraintMatchPolicy, - expectShadowVariablesInCorrectState, incrementalScoreCalculator); + return new IncrementalScoreDirector<>(this); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java index 9b864fc1ba7..a1bf9692c02 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java @@ -11,7 +11,6 @@ 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.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintSession; @@ -32,11 +31,8 @@ public final class BavetConstraintStreamScoreDirector session; - private BavetConstraintStreamScoreDirector( - BavetConstraintStreamScoreDirectorFactory scoreDirectorFactory, boolean lookUpEnabled, - ConstraintMatchPolicy constraintMatchPolicy, boolean expectShadowVariablesInCorrectState, boolean derived) { - super(scoreDirectorFactory, lookUpEnabled, constraintMatchPolicy, - expectShadowVariablesInCorrectState); + private BavetConstraintStreamScoreDirector(Builder builder, boolean derived) { + super(builder); this.derived = derived; } @@ -208,15 +204,13 @@ public Builder(BavetConstraintStreamScoreDirectorFactory scor @Override public BavetConstraintStreamScoreDirector build() { - return new BavetConstraintStreamScoreDirector<>(scoreDirectorFactory, lookUpEnabled, constraintMatchPolicy, - expectShadowVariablesInCorrectState, false); + return new BavetConstraintStreamScoreDirector<>(this, false); } @Override public AbstractScoreDirector> buildDerived() { - return new BavetConstraintStreamScoreDirector<>(scoreDirectorFactory, lookUpEnabled, constraintMatchPolicy, - expectShadowVariablesInCorrectState, true); + return new BavetConstraintStreamScoreDirector<>(this, true); } } 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 a5c7a5265f0..070c0da0c95 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 @@ -34,6 +34,7 @@ import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; +import ai.timefold.solver.core.impl.score.director.ValueRangeState; import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.random.DefaultRandomFactory; import ai.timefold.solver.core.impl.solver.random.RandomFactory; @@ -116,7 +117,10 @@ public > ScoreDirectorFactory ge metricsRequiringConstraintMatchSet); } + var valueRangeState = new ValueRangeState(); + solverScope.setValueRangeState(valueRangeState); var castScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() + .withValueRangeState(valueRangeState) .withLookUpEnabled(true) .withConstraintMatchPolicy( constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java index 5e5e05b5ba6..faf95a7b103 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java @@ -24,6 +24,7 @@ import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.ValueRangeState; import ai.timefold.solver.core.impl.solver.AbstractSolver; import ai.timefold.solver.core.impl.solver.change.DefaultProblemChangeDirector; import ai.timefold.solver.core.impl.solver.monitoring.ScoreLevels; @@ -50,6 +51,7 @@ public class SolverScope { private Tags monitoringTags; private int startingSolverCount; private Random workingRandom; + private ValueRangeState valueRangeState; private InnerScoreDirector scoreDirector; private AbstractSolver solver; private DefaultProblemChangeDirector problemChangeDirector; @@ -98,6 +100,14 @@ public Clock getClock() { return clock; } + public ValueRangeState getValueRangeState() { + return valueRangeState; + } + + public void setValueRangeState(ValueRangeState valueRangeState) { + this.valueRangeState = valueRangeState; + } + public AbstractSolver getSolver() { return solver; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java index 624a73d9e4f..9b549369bf2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java @@ -34,6 +34,8 @@ default boolean isGenuine() { return true; } + boolean hasValueRangeOnEntity(); + default PlanningVariableMetaModel ensurePlanningVariable() { if (this instanceof PlanningVariableMetaModel planningVariableMetaModel) { return planningVariableMetaModel; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index 717618bcfbe..ddf5a5c8601 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -1,8 +1,10 @@ package ai.timefold.solver.core.preview.api.move; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; @@ -61,4 +63,36 @@ Value_ getValueAtIndex(PlanningListVariableMetaModel ElementPosition getPositionOf(PlanningListVariableMetaModel variableMetaModel, Value_ value); + /** + * Checks if a given value is present in the value range of a genuine planning variable, + * when the value range is defined on {@link PlanningSolution}. + * + * @param variableMetaModel variable in question + * @param value value to check + * @return true if the value is acceptable for the variable + * @param generic type of the entity that the variable is defined on + * @param generic type of the value that the variable can take + * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution; + * use {@link #isValueInRange(GenuineVariableMetaModel, Object, Object)} instead. + */ + default boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Value_ value) { + return isValueInRange(variableMetaModel, null, value); + } + + /** + * Checks if a given value is present in the value range of a genuine planning variable. + * + * @param variableMetaModel variable in question + * @param entity entity that the value would be applied to; + * must be of a type that the variable is defined on + * @param value value to check + * @return true if the value is acceptable for the variable + * @param generic type of the entity that the variable is defined on + * @param generic type of the value that the variable can take + * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution, and the entity is null + */ + boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value); + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java index 809d44c5a7b..6b8ccf0bdd3 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java @@ -55,9 +55,9 @@ import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.thirdparty.TestdataExtendedThirdPartyEntity; import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.thirdparty.TestdataExtendedThirdPartySolution; import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.thirdparty.TestdataThirdPartyEntityPojo; -import ai.timefold.solver.core.testdomain.list.externalized.TestdataListEntityExternalized; -import ai.timefold.solver.core.testdomain.list.externalized.TestdataListSolutionExternalized; -import ai.timefold.solver.core.testdomain.list.externalized.TestdataListValueExternalized; +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.reflect.accessmodifier.TestdataAccessModifierSolution; import ai.timefold.solver.core.testdomain.reflect.field.TestdataFieldAnnotatedEntity; import ai.timefold.solver.core.testdomain.reflect.field.TestdataFieldAnnotatedSolution; @@ -118,19 +118,18 @@ void cloneSolution() { @Test void cloneListVariableSolution() { - var solutionDescriptor = SolutionDescriptor.buildSolutionDescriptor( - TestdataListSolutionExternalized.class, - TestdataListEntityExternalized.class); + var solutionDescriptor = + SolutionDescriptor.buildSolutionDescriptor(TestdataListSolution.class, TestdataListEntity.class); var cloner = createSolutionCloner(solutionDescriptor); - var val1 = new TestdataListValueExternalized("1"); - var val2 = new TestdataListValueExternalized("2"); - var val3 = new TestdataListValueExternalized("3"); - var a = new TestdataListEntityExternalized("a", new ArrayList<>(List.of(val1, val3))); - var b = new TestdataListEntityExternalized("b", new ArrayList<>(List.of(val2))); + var val1 = new TestdataListValue("1"); + var val2 = new TestdataListValue("2"); + var val3 = new TestdataListValue("3"); + var a = new TestdataListEntity("a", new ArrayList<>(List.of(val1, val3))); + var b = new TestdataListEntity("b", new ArrayList<>(List.of(val2))); - var original = new TestdataListSolutionExternalized(); + var original = new TestdataListSolution(); var valueList = Arrays.asList(val1, val2, val3); original.setValueList(valueList); var originalEntityList = List.of(a, b); @@ -140,7 +139,7 @@ void cloneListVariableSolution() { var clone = cloner.cloneSolution(original); assertThat(clone).isNotSameAs(original); - assertThat(clone.getValueList()).isSameAs(valueList); + assertThat(clone.getValueList()).isEqualTo(valueList); assertThat(clone.getScore()).isEqualTo(original.getScore()); var cloneEntityList = clone.getEntityList(); @@ -410,8 +409,8 @@ private void assertEntityClone(TestdataThirdPartyEntityPojo originalEntity, assertCode(valueCode, cloneEntity.getValue()); } - private void assertEntityListClone(TestdataListEntityExternalized originalEntity, - TestdataListEntityExternalized cloneEntity, String entityCode, List valueCodeList) { + private void assertEntityListClone(TestdataListEntity originalEntity, TestdataListEntity cloneEntity, String entityCode, + List valueCodeList) { assertThat(cloneEntity).isNotSameAs(originalEntity); assertThat(cloneEntity.getValueList()).isNotSameAs(originalEntity.getValueList()); assertCode(entityCode, cloneEntity); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRangeTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRangeTest.java new file mode 100644 index 00000000000..db488b51a95 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRangeTest.java @@ -0,0 +1,72 @@ +package ai.timefold.solver.core.impl.domain.valuerange.buildin.collection; + +import static ai.timefold.solver.core.testutil.PlannerAssert.assertAllElementsOfIterator; +import static ai.timefold.solver.core.testutil.PlannerAssert.assertElementsOfIterator; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Random; +import java.util.Set; + +import ai.timefold.solver.core.testutil.TestRandom; + +import org.junit.jupiter.api.Test; + +class SetValueRangeTest { + + @Test + void getSize() { + assertThat(new SetValueRange<>(of(0, 2, 5, 10)).getSize()).isEqualTo(4L); + assertThat(new SetValueRange<>(of(100, 120, 5, 7, 8)).getSize()).isEqualTo(5L); + assertThat(new SetValueRange<>(of(-15, 25, 0)).getSize()).isEqualTo(3L); + assertThat(new SetValueRange<>(of("b", "z", "a")).getSize()).isEqualTo(3L); + assertThat(new SetValueRange<>(Collections.emptySet()).getSize()).isEqualTo(0L); + } + + @Test + void get() { + assertThat(new SetValueRange<>(of(0, 2, 5, 10)).get(2L).intValue()).isEqualTo(5); + assertThat(new SetValueRange<>(of(100, -120)).get(1L).intValue()).isEqualTo(-120); + assertThat(new SetValueRange<>(of("b", "z", "a", "c", "g", "d")).get(3L)).isEqualTo("c"); + } + + @Test + void contains() { + assertThat(new SetValueRange<>(of(0, 2, 5, 10)).contains(5)).isTrue(); + assertThat(new SetValueRange<>(of(0, 2, 5, 10)).contains(4)).isFalse(); + assertThat(new SetValueRange<>(of(100, 120, 5, 7, 8)).contains(7)).isTrue(); + assertThat(new SetValueRange<>(of(100, 120, 5, 7, 8)).contains(9)).isFalse(); + assertThat(new SetValueRange<>(of(-15, 25, 0)).contains(-15)).isTrue(); + assertThat(new SetValueRange<>(of(-15, 25, 0)).contains(-14)).isFalse(); + assertThat(new SetValueRange<>(of("b", "z", "a")).contains("a")).isTrue(); + assertThat(new SetValueRange<>(of("b", "z", "a")).contains("n")).isFalse(); + } + + @Test + void createOriginalIterator() { + assertAllElementsOfIterator(new SetValueRange<>(of(0, 2, 5, 10)).createOriginalIterator(), 0, 2, 5, 10); + assertAllElementsOfIterator(new SetValueRange<>(of(100, 120, 5, 7, 8)).createOriginalIterator(), 100, 120, 5, 7, 8); + assertAllElementsOfIterator(new SetValueRange<>(of(-15, 25, 0)).createOriginalIterator(), -15, 25, 0); + assertAllElementsOfIterator(new SetValueRange<>(of("b", "z", "a")).createOriginalIterator(), "b", "z", "a"); + assertAllElementsOfIterator(new SetValueRange<>(Collections.emptySet()).createOriginalIterator()); + } + + @Test + void createRandomIterator() { + assertElementsOfIterator(new SetValueRange<>(of(0, 2, 5, 10)).createRandomIterator(new TestRandom(2, 0)), 5, 0); + assertElementsOfIterator(new SetValueRange<>(of(100, 120, 5, 7, 8)).createRandomIterator(new TestRandom(2, 0)), 5, 100); + assertElementsOfIterator(new SetValueRange<>(of(-15, 25, 0)).createRandomIterator(new TestRandom(2, 0)), 0, -15); + assertElementsOfIterator(new SetValueRange<>(of("b", "z", "a")).createRandomIterator(new TestRandom(2, 0)), "a", "b"); + assertAllElementsOfIterator(new SetValueRange<>(Collections.emptySet()).createRandomIterator(new Random(0))); + } + + @SafeVarargs + private static Set of(T... elements) { + var set = new LinkedHashSet(elements.length); + set.addAll(Arrays.asList(elements)); + return set; + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseTest.java index fc6a31cfcdf..6f3d8c2ff8b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/DefaultLocalSearchPhaseTest.java @@ -28,8 +28,6 @@ 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.externalized.TestdataListEntityExternalized; -import ai.timefold.solver.core.testdomain.list.externalized.TestdataListSolutionExternalized; 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; @@ -370,17 +368,6 @@ void solveMultiVarChainedVariable() { assertThat(solution).isNotNull(); } - @Test - void solveListVariableWithExternalizedInverseAndIndexSupplies() { - var solverConfig = PlannerTestUtils.buildSolverConfig( - TestdataListSolutionExternalized.class, TestdataListEntityExternalized.class); - - var solution = TestdataListSolutionExternalized.generateUninitializedSolution(6, 2); - - solution = PlannerTestUtils.solve(solverConfig, solution); - assertThat(solution).isNotNull(); - } - @Test void failsFastWithUninitializedSolutionBasicVariable() { var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class); @@ -392,17 +379,4 @@ void failsFastWithUninitializedSolutionBasicVariable() { .hasMessageContaining("uninitialized entities"); } - @Test - void failsFastWithUninitializedSolutionListVariable() { - var solverConfig = PlannerTestUtils.buildSolverConfig( - TestdataListSolutionExternalized.class, TestdataListEntityExternalized.class); - solverConfig.withPhases(solverConfig.getPhaseConfigList().get(1)); // Remove construction heuristic. - - var solution = TestdataListSolutionExternalized.generateUninitializedSolution(6, 2); - - assertThatThrownBy(() -> PlannerTestUtils.solve(solverConfig, solution)) - .hasMessageContaining("planning list variable") - .hasMessageContaining("unexpected unassigned values"); - } - } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedLocalSearchTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedLocalSearchTest.java index 06f0a2eb241..216b8492122 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedLocalSearchTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveStreamsBasedLocalSearchTest.java @@ -19,7 +19,7 @@ import ai.timefold.solver.core.impl.localsearch.decider.acceptor.AcceptorFactory; import ai.timefold.solver.core.impl.localsearch.decider.forager.LocalSearchForagerFactory; import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.move.streams.generic.provider.ChangeMoveProvider; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider.ChangeMoveProvider; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.impl.solver.AbstractSolver; import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java index 77baaeadcec..65d339b14ef 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java @@ -28,7 +28,7 @@ class UniDatasetStreamTest { void forEachBasicVariable() { var dataStreamFactory = new DataStreamFactory<>(TestdataSolution.buildSolutionDescriptor()); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachNonDiscriminating(TestdataEntity.class)) + .forEachNonDiscriminating(TestdataEntity.class, false)) .createDataset(); var supplyManager = mock(SupplyManager.class); @@ -57,11 +57,44 @@ void forEachBasicVariable() { } } + @Test + void forEachBasicVariableIncludingNull() { + var dataStreamFactory = new DataStreamFactory<>(TestdataSolution.buildSolutionDescriptor()); + var uniDataset = ((AbstractUniDataStream) dataStreamFactory + .forEachNonDiscriminating(TestdataEntity.class, true)) + .createDataset(); + + var supplyManager = mock(SupplyManager.class); + var solution = TestdataSolution.generateSolution(2, 2); + try (var datasetSession = UniDatasetStreamTest.createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + var entity1 = solution.getEntityList().get(0); + var entity2 = solution.getEntityList().get(1); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, entity1, entity2); + + // Make incremental changes. + var entity3 = new TestdataEntity("entity3", solution.getValueList().get(0)); + datasetSession.insert(entity3); + datasetSession.retract(entity2); + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, entity1, entity3); + } + } + @Test void forEachListVariable() { var dataStreamFactory = new DataStreamFactory<>(TestdataListSolution.buildSolutionDescriptor()); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachNonDiscriminating(TestdataListEntity.class)) + .forEachNonDiscriminating(TestdataListEntity.class, false)) .createDataset(); var supplyManager = mock(SupplyManager.class); @@ -90,6 +123,39 @@ void forEachListVariable() { } } + @Test + void forEachListVariableIncludingNull() { + var dataStreamFactory = new DataStreamFactory<>(TestdataListSolution.buildSolutionDescriptor()); + var uniDataset = ((AbstractUniDataStream) dataStreamFactory + .forEachNonDiscriminating(TestdataListEntity.class, true)) + .createDataset(); + + var supplyManager = mock(SupplyManager.class); + var solution = TestdataListSolution.generateInitializedSolution(2, 2); + try (var datasetSession = createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + var entity1 = solution.getEntityList().get(0); + var entity2 = solution.getEntityList().get(1); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, entity1, entity2); + + // Make incremental changes. + var entity3 = new TestdataListEntity("entity3"); + datasetSession.insert(entity3); + datasetSession.retract(entity2); + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, entity1, entity3); + } + } + private static DatasetSession createSession(DataStreamFactory dataStreamFactory, Solution_ solution, SupplyManager supplyManager) { var datasetSessionFactory = new DatasetSessionFactory<>(dataStreamFactory); @@ -108,7 +174,7 @@ void forEachListVariableIncludingPinned() { var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachNonDiscriminating(TestdataPinnedWithIndexListEntity.class)) + .forEachNonDiscriminating(TestdataPinnedWithIndexListEntity.class, false)) .createDataset(); var supplyManager = mock(SupplyManager.class); @@ -148,12 +214,57 @@ void forEachListVariableIncludingPinned() { } } + @Test + void forEachListVariableIncludingPinnedAndNull() { + var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); + var uniDataset = + ((AbstractUniDataStream) dataStreamFactory + .forEachNonDiscriminating(TestdataPinnedWithIndexListEntity.class, true)) + .createDataset(); + + var supplyManager = mock(SupplyManager.class); + + // Prepare the solution; + var solution = TestdataPinnedWithIndexListSolution.generateInitializedSolution(5, 3); + // 1 value, entity pinned. + var fullyPinnedEntity = solution.getEntityList().get(0); + fullyPinnedEntity.setPinned(true); + // 2 values, 1 pinned. + var partiallyPinnedEntity = solution.getEntityList().get(1); + partiallyPinnedEntity.setPlanningPinToIndex(1); + // 1 value, not pinned. + var unpinnedEntity = solution.getEntityList().get(2); + unpinnedEntity.setPinned(false); + unpinnedEntity.setPlanningPinToIndex(0); + + try (var datasetSession = createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, fullyPinnedEntity, partiallyPinnedEntity, unpinnedEntity); + + // Make incremental changes. + var entity4 = new TestdataPinnedWithIndexListEntity("entity4"); + entity4.setPinned(true); + datasetSession.insert(entity4); + datasetSession.retract(partiallyPinnedEntity); + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, fullyPinnedEntity, unpinnedEntity, entity4); + } + } + @Test void forEachListVariableExcludingPinned() { // Entities with planningPin true will be skipped. var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachExcludingPinned(TestdataPinnedWithIndexListEntity.class)) + .forEachExcludingPinned(TestdataPinnedWithIndexListEntity.class, false)) .createDataset(); var supplyManager = mock(SupplyManager.class); @@ -194,12 +305,58 @@ void forEachListVariableExcludingPinned() { // Entities with planningPin true wi } } + @Test + void forEachListVariableExcludingPinnedIncludingNull() { // Entities with planningPin true will be skipped. + var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); + var uniDataset = + ((AbstractUniDataStream) dataStreamFactory + .forEachExcludingPinned(TestdataPinnedWithIndexListEntity.class, true)) + .createDataset(); + + var supplyManager = mock(SupplyManager.class); + + // Prepare the solution; + var solution = TestdataPinnedWithIndexListSolution.generateInitializedSolution(5, 3); + // 1 value, entity pinned. + var fullyPinnedEntity = solution.getEntityList().get(0); + fullyPinnedEntity.setPinned(true); + // 2 values, 1 pinned. + var partiallyPinnedEntity = solution.getEntityList().get(1); + partiallyPinnedEntity.setPinned(false); + partiallyPinnedEntity.setPlanningPinToIndex(1); + // 1 value, not pinned. + var unpinnedEntity = solution.getEntityList().get(2); + unpinnedEntity.setPinned(false); + unpinnedEntity.setPlanningPinToIndex(0); + + try (var datasetSession = createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, partiallyPinnedEntity, unpinnedEntity); + + // Make incremental changes. + var entity4 = new TestdataPinnedWithIndexListEntity("entity4"); + entity4.setPinned(true); + datasetSession.insert(entity4); + datasetSession.retract(partiallyPinnedEntity); + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, unpinnedEntity); + } + } + @Test void forEachListVariableIncludingPinnedValues() { var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachNonDiscriminating(TestdataPinnedWithIndexListValue.class)) + .forEachNonDiscriminating(TestdataPinnedWithIndexListValue.class, false)) .createDataset(); var supplyManager = mock(SupplyManager.class); @@ -246,13 +403,65 @@ void forEachListVariableIncludingPinnedValues() { } } + @Test + void forEachListVariableIncludingPinnedValuesAndNull() { + var dataStreamFactory = new DataStreamFactory<>(TestdataPinnedWithIndexListSolution.buildSolutionDescriptor()); + var uniDataset = + ((AbstractUniDataStream) dataStreamFactory + .forEachNonDiscriminating(TestdataPinnedWithIndexListValue.class, true)) + .createDataset(); + + var supplyManager = mock(SupplyManager.class); + + // Prepare the solution; + var solution = TestdataPinnedWithIndexListSolution.generateInitializedSolution(5, 3); + var value1 = solution.getValueList().get(0); + var value2 = solution.getValueList().get(1); + var value3 = solution.getValueList().get(2); + var value4 = solution.getValueList().get(3); + var unassignedValue = solution.getValueList().get(4); + // 1 value, entity pinned. + var fullyPinnedEntity = solution.getEntityList().get(0); + fullyPinnedEntity.setPinned(true); + fullyPinnedEntity.setValueList(List.of(value1)); + // 2 values, 1 pinned. + var partiallyPinnedEntity = solution.getEntityList().get(1); + partiallyPinnedEntity.setPlanningPinToIndex(1); + partiallyPinnedEntity.setValueList(List.of(value2, value3)); + // 1 value, not pinned. + var unpinnedEntity = solution.getEntityList().get(2); + unpinnedEntity.setPinned(false); + unpinnedEntity.setPlanningPinToIndex(0); + unpinnedEntity.setValueList(List.of(value4)); + + try (var datasetSession = createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, value1, value2, value3, value4, unassignedValue); + + // Make incremental changes. + var entity4 = new TestdataPinnedWithIndexListEntity("entity4", unassignedValue); + datasetSession.insert(entity4); // This will add the value to the dataset. + datasetSession.retract(partiallyPinnedEntity); // This will remove the pin on value3. + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, value1, value2, value3, value4, unassignedValue); + } + } + @Test void forEachListVariableExcludingPinnedValues() { var solutionDescriptor = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(); var dataStreamFactory = new DataStreamFactory<>(solutionDescriptor); var uniDataset = ((AbstractUniDataStream) dataStreamFactory - .forEachExcludingPinned(TestdataPinnedWithIndexListValue.class)) + .forEachExcludingPinned(TestdataPinnedWithIndexListValue.class, false)) .createDataset(); // Prepare the solution; @@ -327,4 +536,85 @@ void forEachListVariableExcludingPinnedValues() { } } + @Test + void forEachListVariableExcludingPinnedValuesIncludingNull() { + var solutionDescriptor = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor(); + var dataStreamFactory = new DataStreamFactory<>(solutionDescriptor); + var uniDataset = + ((AbstractUniDataStream) dataStreamFactory + .forEachExcludingPinned(TestdataPinnedWithIndexListValue.class, true)) + .createDataset(); + + // Prepare the solution; + var solution = TestdataPinnedWithIndexListSolution.generateInitializedSolution(5, 3); + var value0 = solution.getValueList().get(0); + var value1 = solution.getValueList().get(1); + var value2 = solution.getValueList().get(2); + var value3 = solution.getValueList().get(3); + var value4 = solution.getValueList().get(4); // Initially unassigned. + // 1 value, entity pinned. + var fullyPinnedEntity = solution.getEntityList().get(0); + fullyPinnedEntity.setPinned(true); + fullyPinnedEntity.setValueList(List.of(value0)); + // 2 values, 1 pinned. + var partiallyPinnedEntity = solution.getEntityList().get(1); + partiallyPinnedEntity.setPlanningPinToIndex(1); + // 1 value, not pinned. + partiallyPinnedEntity.setValueList(List.of(value1, value2)); + var unpinnedEntity = solution.getEntityList().get(2); + unpinnedEntity.setPinned(false); + unpinnedEntity.setPlanningPinToIndex(0); + unpinnedEntity.setValueList(List.of(value3)); + // Fully pinned, but not initially present in the solution. + var entityAddedLater = new TestdataPinnedWithIndexListEntity("entity4", value4); + entityAddedLater.setPinned(true); + + // Emulate pinning logic. + // Otherwise we'd have to mock everything from score director down. + // We're not testing pin detection; we're testing that the session can ignore values already marked as pinned. + var supplyManager = mock(SupplyManager.class); + var listVariableStateSupply = mock(ListVariableStateSupply.class); + var effectiveEntityList = new ArrayList<>(List.of(fullyPinnedEntity, partiallyPinnedEntity, unpinnedEntity)); + doAnswer(invocation -> { + var element = (TestdataPinnedWithIndexListValue) invocation.getArgument(0); + for (var entity : effectiveEntityList) { + var indexOf = entity.getValueList().indexOf(element); + if (indexOf < 0) { + continue; + } + if (entity.isPinned()) { + return true; + } else { + var pinToIndex = entity.getPinIndex(); + return indexOf < pinToIndex; + } + } + return false; + }).when(listVariableStateSupply).isPinned(any()); + doReturn(listVariableStateSupply).when(supplyManager).demand(any(ListVariableStateDemand.class)); + + try (var datasetSession = createSession(dataStreamFactory, solution, supplyManager)) { + var uniDatasetInstance = datasetSession.getInstance(uniDataset); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactly(null, value2, value3, value4); + + // Make incremental changes. + partiallyPinnedEntity.setPlanningPinToIndex(0); + effectiveEntityList.add(entityAddedLater); + effectiveEntityList.remove(fullyPinnedEntity); + datasetSession.insert(entityAddedLater); // This will add the value to the dataset, but make it pinned. + datasetSession.retract(fullyPinnedEntity); // This will remove value0. + datasetSession.update(partiallyPinnedEntity); // This will remove the pin from value1. + datasetSession.settle(); + + assertThat(uniDatasetInstance.iterator()) + .toIterable() + .map(t -> t.factA) + .containsExactlyInAnyOrder(null, value0, value1, value2, value3); + } + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/streams/maybeapi/provider/ChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/streams/maybeapi/provider/ChangeMoveProviderTest.java index 72726cdab60..68583a05571 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/streams/maybeapi/provider/ChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/streams/maybeapi/provider/ChangeMoveProviderTest.java @@ -2,21 +2,32 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; -import static org.mockito.Mockito.mock; +import java.util.Collections; import java.util.stream.StreamSupport; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; import ai.timefold.solver.core.impl.move.streams.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.move.streams.generic.move.ChangeMove; -import ai.timefold.solver.core.impl.move.streams.generic.provider.ChangeMoveProvider; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.ChangeMove; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider.ChangeMoveProvider; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamSession; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.testdomain.TestdataConstraintProvider; 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.unassignedvar.TestdataAllowsUnassignedConstraintProvider; import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingConstraintProvider; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; +import ai.timefold.solver.core.testdomain.valuerange.incomplete.TestdataIncompleteValueRangeConstraintProvider; +import ai.timefold.solver.core.testdomain.valuerange.incomplete.TestdataIncompleteValueRangeEntity; +import ai.timefold.solver.core.testdomain.valuerange.incomplete.TestdataIncompleteValueRangeSolution; import org.junit.jupiter.api.Test; @@ -40,8 +51,8 @@ void fromSolutionBasicVariable() { secondEntity.setValue(null); var firstValue = solution.getValueList().get(0); var secondValue = solution.getValueList().get(1); - var moveStreamSession = createSession(moveStreamFactory, solutionDescriptor, solution, - mock(SupplyManager.class)); + var scoreDirector = createScoreDirector(solutionDescriptor, new TestdataConstraintProvider(), solution); + var moveStreamSession = createSession(moveStreamFactory, scoreDirector); var moveIterable = moveProducer.getMoveIterable(moveStreamSession); assertThat(moveIterable).hasSize(4); @@ -84,6 +95,133 @@ void fromSolutionBasicVariable() { }); } + @Test + void fromSolutionBasicVariableIncompleteValueRange() { + var solutionDescriptor = TestdataIncompleteValueRangeSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .entity(TestdataIncompleteValueRangeEntity.class) + .genuineVariable() + .ensurePlanningVariable(); + var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor); + var moveProvider = new ChangeMoveProvider<>(variableMetaModel); + var moveProducer = moveProvider.apply(moveStreamFactory); + + // The point of this test is to ensure that the move provider skips values that are not in the value range. + var solution = TestdataIncompleteValueRangeSolution.generateSolution(2, 2); + var valueNotInValueRange = new TestdataValue("third"); + solution.setValueListNotInValueRange(Collections.singletonList(valueNotInValueRange)); + + var firstEntity = solution.getEntityList().get(0); + firstEntity.setValue(null); + var secondEntity = solution.getEntityList().get(1); + secondEntity.setValue(null); + var firstValue = solution.getValueList().get(0); + var secondValue = solution.getValueList().get(1); + var scoreDirector = + createScoreDirector(solutionDescriptor, new TestdataIncompleteValueRangeConstraintProvider(), solution); + var moveStreamSession = createSession(moveStreamFactory, scoreDirector); + + var moveIterable = moveProducer.getMoveIterable(moveStreamSession); + assertThat(moveIterable).hasSize(4); + + var moveList = StreamSupport.stream(moveIterable.spliterator(), false) + .map(m -> (ChangeMove) m) + .toList(); + assertThat(moveList).hasSize(4); + + var firstMove = moveList.get(0); + assertSoftly(softly -> { + softly.assertThat(firstMove.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(firstMove.extractPlanningValues()) + .containsExactly(firstValue); + }); + + var secondMove = moveList.get(1); + assertSoftly(softly -> { + softly.assertThat(secondMove.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(secondMove.extractPlanningValues()) + .containsExactly(secondValue); + }); + + var thirdMove = moveList.get(2); + assertSoftly(softly -> { + softly.assertThat(thirdMove.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(thirdMove.extractPlanningValues()) + .containsExactly(firstValue); + }); + + var fourthMove = moveList.get(3); + assertSoftly(softly -> { + softly.assertThat(fourthMove.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(fourthMove.extractPlanningValues()) + .containsExactly(secondValue); + }); + } + + @Test + void fromEntityBasicVariable() { + var solutionDescriptor = TestdataEntityProvidingSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .entity(TestdataEntityProvidingEntity.class) + .genuineVariable() + .ensurePlanningVariable(); + var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor); + var moveProvider = new ChangeMoveProvider<>(variableMetaModel); + var moveProducer = moveProvider.apply(moveStreamFactory); + + var solution = TestdataEntityProvidingSolution.generateSolution(2, 2); + var firstEntity = solution.getEntityList().get(0); + var secondEntity = solution.getEntityList().get(1); + var firstValue = firstEntity.getValueRange().get(0); + var scoreDirector = createScoreDirector(solutionDescriptor, new TestdataEntityProvidingConstraintProvider(), solution); + var moveStreamSession = createSession(moveStreamFactory, scoreDirector); + + // Three moves are expected: + // - Assign firstEntity to null, + // as it is currently assigned to firstValue, and the value range only contains firstValue. + // - Assign secondEntity to null and to firstValue, + // as it is currently assigned to secondValue, and the value range only contains firstValue. + // Null is not in the value range, but as documented, + // null is added automatically to value ranges when allowsUnassigned is true. + var moveIterable = moveProducer.getMoveIterable(moveStreamSession); + assertThat(moveIterable).hasSize(3); + + var moveList = StreamSupport.stream(moveIterable.spliterator(), false) + .map(m -> (ChangeMove) m) + .toList(); + assertThat(moveList).hasSize(3); + + var firstMove = moveList.get(0); + assertSoftly(softly -> { + softly.assertThat(firstMove.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(firstMove.extractPlanningValues()) + .hasSize(1) + .containsNull(); + }); + + var secondMove = moveList.get(1); + assertSoftly(softly -> { + softly.assertThat(secondMove.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(secondMove.extractPlanningValues()) + .hasSize(1) + .containsNull(); + }); + + var thirdMove = moveList.get(2); + assertSoftly(softly -> { + softly.assertThat(thirdMove.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(thirdMove.extractPlanningValues()) + .containsExactly(firstValue); + }); + } + @Test void fromSolutionBasicVariableAllowsUnassigned() { var solutionDescriptor = TestdataAllowsUnassignedSolution.buildSolutionDescriptor(); @@ -100,8 +238,8 @@ void fromSolutionBasicVariableAllowsUnassigned() { var secondEntity = solution.getEntityList().get(1); // Assigned to secondValue. var firstValue = solution.getValueList().get(0); // Not assigned to any entity. var secondValue = solution.getValueList().get(1); - var moveStreamSession = createSession(moveStreamFactory, solutionDescriptor, solution, - mock(SupplyManager.class)); + var scoreDirector = createScoreDirector(solutionDescriptor, new TestdataAllowsUnassignedConstraintProvider(), solution); + var moveStreamSession = createSession(moveStreamFactory, scoreDirector); // Filters out moves that would change the value to the value the entity already has. // Therefore this will have 4 moves (2 entities * 2 values) as opposed to 6 (2 entities * 3 values). @@ -148,10 +286,22 @@ void fromSolutionBasicVariableAllowsUnassigned() { }); } + private InnerScoreDirector createScoreDirector(SolutionDescriptor solutionDescriptor, + ConstraintProvider constraintProvider, Solution_ solution) { + var scoreDirectorFactory = + new BavetConstraintStreamScoreDirectorFactory<>(solutionDescriptor, constraintProvider, + EnvironmentMode.TRACKED_FULL_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + return scoreDirector; + } + private MoveStreamSession createSession(DefaultMoveStreamFactory moveStreamFactory, - SolutionDescriptor solutionDescriptor, Solution_ solution, SupplyManager supplyManager) { - var moveStreamSession = moveStreamFactory.createSession(solution, supplyManager); - solutionDescriptor.visitAll(solution, moveStreamSession::insert); + InnerScoreDirector scoreDirector) { + var solution = scoreDirector.getWorkingSolution(); + var moveStreamSession = + moveStreamFactory.createSession(solution, scoreDirector.getMoveDirector(), scoreDirector.getSupplyManager()); + scoreDirector.getSolutionDescriptor().visitAll(solution, moveStreamSession::insert); moveStreamSession.settle(); return moveStreamSession; } 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 79c246606ed..d59f42cee50 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 @@ -80,7 +80,7 @@ import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter; import ai.timefold.solver.core.impl.heuristic.selector.move.generic.ChangeMove; -import ai.timefold.solver.core.impl.move.streams.generic.provider.ChangeMoveProvider; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider.ChangeMoveProvider; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProvider; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProviders; import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListenerAdapter; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirector.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirector.java index 2bb10f41046..675a005e072 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirector.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirector.java @@ -1,29 +1,27 @@ package ai.timefold.solver.core.impl.solver; import java.util.Map; +import java.util.Objects; import java.util.function.Consumer; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.constraint.ConstraintMatchTotal; import ai.timefold.solver.core.api.score.constraint.Indictment; -import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; import ai.timefold.solver.core.impl.score.director.InnerScore; -public class MoveAssertScoreDirector> +import org.jspecify.annotations.NullMarked; + +public final class MoveAssertScoreDirector> extends AbstractScoreDirector> { + private final Consumer moveSolutionConsumer; private boolean firstTrigger = true; private final boolean isDerived; - protected MoveAssertScoreDirector(MoveAssertScoreDirectorFactory scoreDirectorFactory, - boolean lookUpEnabled, - ConstraintMatchPolicy constraintMatchPolicy, - boolean expectShadowVariablesInCorrectState, - Consumer moveSolutionConsumer, - boolean isDerived) { - super(scoreDirectorFactory, lookUpEnabled, constraintMatchPolicy, expectShadowVariablesInCorrectState); - this.moveSolutionConsumer = moveSolutionConsumer; + private MoveAssertScoreDirector(Builder builder, boolean isDerived) { + super(builder); + this.moveSolutionConsumer = Objects.requireNonNull(builder.moveSolutionConsumer); this.isDerived = isDerived; } @@ -61,4 +59,32 @@ public Map> getIndictmentMap() { public boolean requiresFlushing() { return false; } + + @NullMarked + public static final class Builder> + extends + AbstractScoreDirectorBuilder, MoveAssertScoreDirector.Builder> { + + private Consumer moveSolutionConsumer; + + public Builder(MoveAssertScoreDirectorFactory scoreDirectorFactory) { + super(scoreDirectorFactory); + } + + public Builder withMoveSolutionConsumer(Consumer moveSolutionConsumer) { + this.moveSolutionConsumer = moveSolutionConsumer; + return this; + } + + @Override + public MoveAssertScoreDirector build() { + return new MoveAssertScoreDirector<>(this, false); + } + + @Override + public MoveAssertScoreDirector buildDerived() { + return new MoveAssertScoreDirector<>(this, true); + } + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirectorFactory.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirectorFactory.java index 4b6ed62ee33..07964192b37 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirectorFactory.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/MoveAssertScoreDirectorFactory.java @@ -10,12 +10,12 @@ import org.jspecify.annotations.NullMarked; @NullMarked -public class MoveAssertScoreDirectorFactory> +public final class MoveAssertScoreDirectorFactory> extends AbstractScoreDirectorFactory> { + private final Consumer moveSolutionConsumer; - public MoveAssertScoreDirectorFactory( - SolutionDescriptor solutionDescriptor, + public MoveAssertScoreDirectorFactory(SolutionDescriptor solutionDescriptor, Consumer moveSolutionConsumer) { super(solutionDescriptor); this.moveSolutionConsumer = moveSolutionConsumer; @@ -23,34 +23,8 @@ public MoveAssertScoreDirectorFactory( @Override public AbstractScoreDirector.AbstractScoreDirectorBuilder createScoreDirectorBuilder() { - return new MoveAssertScoreDirectorBuilder(this); + return new MoveAssertScoreDirector.Builder<>(this) + .withMoveSolutionConsumer(moveSolutionConsumer); } - public class MoveAssertScoreDirectorBuilder extends - AbstractScoreDirector.AbstractScoreDirectorBuilder, MoveAssertScoreDirectorBuilder> { - - protected MoveAssertScoreDirectorBuilder(MoveAssertScoreDirectorFactory scoreDirectorFactory) { - super(scoreDirectorFactory); - } - - @Override - public MoveAssertScoreDirector build() { - return new MoveAssertScoreDirector<>(scoreDirectorFactory, - lookUpEnabled, - constraintMatchPolicy, - expectShadowVariablesInCorrectState, - moveSolutionConsumer, - false); - } - - @Override - public MoveAssertScoreDirector buildDerived() { - return new MoveAssertScoreDirector<>(scoreDirectorFactory, - lookUpEnabled, - constraintMatchPolicy, - expectShadowVariablesInCorrectState, - moveSolutionConsumer, - true); - } - } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/dependent/DependencyValuesShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/dependent/DependencyValuesShadowVariableTest.java index ac9e9dc9799..b598e74ddb4 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/dependent/DependencyValuesShadowVariableTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/dependent/DependencyValuesShadowVariableTest.java @@ -14,7 +14,7 @@ import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.move.streams.generic.move.ListAssignMove; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.ListAssignMove; import ai.timefold.solver.core.impl.solver.MoveAsserter; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.testdomain.declarative.dependency.TestdataDependencyConstraintProvider; diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower/FollowerValuesShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower/FollowerValuesShadowVariableTest.java index 76b8375c2e3..d213becc6e0 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower/FollowerValuesShadowVariableTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower/FollowerValuesShadowVariableTest.java @@ -9,7 +9,7 @@ import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; -import ai.timefold.solver.core.impl.move.streams.generic.move.ChangeMove; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.ChangeMove; import ai.timefold.solver.core.impl.solver.MoveAsserter; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.testdomain.TestdataValue; diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower_set/FollowerValuesShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower_set/FollowerValuesShadowVariableTest.java index 4efab6dacb8..abffa9ca7e5 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower_set/FollowerValuesShadowVariableTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/variable/declarative/follower_set/FollowerValuesShadowVariableTest.java @@ -9,7 +9,7 @@ import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; -import ai.timefold.solver.core.impl.move.streams.generic.move.ChangeMove; +import ai.timefold.solver.core.impl.move.streams.maybeapi.generic.ChangeMove; import ai.timefold.solver.core.impl.solver.MoveAsserter; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.testdomain.TestdataValue; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListEntity.java index c0ca690ef7e..a6e08ff191e 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListEntity.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/list/TestdataListEntity.java @@ -54,6 +54,10 @@ public List getValueList() { return valueList; } + public void setValueList(List valueList) { + this.valueList = valueList; + } + public void addValue(TestdataListValue value) { addValueAt(valueList.size(), value); } diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListEntityExternalized.java b/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListEntityExternalized.java deleted file mode 100644 index 5170b900585..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListEntityExternalized.java +++ /dev/null @@ -1,36 +0,0 @@ -package ai.timefold.solver.core.testdomain.list.externalized; - -import java.util.ArrayList; -import java.util.Arrays; -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 TestdataListEntityExternalized extends TestdataObject { - - @PlanningListVariable(valueRangeProviderRefs = "valueRange") - private List valueList; - - public TestdataListEntityExternalized() { - } - - public TestdataListEntityExternalized(String code, List valueList) { - super(code); - this.valueList = valueList; - } - - public TestdataListEntityExternalized(String code, TestdataListValueExternalized... values) { - this(code, new ArrayList<>(Arrays.asList(values))); - } - - 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/list/externalized/TestdataListSolutionExternalized.java b/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListSolutionExternalized.java deleted file mode 100644 index 73d6c049c72..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListSolutionExternalized.java +++ /dev/null @@ -1,61 +0,0 @@ -package ai.timefold.solver.core.testdomain.list.externalized; - -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -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 TestdataListSolutionExternalized { - - public static TestdataListSolutionExternalized generateUninitializedSolution(int valueCount, int entityCount) { - List entityList = IntStream.range(0, entityCount) - .mapToObj(i -> new TestdataListEntityExternalized("Generated Entity " + i)) - .collect(Collectors.toList()); - List valueList = IntStream.range(0, valueCount) - .mapToObj(i -> new TestdataListValueExternalized("Generated Value " + i)) - .collect(Collectors.toList()); - TestdataListSolutionExternalized solution = new TestdataListSolutionExternalized(); - solution.setValueList(valueList); - solution.setEntityList(entityList); - return solution; - } - - private List valueList; - private List entityList; - private SimpleScore score; - - @ValueRangeProvider(id = "valueRange") - @ProblemFactCollectionProperty - public List getValueList() { - return valueList; - } - - public void setValueList(List valueList) { - this.valueList = valueList; - } - - @PlanningEntityCollectionProperty - public List getEntityList() { - return entityList; - } - - public void setEntityList(List entityList) { - this.entityList = entityList; - } - - @PlanningScore - 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/externalized/TestdataListValueExternalized.java b/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListValueExternalized.java deleted file mode 100644 index 584ac2da898..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListValueExternalized.java +++ /dev/null @@ -1,25 +0,0 @@ -package ai.timefold.solver.core.testdomain.list.externalized; - -import ai.timefold.solver.core.testdomain.TestdataObject; - -public class TestdataListValueExternalized extends TestdataObject { - - public TestdataListValueExternalized() { - } - - public TestdataListValueExternalized(String code) { - super(code); - } - - @Override - public boolean equals(Object obj) { - // Pretend a bad equals() design that makes all values equal. This proves that external supplies must use - // the IdentityHasMap to eliminate dependency on user domain implementation of equals(). - return obj != null && obj.getClass().equals(this.getClass()); - } - - @Override - public int hashCode() { - return 0; - } -} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/unassignedvar/TestdataAllowsUnassignedConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/unassignedvar/TestdataAllowsUnassignedConstraintProvider.java new file mode 100644 index 00000000000..6e460925700 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/unassignedvar/TestdataAllowsUnassignedConstraintProvider.java @@ -0,0 +1,26 @@ +package ai.timefold.solver.core.testdomain.unassignedvar; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +public final class TestdataAllowsUnassignedConstraintProvider implements ConstraintProvider { + + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { + valueConstraint(constraintFactory) + }; + } + + private Constraint valueConstraint(ConstraintFactory constraintFactory) { + return constraintFactory.forEachIncludingUnassigned(TestdataAllowsUnassignedEntity.class) + .filter(entity -> entity.getValue() == null) + .penalize(SimpleScore.ONE) + .asConstraint("Unassigned entities"); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingConstraintProvider.java new file mode 100644 index 00000000000..a631dc0f57f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingConstraintProvider.java @@ -0,0 +1,23 @@ +package ai.timefold.solver.core.testdomain.valuerange.entityproviding; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +public final class TestdataEntityProvidingConstraintProvider implements ConstraintProvider { + + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { alwaysPenalizingConstraint(constraintFactory) }; + } + + private Constraint alwaysPenalizingConstraint(ConstraintFactory constraintFactory) { + return constraintFactory.forEach(TestdataEntityProvidingEntity.class) + .penalize(SimpleScore.ONE) + .asConstraint("Always penalize"); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingSolution.java index c548df992bc..1b32173a792 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingSolution.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingSolution.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.testdomain.valuerange.entityproviding; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -22,6 +23,41 @@ public static SolutionDescriptor buildSolutionD TestdataEntityProvidingEntity.class); } + public static TestdataEntityProvidingSolution generateSolution() { + return generateSolution(5, 5); + } + + public static TestdataEntityProvidingSolution generateSolution(int valueListSize, int entityListSize) { + return generateSolution(valueListSize, entityListSize, true); + } + + public static TestdataEntityProvidingSolution generateUninitializedSolution(int valueListSize, int entityListSize) { + return generateSolution(valueListSize, entityListSize, false); + } + + private static TestdataEntityProvidingSolution generateSolution(int valueListSize, int entityListSize, + boolean initialized) { + var solution = new TestdataEntityProvidingSolution("Generated Solution 0"); + var valueList = new ArrayList(valueListSize); + for (var i = 0; i < valueListSize; i++) { + var value = new TestdataValue("Generated Value " + i); + valueList.add(value); + } + var entityList = new ArrayList(entityListSize); + for (var i = 0; i < entityListSize; i++) { + var expectedCount = Math.max(1, valueListSize / entityListSize); + var valueRange = new ArrayList(); + for (var j = 0; j < expectedCount; j++) { + valueRange.add(valueList.get((i * j) % valueListSize)); + } + var entity = new TestdataEntityProvidingEntity("Generated Entity " + i, valueRange); + entity.setValue(initialized ? valueList.get(i % valueListSize) : null); + entityList.add(entity); + } + solution.setEntityList(entityList); + return solution; + } + private List entityList; private SimpleScore score; @@ -58,7 +94,7 @@ public void setScore(SimpleScore score) { @ProblemFactCollectionProperty public Collection getProblemFacts() { Set valueSet = new HashSet<>(); - for (TestdataEntityProvidingEntity entity : entityList) { + for (var entity : entityList) { valueSet.addAll(entity.getValueRange()); } return valueSet; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeConstraintProvider.java new file mode 100644 index 00000000000..10247c009f3 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeConstraintProvider.java @@ -0,0 +1,23 @@ +package ai.timefold.solver.core.testdomain.valuerange.incomplete; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +public final class TestdataIncompleteValueRangeConstraintProvider implements ConstraintProvider { + + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { alwaysPenalizingConstraint(constraintFactory) }; + } + + private Constraint alwaysPenalizingConstraint(ConstraintFactory constraintFactory) { + return constraintFactory.forEach(TestdataIncompleteValueRangeEntity.class) + .penalize(SimpleScore.ONE) + .asConstraint("Always penalize"); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeEntity.java new file mode 100644 index 00000000000..342035a5e89 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeEntity.java @@ -0,0 +1,45 @@ +package ai.timefold.solver.core.testdomain.valuerange.incomplete; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.testdomain.TestdataObject; +import ai.timefold.solver.core.testdomain.TestdataValue; + +@PlanningEntity +public class TestdataIncompleteValueRangeEntity extends TestdataObject { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataIncompleteValueRangeSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataIncompleteValueRangeEntity.class); + } + + public static GenuineVariableDescriptor buildVariableDescriptorForValue() { + return buildEntityDescriptor().getGenuineVariableDescriptor("value"); + } + + private TestdataValue value; + + public TestdataIncompleteValueRangeEntity() { + } + + public TestdataIncompleteValueRangeEntity(String code) { + super(code); + } + + public TestdataIncompleteValueRangeEntity(String code, TestdataValue value) { + this(code); + this.value = value; + } + + @PlanningVariable(valueRangeProviderRefs = "valueRange") + public TestdataValue getValue() { + return value; + } + + public void setValue(TestdataValue value) { + this.value = value; + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeSolution.java new file mode 100644 index 00000000000..bc69be7a622 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeSolution.java @@ -0,0 +1,109 @@ +package ai.timefold.solver.core.testdomain.valuerange.incomplete; + +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; +import ai.timefold.solver.core.testdomain.TestdataObject; +import ai.timefold.solver.core.testdomain.TestdataValue; + +@PlanningSolution +public class TestdataIncompleteValueRangeSolution extends TestdataObject { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataIncompleteValueRangeSolution.class, + TestdataIncompleteValueRangeEntity.class); + } + + public static TestdataIncompleteValueRangeSolution generateSolution() { + return generateSolution(5, 7); + } + + public static TestdataIncompleteValueRangeSolution generateSolution(int valueListSize, int entityListSize) { + return generateSolution(valueListSize, entityListSize, true); + } + + public static TestdataIncompleteValueRangeSolution generateUninitializedSolution(int valueListSize, int entityListSize) { + return generateSolution(valueListSize, entityListSize, false); + } + + private static TestdataIncompleteValueRangeSolution generateSolution(int valueListSize, int entityListSize, + boolean initialized) { + var solution = new TestdataIncompleteValueRangeSolution("Generated Solution 0"); + var valueList = new ArrayList(valueListSize); + for (var i = 0; i < valueListSize; i++) { + var value = new TestdataValue("Generated Value " + i); + valueList.add(value); + } + solution.setValueList(valueList); + var entityList = new ArrayList(entityListSize); + for (var i = 0; i < entityListSize; i++) { + var value = initialized ? valueList.get(i % valueListSize) : null; + var entity = new TestdataIncompleteValueRangeEntity("Generated Entity " + i, value); + entityList.add(entity); + } + solution.setEntityList(entityList); + return solution; + } + + private List valueList; + private List valueListNotInValueRange; + private List entityList; + + private SimpleScore score; + + public TestdataIncompleteValueRangeSolution() { + } + + public TestdataIncompleteValueRangeSolution(String code) { + super(code); + } + + @ValueRangeProvider(id = "valueRange") + @ProblemFactCollectionProperty + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @ProblemFactCollectionProperty + public List getValueListNotInValueRange() { + return valueListNotInValueRange == null ? new ArrayList<>() : valueListNotInValueRange; + } + + public void setValueListNotInValueRange(List valueListNotInValueRange) { + this.valueListNotInValueRange = valueListNotInValueRange; + } + + @PlanningEntityCollectionProperty + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + @PlanningScore + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } + + // ************************************************************************ + // Complex methods + // ************************************************************************ + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerAssert.java b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerAssert.java index 25934ad0ea6..79e8972880a 100644 --- a/core/src/test/java/ai/timefold/solver/core/testutil/PlannerAssert.java +++ b/core/src/test/java/ai/timefold/solver/core/testutil/PlannerAssert.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.testutil; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.Mockito.any; import static org.mockito.Mockito.times; @@ -12,7 +11,6 @@ import java.util.Iterator; import java.util.List; import java.util.ListIterator; -import java.util.NoSuchElementException; import ai.timefold.solver.core.impl.constructionheuristic.event.ConstructionHeuristicPhaseLifecycleListener; import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope; @@ -176,15 +174,9 @@ public static void assertElementsOfIterator(Iterator iterator, O... eleme @SafeVarargs public static void assertAllElementsOfIterator(Iterator iterator, O... elements) { - assertElementsOfIterator(iterator, elements); - assertThat(iterator).isExhausted(); - try { - iterator.next(); - fail("The iterator with hasNext() (" + false + ") is expected to throw a " - + NoSuchElementException.class.getSimpleName() + " when calling next()."); - } catch (NoSuchElementException e) { - // Do nothing - } + assertThat(iterator) + .toIterable() + .containsExactly(elements); } // ************************************************************************ diff --git a/core/src/test/resources/logback-test.xml b/core/src/test/resources/logback-test.xml index 833262ebba3..6da2ae579b4 100644 --- a/core/src/test/resources/logback-test.xml +++ b/core/src/test/resources/logback-test.xml @@ -9,9 +9,9 @@ - + - +