From 3f4ed0dbd95a8d2d6be29eca08340711ad87eb91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 10:07:21 +0200 Subject: [PATCH 01/22] Adapt API to the need of filtering the second pick --- .../DefaultPlanningListVariableMetaModel.java | 5 + .../DefaultPlanningVariableMetaModel.java | 6 + .../streams/DefaultMoveStreamFactory.java | 34 --- .../move/streams/DefaultUniMoveStream.java | 6 + .../common/pickers/AbstractPicker.java | 37 +++ .../common/pickers/BiPickerComber.java | 73 ++++++ .../common/pickers/DefaultBiPicker.java | 92 +++++++ .../common/pickers/FilteringBiPicker.java | 32 +++ .../generic/common/pickers/PickerType.java | 46 ++++ .../SolutionBasedFilteringBiPicker.java | 42 +++ .../generic/provider/ChangeMoveProvider.java | 71 ++--- .../maybeapi/stream/MoveStreamFactory.java | 19 -- .../maybeapi/stream/UniMoveStream.java | 21 ++ .../maybeapi/stream/pickers/BiPicker.java | 20 ++ .../maybeapi/stream/pickers/Pickers.java | 243 ++++++++++++++++++ .../metamodel/GenuineVariableMetaModel.java | 2 + 16 files changed, 651 insertions(+), 98 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java 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..d4e68c3981f 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 @@ -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..96f0840ac9a 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 @@ -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/move/streams/DefaultMoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/DefaultMoveStreamFactory.java index c2fe441ced3..33ea43d9eae 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,6 @@ 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 org.jspecify.annotations.NullMarked; @@ -63,35 +58,6 @@ public UniDataStream enumerateIncludingPinned(Class sourceC 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())); - } - } - @Override public UniMoveStream pick(UniDataStream dataStream) { return new DefaultUniMoveStream<>(this, 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..0c6287b9437 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 @@ -8,6 +8,7 @@ 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.UniDataStream; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; import org.jspecify.annotations.NullMarked; @@ -27,6 +28,11 @@ public BiMoveStream pick(UniDataStream uniDat return new DefaultBiMoveStream<>(this, ((AbstractUniDataStream) uniDataStream).createDataset(), filter); } + @Override + public BiMoveStream pick(UniDataStream uniDataStream, BiPicker... pickers) { + return null; + } + @Override public UniDataset getDataset() { return dataset; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java new file mode 100644 index 00000000000..8518c1fcf91 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java @@ -0,0 +1,37 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.Objects; +import java.util.function.Function; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +@SuppressWarnings({ "unchecked" }) +public sealed abstract class AbstractPicker + permits DefaultBiPicker { + + protected final Function[] rightMappings; + protected final PickerType[] pickerTypes; + + protected AbstractPicker(Function rightMapping, PickerType pickerType) { + this(new Function[] { rightMapping }, new PickerType[] { pickerType }); + } + + protected AbstractPicker(Function[] rightMappings, PickerType[] pickerTypes) { + this.rightMappings = (Function[]) Objects.requireNonNull(rightMappings); + this.pickerTypes = Objects.requireNonNull(pickerTypes); + } + + public final Function getRightMapping(int index) { + return rightMappings[index]; + } + + public final int getJoinerCount() { + return pickerTypes.length; + } + + public final PickerType getPickerType(int index) { + return pickerTypes[index]; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java new file mode 100644 index 00000000000..0d8511a7d3a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java @@ -0,0 +1,73 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiPredicate; + +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Combs an array of {@link BiPicker} instances into a {@link #mergedPicker()} and {@link #mergedFiltering()}. + * + * @param + * @param + * @param mergedPicker the merged {@link DefaultBiPicker} from all indexing pickers + * @param mergedFiltering null if not applicable + */ +@NullMarked +public record BiPickerComber(DefaultBiPicker mergedPicker, @Nullable BiPredicate mergedFiltering) { + + public static BiPickerComber comb(BiPicker[] pickers) { + var defaultPickerList = new ArrayList>(pickers.length); + var filteringList = new ArrayList>(pickers.length); + + var indexOfFirstFilter = -1; + // Make sure all indexing pickers, if any, come before filtering pickers. This is necessary for performance. + for (var i = 0; i < pickers.length; i++) { + var picker = pickers[i]; + if (picker instanceof FilteringBiPicker filteringBiPicker) { + // From now on, only allow filtering joiners. + indexOfFirstFilter = i; + filteringList.add(filteringBiPicker.filter()); + } else if (picker instanceof DefaultBiPicker defaultBiPicker) { + if (indexOfFirstFilter >= 0) { + throw new IllegalStateException(""" + Indexing picker (%s) must not follow a filtering picker (%s). + Maybe reorder the pickers such that filtering() pickers are later in the parameter list.""" + .formatted(picker, pickers[indexOfFirstFilter])); + } + defaultPickerList.add(defaultBiPicker); + } else { + throw new IllegalArgumentException("The picker class (%s) is not supported." + .formatted(picker.getClass().getCanonicalName())); + } + } + var mergedPicker = DefaultBiPicker.merge(defaultPickerList); + var mergedFiltering = mergeFiltering(filteringList); + return new BiPickerComber<>(mergedPicker, mergedFiltering); + } + + private static @Nullable BiPredicate mergeFiltering(List> filteringList) { + if (filteringList.isEmpty()) { + return null; + } + return switch (filteringList.size()) { + case 1 -> filteringList.get(0); + case 2 -> filteringList.get(0).and(filteringList.get(1)); + default -> + // Avoid predicate.and() when more than 2 predicates for debugging and potentially performance + (A a, B b) -> { + for (var predicate : filteringList) { + if (!predicate.test(a, b)) { + return false; + } + } + return true; + }; + }; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java new file mode 100644 index 00000000000..9a250644e8e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java @@ -0,0 +1,92 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +@SuppressWarnings({ "unchecked", "rawtypes" }) +public final class DefaultBiPicker + extends AbstractPicker + implements BiPicker { + + private static final DefaultBiPicker NONE = new DefaultBiPicker(new Function[0], new PickerType[0], new Function[0]); + + private final Function[] leftMappings; + + public DefaultBiPicker(Function leftMapping, PickerType pickerType, + Function rightMapping) { + super(rightMapping, pickerType); + this.leftMappings = new Function[] { leftMapping }; + } + + private DefaultBiPicker(Function[] leftMappings, PickerType[] pickerTypes, + Function[] rightMappings) { + super(rightMappings, pickerTypes); + this.leftMappings = leftMappings; + } + + public static DefaultBiPicker merge(List> pickerList) { + if (pickerList.size() == 1) { + return pickerList.get(0); + } + return pickerList.stream().reduce(NONE, DefaultBiPicker::and); + } + + @Override + public DefaultBiPicker and(BiPicker otherPicker) { + var castPicker = (DefaultBiPicker) otherPicker; + var pickerCount = getJoinerCount(); + var castPickerCount = castPicker.getJoinerCount(); + var newPickerCount = pickerCount + castPickerCount; + var newPickerTypes = Arrays.copyOf(this.pickerTypes, newPickerCount); + var newLeftMappings = Arrays.copyOf(this.leftMappings, newPickerCount); + var newRightMappings = Arrays.copyOf(this.rightMappings, newPickerCount); + for (var i = 0; i < castPickerCount; i++) { + var newJoinerIndex = i + pickerCount; + newPickerTypes[newJoinerIndex] = castPicker.getPickerType(i); + newLeftMappings[newJoinerIndex] = castPicker.getLeftMapping(i); + newRightMappings[newJoinerIndex] = castPicker.getRightMapping(i); + } + return new DefaultBiPicker(newLeftMappings, newPickerTypes, newRightMappings); + } + + public Function getLeftMapping(int index) { + return (Function) leftMappings[index]; + } + + public boolean matches(A a, B b) { + var pickerCount = getJoinerCount(); + for (var i = 0; i < pickerCount; i++) { + var pickerType = getPickerType(i); + var leftMapping = getLeftMapping(i).apply(a); + var rightMapping = getRightMapping(i).apply(b); + if (!pickerType.matches(leftMapping, rightMapping)) { + return false; + } + } + return true; + } + + @Override + public boolean equals(Object o) { + return o instanceof DefaultBiPicker other + && Arrays.equals(pickerTypes, other.pickerTypes) + && Arrays.equals(leftMappings, other.leftMappings) + && Arrays.equals(rightMappings, other.rightMappings); + } + + @Override + public int hashCode() { + var hashCode = 31; + hashCode = hashCode * 31 + Arrays.hashCode(pickerTypes); + hashCode = hashCode * 31 + Arrays.hashCode(leftMappings); + hashCode = hashCode * 31 + Arrays.hashCode(rightMappings); + return hashCode; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java new file mode 100644 index 00000000000..20b683ee165 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.Objects; +import java.util.function.BiPredicate; + +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record FilteringBiPicker(BiPredicate filter) + implements + BiPicker { + + @Override + public FilteringBiPicker and(BiPicker otherPicker) { + var castJoiner = (FilteringBiPicker) otherPicker; + return new FilteringBiPicker<>(filter.and(castJoiner.filter())); + } + + @Override + public boolean equals(Object o) { + return o instanceof FilteringBiPicker other + && Objects.equals(filter, other.filter); + } + + @Override + public int hashCode() { + return Objects.hashCode(filter); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java new file mode 100644 index 00000000000..5217edb2357 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java @@ -0,0 +1,46 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.Objects; +import java.util.function.BiPredicate; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +@SuppressWarnings({ "unchecked", "rawtypes" }) +public enum PickerType { + + EQUAL(Objects::equals), + LESS_THAN((a, b) -> ((Comparable) a).compareTo(b) < 0), + LESS_THAN_OR_EQUAL((a, b) -> ((Comparable) a).compareTo(b) <= 0), + GREATER_THAN((a, b) -> ((Comparable) a).compareTo(b) > 0), + GREATER_THAN_OR_EQUAL((a, b) -> ((Comparable) a).compareTo(b) >= 0); + + private final BiPredicate matcher; + + PickerType(BiPredicate matcher) { + this.matcher = matcher; + } + + public PickerType flip() { + return switch (this) { + case LESS_THAN -> GREATER_THAN; + case LESS_THAN_OR_EQUAL -> GREATER_THAN_OR_EQUAL; + case GREATER_THAN -> LESS_THAN; + case GREATER_THAN_OR_EQUAL -> LESS_THAN_OR_EQUAL; + default -> throw new IllegalStateException("The joinerType (%s) cannot be flipped." + .formatted(this)); + }; + } + + public boolean matches(Object left, Object right) { + try { + return matcher.test(left, right); + } catch (Exception e) { + throw new IllegalStateException( + "Joiner (%s) threw an exception matching left (%s) and right (%s) objects." + .formatted(this, left, right), + e); + } + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java new file mode 100644 index 00000000000..c7eda14ee4e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java @@ -0,0 +1,42 @@ +package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; + +import java.util.Objects; +import java.util.function.BiPredicate; + +import ai.timefold.solver.core.api.function.TriPredicate; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record SolutionBasedFilteringBiPicker(BiPredicate tupleFilter, + TriPredicate valueFilter) + implements + BiPicker { + + public static SolutionBasedFilteringBiPicker + wrap(FilteringBiPicker filteringBiPicker, TriPredicate valueFilter) { + return new SolutionBasedFilteringBiPicker<>(filteringBiPicker.filter(), valueFilter); + } + + @Override + public SolutionBasedFilteringBiPicker and(BiPicker otherPicker) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean equals(Object o) { + return o instanceof SolutionBasedFilteringBiPicker other + && Objects.equals(tupleFilter, other.tupleFilter) + && Objects.equals(valueFilter, other.valueFilter); + } + + @Override + public int hashCode() { + var hashCode = 31; + hashCode = hashCode * 31 + Objects.hashCode(tupleFilter); + hashCode = hashCode * 31 + Objects.hashCode(valueFilter); + return hashCode; + } + +} 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 index e735bad8cfd..74d86fffecb 100644 --- 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 @@ -1,80 +1,61 @@ 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.common.pickers.FilteringBiPicker; +import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.SolutionBasedFilteringBiPicker; 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.impl.move.streams.maybeapi.stream.pickers.BiPicker; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @NullMarked -public class ChangeMoveProvider +public final class ChangeMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; - private final Predicate<@Nullable Value_> valueFilter; - private final BiPredicate entityAndValueFilter; + private final @Nullable Predicate<@Nullable Value_> valueFilter; + private final BiPicker picker; public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.valueFilter = variableMetaModel.allowsUnassigned() ? null : Objects::nonNull; var variableDescriptor = ((DefaultPlanningVariableMetaModel) variableMetaModel) .variableDescriptor(); - this.valueFilter = variableMetaModel.allowsUnassigned() ? value -> true : Objects::nonNull; - this.entityAndValueFilter = (entity, value) -> variableDescriptor.getValue(entity) != value; + var basePicker = new FilteringBiPicker((entity, value) -> { + var oldValue = variableDescriptor.getValue(entity); + return !Objects.equals(oldValue, value); + }); + var valueRangeDescriptor = variableDescriptor.getValueRangeDescriptor(); + this.picker = variableMetaModel.hasValueRangeOnEntity() + ? SolutionBasedFilteringBiPicker. wrap(basePicker, + (solution, entity, value) -> { + // TODO Optimize this by caching the result when possible. + var valueRange = valueRangeDescriptor.extractValueRange(solution, entity); + return valueRange.contains(value); + }) + : basePicker; } @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); + var entityStream = defaultMoveStreamFactory.enumerate(variableMetaModel.entity().type()); + var valueStream = defaultMoveStreamFactory.enumerate(variableMetaModel.type()); + if (valueFilter != null) { + valueStream = valueStream.filter(valueFilter); + } return moveStreamFactory.pick(entityStream) - .pick(valueStream, this::acceptEntityValuePair) + .pick(valueStream, picker) .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/maybeapi/stream/MoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/MoveStreamFactory.java index 596d6d31ec0..2ece6cd9eb9 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,8 +8,6 @@ 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; @@ -50,23 +48,6 @@ public interface MoveStreamFactory { */ 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 - } - 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/UniMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/UniMoveStream.java index 453c1bd508e..a1445d01616 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 @@ -2,6 +2,8 @@ import java.util.function.BiPredicate; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; + import org.jspecify.annotations.NullMarked; @NullMarked @@ -13,4 +15,23 @@ default BiMoveStream pick(UniDataStream uniDa BiMoveStream pick(UniDataStream uniDataStream, BiPredicate filter); + @SuppressWarnings("unchecked") + default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker) { + return pick(uniDataStream, new BiPicker[] { picker }); + } + + @SuppressWarnings("unchecked") + default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker1, + BiPicker picker2) { + return pick(uniDataStream, new BiPicker[] { picker1, picker2 }); + } + + @SuppressWarnings("unchecked") + default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker1, + BiPicker picker2, BiPicker picker3) { + return pick(uniDataStream, new BiPicker[] { picker1, picker2, picker3 }); + } + + BiMoveStream pick(UniDataStream uniDataStream, BiPicker... pickers); + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java new file mode 100644 index 00000000000..30785270584 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java @@ -0,0 +1,20 @@ +package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; + +import ai.timefold.solver.core.api.score.stream.Joiners; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; +import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniMoveStream; + +import org.jspecify.annotations.NullMarked; + +/** + * Created with {@link Joiners}. + * Used by {@link UniMoveStream#pick(UniDataStream, BiPicker[])} , ... + * + * @see Joiners + */ +@NullMarked +public interface BiPicker { + + BiPicker and(BiPicker otherPicker); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java new file mode 100644 index 00000000000..8465cdfbc77 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java @@ -0,0 +1,243 @@ +package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; + +import java.util.function.BiPredicate; +import java.util.function.Function; + +import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.DefaultBiPicker; +import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.FilteringBiPicker; +import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.PickerType; +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.impl.util.ConstantLambdaUtils; + +import org.jspecify.annotations.NullMarked; + +/** + * Creates a {@link BiPicker}, ... instance + * for use in {@link UniMoveStream#pick(UniDataStream, BiPicker[])}, ... + */ +@NullMarked +public final class Pickers { + + // ************************************************************************ + // BiJoiner + // ************************************************************************ + + /** + * As defined by {@link #equal(Function)} with {@link Function#identity()} as the argument. + * + * @param the type of both objects + */ + public static BiPicker equal() { + return equal(ConstantLambdaUtils.identity()); + } + + /** + * As defined by {@link #equal(Function, Function)} with both arguments using the same mapping. + * + * @param the type of both objects + * @param the type of the property to compare + * @param mapping mapping function to apply to both A and B + */ + public static BiPicker equal(Function mapping) { + return equal(mapping, mapping); + } + + /** + * Joins every A and B that share a property. + * These are exactly the pairs where {@code leftMapping.apply(A).equals(rightMapping.apply(B))}. + * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with both leftMapping and rightMapping being {@code Person::getAge}, + * this joiner will produce pairs {@code (Ann, Ann), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Eric)}. + * + * @param the type of object on the right + * @param the type of the property to compare + * @param leftMapping mapping function to apply to A + * @param rightMapping mapping function to apply to B + */ + public static BiPicker equal(Function leftMapping, + Function rightMapping) { + return new DefaultBiPicker<>(leftMapping, PickerType.EQUAL, rightMapping); + } + + /** + * As defined by {@link #lessThan(Function, Function)} with both arguments using the same mapping. + * + * @param mapping mapping function to apply + * @param the type of both objects + * @param the type of the property to compare + */ + public static > BiPicker lessThan(Function mapping) { + return lessThan(mapping, mapping); + } + + /** + * Joins every A and B where a value of property on A is less than the value of a property on B. + * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) < 0}. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with both leftMapping and rightMapping being {@code Person::getAge}, + * this joiner will produce pairs {@code (Ann, Beth), (Eric, Beth)}. + * + * @param leftMapping mapping function to apply to A + * @param rightMapping mapping function to apply to B + * @param the type of object on the left + * @param the type of object on the right + * @param the type of the property to compare + */ + public static > BiPicker lessThan(Function leftMapping, + Function rightMapping) { + return new DefaultBiPicker<>(leftMapping, PickerType.LESS_THAN, rightMapping); + } + + /** + * As defined by {@link #lessThanOrEqual(Function, Function)} with both arguments using the same mapping. + * + * @param mapping mapping function to apply + * @param the type of both objects + * @param the type of the property to compare + */ + public static > BiPicker lessThanOrEqual(Function mapping) { + return lessThanOrEqual(mapping, mapping); + } + + /** + * Joins every A and B where a value of property on A is less than or equal to the value of a property on B. + * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) <= 0}. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with both leftMapping and rightMapping being {@code Person::getAge}, + * this joiner will produce pairs + * {@code (Ann, Ann), (Ann, Beth), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Beth), (Eric, Eric)}. + * + * @param leftMapping mapping function to apply to A + * @param rightMapping mapping function to apply to B + * @param the type of object on the left + * @param the type of object on the right + * @param the type of the property to compare + */ + public static > BiPicker + lessThanOrEqual(Function leftMapping, Function rightMapping) { + return new DefaultBiPicker<>(leftMapping, PickerType.LESS_THAN_OR_EQUAL, rightMapping); + } + + /** + * As defined by {@link #greaterThan(Function, Function)} with both arguments using the same mapping. + * + * @param mapping mapping function to apply + * @param the type of both objects + * @param the type of the property to compare + */ + public static > BiPicker greaterThan(Function mapping) { + return greaterThan(mapping, mapping); + } + + /** + * Joins every A and B where a value of property on A is greater than the value of a property on B. + * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) > 0}. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with both leftMapping and rightMapping being {@code Person::getAge}, + * this joiner will produce pairs {@code (Beth, Ann), (Beth, Eric)}. + * + * @param leftMapping mapping function to apply to A + * @param rightMapping mapping function to apply to B + * @param the type of object on the left + * @param the type of object on the right + * @param the type of the property to compare + */ + public static > BiPicker greaterThan(Function leftMapping, + Function rightMapping) { + return new DefaultBiPicker<>(leftMapping, PickerType.GREATER_THAN, rightMapping); + } + + /** + * As defined by {@link #greaterThanOrEqual(Function, Function)} with both arguments using the same mapping. + * + * @param mapping mapping function to apply + * @param the type of both objects + * @param the type of the property to compare + */ + public static > BiPicker + greaterThanOrEqual(Function mapping) { + return greaterThanOrEqual(mapping, mapping); + } + + /** + * Joins every A and B where a value of property on A is greater than or equal to the value of a property on B. + * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) >= 0}. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with both leftMapping and rightMapping being {@code Person::getAge}, + * this joiner will produce pairs + * {@code (Ann, Ann), (Ann, Eric), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Ann), (Eric, Eric)}. + * + * @param leftMapping mapping function to apply to A + * @param rightMapping mapping function to apply to B + * @param the type of object on the left + * @param the type of object on the right + * @param the type of the property to compare + */ + public static > BiPicker + greaterThanOrEqual(Function leftMapping, Function rightMapping) { + return new DefaultBiPicker<>(leftMapping, PickerType.GREATER_THAN_OR_EQUAL, rightMapping); + } + + /** + * Applies a filter to the joined tuple, + * the tuple returning false will be ignored. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with filter being {@code age == 20}, + * this joiner will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. + * + * @param filter filter to apply + * @param type of the first fact in the tuple + * @param type of the second fact in the tuple + */ + public static BiPicker filtering(BiPredicate filter) { + return new FilteringBiPicker<>(filter); + } + + /** + * Joins every A and B that overlap for an interval which is specified by a start and end property on both A and B. + * These are exactly the pairs where {@code A.start < B.end} and {@code A.end > B.start}. + *

+ * For example, on a cartesian product of list + * {@code [Ann(start=08:00, end=14:00), Beth(start=12:00, end=18:00), Eric(start=16:00, end=22:00)]} + * with startMapping being {@code Person::getStart} and endMapping being {@code Person::getEnd}, + * this joiner will produce pairs + * {@code (Ann, Ann), (Ann, Beth), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Beth), (Eric, Eric)}. + * + * @param startMapping maps the argument to the start point of its interval (inclusive) + * @param endMapping maps the argument to the end point of its interval (exclusive) + * @param the type of both the first and second argument + * @param the type used to define the interval, comparable + */ + public static > BiPicker overlapping(Function startMapping, + Function endMapping) { + return overlapping(startMapping, endMapping, startMapping, endMapping); + } + + /** + * As defined by {@link #overlapping(Function, Function)}. + * + * @param leftStartMapping maps the first argument to its interval start point (inclusive) + * @param leftEndMapping maps the first argument to its interval end point (exclusive) + * @param rightStartMapping maps the second argument to its interval start point (inclusive) + * @param rightEndMapping maps the second argument to its interval end point (exclusive) + * @param the type of the first argument + * @param the type of the second argument + * @param the type used to define the interval, comparable + */ + public static > BiPicker overlapping( + Function leftStartMapping, Function leftEndMapping, + Function rightStartMapping, Function rightEndMapping) { + return Pickers.lessThan(leftStartMapping, rightEndMapping) + .and(Pickers.greaterThan(leftEndMapping, rightStartMapping)); + } + + private Pickers() { + } + +} 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; From 321cc77ff336de6d7e613595174596225cae00a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 10:19:42 +0200 Subject: [PATCH 02/22] Move things around to their intended packages --- .../{generic/move => maybeapi/generic}/AbstractMove.java | 2 +- .../move => maybeapi/generic}/ChainedChangeMove.java | 2 +- .../{generic/move => maybeapi/generic}/ChangeMove.java | 2 +- .../move => maybeapi/generic}/ListAssignMove.java | 2 +- .../move => maybeapi/generic}/ListChangeMove.java | 2 +- .../move => maybeapi/generic}/ListUnassignMove.java | 2 +- .../generic/provider/ChangeMoveProvider.java | 8 ++++---- .../move/streams/maybeapi/stream/pickers/Pickers.java | 6 +++--- .../{generic/common => }/pickers/AbstractPicker.java | 2 +- .../{generic/common => }/pickers/BiPickerComber.java | 2 +- .../{generic/common => }/pickers/DefaultBiPicker.java | 2 +- .../{generic/common => }/pickers/FilteringBiPicker.java | 2 +- .../streams/{generic/common => }/pickers/PickerType.java | 2 +- .../pickers/SolutionBasedFilteringBiPicker.java | 2 +- .../core/impl/move/MoveStreamsBasedLocalSearchTest.java | 2 +- .../streams/maybeapi/provider/ChangeMoveProviderTest.java | 4 ++-- .../solver/core/impl/solver/DefaultSolverTest.java | 2 +- .../dependent/DependencyValuesShadowVariableTest.java | 2 +- .../follower/FollowerValuesShadowVariableTest.java | 2 +- .../follower_set/FollowerValuesShadowVariableTest.java | 2 +- 20 files changed, 26 insertions(+), 26 deletions(-) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/AbstractMove.java (98%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/ChainedChangeMove.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/ChangeMove.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/ListAssignMove.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/ListChangeMove.java (99%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/move => maybeapi/generic}/ListUnassignMove.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{ => maybeapi}/generic/provider/ChangeMoveProvider.java (90%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/AbstractPicker.java (93%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/BiPickerComber.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/DefaultBiPicker.java (97%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/FilteringBiPicker.java (91%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/PickerType.java (95%) rename core/src/main/java/ai/timefold/solver/core/impl/move/streams/{generic/common => }/pickers/SolutionBasedFilteringBiPicker.java (95%) 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/generic/provider/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java similarity index 90% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/provider/ChangeMoveProvider.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java index 74d86fffecb..0298825ee25 100644 --- 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/maybeapi/generic/provider/ChangeMoveProvider.java @@ -1,17 +1,17 @@ -package ai.timefold.solver.core.impl.move.streams.generic.provider; +package ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider; import java.util.Objects; import java.util.function.Predicate; 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.common.pickers.FilteringBiPicker; -import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.SolutionBasedFilteringBiPicker; -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.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.pickers.BiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.SolutionBasedFilteringBiPicker; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import org.jspecify.annotations.NullMarked; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java index 8465cdfbc77..38579bfe102 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java @@ -3,11 +3,11 @@ import java.util.function.BiPredicate; import java.util.function.Function; -import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.DefaultBiPicker; -import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.FilteringBiPicker; -import ai.timefold.solver.core.impl.move.streams.generic.common.pickers.PickerType; 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.impl.move.streams.pickers.DefaultBiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.PickerType; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; import org.jspecify.annotations.NullMarked; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java similarity index 93% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java index 8518c1fcf91..0c3f7175362 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/AbstractPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; import java.util.function.Function; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java index 0d8511a7d3a..97b0bf7e521 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/BiPickerComber.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.ArrayList; import java.util.List; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java similarity index 97% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java index 9a250644e8e..38453d75541 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/DefaultBiPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Arrays; import java.util.List; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java similarity index 91% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java index 20b683ee165..13e19455790 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/FilteringBiPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; import java.util.function.BiPredicate; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java similarity index 95% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java index 5217edb2357..90574ed473a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/PickerType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; import java.util.function.BiPredicate; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java similarity index 95% rename from core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java rename to core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java index c7eda14ee4e..fc624ed8156 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/generic/common/pickers/SolutionBasedFilteringBiPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.move.streams.generic.common.pickers; +package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; import java.util.function.BiPredicate; 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/maybeapi/provider/ChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/streams/maybeapi/provider/ChangeMoveProviderTest.java index 72726cdab60..caa5b6d41f1 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 @@ -9,8 +9,8 @@ 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.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; 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/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; From cbef71aa56e1c0812ee1d30416f0d3329d49cec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 10:22:37 +0200 Subject: [PATCH 03/22] Remove ForEach which is no longer used --- .../dataset/AbstractForEachDataStream.java | 2 +- .../streams/dataset/DataStreamFactory.java | 6 --- .../ForEachFromSolutionDataStream.java | 49 ------------------- 3 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/dataset/ForEachFromSolutionDataStream.java 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..5a3e98c8ab2 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,7 +15,7 @@ abstract sealed class AbstractForEachDataStream extends AbstractUniDataStream implements TupleSource - permits ForEachIncludingPinnedDataStream, ForEachExcludingPinnedDataStream, ForEachFromSolutionDataStream { + permits ForEachIncludingPinnedDataStream, ForEachExcludingPinnedDataStream { protected final Class forEachClass; 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..098a13f8ef8 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; @@ -51,11 +50,6 @@ public UniDataStream forEachExcludingPinned(Class sourceCla } - 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/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"; - } - -} From 106aadefb751ccbb6aab949855971851def51685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 10:26:52 +0200 Subject: [PATCH 04/22] Naming --- .../maybeapi/stream/pickers/Pickers.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java index 38579bfe102..244762ba94f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java @@ -15,12 +15,15 @@ /** * Creates a {@link BiPicker}, ... instance * for use in {@link UniMoveStream#pick(UniDataStream, BiPicker[])}, ... + * + * TODO needs a better name, this suggests that this actually picks something; + * joiner is also a bad name, as it could be confused with joiners in CS, which are different. */ @NullMarked public final class Pickers { // ************************************************************************ - // BiJoiner + // BiPicker // ************************************************************************ /** @@ -48,7 +51,7 @@ public static BiPicker equal(Function mapping * These are exactly the pairs where {@code leftMapping.apply(A).equals(rightMapping.apply(B))}. * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with both leftMapping and rightMapping being {@code Person::getAge}, - * this joiner will produce pairs {@code (Ann, Ann), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Eric)}. + * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Eric)}. * * @param the type of object on the right * @param the type of the property to compare @@ -77,7 +80,7 @@ public static > BiPicker lessTh *

* For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with both leftMapping and rightMapping being {@code Person::getAge}, - * this joiner will produce pairs {@code (Ann, Beth), (Eric, Beth)}. + * this picker will produce pairs {@code (Ann, Beth), (Eric, Beth)}. * * @param leftMapping mapping function to apply to A * @param rightMapping mapping function to apply to B @@ -107,7 +110,7 @@ public static > BiPicker lessTh *

* For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with both leftMapping and rightMapping being {@code Person::getAge}, - * this joiner will produce pairs + * this picker will produce pairs * {@code (Ann, Ann), (Ann, Beth), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Beth), (Eric, Eric)}. * * @param leftMapping mapping function to apply to A @@ -138,7 +141,7 @@ public static > BiPicker greate *

* For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with both leftMapping and rightMapping being {@code Person::getAge}, - * this joiner will produce pairs {@code (Beth, Ann), (Beth, Eric)}. + * this picker will produce pairs {@code (Beth, Ann), (Beth, Eric)}. * * @param leftMapping mapping function to apply to A * @param rightMapping mapping function to apply to B @@ -169,7 +172,7 @@ public static > BiPicker gre *

* For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with both leftMapping and rightMapping being {@code Person::getAge}, - * this joiner will produce pairs + * this picker will produce pairs * {@code (Ann, Ann), (Ann, Eric), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Ann), (Eric, Eric)}. * * @param leftMapping mapping function to apply to A @@ -184,12 +187,12 @@ public static > BiPicker gre } /** - * Applies a filter to the joined tuple, + * Applies a filter to the picked tuple, * the tuple returning false will be ignored. *

* For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with filter being {@code age == 20}, - * this joiner will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. + * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. * * @param filter filter to apply * @param type of the first fact in the tuple @@ -206,7 +209,7 @@ public static BiPicker filtering(BiPredicate filter) { * For example, on a cartesian product of list * {@code [Ann(start=08:00, end=14:00), Beth(start=12:00, end=18:00), Eric(start=16:00, end=22:00)]} * with startMapping being {@code Person::getStart} and endMapping being {@code Person::getEnd}, - * this joiner will produce pairs + * this picker will produce pairs * {@code (Ann, Ann), (Ann, Beth), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Beth), (Eric, Eric)}. * * @param startMapping maps the argument to the start point of its interval (inclusive) From 777d21c59842ed822ac3493857f0ad055beb2b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 10:29:03 +0200 Subject: [PATCH 05/22] Package desc --- .../solver/core/impl/move/streams/maybeapi/package-info.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/package-info.java 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 From 82b2c236442dc24e4afa463129c9d1cde272438a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 12:05:44 +0200 Subject: [PATCH 06/22] Better interface for the predicate --- .../core/impl/move/director/MoveDirector.java | 39 +++++++++++++++++ .../generic/provider/ChangeMoveProvider.java | 28 +++---------- .../maybeapi/stream/pickers/Pickers.java | 9 ++-- .../move/streams/pickers/BiPickerComber.java | 41 +++++++++--------- .../streams/pickers/FilteringBiPicker.java | 21 +++++++--- .../SolutionBasedFilteringBiPicker.java | 42 ------------------- .../core/preview/api/move/SolutionView.java | 34 +++++++++++++++ 7 files changed, 122 insertions(+), 92 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java 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..58020a568aa 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.valuerange.descriptor.ValueRangeDescriptor; 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,11 +186,48 @@ protected static ElementPosition getPositionOf(Inne .getElementPosition(value); } + @Override + public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value) { + if (value == null) { + if (variableMetaModel instanceof PlanningVariableMetaModel basicVariableMetaModel) { + return basicVariableMetaModel.allowsUnassigned(); + } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { + return listVariableMetaModel.allowsUnassignedValues(); + } else { + throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." + .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), + PlanningListVariableMetaModel.class.getSimpleName())); + } + } + var valueRangeDescriptor = extractValueRangeDescriptor(variableMetaModel); + 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)); + } + // TODO Optimize this by caching the lookup on a potentially very long list. + var valueRange = valueRangeDescriptor.extractValueRange(backingScoreDirector.getWorkingSolution(), entity); + return valueRange.contains(value); + } + @Override public final @Nullable T rebase(@Nullable T problemFactOrPlanningEntity) { return externalScoreDirector.lookUpWorkingObject(problemFactOrPlanningEntity); } + private static ValueRangeDescriptor + extractValueRangeDescriptor(GenuineVariableMetaModel variableMetaModel) { + if (variableMetaModel instanceof PlanningVariableMetaModel variableMetaModel_) { + return extractVariableDescriptor(variableMetaModel_).getValueRangeDescriptor(); + } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { + return extractVariableDescriptor(listVariableMetaModel).getValueRangeDescriptor(); + } else { + throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." + .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), + PlanningListVariableMetaModel.class.getSimpleName())); + } + } + private static BasicVariableDescriptor extractVariableDescriptor(PlanningVariableMetaModel variableMetaModel) { return ((DefaultPlanningVariableMetaModel) variableMetaModel).variableDescriptor(); 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 index 0298825ee25..0e5e4b993c8 100644 --- 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 @@ -1,9 +1,7 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.generic.provider; import java.util.Objects; -import java.util.function.Predicate; -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.maybeapi.generic.ChangeMove; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveProducer; @@ -11,7 +9,6 @@ import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.MoveStreamFactory; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; -import ai.timefold.solver.core.impl.move.streams.pickers.SolutionBasedFilteringBiPicker; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -22,27 +19,17 @@ public final class ChangeMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; - private final @Nullable Predicate<@Nullable Value_> valueFilter; private final BiPicker picker; public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - this.valueFilter = variableMetaModel.allowsUnassigned() ? null : Objects::nonNull; - var variableDescriptor = ((DefaultPlanningVariableMetaModel) variableMetaModel) - .variableDescriptor(); - var basePicker = new FilteringBiPicker((entity, value) -> { - var oldValue = variableDescriptor.getValue(entity); - return !Objects.equals(oldValue, value); + this.picker = new FilteringBiPicker((solutionView, entity, value) -> { + Value_ oldValue = solutionView.getValue(variableMetaModel, entity); + if (Objects.equals(oldValue, value)) { + return false; + } + return solutionView.isValueInRange(variableMetaModel, entity, value); }); - var valueRangeDescriptor = variableDescriptor.getValueRangeDescriptor(); - this.picker = variableMetaModel.hasValueRangeOnEntity() - ? SolutionBasedFilteringBiPicker. wrap(basePicker, - (solution, entity, value) -> { - // TODO Optimize this by caching the result when possible. - var valueRange = valueRangeDescriptor.extractValueRange(solution, entity); - return valueRange.contains(value); - }) - : basePicker; } @Override @@ -50,9 +37,6 @@ public MoveProducer apply(MoveStreamFactory moveStreamFact var defaultMoveStreamFactory = (DefaultMoveStreamFactory) moveStreamFactory; var entityStream = defaultMoveStreamFactory.enumerate(variableMetaModel.entity().type()); var valueStream = defaultMoveStreamFactory.enumerate(variableMetaModel.type()); - if (valueFilter != null) { - valueStream = valueStream.filter(valueFilter); - } return moveStreamFactory.pick(entityStream) .pick(valueStream, picker) .asMove((solution, entity, value) -> new ChangeMove<>(variableMetaModel, entity, value)); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java index 244762ba94f..519430b3f10 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; -import java.util.function.BiPredicate; import java.util.function.Function; 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.impl.move.streams.pickers.DefaultBiPicker; import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker.FilteringBiPickerPredicate; import ai.timefold.solver.core.impl.move.streams.pickers.PickerType; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; @@ -17,7 +17,7 @@ * for use in {@link UniMoveStream#pick(UniDataStream, BiPicker[])}, ... * * TODO needs a better name, this suggests that this actually picks something; - * joiner is also a bad name, as it could be confused with joiners in CS, which are different. + * joiner is also a bad name, as it could be confused with joiners in CS, which are different. */ @NullMarked public final class Pickers { @@ -193,12 +193,15 @@ public static > BiPicker gre * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} * with filter being {@code age == 20}, * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. + *

+ * The first argument to the predicate allows the filter to access the working solution state. * * @param filter filter to apply + * @param the solution type * @param type of the first fact in the tuple * @param type of the second fact in the tuple */ - public static BiPicker filtering(BiPredicate filter) { + public static BiPicker filtering(FilteringBiPickerPredicate filter) { return new FilteringBiPicker<>(filter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java index 97b0bf7e521..c48384763bf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java @@ -2,9 +2,10 @@ import java.util.ArrayList; import java.util.List; -import java.util.function.BiPredicate; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; +import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker.FilteringBiPickerPredicate; +import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -18,20 +19,22 @@ * @param mergedFiltering null if not applicable */ @NullMarked -public record BiPickerComber(DefaultBiPicker mergedPicker, @Nullable BiPredicate mergedFiltering) { +public record BiPickerComber(DefaultBiPicker mergedPicker, + @Nullable FilteringBiPickerPredicate mergedFiltering) { - public static BiPickerComber comb(BiPicker[] pickers) { + @SuppressWarnings("unchecked") + public static BiPickerComber comb(BiPicker[] pickers) { var defaultPickerList = new ArrayList>(pickers.length); - var filteringList = new ArrayList>(pickers.length); + var filteringList = new ArrayList>(pickers.length); var indexOfFirstFilter = -1; // Make sure all indexing pickers, if any, come before filtering pickers. This is necessary for performance. for (var i = 0; i < pickers.length; i++) { var picker = pickers[i]; - if (picker instanceof FilteringBiPicker filteringBiPicker) { + if (picker instanceof FilteringBiPicker filteringBiPicker) { // From now on, only allow filtering joiners. indexOfFirstFilter = i; - filteringList.add(filteringBiPicker.filter()); + filteringList.add((FilteringBiPickerPredicate) filteringBiPicker.filter()); } else if (picker instanceof DefaultBiPicker defaultBiPicker) { if (indexOfFirstFilter >= 0) { throw new IllegalStateException(""" @@ -50,23 +53,21 @@ Maybe reorder the pickers such that filtering() pickers are later in the paramet return new BiPickerComber<>(mergedPicker, mergedFiltering); } - private static @Nullable BiPredicate mergeFiltering(List> filteringList) { + private static @Nullable FilteringBiPickerPredicate + mergeFiltering(List> filteringList) { if (filteringList.isEmpty()) { return null; + } else if (filteringList.size() == 1) { + return filteringList.get(0); } - return switch (filteringList.size()) { - case 1 -> filteringList.get(0); - case 2 -> filteringList.get(0).and(filteringList.get(1)); - default -> - // Avoid predicate.and() when more than 2 predicates for debugging and potentially performance - (A a, B b) -> { - for (var predicate : filteringList) { - if (!predicate.test(a, b)) { - return false; - } - } - return true; - }; + // Avoid predicate.and() for debugging and potentially performance. + return (SolutionView solutionView, A a, B b) -> { + for (var predicate : filteringList) { + if (!predicate.test(solutionView, a, b)) { + return false; + } + } + return true; }; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java index 13e19455790..6089dea6e31 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java @@ -1,26 +1,27 @@ package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; -import java.util.function.BiPredicate; +import ai.timefold.solver.core.api.function.TriPredicate; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; +import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; @NullMarked -public record FilteringBiPicker(BiPredicate filter) +public record FilteringBiPicker(FilteringBiPickerPredicate filter) implements BiPicker { @Override - public FilteringBiPicker and(BiPicker otherPicker) { - var castJoiner = (FilteringBiPicker) otherPicker; + public FilteringBiPicker and(BiPicker otherPicker) { + var castJoiner = (FilteringBiPicker) otherPicker; return new FilteringBiPicker<>(filter.and(castJoiner.filter())); } @Override public boolean equals(Object o) { - return o instanceof FilteringBiPicker other + return o instanceof FilteringBiPicker other && Objects.equals(filter, other.filter); } @@ -29,4 +30,14 @@ public int hashCode() { return Objects.hashCode(filter); } + @FunctionalInterface + public interface FilteringBiPickerPredicate + extends TriPredicate, A, B> { + + default FilteringBiPickerPredicate and(FilteringBiPickerPredicate 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/pickers/SolutionBasedFilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java deleted file mode 100644 index fc624ed8156..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/SolutionBasedFilteringBiPicker.java +++ /dev/null @@ -1,42 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.Objects; -import java.util.function.BiPredicate; - -import ai.timefold.solver.core.api.function.TriPredicate; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public record SolutionBasedFilteringBiPicker(BiPredicate tupleFilter, - TriPredicate valueFilter) - implements - BiPicker { - - public static SolutionBasedFilteringBiPicker - wrap(FilteringBiPicker filteringBiPicker, TriPredicate valueFilter) { - return new SolutionBasedFilteringBiPicker<>(filteringBiPicker.filter(), valueFilter); - } - - @Override - public SolutionBasedFilteringBiPicker and(BiPicker otherPicker) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean equals(Object o) { - return o instanceof SolutionBasedFilteringBiPicker other - && Objects.equals(tupleFilter, other.tupleFilter) - && Objects.equals(valueFilter, other.valueFilter); - } - - @Override - public int hashCode() { - var hashCode = 31; - hashCode = hashCode * 31 + Objects.hashCode(tupleFilter); - hashCode = hashCode * 31 + Objects.hashCode(valueFilter); - return hashCode; - } - -} 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); + } From 34f8a540eb09b9fe7c31e18e0a9839413f6d150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 23 Jun 2025 12:15:31 +0200 Subject: [PATCH 07/22] Make the complexity optional --- .../maybeapi/stream/pickers/Pickers.java | 20 +++++++++++ .../streams/pickers/FilteringBiPicker.java | 35 +++++++++++++------ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java index 519430b3f10..eec8f4dd767 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; +import java.util.function.BiPredicate; import java.util.function.Function; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; @@ -186,6 +187,25 @@ public static > BiPicker gre return new DefaultBiPicker<>(leftMapping, PickerType.GREATER_THAN_OR_EQUAL, rightMapping); } + /** + * Applies a filter to the picked tuple, + * the tuple returning false will be ignored. + *

+ * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} + * with filter being {@code age == 20}, + * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. + *

+ * If you wish to access the working solution state, + * use {@link #filtering(FilteringBiPickerPredicate)} instead. + * + * @param filter filter to apply + * @param type of the first fact in the tuple + * @param type of the second fact in the tuple + */ + public static BiPicker filtering(BiPredicate filter) { + return FilteringBiPicker.of(filter); + } + /** * Applies a filter to the picked tuple, * the tuple returning false will be ignored. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java index 6089dea6e31..47b99a4390e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.move.streams.pickers; import java.util.Objects; +import java.util.function.BiPredicate; import ai.timefold.solver.core.api.function.TriPredicate; import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; @@ -13,23 +14,16 @@ public record FilteringBiPicker(FilteringBiPickerPredicate { + public static FilteringBiPicker of(BiPredicate filter) { + return new FilteringBiPicker<>(new WrappedPredicate<>(filter)); + } + @Override public FilteringBiPicker and(BiPicker otherPicker) { var castJoiner = (FilteringBiPicker) otherPicker; return new FilteringBiPicker<>(filter.and(castJoiner.filter())); } - @Override - public boolean equals(Object o) { - return o instanceof FilteringBiPicker other - && Objects.equals(filter, other.filter); - } - - @Override - public int hashCode() { - return Objects.hashCode(filter); - } - @FunctionalInterface public interface FilteringBiPickerPredicate extends TriPredicate, A, B> { @@ -40,4 +34,23 @@ default FilteringBiPickerPredicate and(FilteringBiPickerPredica } + /** + * Exists to make sure that node sharing still works. + * Instances of this class need to equal if the underlying predicate is equal. + */ + private record WrappedPredicate(BiPredicate predicate) + implements + FilteringBiPickerPredicate { + + private WrappedPredicate(BiPredicate predicate) { + this.predicate = Objects.requireNonNull(predicate); + } + + @Override + public boolean test(SolutionView solutionView, A a, B b) { + return predicate.test(a, b); + } + + } + } From 0c4d0c600114719415a6ffbdbe4442253e773158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 09:10:14 +0200 Subject: [PATCH 08/22] Tests finally pass --- .../bavet/uni/AbstractForEachUniNode.java | 3 +- .../bavet/uni/ForEachFromSolutionUniNode.java | 78 ----- .../ForEachIncludingUnassignedUniNode.java | 5 +- .../descriptor/ListVariableDescriptor.java | 3 + .../solver/core/impl/move/MoveRepository.java | 3 +- .../move/MoveSelectorBasedMoveRepository.java | 3 +- .../move/MoveStreamsBasedMoveRepository.java | 6 +- .../impl/move/PlacerBasedMoveRepository.java | 3 +- .../impl/move/streams/BiMoveProducer.java | 17 +- .../move/streams/DefaultBiMoveStream.java | 6 +- .../streams/DefaultMoveStreamFactory.java | 24 +- .../streams/DefaultMoveStreamSession.java | 11 +- .../move/streams/DefaultUniMoveStream.java | 11 +- .../FromSolutionValueCollectingFunction.java | 37 --- .../dataset/AbstractForEachDataStream.java | 8 +- .../dataset/AbstractUniDataStream.java | 4 +- .../streams/dataset/DataStreamFactory.java | 12 +- .../ForEachExcludingPinnedDataStream.java | 4 +- .../ForEachIncludingPinnedDataStream.java | 5 +- .../generic/provider/ChangeMoveProvider.java | 20 +- .../maybeapi/stream/BiMoveConstructor.java | 3 +- .../maybeapi/stream/MoveStreamFactory.java | 29 +- .../stream/SolutionViewTriPredicate.java | 17 + .../maybeapi/stream/UniMoveStream.java | 29 +- .../maybeapi/stream/pickers/BiPicker.java | 20 -- .../maybeapi/stream/pickers/Pickers.java | 269 ---------------- .../move/streams/pickers/AbstractPicker.java | 37 --- .../move/streams/pickers/BiPickerComber.java | 74 ----- .../move/streams/pickers/DefaultBiPicker.java | 92 ------ .../streams/pickers/FilteringBiPicker.java | 56 ---- .../impl/move/streams/pickers/PickerType.java | 46 --- .../score/director/AbstractScoreDirector.java | 4 +- .../streams/dataset/UniDatasetStreamTest.java | 302 +++++++++++++++++- .../provider/ChangeMoveProviderTest.java | 34 +- ...ataAllowsUnassignedConstraintProvider.java | 26 ++ 35 files changed, 482 insertions(+), 819 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/ForEachFromSolutionUniNode.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/FromSolutionValueCollectingFunction.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/SolutionViewTriPredicate.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/unassignedvar/TestdataAllowsUnassignedConstraintProvider.java 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/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/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 33ea43d9eae..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 @@ -8,6 +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.move.SolutionView; import org.jspecify.annotations.NullMarked; @@ -23,39 +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); + 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 0c6287b9437..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,14 +1,13 @@ 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 ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; import org.jspecify.annotations.NullMarked; @@ -24,15 +23,11 @@ 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); } - @Override - public BiMoveStream pick(UniDataStream uniDataStream, BiPicker... pickers) { - return null; - } - @Override public UniDataset getDataset() { return dataset; 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 5a3e98c8ab2..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 @@ -18,10 +18,13 @@ abstract sealed class AbstractForEachDataStream 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 098a13f8ef8..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 @@ -23,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(); @@ -43,7 +43,7 @@ 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); 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/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/maybeapi/generic/provider/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/generic/provider/ChangeMoveProvider.java index 0e5e4b993c8..0d82d63a709 100644 --- 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 @@ -7,8 +7,7 @@ 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.pickers.BiPicker; -import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; +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; @@ -19,26 +18,31 @@ public final class ChangeMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; - private final BiPicker picker; + private final SolutionViewTriPredicate entityValueFilter; public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - this.picker = new FilteringBiPicker((solutionView, entity, value) -> { + this.entityValueFilter = (solutionView, entity, value) -> { Value_ oldValue = solutionView.getValue(variableMetaModel, entity); if (Objects.equals(oldValue, value)) { + System.out.println("Skipping ChangeMove for entity " + entity + " with value " + value + + " because it is the same as the current value " + oldValue); return false; } - return solutionView.isValueInRange(variableMetaModel, entity, value); - }); + var isInRange = solutionView.isValueInRange(variableMetaModel, entity, value); + System.out.println("ChangeMove for entity " + entity + " with value " + value + + " isInRange: " + isInRange); + return isInRange; + }; } @Override public MoveProducer apply(MoveStreamFactory moveStreamFactory) { var defaultMoveStreamFactory = (DefaultMoveStreamFactory) moveStreamFactory; var entityStream = defaultMoveStreamFactory.enumerate(variableMetaModel.entity().type()); - var valueStream = defaultMoveStreamFactory.enumerate(variableMetaModel.type()); + var valueStream = defaultMoveStreamFactory.enumerate(variableMetaModel.type(), true); return moveStreamFactory.pick(entityStream) - .pick(valueStream, picker) + .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/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 2ece6cd9eb9..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 @@ -14,6 +14,13 @@ @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}. @@ -32,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 @@ -45,8 +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); + 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 a1445d01616..ad4451c342d 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,37 +1,16 @@ package ai.timefold.solver.core.impl.move.streams.maybeapi.stream; -import java.util.function.BiPredicate; - -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; - import org.jspecify.annotations.NullMarked; @NullMarked public interface UniMoveStream extends MoveStream { - default BiMoveStream pick(UniDataStream uniDataStream) { - return pick(uniDataStream, (a, b) -> true); - } - - BiMoveStream pick(UniDataStream uniDataStream, BiPredicate filter); - @SuppressWarnings("unchecked") - default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker) { - return pick(uniDataStream, new BiPicker[] { picker }); - } - - @SuppressWarnings("unchecked") - default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker1, - BiPicker picker2) { - return pick(uniDataStream, new BiPicker[] { picker1, picker2 }); - } - - @SuppressWarnings("unchecked") - default BiMoveStream pick(UniDataStream uniDataStream, BiPicker picker1, - BiPicker picker2, BiPicker picker3) { - return pick(uniDataStream, new BiPicker[] { picker1, picker2, picker3 }); + default BiMoveStream pick(UniDataStream uniDataStream) { + return pick(uniDataStream, SolutionViewTriPredicate.TRUE); } - BiMoveStream pick(UniDataStream uniDataStream, BiPicker... pickers); + BiMoveStream pick(UniDataStream uniDataStream, + SolutionViewTriPredicate filter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java deleted file mode 100644 index 30785270584..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/BiPicker.java +++ /dev/null @@ -1,20 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; - -import ai.timefold.solver.core.api.score.stream.Joiners; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniDataStream; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.UniMoveStream; - -import org.jspecify.annotations.NullMarked; - -/** - * Created with {@link Joiners}. - * Used by {@link UniMoveStream#pick(UniDataStream, BiPicker[])} , ... - * - * @see Joiners - */ -@NullMarked -public interface BiPicker { - - BiPicker and(BiPicker otherPicker); - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java deleted file mode 100644 index eec8f4dd767..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/maybeapi/stream/pickers/Pickers.java +++ /dev/null @@ -1,269 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers; - -import java.util.function.BiPredicate; -import java.util.function.Function; - -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.impl.move.streams.pickers.DefaultBiPicker; -import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker; -import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker.FilteringBiPickerPredicate; -import ai.timefold.solver.core.impl.move.streams.pickers.PickerType; -import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; - -import org.jspecify.annotations.NullMarked; - -/** - * Creates a {@link BiPicker}, ... instance - * for use in {@link UniMoveStream#pick(UniDataStream, BiPicker[])}, ... - * - * TODO needs a better name, this suggests that this actually picks something; - * joiner is also a bad name, as it could be confused with joiners in CS, which are different. - */ -@NullMarked -public final class Pickers { - - // ************************************************************************ - // BiPicker - // ************************************************************************ - - /** - * As defined by {@link #equal(Function)} with {@link Function#identity()} as the argument. - * - * @param the type of both objects - */ - public static BiPicker equal() { - return equal(ConstantLambdaUtils.identity()); - } - - /** - * As defined by {@link #equal(Function, Function)} with both arguments using the same mapping. - * - * @param the type of both objects - * @param the type of the property to compare - * @param mapping mapping function to apply to both A and B - */ - public static BiPicker equal(Function mapping) { - return equal(mapping, mapping); - } - - /** - * Joins every A and B that share a property. - * These are exactly the pairs where {@code leftMapping.apply(A).equals(rightMapping.apply(B))}. - * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with both leftMapping and rightMapping being {@code Person::getAge}, - * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Eric)}. - * - * @param the type of object on the right - * @param the type of the property to compare - * @param leftMapping mapping function to apply to A - * @param rightMapping mapping function to apply to B - */ - public static BiPicker equal(Function leftMapping, - Function rightMapping) { - return new DefaultBiPicker<>(leftMapping, PickerType.EQUAL, rightMapping); - } - - /** - * As defined by {@link #lessThan(Function, Function)} with both arguments using the same mapping. - * - * @param mapping mapping function to apply - * @param the type of both objects - * @param the type of the property to compare - */ - public static > BiPicker lessThan(Function mapping) { - return lessThan(mapping, mapping); - } - - /** - * Joins every A and B where a value of property on A is less than the value of a property on B. - * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) < 0}. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with both leftMapping and rightMapping being {@code Person::getAge}, - * this picker will produce pairs {@code (Ann, Beth), (Eric, Beth)}. - * - * @param leftMapping mapping function to apply to A - * @param rightMapping mapping function to apply to B - * @param the type of object on the left - * @param the type of object on the right - * @param the type of the property to compare - */ - public static > BiPicker lessThan(Function leftMapping, - Function rightMapping) { - return new DefaultBiPicker<>(leftMapping, PickerType.LESS_THAN, rightMapping); - } - - /** - * As defined by {@link #lessThanOrEqual(Function, Function)} with both arguments using the same mapping. - * - * @param mapping mapping function to apply - * @param the type of both objects - * @param the type of the property to compare - */ - public static > BiPicker lessThanOrEqual(Function mapping) { - return lessThanOrEqual(mapping, mapping); - } - - /** - * Joins every A and B where a value of property on A is less than or equal to the value of a property on B. - * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) <= 0}. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with both leftMapping and rightMapping being {@code Person::getAge}, - * this picker will produce pairs - * {@code (Ann, Ann), (Ann, Beth), (Ann, Eric), (Beth, Beth), (Eric, Ann), (Eric, Beth), (Eric, Eric)}. - * - * @param leftMapping mapping function to apply to A - * @param rightMapping mapping function to apply to B - * @param the type of object on the left - * @param the type of object on the right - * @param the type of the property to compare - */ - public static > BiPicker - lessThanOrEqual(Function leftMapping, Function rightMapping) { - return new DefaultBiPicker<>(leftMapping, PickerType.LESS_THAN_OR_EQUAL, rightMapping); - } - - /** - * As defined by {@link #greaterThan(Function, Function)} with both arguments using the same mapping. - * - * @param mapping mapping function to apply - * @param the type of both objects - * @param the type of the property to compare - */ - public static > BiPicker greaterThan(Function mapping) { - return greaterThan(mapping, mapping); - } - - /** - * Joins every A and B where a value of property on A is greater than the value of a property on B. - * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) > 0}. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with both leftMapping and rightMapping being {@code Person::getAge}, - * this picker will produce pairs {@code (Beth, Ann), (Beth, Eric)}. - * - * @param leftMapping mapping function to apply to A - * @param rightMapping mapping function to apply to B - * @param the type of object on the left - * @param the type of object on the right - * @param the type of the property to compare - */ - public static > BiPicker greaterThan(Function leftMapping, - Function rightMapping) { - return new DefaultBiPicker<>(leftMapping, PickerType.GREATER_THAN, rightMapping); - } - - /** - * As defined by {@link #greaterThanOrEqual(Function, Function)} with both arguments using the same mapping. - * - * @param mapping mapping function to apply - * @param the type of both objects - * @param the type of the property to compare - */ - public static > BiPicker - greaterThanOrEqual(Function mapping) { - return greaterThanOrEqual(mapping, mapping); - } - - /** - * Joins every A and B where a value of property on A is greater than or equal to the value of a property on B. - * These are exactly the pairs where {@code leftMapping.apply(A).compareTo(rightMapping.apply(B)) >= 0}. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with both leftMapping and rightMapping being {@code Person::getAge}, - * this picker will produce pairs - * {@code (Ann, Ann), (Ann, Eric), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Ann), (Eric, Eric)}. - * - * @param leftMapping mapping function to apply to A - * @param rightMapping mapping function to apply to B - * @param the type of object on the left - * @param the type of object on the right - * @param the type of the property to compare - */ - public static > BiPicker - greaterThanOrEqual(Function leftMapping, Function rightMapping) { - return new DefaultBiPicker<>(leftMapping, PickerType.GREATER_THAN_OR_EQUAL, rightMapping); - } - - /** - * Applies a filter to the picked tuple, - * the tuple returning false will be ignored. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with filter being {@code age == 20}, - * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. - *

- * If you wish to access the working solution state, - * use {@link #filtering(FilteringBiPickerPredicate)} instead. - * - * @param filter filter to apply - * @param type of the first fact in the tuple - * @param type of the second fact in the tuple - */ - public static BiPicker filtering(BiPredicate filter) { - return FilteringBiPicker.of(filter); - } - - /** - * Applies a filter to the picked tuple, - * the tuple returning false will be ignored. - *

- * For example, on a cartesian product of list {@code [Ann(age = 20), Beth(age = 25), Eric(age = 20)]} - * with filter being {@code age == 20}, - * this picker will produce pairs {@code (Ann, Ann), (Ann, Eric), (Eric, Ann), (Eric, Eric)}. - *

- * The first argument to the predicate allows the filter to access the working solution state. - * - * @param filter filter to apply - * @param the solution type - * @param type of the first fact in the tuple - * @param type of the second fact in the tuple - */ - public static BiPicker filtering(FilteringBiPickerPredicate filter) { - return new FilteringBiPicker<>(filter); - } - - /** - * Joins every A and B that overlap for an interval which is specified by a start and end property on both A and B. - * These are exactly the pairs where {@code A.start < B.end} and {@code A.end > B.start}. - *

- * For example, on a cartesian product of list - * {@code [Ann(start=08:00, end=14:00), Beth(start=12:00, end=18:00), Eric(start=16:00, end=22:00)]} - * with startMapping being {@code Person::getStart} and endMapping being {@code Person::getEnd}, - * this picker will produce pairs - * {@code (Ann, Ann), (Ann, Beth), (Beth, Ann), (Beth, Beth), (Beth, Eric), (Eric, Beth), (Eric, Eric)}. - * - * @param startMapping maps the argument to the start point of its interval (inclusive) - * @param endMapping maps the argument to the end point of its interval (exclusive) - * @param the type of both the first and second argument - * @param the type used to define the interval, comparable - */ - public static > BiPicker overlapping(Function startMapping, - Function endMapping) { - return overlapping(startMapping, endMapping, startMapping, endMapping); - } - - /** - * As defined by {@link #overlapping(Function, Function)}. - * - * @param leftStartMapping maps the first argument to its interval start point (inclusive) - * @param leftEndMapping maps the first argument to its interval end point (exclusive) - * @param rightStartMapping maps the second argument to its interval start point (inclusive) - * @param rightEndMapping maps the second argument to its interval end point (exclusive) - * @param the type of the first argument - * @param the type of the second argument - * @param the type used to define the interval, comparable - */ - public static > BiPicker overlapping( - Function leftStartMapping, Function leftEndMapping, - Function rightStartMapping, Function rightEndMapping) { - return Pickers.lessThan(leftStartMapping, rightEndMapping) - .and(Pickers.greaterThan(leftEndMapping, rightStartMapping)); - } - - private Pickers() { - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java deleted file mode 100644 index 0c3f7175362..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/AbstractPicker.java +++ /dev/null @@ -1,37 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.Objects; -import java.util.function.Function; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -@SuppressWarnings({ "unchecked" }) -public sealed abstract class AbstractPicker - permits DefaultBiPicker { - - protected final Function[] rightMappings; - protected final PickerType[] pickerTypes; - - protected AbstractPicker(Function rightMapping, PickerType pickerType) { - this(new Function[] { rightMapping }, new PickerType[] { pickerType }); - } - - protected AbstractPicker(Function[] rightMappings, PickerType[] pickerTypes) { - this.rightMappings = (Function[]) Objects.requireNonNull(rightMappings); - this.pickerTypes = Objects.requireNonNull(pickerTypes); - } - - public final Function getRightMapping(int index) { - return rightMappings[index]; - } - - public final int getJoinerCount() { - return pickerTypes.length; - } - - public final PickerType getPickerType(int index) { - return pickerTypes[index]; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java deleted file mode 100644 index c48384763bf..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/BiPickerComber.java +++ /dev/null @@ -1,74 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.ArrayList; -import java.util.List; - -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; -import ai.timefold.solver.core.impl.move.streams.pickers.FilteringBiPicker.FilteringBiPickerPredicate; -import ai.timefold.solver.core.preview.api.move.SolutionView; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -/** - * Combs an array of {@link BiPicker} instances into a {@link #mergedPicker()} and {@link #mergedFiltering()}. - * - * @param - * @param - * @param mergedPicker the merged {@link DefaultBiPicker} from all indexing pickers - * @param mergedFiltering null if not applicable - */ -@NullMarked -public record BiPickerComber(DefaultBiPicker mergedPicker, - @Nullable FilteringBiPickerPredicate mergedFiltering) { - - @SuppressWarnings("unchecked") - public static BiPickerComber comb(BiPicker[] pickers) { - var defaultPickerList = new ArrayList>(pickers.length); - var filteringList = new ArrayList>(pickers.length); - - var indexOfFirstFilter = -1; - // Make sure all indexing pickers, if any, come before filtering pickers. This is necessary for performance. - for (var i = 0; i < pickers.length; i++) { - var picker = pickers[i]; - if (picker instanceof FilteringBiPicker filteringBiPicker) { - // From now on, only allow filtering joiners. - indexOfFirstFilter = i; - filteringList.add((FilteringBiPickerPredicate) filteringBiPicker.filter()); - } else if (picker instanceof DefaultBiPicker defaultBiPicker) { - if (indexOfFirstFilter >= 0) { - throw new IllegalStateException(""" - Indexing picker (%s) must not follow a filtering picker (%s). - Maybe reorder the pickers such that filtering() pickers are later in the parameter list.""" - .formatted(picker, pickers[indexOfFirstFilter])); - } - defaultPickerList.add(defaultBiPicker); - } else { - throw new IllegalArgumentException("The picker class (%s) is not supported." - .formatted(picker.getClass().getCanonicalName())); - } - } - var mergedPicker = DefaultBiPicker.merge(defaultPickerList); - var mergedFiltering = mergeFiltering(filteringList); - return new BiPickerComber<>(mergedPicker, mergedFiltering); - } - - private static @Nullable FilteringBiPickerPredicate - mergeFiltering(List> filteringList) { - if (filteringList.isEmpty()) { - return null; - } else if (filteringList.size() == 1) { - return filteringList.get(0); - } - // Avoid predicate.and() for debugging and potentially performance. - return (SolutionView solutionView, A a, B b) -> { - for (var predicate : filteringList) { - if (!predicate.test(solutionView, a, b)) { - return false; - } - } - return true; - }; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java deleted file mode 100644 index 38453d75541..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/DefaultBiPicker.java +++ /dev/null @@ -1,92 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.Arrays; -import java.util.List; -import java.util.function.Function; - -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -@SuppressWarnings({ "unchecked", "rawtypes" }) -public final class DefaultBiPicker - extends AbstractPicker - implements BiPicker { - - private static final DefaultBiPicker NONE = new DefaultBiPicker(new Function[0], new PickerType[0], new Function[0]); - - private final Function[] leftMappings; - - public DefaultBiPicker(Function leftMapping, PickerType pickerType, - Function rightMapping) { - super(rightMapping, pickerType); - this.leftMappings = new Function[] { leftMapping }; - } - - private DefaultBiPicker(Function[] leftMappings, PickerType[] pickerTypes, - Function[] rightMappings) { - super(rightMappings, pickerTypes); - this.leftMappings = leftMappings; - } - - public static DefaultBiPicker merge(List> pickerList) { - if (pickerList.size() == 1) { - return pickerList.get(0); - } - return pickerList.stream().reduce(NONE, DefaultBiPicker::and); - } - - @Override - public DefaultBiPicker and(BiPicker otherPicker) { - var castPicker = (DefaultBiPicker) otherPicker; - var pickerCount = getJoinerCount(); - var castPickerCount = castPicker.getJoinerCount(); - var newPickerCount = pickerCount + castPickerCount; - var newPickerTypes = Arrays.copyOf(this.pickerTypes, newPickerCount); - var newLeftMappings = Arrays.copyOf(this.leftMappings, newPickerCount); - var newRightMappings = Arrays.copyOf(this.rightMappings, newPickerCount); - for (var i = 0; i < castPickerCount; i++) { - var newJoinerIndex = i + pickerCount; - newPickerTypes[newJoinerIndex] = castPicker.getPickerType(i); - newLeftMappings[newJoinerIndex] = castPicker.getLeftMapping(i); - newRightMappings[newJoinerIndex] = castPicker.getRightMapping(i); - } - return new DefaultBiPicker(newLeftMappings, newPickerTypes, newRightMappings); - } - - public Function getLeftMapping(int index) { - return (Function) leftMappings[index]; - } - - public boolean matches(A a, B b) { - var pickerCount = getJoinerCount(); - for (var i = 0; i < pickerCount; i++) { - var pickerType = getPickerType(i); - var leftMapping = getLeftMapping(i).apply(a); - var rightMapping = getRightMapping(i).apply(b); - if (!pickerType.matches(leftMapping, rightMapping)) { - return false; - } - } - return true; - } - - @Override - public boolean equals(Object o) { - return o instanceof DefaultBiPicker other - && Arrays.equals(pickerTypes, other.pickerTypes) - && Arrays.equals(leftMappings, other.leftMappings) - && Arrays.equals(rightMappings, other.rightMappings); - } - - @Override - public int hashCode() { - var hashCode = 31; - hashCode = hashCode * 31 + Arrays.hashCode(pickerTypes); - hashCode = hashCode * 31 + Arrays.hashCode(leftMappings); - hashCode = hashCode * 31 + Arrays.hashCode(rightMappings); - return hashCode; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java deleted file mode 100644 index 47b99a4390e..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/FilteringBiPicker.java +++ /dev/null @@ -1,56 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.Objects; -import java.util.function.BiPredicate; - -import ai.timefold.solver.core.api.function.TriPredicate; -import ai.timefold.solver.core.impl.move.streams.maybeapi.stream.pickers.BiPicker; -import ai.timefold.solver.core.preview.api.move.SolutionView; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public record FilteringBiPicker(FilteringBiPickerPredicate filter) - implements - BiPicker { - - public static FilteringBiPicker of(BiPredicate filter) { - return new FilteringBiPicker<>(new WrappedPredicate<>(filter)); - } - - @Override - public FilteringBiPicker and(BiPicker otherPicker) { - var castJoiner = (FilteringBiPicker) otherPicker; - return new FilteringBiPicker<>(filter.and(castJoiner.filter())); - } - - @FunctionalInterface - public interface FilteringBiPickerPredicate - extends TriPredicate, A, B> { - - default FilteringBiPickerPredicate and(FilteringBiPickerPredicate other) { - return (solutionView, a, b) -> this.test(solutionView, a, b) && other.test(solutionView, a, b); - } - - } - - /** - * Exists to make sure that node sharing still works. - * Instances of this class need to equal if the underlying predicate is equal. - */ - private record WrappedPredicate(BiPredicate predicate) - implements - FilteringBiPickerPredicate { - - private WrappedPredicate(BiPredicate predicate) { - this.predicate = Objects.requireNonNull(predicate); - } - - @Override - public boolean test(SolutionView solutionView, A a, B b) { - return predicate.test(a, b); - } - - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java b/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java deleted file mode 100644 index 90574ed473a..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/streams/pickers/PickerType.java +++ /dev/null @@ -1,46 +0,0 @@ -package ai.timefold.solver.core.impl.move.streams.pickers; - -import java.util.Objects; -import java.util.function.BiPredicate; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -@SuppressWarnings({ "unchecked", "rawtypes" }) -public enum PickerType { - - EQUAL(Objects::equals), - LESS_THAN((a, b) -> ((Comparable) a).compareTo(b) < 0), - LESS_THAN_OR_EQUAL((a, b) -> ((Comparable) a).compareTo(b) <= 0), - GREATER_THAN((a, b) -> ((Comparable) a).compareTo(b) > 0), - GREATER_THAN_OR_EQUAL((a, b) -> ((Comparable) a).compareTo(b) >= 0); - - private final BiPredicate matcher; - - PickerType(BiPredicate matcher) { - this.matcher = matcher; - } - - public PickerType flip() { - return switch (this) { - case LESS_THAN -> GREATER_THAN; - case LESS_THAN_OR_EQUAL -> GREATER_THAN_OR_EQUAL; - case GREATER_THAN -> LESS_THAN; - case GREATER_THAN_OR_EQUAL -> LESS_THAN_OR_EQUAL; - default -> throw new IllegalStateException("The joinerType (%s) cannot be flipped." - .formatted(this)); - }; - } - - public boolean matches(Object left, Object right) { - try { - return matcher.test(left, right); - } catch (Exception e) { - throw new IllegalStateException( - "Joiner (%s) threw an exception matching left (%s) and right (%s) objects." - .formatted(this, left, right), - e); - } - } - -} 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..7826c0c76e7 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 @@ -251,7 +251,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 +259,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()); } } 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..cbfd7cddcf9 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); + partiallyPinnedEntity.setValueList(List.of(value1, value2)); + // 1 value, not pinned. + 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 caa5b6d41f1..8ef0346e221 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,19 +2,23 @@ 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.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.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; @@ -40,8 +44,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); @@ -100,8 +104,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 +152,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/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"); + } + +} From 7dae6807368a37103aa1cf824fb6501ce90bf01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 09:55:57 +0200 Subject: [PATCH 09/22] Test from entity --- .../generic/provider/ChangeMoveProvider.java | 10 +- .../maybeapi/stream/UniMoveStream.java | 2 + .../provider/ChangeMoveProviderTest.java | 134 ++++++++++++++++++ ...dataEntityProvidingConstraintProvider.java | 23 +++ .../TestdataEntityProvidingSolution.java | 38 ++++- ...ncompleteValueRangeConstraintProvider.java | 23 +++ .../TestdataIncompleteValueRangeEntity.java | 45 ++++++ .../TestdataIncompleteValueRangeSolution.java | 109 ++++++++++++++ 8 files changed, 377 insertions(+), 7 deletions(-) create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/TestdataEntityProvidingConstraintProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeConstraintProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeEntity.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/incomplete/TestdataIncompleteValueRangeSolution.java 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 index 0d82d63a709..0af886cfa9e 100644 --- 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 @@ -25,14 +25,9 @@ public ChangeMoveProvider(PlanningVariableMetaModel this.entityValueFilter = (solutionView, entity, value) -> { Value_ oldValue = solutionView.getValue(variableMetaModel, entity); if (Objects.equals(oldValue, value)) { - System.out.println("Skipping ChangeMove for entity " + entity + " with value " + value - + " because it is the same as the current value " + oldValue); return false; } - var isInRange = solutionView.isValueInRange(variableMetaModel, entity, value); - System.out.println("ChangeMove for entity " + entity + " with value " + value - + " isInRange: " + isInRange); - return isInRange; + return solutionView.isValueInRange(variableMetaModel, entity, value); }; } @@ -40,6 +35,9 @@ public ChangeMoveProvider(PlanningVariableMetaModel 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) 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 ad4451c342d..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 @@ -10,6 +10,8 @@ default BiMoveStream pick(UniDataStream uniDa return pick(uniDataStream, SolutionViewTriPredicate.TRUE); } + // 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/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 8ef0346e221..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 @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import java.util.Collections; import java.util.stream.StreamSupport; import ai.timefold.solver.core.api.score.stream.ConstraintProvider; @@ -21,6 +22,12 @@ 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; @@ -88,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(); 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 + // ************************************************************************ + +} From 6692d7bd8893aac706ed162e8157f46a933f7c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 10:39:57 +0200 Subject: [PATCH 10/22] Move the range code to score director --- .../core/impl/move/director/MoveDirector.java | 33 +------------- .../score/director/AbstractScoreDirector.java | 43 +++++++++++++++++++ .../score/director/InnerScoreDirector.java | 3 ++ 3 files changed, 47 insertions(+), 32 deletions(-) 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 58020a568aa..6972739b573 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 @@ -189,25 +189,7 @@ protected static ElementPosition getPositionOf(Inne @Override public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { - if (value == null) { - if (variableMetaModel instanceof PlanningVariableMetaModel basicVariableMetaModel) { - return basicVariableMetaModel.allowsUnassigned(); - } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { - return listVariableMetaModel.allowsUnassignedValues(); - } else { - throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." - .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), - PlanningListVariableMetaModel.class.getSimpleName())); - } - } - var valueRangeDescriptor = extractValueRangeDescriptor(variableMetaModel); - 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)); - } - // TODO Optimize this by caching the lookup on a potentially very long list. - var valueRange = valueRangeDescriptor.extractValueRange(backingScoreDirector.getWorkingSolution(), entity); - return valueRange.contains(value); + return backingScoreDirector.isValueInValueRange(variableMetaModel, entity, value); } @Override @@ -215,19 +197,6 @@ public boolean isValueInRange(GenuineVariableMetaModel ValueRangeDescriptor - extractValueRangeDescriptor(GenuineVariableMetaModel variableMetaModel) { - if (variableMetaModel instanceof PlanningVariableMetaModel variableMetaModel_) { - return extractVariableDescriptor(variableMetaModel_).getValueRangeDescriptor(); - } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { - return extractVariableDescriptor(listVariableMetaModel).getValueRangeDescriptor(); - } else { - throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." - .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), - PlanningListVariableMetaModel.class.getSimpleName())); - } - } - private static BasicVariableDescriptor extractVariableDescriptor(PlanningVariableMetaModel variableMetaModel) { return ((DefaultPlanningVariableMetaModel) variableMetaModel).variableDescriptor(); 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 7826c0c76e7..4b34b7dbefb 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,7 +23,10 @@ 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.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.valuerange.descriptor.ValueRangeDescriptor; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; @@ -41,6 +44,9 @@ import ai.timefold.solver.core.impl.solver.exception.UndoScoreCorruptionException; import ai.timefold.solver.core.impl.solver.exception.VariableCorruptionException; import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; +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; import org.jspecify.annotations.NonNull; @@ -313,6 +319,43 @@ protected void setWorkingEntityListDirty() { workingEntityListRevision++; } + @Override + public boolean isValueInValueRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value) { + if (value == null) { + if (variableMetaModel instanceof PlanningVariableMetaModel basicVariableMetaModel) { + return basicVariableMetaModel.allowsUnassigned(); + } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { + return listVariableMetaModel.allowsUnassignedValues(); + } else { + throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." + .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), + PlanningListVariableMetaModel.class.getSimpleName())); + } + } + var valueRangeDescriptor = extractValueRangeDescriptor(variableMetaModel); + 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)); + } + // TODO Optimize this by caching the lookup on a potentially very long list. + var valueRange = valueRangeDescriptor.extractValueRange(getWorkingSolution(), entity); + return valueRange.contains(value); + } + + private static ValueRangeDescriptor + extractValueRangeDescriptor(GenuineVariableMetaModel variableMetaModel) { + if (variableMetaModel instanceof DefaultPlanningVariableMetaModel basicVariableMetaModel) { + return basicVariableMetaModel.variableDescriptor().getValueRangeDescriptor(); + } else if (variableMetaModel instanceof DefaultPlanningListVariableMetaModel listVariableMetaModel) { + return listVariableMetaModel.variableDescriptor().getValueRangeDescriptor(); + } else { + throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." + .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), + PlanningListVariableMetaModel.class.getSimpleName())); + } + } + @Override public Solution_ cloneSolution(Solution_ originalSolution) { SolutionDescriptor solutionDescriptor = getSolutionDescriptor(); 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..7896322d175 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 @@ -36,6 +36,7 @@ import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; import org.jspecify.annotations.Nullable; @@ -183,6 +184,8 @@ static > ConstraintAnalysis getConstraintAn boolean isWorkingSolutionInitialized(); + boolean isValueInValueRange(GenuineVariableMetaModel variableDescriptor, @Nullable Entity_ entity, @Nullable Value_ value); + /** * Some score directors keep a set of changes * that they only apply when {@link #calculateScore()} is called. From 60b28ac2ef556579ae63103a31fd48801cdf111f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 11:26:41 +0200 Subject: [PATCH 11/22] Optimize the code path --- .../buildin/collection/ListValueRange.java | 13 ++- .../core/impl/move/director/MoveDirector.java | 3 +- .../score/director/AbstractScoreDirector.java | 27 ++---- .../score/director/InnerScoreDirector.java | 3 +- .../impl/score/director/ValueRangeState.java | 88 +++++++++++++++++++ 5 files changed, 112 insertions(+), 22 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java 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..d4b98799299 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 @@ -3,6 +3,7 @@ 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; @@ -11,7 +12,10 @@ public final class ListValueRange extends AbstractCountableValueRange { + private static final int LIST_SIZE_LOOKUP_LIMIT = 10; + private final List list; + private Set lookupSet; public ListValueRange(List list) { this.list = list; @@ -32,7 +36,14 @@ public T get(long index) { @Override public boolean contains(T value) { - return list.contains(value); + if (list.size() > LIST_SIZE_LOOKUP_LIMIT) { + if (lookupSet == null) { // Lazy initialization of the lookup set for performance + lookupSet = Set.copyOf(list); + } + return lookupSet.contains(value); + } else { // For small lists, sequential scanning is not a performance issue. + return list.contains(value); + } } @Override 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 6972739b573..ebedef77def 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,7 +7,6 @@ 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.valuerange.descriptor.ValueRangeDescriptor; 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; @@ -189,7 +188,7 @@ protected static ElementPosition getPositionOf(Inne @Override public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { - return backingScoreDirector.isValueInValueRange(variableMetaModel, entity, value); + return backingScoreDirector.isValueInRange(variableMetaModel, entity, value); } @Override 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 4b34b7dbefb..8e6da539135 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 @@ -87,11 +87,11 @@ public abstract class AbstractScoreDirector solutionTracker; // Null when tracking disabled. + + private final ValueRangeState valueRangeState = new ValueRangeState<>(); private final MoveDirector moveDirector = new MoveDirector<>(this); private @Nullable MoveRepository moveRepository; - - // Null when no list variable - private final ListVariableStateSupply listVariableStateSupply; + private final ListVariableStateSupply listVariableStateSupply; // Null when no list variable. protected AbstractScoreDirector(Factory_ scoreDirectorFactory, boolean lookUpEnabled, ConstraintMatchPolicy constraintMatchPolicy, boolean expectShadowVariablesInCorrectState) { @@ -223,6 +223,7 @@ public MoveDirector getMoveDirector() { */ protected void setWorkingSolution(Solution_ workingSolution, Consumer entityAndFactVisitor) { this.workingSolution = requireNonNull(workingSolution); + this.valueRangeState.resetWorkingSolution(workingSolution); var solutionDescriptor = getSolutionDescriptor(); /* @@ -320,27 +321,14 @@ protected void setWorkingEntityListDirty() { } @Override - public boolean isValueInValueRange(GenuineVariableMetaModel variableMetaModel, + public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { - if (value == null) { - if (variableMetaModel instanceof PlanningVariableMetaModel basicVariableMetaModel) { - return basicVariableMetaModel.allowsUnassigned(); - } else if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { - return listVariableMetaModel.allowsUnassignedValues(); - } else { - throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." - .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), - PlanningListVariableMetaModel.class.getSimpleName())); - } - } var valueRangeDescriptor = extractValueRangeDescriptor(variableMetaModel); 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)); } - // TODO Optimize this by caching the lookup on a potentially very long list. - var valueRange = valueRangeDescriptor.extractValueRange(getWorkingSolution(), entity); - return valueRange.contains(value); + return valueRangeState.isInRange(valueRangeDescriptor, entity, value); } private static ValueRangeDescriptor @@ -497,6 +485,7 @@ public void afterVariableChanged(VariableDescriptor variableDescripto if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); } + valueRangeState.resetEntityValueRange(entity); variableListenerSupport.afterVariableChanged(variableDescriptor, entity); } @@ -550,6 +539,7 @@ Attempting to change list variable (%s) on an entity (%s) in range [%d, %d), whi @Override public void afterListVariableChanged(ListVariableDescriptor variableDescriptor, Object entity, int fromIndex, int toIndex) { + valueRangeState.resetEntityValueRange(entity); variableListenerSupport.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); @@ -569,6 +559,7 @@ public void afterEntityRemoved(EntityDescriptor entityDescriptor, Obj if (lookUpEnabled) { lookUpManager.removeWorkingObject(entity); } + valueRangeState.resetEntityValueRange(entity); if (!allChangesWillBeUndoneBeforeStepEnds) { if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.retract(entity); 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 7896322d175..f1310dc0d63 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 @@ -184,7 +184,8 @@ static > ConstraintAnalysis getConstraintAn boolean isWorkingSolutionInitialized(); - boolean isValueInValueRange(GenuineVariableMetaModel variableDescriptor, @Nullable Entity_ entity, @Nullable Value_ value); + boolean isValueInRange(GenuineVariableMetaModel variableDescriptor, + @Nullable Entity_ entity, @Nullable Value_ value); /** * Some score directors keep a set of changes 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..e9bbdbae77a --- /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.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 ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; + +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(GenuineVariableMetaModel, 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. + * + *

+ * Call {@link #resetWorkingSolution(Object)} every time the working solution changes through a problem fact, + * so that all caches can be invalidated. + * Call {@link #resetEntityValueRange(Object)} every time an entity is changed, + * so that the cached value range can be invalidated. + */ +@NullMarked +final class ValueRangeState { + + private final Map, ValueRange> fromSolutionValueRangeMap = new IdentityHashMap<>(); + private final Map, ValueRange>> fromEntityValueRangeMap = + new IdentityHashMap<>(); + + private @Nullable Solution_ workingSolution; + + public 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); + } + } + + public void resetEntityValueRange(Object entity) { + fromEntityValueRangeMap.remove(entity); + } + +} From b69b9cfa7fa3feb276e0326b06d700d1ed85643e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 11:32:46 +0200 Subject: [PATCH 12/22] Clean up --- .../valuerange/buildin/collection/ListValueRange.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 d4b98799299..a319c09ba73 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 @@ -9,13 +9,16 @@ 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 ListValueRange extends AbstractCountableValueRange { private static final int LIST_SIZE_LOOKUP_LIMIT = 10; private final List list; - private Set lookupSet; + private @Nullable Set lookupSet; // Initialized lazily for large lists. public ListValueRange(List list) { this.list = list; @@ -35,9 +38,9 @@ public T get(long index) { } @Override - public boolean contains(T value) { + public boolean contains(@Nullable T value) { if (list.size() > LIST_SIZE_LOOKUP_LIMIT) { - if (lookupSet == null) { // Lazy initialization of the lookup set for performance + if (lookupSet == null) { lookupSet = Set.copyOf(list); } return lookupSet.contains(value); From 0b7fd55bb99f8d3c84e5b8d01dbcff87eb617254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 24 Jun 2025 14:42:37 +0200 Subject: [PATCH 13/22] Performance improvement --- .../variable/ShadowVariableUpdateHelper.java | 34 ++++++++++-- .../score/director/AbstractScoreDirector.java | 27 ++++++---- .../impl/score/director/ValueRangeState.java | 54 +++++++++++++++++-- .../director/easy/EasyScoreDirector.java | 17 +++--- .../incremental/IncrementalScoreDirector.java | 20 +++---- .../BavetConstraintStreamScoreDirector.java | 14 ++--- .../core/impl/solver/AbstractSolver.java | 6 +++ .../impl/solver/DefaultSolverFactory.java | 4 ++ .../core/impl/solver/scope/SolverScope.java | 10 ++++ .../impl/solver/MoveAssertScoreDirector.java | 46 ++++++++++++---- .../MoveAssertScoreDirectorFactory.java | 36 ++----------- 11 files changed, 183 insertions(+), 85 deletions(-) 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/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 8e6da539135..cce5cc355f8 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 @@ -88,20 +88,20 @@ public abstract class AbstractScoreDirector solutionTracker; // Null when tracking disabled. - private final ValueRangeState valueRangeState = new ValueRangeState<>(); + private final ValueRangeState valueRangeState; private final MoveDirector moveDirector = new MoveDirector<>(this); private @Nullable MoveRepository moveRepository; private final ListVariableStateSupply listVariableStateSupply; // Null when no list variable. - 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); @@ -109,6 +109,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; @@ -485,7 +486,7 @@ public void afterVariableChanged(VariableDescriptor variableDescripto if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); } - valueRangeState.resetEntityValueRange(entity); + valueRangeState.markEntityDependentValueRangesAsInvalid(entity); variableListenerSupport.afterVariableChanged(variableDescriptor, entity); } @@ -539,7 +540,7 @@ Attempting to change list variable (%s) on an entity (%s) in range [%d, %d), whi @Override public void afterListVariableChanged(ListVariableDescriptor variableDescriptor, Object entity, int fromIndex, int toIndex) { - valueRangeState.resetEntityValueRange(entity); + valueRangeState.markEntityDependentValueRangesAsInvalid(entity); variableListenerSupport.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); @@ -559,7 +560,7 @@ public void afterEntityRemoved(EntityDescriptor entityDescriptor, Obj if (lookUpEnabled) { lookUpManager.removeWorkingObject(entity); } - valueRangeState.resetEntityValueRange(entity); + valueRangeState.markEntityDependentValueRangesAsInvalid(entity); if (!allChangesWillBeUndoneBeforeStepEnds) { if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.retract(entity); @@ -1009,6 +1010,8 @@ public abstract static class AbstractScoreDirectorBuilder valueRangeState = new ValueRangeState<>(); protected ConstraintMatchPolicy constraintMatchPolicy = ConstraintMatchPolicy.DISABLED; protected boolean lookUpEnabled = false; protected boolean expectShadowVariablesInCorrectState = true; @@ -1017,6 +1020,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/ValueRangeState.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java index e9bbdbae77a..e843b401566 100644 --- 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 @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.score.director; +import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Objects; @@ -8,6 +9,10 @@ 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 ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -25,19 +30,26 @@ *

* Call {@link #resetWorkingSolution(Object)} every time the working solution changes through a problem fact, * so that all caches can be invalidated. - * Call {@link #resetEntityValueRange(Object)} every time an entity is changed, + * + *

+ * Call {@link #markEntityDependentValueRangesAsInvalid(Object)} every time an entity is changed, * so that the cached value range can be invalidated. + * The actual invalidation happens at {@link #stepEnded(AbstractStepScope)}, called by the solver. + * Otherwise every undone move would be invalidating the value ranges. + * Value ranges should only be invalidated when a move has been selected at the end of a step. */ @NullMarked -final class ValueRangeState { +public final class ValueRangeState + implements PhaseLifecycleListener { + private final HashSet entitiesWithInvalidValueRangesSet = new HashSet<>(); private final Map, ValueRange> fromSolutionValueRangeMap = new IdentityHashMap<>(); private final Map, ValueRange>> fromEntityValueRangeMap = new IdentityHashMap<>(); private @Nullable Solution_ workingSolution; - public void resetWorkingSolution(Solution_ workingSolution) { + void resetWorkingSolution(Solution_ workingSolution) { this.workingSolution = workingSolution; fromSolutionValueRangeMap.clear(); fromEntityValueRangeMap.clear(); @@ -81,8 +93,40 @@ public boolean isInRange(ValueRangeDescriptor valueRangeDescriptor, @ } } - public void resetEntityValueRange(Object entity) { - fromEntityValueRangeMap.remove(entity); + void markEntityDependentValueRangesAsInvalid(Object entity) { + entitiesWithInvalidValueRangesSet.add(entity); + } + + @Override + public void solvingStarted(SolverScope solverScope) { + // No need to do anything here. + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + // No need to do anything here. + } + + @Override + public void stepStarted(AbstractStepScope stepScope) { + // No need to do anything here. } + @Override + public void stepEnded(AbstractStepScope stepScope) { + entitiesWithInvalidValueRangesSet.forEach(fromEntityValueRangeMap::remove); + entitiesWithInvalidValueRangesSet.clear(); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + // No need to do anything here. + } + + @Override + public void solvingEnded(SolverScope solverScope) { + this.workingSolution = null; + fromSolutionValueRangeMap.clear(); + fromEntityValueRangeMap.clear(); + } } 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/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index 20b25d05cb4..f25600dc698 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -57,6 +57,7 @@ protected AbstractSolver(BestSolutionRecaller bestSolutionRecaller, public void solvingStarted(SolverScope solverScope) { solverScope.setWorkingSolutionFromBestSolution(); + solverScope.getValueRangeState().solvingStarted(solverScope); bestSolutionRecaller.solvingStarted(solverScope); globalTermination.solvingStarted(solverScope); phaseLifecycleSupport.fireSolvingStarted(solverScope); @@ -92,6 +93,7 @@ public void solvingEnded(SolverScope solverScope) { bestSolutionRecaller.solvingEnded(solverScope); globalTermination.solvingEnded(solverScope); phaseLifecycleSupport.fireSolvingEnded(solverScope); + solverScope.getValueRangeState().solvingEnded(solverScope); } public void solvingError(SolverScope solverScope, Exception exception) { @@ -102,6 +104,7 @@ public void solvingError(SolverScope solverScope, Exception exception } public void phaseStarted(AbstractPhaseScope phaseScope) { + phaseScope.getSolverScope().getValueRangeState().phaseStarted(phaseScope); bestSolutionRecaller.phaseStarted(phaseScope); phaseLifecycleSupport.firePhaseStarted(phaseScope); globalTermination.phaseStarted(phaseScope); @@ -112,10 +115,12 @@ public void phaseEnded(AbstractPhaseScope phaseScope) { bestSolutionRecaller.phaseEnded(phaseScope); phaseLifecycleSupport.firePhaseEnded(phaseScope); globalTermination.phaseEnded(phaseScope); + phaseScope.getSolverScope().getValueRangeState().phaseEnded(phaseScope); // Do not propagate to phases; the active phase does that for itself and they should not propagate further. } public void stepStarted(AbstractStepScope stepScope) { + stepScope.getPhaseScope().getSolverScope().getValueRangeState().stepStarted(stepScope); bestSolutionRecaller.stepStarted(stepScope); phaseLifecycleSupport.fireStepStarted(stepScope); globalTermination.stepStarted(stepScope); @@ -126,6 +131,7 @@ public void stepEnded(AbstractStepScope stepScope) { bestSolutionRecaller.stepEnded(stepScope); phaseLifecycleSupport.fireStepEnded(stepScope); globalTermination.stepEnded(stepScope); + stepScope.getPhaseScope().getSolverScope().getValueRangeState().stepEnded(stepScope); // Do not propagate to phases; the active phase does that for itself and they should not propagate further. } 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/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); - } - } } From c3c5da49e227661ee591f87521252a33fd4c329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 25 Jun 2025 08:06:47 +0200 Subject: [PATCH 14/22] Update core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Frederico Gonçalves --- .../core/impl/move/streams/dataset/UniDatasetStreamTest.java | 1 - 1 file changed, 1 deletion(-) 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 cbfd7cddcf9..93e2f4c1e06 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 @@ -560,7 +560,6 @@ void forEachListVariableExcludingPinnedValuesIncludingNull() { var partiallyPinnedEntity = solution.getEntityList().get(1); partiallyPinnedEntity.setPlanningPinToIndex(1); partiallyPinnedEntity.setValueList(List.of(value1, value2)); - // 1 value, not pinned. var unpinnedEntity = solution.getEntityList().get(2); unpinnedEntity.setPinned(false); unpinnedEntity.setPlanningPinToIndex(0); From 3421c56a188b519e9e8a0cbef6eafe951dc8876e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 25 Jun 2025 08:07:00 +0200 Subject: [PATCH 15/22] Update core/src/test/java/ai/timefold/solver/core/impl/move/streams/dataset/UniDatasetStreamTest.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Frederico Gonçalves --- .../core/impl/move/streams/dataset/UniDatasetStreamTest.java | 1 + 1 file changed, 1 insertion(+) 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 93e2f4c1e06..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 @@ -559,6 +559,7 @@ void forEachListVariableExcludingPinnedValuesIncludingNull() { // 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); From 42711e4ed964500d3853db8a7ffab6beb61a3b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 25 Jun 2025 08:50:40 +0200 Subject: [PATCH 16/22] Improve ValueRangeProvider Javadoc --- .../api/domain/valuerange/ValueRangeProvider.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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..32e2907ef91 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 @@ -8,8 +8,10 @@ import java.lang.annotation.Target; import java.util.Collection; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; 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; @@ -19,6 +21,17 @@ * 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}. + * + *

+ * 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) From 85242e08e748b20e6b9b609bd50c78fa7b03d4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 25 Jun 2025 11:38:58 +0200 Subject: [PATCH 17/22] WIP --- .../domain/valuerange/ValueRangeProvider.java | 6 ++ ...tractFromPropertyValueRangeDescriptor.java | 40 +++++++----- .../cloner/AbstractSolutionClonerTest.java | 29 +++++---- .../DefaultLocalSearchPhaseTest.java | 26 -------- .../testdomain/list/TestdataListEntity.java | 4 ++ .../TestdataListEntityExternalized.java | 36 ----------- .../TestdataListSolutionExternalized.java | 61 ------------------- .../TestdataListValueExternalized.java | 25 -------- 8 files changed, 48 insertions(+), 179 deletions(-) delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListEntityExternalized.java delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListSolutionExternalized.java delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/list/externalized/TestdataListValueExternalized.java 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 32e2907ef91..8eeb3878b20 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 @@ -23,6 +23,12 @@ * A {@link Collection} is implicitly converted to a {@link ValueRange}. * *

+ * If two values in a value range are equal according to {@link Object#equals(Object)}, + * then they are considered the same value + * and will only be present once in the value range, + * regardless of how many times they are present originally. + * + *

* 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. 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..efe3d7bc919 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,9 +1,9 @@ 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.List; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; @@ -12,7 +12,6 @@ import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; 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; @@ -105,8 +104,8 @@ protected ValueRange readValueRange(Object bean) { } ValueRange valueRange; if (collectionWrapping || arrayWrapping) { - List list = collectionWrapping ? transformCollectionToList((Collection) valueRangeObject) - : ReflectionHelper.transformArrayToList(valueRangeObject); + List list = collectionWrapping ? transformCollectionToUniqueList((Collection) valueRangeObject) + : transformArrayToUniqueList(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( @@ -163,18 +162,27 @@ 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; - } - } else { - // TODO If only ValueRange.createOriginalIterator() is used, cloning a Set to a List is a waste of time. - return new ArrayList<>(collection); + private List transformCollectionToUniqueList(Collection collection) { + if (collection.isEmpty()) { + return Collections.emptyList(); + } + return collection.stream() + .distinct() + .toList(); + } + + @SuppressWarnings("unchecked") + public static List transformArrayToUniqueList(Object arrayObject) { + if (arrayObject == null) { + return Collections.emptyList(); + } + var array = (Value_[]) arrayObject; + if (array.length == 0) { + return Collections.emptyList(); } + return Arrays.stream(array) + .distinct() + .toList(); } } 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/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/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; - } -} From f73241501452cbdb016b82daa29361970bf1e307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 30 Jun 2025 11:39:38 +0200 Subject: [PATCH 18/22] disable test logging --- core/src/test/resources/logback-test.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 @@ - + - + From d621504f61bc042c11cf9c24867b4ea7058c232e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 30 Jun 2025 12:48:03 +0200 Subject: [PATCH 19/22] Improve performance of the system --- .../PlanningEntityCollectionProperty.java | 6 ++ .../ProblemFactCollectionProperty.java | 6 ++ .../domain/valuerange/ValueRangeProvider.java | 25 ++++-- .../buildin/collection/ListValueRange.java | 18 +++-- .../buildin/collection/SetValueRange.java | 75 ++++++++++++++++++ ...tractFromPropertyValueRangeDescriptor.java | 78 +++++++++++++------ .../AbstractValueRangeDescriptor.java | 2 +- .../buildin/collection/SetValueRangeTest.java | 72 +++++++++++++++++ .../solver/core/testutil/PlannerAssert.java | 14 +--- 9 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRange.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/valuerange/buildin/collection/SetValueRangeTest.java 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 8eeb3878b20..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,8 +7,12 @@ 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; @@ -17,23 +21,32 @@ /** * 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}. * *

- * If two values in a value range are equal according to {@link Object#equals(Object)}, - * then they are considered the same value - * and will only be present once in the value range, - * regardless of how many times they are present originally. + * 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, 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 a319c09ba73..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,5 +1,6 @@ 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; @@ -7,6 +8,7 @@ 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; @@ -15,7 +17,7 @@ @NullMarked public final class ListValueRange extends AbstractCountableValueRange { - private static final int LIST_SIZE_LOOKUP_LIMIT = 10; + private static final int LIST_SIZE_LOOKUP_LIMIT = 10; // Selected arbitrarily. private final List list; private @Nullable Set lookupSet; // Initialized lazily for large lists. @@ -32,7 +34,8 @@ 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); } @@ -41,7 +44,11 @@ public T get(long index) { public boolean contains(@Nullable T value) { if (list.size() > LIST_SIZE_LOOKUP_LIMIT) { if (lookupSet == null) { - lookupSet = Set.copyOf(list); + 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. @@ -60,9 +67,8 @@ public boolean contains(@Nullable 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 efe3d7bc919..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 @@ -4,17 +4,22 @@ import java.util.Arrays; import java.util.Collection; 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.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; /** @@ -103,23 +108,35 @@ protected ValueRange readValueRange(Object bean) { .formatted(ValueRangeProvider.class.getSimpleName(), memberAccessor, bean, valueRangeObject)); } ValueRange valueRange; - if (collectionWrapping || arrayWrapping) { - List list = collectionWrapping ? transformCollectionToUniqueList((Collection) valueRangeObject) - : transformArrayToUniqueList(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; } @@ -133,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); @@ -162,17 +193,18 @@ protected long readValueRangeSize(Object bean) { } } - private List transformCollectionToUniqueList(Collection collection) { + private List transformCollectionToList(Collection collection) { if (collection.isEmpty()) { return Collections.emptyList(); + } else if (collection instanceof List list) { + return list; + } else { + return List.copyOf(collection); } - return collection.stream() - .distinct() - .toList(); } @SuppressWarnings("unchecked") - public static List transformArrayToUniqueList(Object arrayObject) { + public static List transformArrayToList(Object arrayObject) { if (arrayObject == null) { return Collections.emptyList(); } @@ -180,9 +212,7 @@ public static List transformArrayToUniqueList(Object arrayObjec if (array.length == 0) { return Collections.emptyList(); } - return Arrays.stream(array) - .distinct() - .toList(); + 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/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/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); } // ************************************************************************ From 4b7e02955310722dc798864b6a4eacd394a4893b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 30 Jun 2025 13:19:18 +0200 Subject: [PATCH 20/22] Value ranges are immutable --- .../score/director/AbstractScoreDirector.java | 3 -- .../impl/score/director/ValueRangeState.java | 54 ++----------------- .../core/impl/solver/AbstractSolver.java | 6 --- 3 files changed, 3 insertions(+), 60 deletions(-) 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 cce5cc355f8..886e9f64d4e 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 @@ -486,7 +486,6 @@ public void afterVariableChanged(VariableDescriptor variableDescripto if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); } - valueRangeState.markEntityDependentValueRangesAsInvalid(entity); variableListenerSupport.afterVariableChanged(variableDescriptor, entity); } @@ -540,7 +539,6 @@ Attempting to change list variable (%s) on an entity (%s) in range [%d, %d), whi @Override public void afterListVariableChanged(ListVariableDescriptor variableDescriptor, Object entity, int fromIndex, int toIndex) { - valueRangeState.markEntityDependentValueRangesAsInvalid(entity); variableListenerSupport.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.update(entity); @@ -560,7 +558,6 @@ public void afterEntityRemoved(EntityDescriptor entityDescriptor, Obj if (lookUpEnabled) { lookUpManager.removeWorkingObject(entity); } - valueRangeState.markEntityDependentValueRangesAsInvalid(entity); if (!allChangesWillBeUndoneBeforeStepEnds) { if (moveRepository instanceof MoveStreamsBasedMoveRepository moveStreamsBasedMoveRepository) { moveStreamsBasedMoveRepository.retract(entity); 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 index e843b401566..600e21fb1f9 100644 --- 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 @@ -1,18 +1,14 @@ package ai.timefold.solver.core.impl.score.director; -import java.util.HashSet; 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.solver.change.ProblemChange; 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 ai.timefold.solver.core.impl.phase.event.PhaseLifecycleListener; -import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; -import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -28,21 +24,13 @@ * 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. - * - *

- * Call {@link #markEntityDependentValueRangesAsInvalid(Object)} every time an entity is changed, - * so that the cached value range can be invalidated. - * The actual invalidation happens at {@link #stepEnded(AbstractStepScope)}, called by the solver. - * Otherwise every undone move would be invalidating the value ranges. - * Value ranges should only be invalidated when a move has been selected at the end of a step. */ @NullMarked -public final class ValueRangeState - implements PhaseLifecycleListener { +public final class ValueRangeState { - private final HashSet entitiesWithInvalidValueRangesSet = new HashSet<>(); private final Map, ValueRange> fromSolutionValueRangeMap = new IdentityHashMap<>(); private final Map, ValueRange>> fromEntityValueRangeMap = new IdentityHashMap<>(); @@ -93,40 +81,4 @@ public boolean isInRange(ValueRangeDescriptor valueRangeDescriptor, @ } } - void markEntityDependentValueRangesAsInvalid(Object entity) { - entitiesWithInvalidValueRangesSet.add(entity); - } - - @Override - public void solvingStarted(SolverScope solverScope) { - // No need to do anything here. - } - - @Override - public void phaseStarted(AbstractPhaseScope phaseScope) { - // No need to do anything here. - } - - @Override - public void stepStarted(AbstractStepScope stepScope) { - // No need to do anything here. - } - - @Override - public void stepEnded(AbstractStepScope stepScope) { - entitiesWithInvalidValueRangesSet.forEach(fromEntityValueRangeMap::remove); - entitiesWithInvalidValueRangesSet.clear(); - } - - @Override - public void phaseEnded(AbstractPhaseScope phaseScope) { - // No need to do anything here. - } - - @Override - public void solvingEnded(SolverScope solverScope) { - this.workingSolution = null; - fromSolutionValueRangeMap.clear(); - fromEntityValueRangeMap.clear(); - } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java index f25600dc698..20b25d05cb4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/AbstractSolver.java @@ -57,7 +57,6 @@ protected AbstractSolver(BestSolutionRecaller bestSolutionRecaller, public void solvingStarted(SolverScope solverScope) { solverScope.setWorkingSolutionFromBestSolution(); - solverScope.getValueRangeState().solvingStarted(solverScope); bestSolutionRecaller.solvingStarted(solverScope); globalTermination.solvingStarted(solverScope); phaseLifecycleSupport.fireSolvingStarted(solverScope); @@ -93,7 +92,6 @@ public void solvingEnded(SolverScope solverScope) { bestSolutionRecaller.solvingEnded(solverScope); globalTermination.solvingEnded(solverScope); phaseLifecycleSupport.fireSolvingEnded(solverScope); - solverScope.getValueRangeState().solvingEnded(solverScope); } public void solvingError(SolverScope solverScope, Exception exception) { @@ -104,7 +102,6 @@ public void solvingError(SolverScope solverScope, Exception exception } public void phaseStarted(AbstractPhaseScope phaseScope) { - phaseScope.getSolverScope().getValueRangeState().phaseStarted(phaseScope); bestSolutionRecaller.phaseStarted(phaseScope); phaseLifecycleSupport.firePhaseStarted(phaseScope); globalTermination.phaseStarted(phaseScope); @@ -115,12 +112,10 @@ public void phaseEnded(AbstractPhaseScope phaseScope) { bestSolutionRecaller.phaseEnded(phaseScope); phaseLifecycleSupport.firePhaseEnded(phaseScope); globalTermination.phaseEnded(phaseScope); - phaseScope.getSolverScope().getValueRangeState().phaseEnded(phaseScope); // Do not propagate to phases; the active phase does that for itself and they should not propagate further. } public void stepStarted(AbstractStepScope stepScope) { - stepScope.getPhaseScope().getSolverScope().getValueRangeState().stepStarted(stepScope); bestSolutionRecaller.stepStarted(stepScope); phaseLifecycleSupport.fireStepStarted(stepScope); globalTermination.stepStarted(stepScope); @@ -131,7 +126,6 @@ public void stepEnded(AbstractStepScope stepScope) { bestSolutionRecaller.stepEnded(stepScope); phaseLifecycleSupport.fireStepEnded(stepScope); globalTermination.stepEnded(stepScope); - stepScope.getPhaseScope().getSolverScope().getValueRangeState().stepEnded(stepScope); // Do not propagate to phases; the active phase does that for itself and they should not propagate further. } From 3a9d2149e1fa29a8b648a28a6b0c67c509d668bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 30 Jun 2025 14:11:42 +0200 Subject: [PATCH 21/22] Fix a bug --- .../DefaultPlanningListVariableMetaModel.java | 2 +- .../DefaultPlanningVariableMetaModel.java | 2 +- .../InnerGenuineVariableMetaModel.java | 15 +++++++++++ .../descriptor/InnerVariableMetaModel.java | 2 +- .../core/impl/move/director/MoveDirector.java | 4 ++- .../score/director/AbstractScoreDirector.java | 26 +++---------------- .../score/director/InnerScoreDirector.java | 4 +-- .../impl/score/director/ValueRangeState.java | 4 +++ 8 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerGenuineVariableMetaModel.java 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 d4e68c3981f..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 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 96f0840ac9a..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 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/move/director/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/director/MoveDirector.java index ebedef77def..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; @@ -185,10 +186,11 @@ protected static ElementPosition getPositionOf(Inne .getElementPosition(value); } + @SuppressWarnings("unchecked") @Override public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { - return backingScoreDirector.isValueInRange(variableMetaModel, entity, value); + return backingScoreDirector.isValueInRange((InnerGenuineVariableMetaModel) variableMetaModel, entity, value); } @Override 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 886e9f64d4e..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,10 +23,8 @@ 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.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.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.domain.valuerange.descriptor.ValueRangeDescriptor; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; @@ -44,9 +42,6 @@ import ai.timefold.solver.core.impl.solver.exception.UndoScoreCorruptionException; import ai.timefold.solver.core.impl.solver.exception.VariableCorruptionException; import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; -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; import org.jspecify.annotations.NonNull; @@ -322,29 +317,16 @@ protected void setWorkingEntityListDirty() { } @Override - public boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + public boolean isValueInRange(InnerGenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { - var valueRangeDescriptor = extractValueRangeDescriptor(variableMetaModel); - if (entity == null && valueRangeDescriptor.isEntityIndependent()) { + 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); } - private static ValueRangeDescriptor - extractValueRangeDescriptor(GenuineVariableMetaModel variableMetaModel) { - if (variableMetaModel instanceof DefaultPlanningVariableMetaModel basicVariableMetaModel) { - return basicVariableMetaModel.variableDescriptor().getValueRangeDescriptor(); - } else if (variableMetaModel instanceof DefaultPlanningListVariableMetaModel listVariableMetaModel) { - return listVariableMetaModel.variableDescriptor().getValueRangeDescriptor(); - } else { - throw new IllegalStateException("Impossible state: The variable metamodel (%s) is neither %s nor %s." - .formatted(variableMetaModel, PlanningVariableMetaModel.class.getSimpleName(), - PlanningListVariableMetaModel.class.getSimpleName())); - } - } - @Override public Solution_ cloneSolution(Solution_ originalSolution) { SolutionDescriptor solutionDescriptor = getSolutionDescriptor(); 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 f1310dc0d63..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; @@ -36,7 +37,6 @@ import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.solver.thread.ChildThreadType; -import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; import org.jspecify.annotations.Nullable; @@ -184,7 +184,7 @@ static > ConstraintAnalysis getConstraintAn boolean isWorkingSolutionInitialized(); - boolean isValueInRange(GenuineVariableMetaModel variableDescriptor, + boolean isValueInRange(InnerGenuineVariableMetaModel variableDescriptor, @Nullable Entity_ entity, @Nullable Value_ value); /** 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 index 600e21fb1f9..487065befba 100644 --- 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 @@ -5,6 +5,7 @@ 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.valuerange.descriptor.ValueRangeDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; @@ -27,6 +28,9 @@ * 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 { From 73ac768ff4f6a89487bd74e55cd40b24edcf15ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 30 Jun 2025 14:37:01 +0200 Subject: [PATCH 22/22] Fix Javadoc --- .../solver/core/impl/score/director/ValueRangeState.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 487065befba..cb3baa6e7d6 100644 --- 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 @@ -7,10 +7,10 @@ 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 ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -18,7 +18,7 @@ /** * Caches value ranges for the current working solution, * allowing to quickly check if a value is in range. - * Used by {@link AbstractScoreDirector#isValueInRange(GenuineVariableMetaModel, Object, Object)}. + * Used by {@link AbstractScoreDirector#isValueInRange(InnerGenuineVariableMetaModel, Object, Object)}. * *

* The state is built on-demand as {@link #isInRange(ValueRangeDescriptor, Object, Object)} is called.