From ec3894cd40dc39494ca771d25c0fbc06cdcf0f6f Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 21 Aug 2025 06:28:51 -0300 Subject: [PATCH 1/9] chore: simplify reachable values logic --- .../selector/common/ReachableValues.java | 117 +++++++++++------- .../FilteringEntityValueRangeSelector.java | 3 +- .../FilteringValueRangeSelector.java | 102 ++++++--------- .../score/director/ValueRangeManager.java | 75 +++++------ ...trixTest.java => ReachableValuesTest.java} | 34 +++-- 5 files changed, 168 insertions(+), 163 deletions(-) rename core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/{ReachableMatrixTest.java => ReachableValuesTest.java} (66%) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java index 8abfa8b851..d62ec0cf94 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java @@ -2,7 +2,7 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.IdentityHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -22,72 +22,93 @@ @NullMarked public final class ReachableValues { - private final Map> valueToEntityMap; - private final Map> valueToValueMap; - private final Map> randomAccessValueToEntityMap; - private final Map> randomAccessValueToValueMap; + private final Map values; private final @Nullable Class valueClass; + private @Nullable ReachableItemValue cachedObject; - public ReachableValues(Map> valueToEntityMap, Map> valueToValueMap) { - this.valueToEntityMap = valueToEntityMap; - this.randomAccessValueToEntityMap = new IdentityHashMap<>(this.valueToEntityMap.size()); - this.valueToValueMap = valueToValueMap; - this.randomAccessValueToValueMap = new IdentityHashMap<>(this.valueToValueMap.size()); - var first = valueToEntityMap.entrySet().stream().findFirst(); - this.valueClass = first.> map(entry -> entry.getKey().getClass()).orElse(null); + public ReachableValues(Map values) { + this.values = values; + var firstValue = values.entrySet().stream().findFirst(); + this.valueClass = firstValue.> map(entry -> entry.getKey().getClass()).orElse(null); } - /** - * @return all reachable values for the given value. - */ - public @Nullable Set extractEntities(Object value) { - return valueToEntityMap.get(value); - } - - /** - * @return all reachable entities for the given value. - */ - public @Nullable Set extractValues(Object value) { - return valueToValueMap.get(value); + private @Nullable ReachableItemValue fetchItemValue(Object value) { + if (cachedObject == null || value != cachedObject.value) { + cachedObject = values.get(value); + } + return cachedObject; } public List extractEntitiesAsList(Object value) { - var result = randomAccessValueToEntityMap.get(value); - if (result == null) { - var entitySet = this.valueToEntityMap.get(value); - if (entitySet != null) { - result = new ArrayList<>(entitySet); - } else { - result = Collections.emptyList(); - } - randomAccessValueToEntityMap.put(value, result); + var itemValue = fetchItemValue(value); + if (itemValue == null) { + return Collections.emptyList(); } - return result; + return itemValue.randomAccessEntityList; } public List extractValuesAsList(Object value) { - var result = randomAccessValueToValueMap.get(value); - if (result == null) { - var valueSet = this.valueToValueMap.get(value); - if (valueSet != null) { - result = new ArrayList<>(valueSet); - } else { - result = Collections.emptyList(); - } - randomAccessValueToValueMap.put(value, result); + var itemValue = fetchItemValue(value); + if (itemValue == null) { + return Collections.emptyList(); } - return result; + return itemValue.randomAccessValueList; } public int getSize() { - return valueToEntityMap.size(); + return values.size(); } - public boolean isValidValueClass(Object value) { - if (valueToEntityMap.isEmpty()) { + public boolean isEntityReachable(Object origin, @Nullable Object entity) { + if (entity == null) { + return true; + } + var originItemValue = fetchItemValue(Objects.requireNonNull(origin)); + if (originItemValue == null) { return false; } - return Objects.requireNonNull(value).getClass().equals(valueClass); + return originItemValue.entitySet.contains(entity); + } + + public boolean isValueReachable(Object origin, Object otherValue) { + var originItemValue = fetchItemValue(Objects.requireNonNull(origin)); + if (originItemValue == null) { + return false; + } + return originItemValue.valueSet.contains(Objects.requireNonNull(otherValue)); + } + + public boolean matchesValueClass(Object value) { + return valueClass != null && valueClass.isAssignableFrom(Objects.requireNonNull(value).getClass()); + } + + @NullMarked + public static final class ReachableItemValue { + private final Object value; + private final Set entitySet; + private final Set valueSet; + private final List randomAccessEntityList; + private final List randomAccessValueList; + + public ReachableItemValue(Object value, int entityListSize, int valueListSize) { + this.value = value; + this.entitySet = new LinkedHashSet<>(entityListSize); + this.randomAccessEntityList = new ArrayList<>(entityListSize); + this.valueSet = new LinkedHashSet<>(valueListSize); + this.randomAccessValueList = new ArrayList<>(valueListSize); + } + + public void addEntity(Object entity) { + if (entitySet.add(entity)) { + randomAccessEntityList.add(entity); + } + } + + public void addValue(Object value) { + if (valueSet.add(value)) { + randomAccessValueList.add(value); + } + } } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java index b5a2bf2f42..5ea044892e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java @@ -170,8 +170,7 @@ private void initialize() { if (currentUpcomingValue == null) { valueIterator = Collections.emptyIterator(); } else { - var allValues = Objects.requireNonNull(reachableValues) - .extractEntitiesAsList(Objects.requireNonNull(currentUpcomingValue)); + var allValues = reachableValues.extractEntitiesAsList(Objects.requireNonNull(currentUpcomingValue)); this.valueIterator = Objects.requireNonNull(allValues).iterator(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java index c3d642d3df..c85bcca3cb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java @@ -5,7 +5,6 @@ import java.util.List; import java.util.Objects; import java.util.Random; -import java.util.Set; import java.util.function.Supplier; import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; @@ -144,18 +143,18 @@ private Object selectReplayedValue() { @Override public Iterator iterator() { if (randomSelection) { - return new RandomFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, listVariableStateSupply, - workingRandom, (int) getSize(), checkSourceAndDestination); + return new RandomFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, + listVariableStateSupply, workingRandom, (int) getSize(), checkSourceAndDestination); } else { - return new OriginalFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, listVariableStateSupply, - checkSourceAndDestination); + return new OriginalFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, + listVariableStateSupply, checkSourceAndDestination); } } @Override public Iterator endingIterator(Object entity) { - return new OriginalFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, listVariableStateSupply, - checkSourceAndDestination); + return new OriginalFilteringValueRangeIterator(this::selectReplayedValue, reachableValues, + listVariableStateSupply, checkSourceAndDestination); } @Override @@ -175,10 +174,7 @@ private abstract class AbstractFilteringValueRangeIterator extends UpcomingSelec private final ListVariableStateSupply listVariableStateSupply; private final ReachableValues reachableValues; - // Check if the source and destination entity range accepts the selected values - private final boolean checkSourceAndDestination; - // Use the value list instead of the set, as it is required by random access iterators - private final boolean useValueList; + boolean checkSourceAndDestination = false; boolean initialized = false; boolean hasData = false; @Nullable @@ -186,44 +182,30 @@ private abstract class AbstractFilteringValueRangeIterator extends UpcomingSelec @Nullable Object currentUpcomingEntity; @Nullable - Set valuesSet; - @Nullable - List valueList; - @Nullable - Set entitiesSet; + List currentUpcomingList; AbstractFilteringValueRangeIterator(ReachableValues reachableValues, - ListVariableStateSupply listVariableStateSupply, boolean checkSourceAndDestination, - boolean useValueList) { + ListVariableStateSupply listVariableStateSupply, boolean checkSourceAndDestination) { this.reachableValues = Objects.requireNonNull(reachableValues); this.listVariableStateSupply = listVariableStateSupply; this.checkSourceAndDestination = checkSourceAndDestination; - this.useValueList = useValueList; } - void loadValues() { - if (currentUpcomingValue == null) { + /** + * This method initializes the basic structure required for the child iterators, + * including the upcoming entity and the upcoming list. + * + * @param upcomingValue the upcoming value + */ + void loadValues(@Nullable Object upcomingValue) { + if (upcomingValue == null) { noData(); return; } - this.entitiesSet = reachableValues.extractEntities(currentUpcomingValue); - this.valueList = null; - this.valuesSet = null; - if (useValueList) { - // Load the random access list - valueList = Objects.requireNonNull(reachableValues.extractValuesAsList(currentUpcomingValue)); - if (valueList.isEmpty()) { - noData(); - return; - } - } else { - // Load the fast access set - this.valuesSet = reachableValues.extractValues(currentUpcomingValue); - if (valuesSet == null || valuesSet.isEmpty()) { - noData(); - return; - } + if (upcomingValue == currentUpcomingValue) { + return; } + currentUpcomingValue = upcomingValue; currentUpcomingEntity = null; if (checkSourceAndDestination) { // Load the current assigned entity of the selected value @@ -232,34 +214,31 @@ void loadValues() { currentUpcomingEntity = positionInList.entity(); } } + currentUpcomingList = reachableValues.extractValuesAsList(currentUpcomingValue); upcomingCreated = false; this.hasData = true; this.initialized = true; } void noData() { - this.entitiesSet = null; - this.valuesSet = null; - this.valueList = null; this.currentUpcomingEntity = null; this.hasData = false; this.initialized = true; + this.currentUpcomingList = Collections.emptyList(); } boolean isReachable(Object destinationValue) { - var sourceValid = true; - var destinationValid = true; - // Test if the source accepts the destination entity + Object destinationEntity = null; var assignedDestinationPosition = listVariableStateSupply.getElementPosition(destinationValue); if (assignedDestinationPosition instanceof PositionInList elementPosition) { - sourceValid = entitiesSet.contains(elementPosition.entity()); + destinationEntity = elementPosition.entity(); } - if (checkSourceAndDestination && sourceValid && currentUpcomingEntity != null) { - // Test if the destination accepts the source entity - destinationValid = Objects.requireNonNull(reachableValues.extractEntities(destinationValue)) - .contains(currentUpcomingEntity); + if (checkSourceAndDestination) { + return reachableValues.isEntityReachable(Objects.requireNonNull(currentUpcomingValue), destinationEntity) + && reachableValues.isEntityReachable(Objects.requireNonNull(destinationValue), currentUpcomingEntity); + } else { + return reachableValues.isEntityReachable(Objects.requireNonNull(currentUpcomingValue), destinationEntity); } - return sourceValid && destinationValid; } } @@ -270,7 +249,7 @@ private class OriginalFilteringValueRangeIterator extends AbstractFilteringValue private OriginalFilteringValueRangeIterator(Supplier upcomingValueSupplier, ReachableValues reachableValues, ListVariableStateSupply listVariableStateSupply, boolean checkSourceAndDestination) { - super(reachableValues, listVariableStateSupply, checkSourceAndDestination, false); + super(reachableValues, listVariableStateSupply, checkSourceAndDestination); this.upcomingValueSupplier = upcomingValueSupplier; } @@ -278,13 +257,8 @@ void initialize() { if (initialized) { return; } - this.currentUpcomingValue = upcomingValueSupplier.get(); - loadValues(); - if (hasData) { - valueIterator = Objects.requireNonNull(valuesSet).iterator(); - } else { - valueIterator = Collections.emptyIterator(); - } + loadValues(upcomingValueSupplier.get()); + valueIterator = Objects.requireNonNull(currentUpcomingList).iterator(); } @Override @@ -313,7 +287,7 @@ private class RandomFilteringValueRangeIterator extends AbstractFilteringValueRa private RandomFilteringValueRangeIterator(Supplier upcomingValueSupplier, ReachableValues reachableValues, ListVariableStateSupply listVariableStateSupply, Random workingRandom, int maxBailoutSize, boolean checkSourceAndDestination) { - super(reachableValues, listVariableStateSupply, checkSourceAndDestination, true); + super(reachableValues, listVariableStateSupply, checkSourceAndDestination); this.upcomingValueSupplier = upcomingValueSupplier; this.workingRandom = workingRandom; this.maxBailoutSize = maxBailoutSize; @@ -323,8 +297,7 @@ private void initialize() { if (initialized) { return; } - this.currentUpcomingValue = upcomingValueSupplier.get(); - loadValues(); + loadValues(upcomingValueSupplier.get()); } @Override @@ -337,8 +310,7 @@ public boolean hasNext() { // even if the entity has changed. // Therefore, // we need to update the value list to ensure it is consistent. - this.currentUpcomingValue = updatedUpcomingValue; - loadValues(); + loadValues(updatedUpcomingValue); } } return super.hasNext(); @@ -357,8 +329,8 @@ protected Object createUpcomingSelection() { return noUpcomingSelection(); } bailoutSize--; - var index = workingRandom.nextInt(valueList.size()); - next = valueList.get(index); + var index = workingRandom.nextInt(Objects.requireNonNull(currentUpcomingList).size()); + next = currentUpcomingList.get(index); } while (!isReachable(next)); return next; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java index 99327c6013..651a026e11 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java @@ -3,10 +3,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.IdentityHashMap; -import java.util.LinkedHashSet; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.function.Consumer; import ai.timefold.solver.core.api.domain.valuerange.CountableValueRange; @@ -26,6 +24,7 @@ import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.common.ReachableValues; +import ai.timefold.solver.core.impl.heuristic.selector.common.ReachableValues.ReachableItemValue; import ai.timefold.solver.core.impl.util.MathUtils; import ai.timefold.solver.core.impl.util.MutableInt; import ai.timefold.solver.core.impl.util.MutableLong; @@ -58,9 +57,9 @@ public final class ValueRangeManager { private final SolutionDescriptor solutionDescriptor; private final CountableValueRange[] fromSolution; + private final ReachableValues[] reachableValues; private final Map[]> fromEntityMap = new IdentityHashMap<>(); - private @Nullable ReachableValues reachableValues = null; private @Nullable Solution_ cachedWorkingSolution = null; private @Nullable SolutionInitializationStatistics cachedInitializationStatistics = null; @@ -82,6 +81,7 @@ public static ValueRangeManager of(SolutionDescriptor solutionDescriptor) { this.solutionDescriptor = Objects.requireNonNull(solutionDescriptor); this.fromSolution = new CountableValueRange[solutionDescriptor.getValueRangeDescriptorCount()]; + this.reachableValues = new ReachableValues[solutionDescriptor.getValueRangeDescriptorCount()]; } public SolutionInitializationStatistics getInitializationStatistics() { @@ -418,73 +418,74 @@ public long countOnEntity(ValueRangeDescriptor valueRangeDescriptor, .getSize(); } - public ReachableValues getReachableValues(ListVariableDescriptor listVariableDescriptor) { - if (reachableValues == null) { + public ReachableValues getReachableValues(GenuineVariableDescriptor variableDescriptor) { + var values = reachableValues[variableDescriptor.getValueRangeDescriptor().getOrdinal()]; + if (values == null) { if (cachedWorkingSolution == null) { throw new IllegalStateException( "Impossible state: value reachability requested before the working solution is known."); } - var entityDescriptor = listVariableDescriptor.getEntityDescriptor(); + var entityDescriptor = variableDescriptor.getEntityDescriptor(); var entityList = entityDescriptor.extractEntities(cachedWorkingSolution); - var allValues = getFromSolution(listVariableDescriptor.getValueRangeDescriptor()); + var allValues = getFromSolution(variableDescriptor.getValueRangeDescriptor()); var valuesSize = allValues.getSize(); if (valuesSize > Integer.MAX_VALUE) { throw new IllegalStateException( "The matrix %s cannot be built for the entity %s (%s) because value range has a size (%d) which is higher than Integer.MAX_VALUE." .formatted(ReachableValues.class.getSimpleName(), entityDescriptor.getEntityClass().getSimpleName(), - listVariableDescriptor.getVariableName(), valuesSize)); + variableDescriptor.getVariableName(), valuesSize)); } - // list of entities reachable for a value - var entityMatrix = new IdentityHashMap>((int) valuesSize); - // list of values reachable for a value - var valueMatrix = new IdentityHashMap>((int) valuesSize); + var reachableValuesMap = new IdentityHashMap((int) valuesSize); for (var entity : entityList) { var valuesIterator = allValues.createOriginalIterator(); - var range = getFromEntity(listVariableDescriptor.getValueRangeDescriptor(), entity); + var range = getFromEntity(variableDescriptor.getValueRangeDescriptor(), entity); while (valuesIterator.hasNext()) { var value = valuesIterator.next(); if (range.contains(value)) { - updateEntityMap(entityMatrix, entity, value, entityList.size()); - updateValueMap(valueMatrix, range, value, (int) valuesSize); + var item = initReachableMap(reachableValuesMap, value, entityList.size(), (int) valuesSize); + item.addEntity(entity); + var iterator = range.createOriginalIterator(); + while (iterator.hasNext()) { + var iteratorValue = iterator.next(); + if (!Objects.equals(iteratorValue, value)) { + item.addValue(iteratorValue); + } + } } } } - reachableValues = new ReachableValues(entityMatrix, valueMatrix); + values = new ReachableValues(reachableValuesMap); + reachableValues[variableDescriptor.getValueRangeDescriptor().getOrdinal()] = values; } - return reachableValues; + return values; } - private static void updateEntityMap(Map> entityMatrix, Object entity, Object value, - int entityListSize) { - var entitySet = entityMatrix.get(value); - if (entitySet == null) { - entitySet = new LinkedHashSet<>(entityListSize); - entityMatrix.put(value, entitySet); + private static ReachableItemValue initReachableMap(Map reachableValuesMap, Object value, + int entityListSize, int valueListSize) { + var item = reachableValuesMap.get(value); + if (item == null) { + item = new ReachableItemValue(value, entityListSize, valueListSize); + reachableValuesMap.put(value, item); } - entitySet.add(entity); + return item; } - private static void updateValueMap(Map> valueMatrix, CountableValueRange range, Object value, - int valueListSize) { - var reachableValues = valueMatrix.get(value); - if (reachableValues == null) { - reachableValues = new LinkedHashSet<>(valueListSize); - valueMatrix.put(value, reachableValues); - } - var entityValuesIterator = range.createOriginalIterator(); - while (entityValuesIterator.hasNext()) { - var entityValue = entityValuesIterator.next(); - if (!Objects.equals(entityValue, value)) { - reachableValues.add(entityValue); + private static void updateMap(ReachableItemValue item, Object entity, Object value, CountableValueRange range) { + item.addEntity(entity); + var iterator = range.createOriginalIterator(); + while (iterator.hasNext()) { + var iteratorValue = iterator.next(); + if (!Objects.equals(iteratorValue, value)) { + item.addValue(iteratorValue); } } } public void reset(@Nullable Solution_ workingSolution) { Arrays.fill(fromSolution, null); + Arrays.fill(reachableValues, null); fromEntityMap.clear(); - reachableValues = null; // We only update the cached solution if it is not null; null means to only reset the maps. if (workingSolution != null) { cachedWorkingSolution = workingSolution; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableMatrixTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java similarity index 66% rename from core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableMatrixTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java index 4d902f0318..72bd975016 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableMatrixTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java @@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test; -class ReachableMatrixTest { +class ReachableValuesTest { @Test void testReachableEntities() { @@ -34,11 +34,18 @@ void testReachableEntities() { var reachableValues = scoreDirector.getValueRangeManager() .getReachableValues(entityDescriptor.getGenuineListVariableDescriptor()); - assertThat(reachableValues.extractEntities(v1)).containsExactlyInAnyOrder(a); - assertThat(reachableValues.extractEntities(v2)).containsExactlyInAnyOrder(a, b); - assertThat(reachableValues.extractEntities(v3)).containsExactlyInAnyOrder(a, b, c); - assertThat(reachableValues.extractEntities(v4)).containsExactlyInAnyOrder(c); - assertThat(reachableValues.extractEntities(v5)).containsExactlyInAnyOrder(c); + assertThat(reachableValues.extractEntitiesAsList(v1)).containsExactlyInAnyOrder(a); + assertThat(reachableValues.extractEntitiesAsList(v2)).containsExactlyInAnyOrder(a, b); + assertThat(reachableValues.extractEntitiesAsList(v3)).containsExactlyInAnyOrder(a, b, c); + assertThat(reachableValues.extractEntitiesAsList(v4)).containsExactlyInAnyOrder(c); + assertThat(reachableValues.extractEntitiesAsList(v5)).containsExactlyInAnyOrder(c); + + assertThat(reachableValues.isEntityReachable(v1, a)).isTrue(); + assertThat(reachableValues.isEntityReachable(v1, b)).isFalse(); + assertThat(reachableValues.isEntityReachable(v1, c)).isFalse(); + + assertThat(reachableValues.matchesValueClass(v1)).isTrue(); + assertThat(reachableValues.matchesValueClass(a)).isFalse(); } @Test @@ -62,10 +69,15 @@ void testReachableValues() { var reachableValues = scoreDirector.getValueRangeManager() .getReachableValues(entityDescriptor.getGenuineListVariableDescriptor()); - assertThat(reachableValues.extractValues(v1)).containsExactlyInAnyOrder(v2, v3); - assertThat(reachableValues.extractValues(v2)).containsExactlyInAnyOrder(v1, v3); - assertThat(reachableValues.extractValues(v3)).containsExactlyInAnyOrder(v1, v2, v4, v5); - assertThat(reachableValues.extractValues(v4)).containsExactlyInAnyOrder(v3, v5); - assertThat(reachableValues.extractValues(v5)).containsExactlyInAnyOrder(v3, v4); + assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v2, v3); + assertThat(reachableValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v1, v3); + assertThat(reachableValues.extractValuesAsList(v3)).containsExactlyInAnyOrder(v1, v2, v4, v5); + assertThat(reachableValues.extractValuesAsList(v4)).containsExactlyInAnyOrder(v3, v5); + assertThat(reachableValues.extractValuesAsList(v5)).containsExactlyInAnyOrder(v3, v4); + + // Only origin + assertThat(reachableValues.isValueReachable(v1, v2)).isTrue(); + assertThat(reachableValues.isValueReachable(v1, v3)).isTrue(); + assertThat(reachableValues.isValueReachable(v1, v5)).isFalse(); } } From 8282950c54582c9f7c5c7e5c9b3c625ad5c414a1 Mon Sep 17 00:00:00 2001 From: fred Date: Fri, 22 Aug 2025 09:50:32 -0300 Subject: [PATCH 2/9] chore: minor updates --- .../score/director/ValueRangeManager.java | 11 --- .../TestdataEntityProvidingSolution.java | 10 +- ...TestdataMultiVarEntityProvidingEntity.java | 89 ++++++++++++++++++ ...stdataMultiVarEntityProvidingSolution.java | 94 +++++++++++++++++++ .../solver/core/testutil/PlannerAssert.java | 4 + 5 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingEntity.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingSolution.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java index 651a026e11..26836966ef 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java @@ -471,17 +471,6 @@ private static ReachableItemValue initReachableMap(Map range) { - item.addEntity(entity); - var iterator = range.createOriginalIterator(); - while (iterator.hasNext()) { - var iteratorValue = iterator.next(); - if (!Objects.equals(iteratorValue, value)) { - item.addValue(iteratorValue); - } - } - } - public void reset(@Nullable Solution_ workingSolution) { Arrays.fill(fromSolution, null); Arrays.fill(reachableValues, null); 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 81882e5f39..2b899d5665 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 @@ -25,11 +25,11 @@ public static SolutionDescriptor buildSolutionD public static TestdataEntityProvidingSolution generateSolution() { var solution = new TestdataEntityProvidingSolution("s1"); - var value1 = new TestdataValue("v1"); - var value2 = new TestdataValue("v2"); - var value3 = new TestdataValue("v3"); - var entity1 = new TestdataEntityProvidingEntity("e1", List.of(value1, value2)); - var entity2 = new TestdataEntityProvidingEntity("e2", List.of(value1, value3)); + var value1 = new TestdataValue("1"); + var value2 = new TestdataValue("2"); + var value3 = new TestdataValue("3"); + var entity1 = new TestdataEntityProvidingEntity("1", List.of(value1, value2)); + var entity2 = new TestdataEntityProvidingEntity("2", List.of(value1, value3)); solution.setEntityList(List.of(entity1, entity2)); return solution; } diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingEntity.java new file mode 100644 index 0000000000..f1051ce40b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingEntity.java @@ -0,0 +1,89 @@ +package ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.testdomain.TestdataObject; +import ai.timefold.solver.core.testdomain.TestdataValue; + +@PlanningEntity +public class TestdataMultiVarEntityProvidingEntity extends TestdataObject { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataMultiVarEntityProvidingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataMultiVarEntityProvidingEntity.class); + } + + @ValueRangeProvider(id = "valueRange") + private List valueRange; + @PlanningVariable(valueRangeProviderRefs = "valueRange") + private TestdataValue value; + @ValueRangeProvider(id = "secondValueRange") + private List secondValueRange; + @PlanningVariable(valueRangeProviderRefs = "secondValueRange") + private TestdataValue secondValue; + @PlanningVariable(valueRangeProviderRefs = "solutionValueRange") + private TestdataValue solutionValue; + + public TestdataMultiVarEntityProvidingEntity() { + // Required for cloning + } + + public TestdataMultiVarEntityProvidingEntity(String code, List valueRange, + List secondValueRange) { + this(code, valueRange, null, secondValueRange, null, null); + } + + public TestdataMultiVarEntityProvidingEntity(String code, List valueRange, TestdataValue value, + List secondValueRange, TestdataValue secondValue, TestdataValue solutionValue) { + super(code); + this.valueRange = valueRange; + this.value = value; + this.secondValueRange = secondValueRange; + this.secondValue = secondValue; + this.solutionValue = solutionValue; + } + + public List getValueRange() { + return valueRange; + } + + public void setValueRange(List valueRange) { + this.valueRange = valueRange; + } + + public TestdataValue getValue() { + return value; + } + + public void setValue(TestdataValue value) { + this.value = value; + } + + public List getSecondValueRange() { + return secondValueRange; + } + + public void setSecondValueRange(List secondValueRange) { + this.secondValueRange = secondValueRange; + } + + public TestdataValue getSecondValue() { + return secondValue; + } + + public void setSecondValue(TestdataValue secondValue) { + this.secondValue = secondValue; + } + + public TestdataValue getSolutionValue() { + return solutionValue; + } + + public void setSolutionValue(TestdataValue solutionValue) { + this.solutionValue = solutionValue; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingSolution.java new file mode 100644 index 0000000000..8ccfb2320a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataMultiVarEntityProvidingSolution.java @@ -0,0 +1,94 @@ +package ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +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 TestdataMultiVarEntityProvidingSolution extends TestdataObject { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataMultiVarEntityProvidingSolution.class, + TestdataMultiVarEntityProvidingEntity.class); + } + + public static TestdataMultiVarEntityProvidingSolution generateSolution() { + var value1 = new TestdataValue("1"); + var value2 = new TestdataValue("2"); + var value3 = new TestdataValue("3"); + var value4 = new TestdataValue("4"); + var value5 = new TestdataValue("5"); + var solution = new TestdataMultiVarEntityProvidingSolution("s1", List.of(value1, value2)); + var entity1 = new TestdataMultiVarEntityProvidingEntity("1", List.of(value1, value2, value3), + List.of(value1, value2, value4, value5)); + var entity2 = new TestdataMultiVarEntityProvidingEntity("2", List.of(value1, value2, value3), + List.of(value1, value2, value4, value5)); + solution.setEntityList(List.of(entity1, entity2)); + return solution; + } + + @PlanningEntityCollectionProperty + private List entityList; + @PlanningScore + private SimpleScore score; + @ValueRangeProvider(id = "solutionValueRange") + private List solutionValueRange; + + public TestdataMultiVarEntityProvidingSolution() { + // Required for cloning + } + + public TestdataMultiVarEntityProvidingSolution(String code, List solutionValueRange) { + super(code); + this.solutionValueRange = solutionValueRange; + } + + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + public List getSolutionValueRange() { + return solutionValueRange; + } + + public void setSolutionValueRange(List solutionValueRange) { + this.solutionValueRange = solutionValueRange; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } + + // ************************************************************************ + // Complex methods + // ************************************************************************ + + @ProblemFactCollectionProperty + public Collection getProblemFacts() { + Set valueSet = new HashSet<>(); + for (TestdataMultiVarEntityProvidingEntity entity : entityList) { + valueSet.addAll(entity.getValueRange()); + } + return valueSet; + } + +} 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 49362c6fe6..51e7e1d736 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 @@ -307,6 +307,10 @@ public static void assertAllCodesOfMoveSelectorWithoutSize(MoveSelector moveS assertAllCodesOfIterableSelector(moveSelector, DO_NOT_ASSERT_SIZE, codes); } + public static void assertIterableSelectorWithoutSize(IterableSelector selector, String... codes) { + assertAllCodesOfIterableSelector(selector, DO_NOT_ASSERT_SIZE, codes); + } + public static void assertCodesOfNeverEndingIterableSelectorWithoutSize(IterableSelector selector, String... codes) { assertCodesOfNeverEndingIterableSelector(selector, DO_NOT_ASSERT_SIZE, codes); } From ef513f8f07a7e9af1dc4fd20dd1ce53bf20699a4 Mon Sep 17 00:00:00 2001 From: fred Date: Mon, 25 Aug 2025 18:10:38 -0300 Subject: [PATCH 3/9] feat: generate only valid moves for basic swap move --- .../selector/common/ValueRangeRecorderId.java | 4 + .../AbstractOriginalSwapIterator.java | 5 +- .../entity/EntitySelectorFactory.java | 40 +- .../FilteringEntityByEntitySelector.java | 343 ++++++++++++++++++ ...va => FilteringEntityByValueSelector.java} | 16 +- .../list/DestinationSelectorFactory.java | 4 +- .../move/generic/SwapMoveSelectorFactory.java | 29 +- .../list/ElementDestinationSelectorTest.java | 12 +- .../move/generic/SwapMoveSelectorTest.java | 256 +++++++++++++ 9 files changed, 676 insertions(+), 33 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ValueRangeRecorderId.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java rename core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/{FilteringEntityValueRangeSelector.java => FilteringEntityByValueSelector.java} (93%) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ValueRangeRecorderId.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ValueRangeRecorderId.java new file mode 100644 index 0000000000..67f0e2a877 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ValueRangeRecorderId.java @@ -0,0 +1,4 @@ +package ai.timefold.solver.core.impl.heuristic.selector.common; + +public record ValueRangeRecorderId(String recorderId, boolean basicVariable) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/iterator/AbstractOriginalSwapIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/iterator/AbstractOriginalSwapIterator.java index 99a7588b6a..1d1ac90fb9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/iterator/AbstractOriginalSwapIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/iterator/AbstractOriginalSwapIterator.java @@ -40,7 +40,7 @@ public AbstractOriginalSwapIterator(ListIterable leftSubSelector, @Override protected Move_ createUpcomingSelection() { - if (!rightSubSelectionIterator.hasNext()) { + while (!rightSubSelectionIterator.hasNext()) { if (!leftSubSelectionIterator.hasNext()) { return noUpcomingSelection(); } @@ -48,9 +48,6 @@ protected Move_ createUpcomingSelection() { if (!leftEqualsRight) { rightSubSelectionIterator = rightSubSelector.listIterator(); - if (!rightSubSelectionIterator.hasNext()) { - return noUpcomingSelection(); - } } else { // Select A-B, A-C, B-C. Do not select B-A, C-A, C-B. Do not select A-A, B-B, C-C. if (!leftSubSelectionIterator.hasNext()) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/EntitySelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/EntitySelectorFactory.java index 4c06e78b0d..4533e233f5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/EntitySelectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/EntitySelectorFactory.java @@ -17,6 +17,7 @@ import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelectorFactory; +import ai.timefold.solver.core.impl.heuristic.selector.common.ValueRangeRecorderId; import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.ComparatorSelectionSorter; import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter; import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionProbabilityWeightFactory; @@ -24,8 +25,9 @@ import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionSorterWeightFactory; import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.WeightFactorySelectionSorter; import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.CachingEntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityByEntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityByValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntitySelector; -import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityValueRangeSelector; import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.ProbabilityEntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.SelectedCountLimitEntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.ShufflingEntitySelector; @@ -79,12 +81,13 @@ public EntitySelector buildEntitySelector(HeuristicConfigPolicy buildEntitySelector(HeuristicConfigPolicy configPolicy, - SelectionCacheType minimumCacheType, SelectionOrder inheritedSelectionOrder, String entityValueRangeRecorderId) { + SelectionCacheType minimumCacheType, SelectionOrder inheritedSelectionOrder, + ValueRangeRecorderId valueRangeRecorderId) { if (config.getMimicSelectorRef() != null) { return buildMimicReplaying(configPolicy); } @@ -115,7 +118,7 @@ public EntitySelector buildEntitySelector(HeuristicConfigPolicy buildMimicReplaying(HeuristicConfigPolicy buildMimicReplaying(HeuristicConfigPolicy configPolicy, + String id) { + var entityMimicRecorder = configPolicy.getEntityMimicRecorder(id); if (entityMimicRecorder == null) { throw new IllegalArgumentException( "The entitySelectorConfig (%s) has a mimicSelectorRef (%s) for which no entitySelector with that id exists (in its solver phase)." @@ -185,17 +193,23 @@ private boolean hasFiltering(EntityDescriptor entityDescriptor) { } private EntitySelector applyEntityValueRangeFiltering(HeuristicConfigPolicy configPolicy, - EntitySelector entitySelector, String entityValueRangeRecorderId, SelectionCacheType minimumCacheType, + EntitySelector entitySelector, ValueRangeRecorderId valueRangeRecorderId, + SelectionCacheType minimumCacheType, SelectionOrder selectionOrder, boolean randomSelection) { - if (entityValueRangeRecorderId == null) { + if (valueRangeRecorderId == null || valueRangeRecorderId.recorderId() == null) { return entitySelector; } - var valueSelectorConfig = new ValueSelectorConfig() - .withMimicSelectorRef(entityValueRangeRecorderId); - var replayingValueSelector = (IterableValueSelector) ValueSelectorFactory - . create(valueSelectorConfig) - .buildValueSelector(configPolicy, entitySelector.getEntityDescriptor(), minimumCacheType, selectionOrder); - return new FilteringEntityValueRangeSelector<>(entitySelector, replayingValueSelector, randomSelection); + if (valueRangeRecorderId.basicVariable()) { + var replayingEntitySelector = buildMimicReplaying(configPolicy, valueRangeRecorderId.recorderId()); + return new FilteringEntityByEntitySelector<>(entitySelector, replayingEntitySelector, randomSelection); + } else { + var valueSelectorConfig = new ValueSelectorConfig() + .withMimicSelectorRef(valueRangeRecorderId.recorderId()); + var replayingValueSelector = (IterableValueSelector) ValueSelectorFactory + . create(valueSelectorConfig) + .buildValueSelector(configPolicy, entitySelector.getEntityDescriptor(), minimumCacheType, selectionOrder); + return new FilteringEntityByValueSelector<>(entitySelector, replayingValueSelector, randomSelection); + } } private EntitySelector applyNearbySelection(HeuristicConfigPolicy configPolicy, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java new file mode 100644 index 0000000000..7a613b606d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java @@ -0,0 +1,343 @@ +package ai.timefold.solver.core.impl.heuristic.selector.entity.decorator; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.heuristic.selector.AbstractDemandEnabledSelector; +import ai.timefold.solver.core.impl.heuristic.selector.common.ReachableValues; +import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionListIterator; +import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; +import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; +import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; + +/** + * The decorator returns a list of reachable entities for a specific entity. + * It enables the creation of a filtering tier when using entity value selectors, + * ensuring only valid and reachable entities are returned. + *

+ * e1 = entity_range[v1, v2, v3] + * e2 = entity_range[v1, v4] + *

+ * Solution: e1(v2) and e2(v1) + * e1 = [e2] -> e1 accepts v1 and e2 is reachable + * e2 = [] -> e2 does not accept v2 and e1 is not reachable + * + * @param the solution type + */ +public final class FilteringEntityByEntitySelector extends AbstractDemandEnabledSelector + implements EntitySelector { + + private final EntitySelector replayingEntitySelector; + private final EntitySelector childEntitySelector; + private final boolean randomSelection; + + private ReplayedEntity replayedEntity; + private List> basicVariableDescriptorList; + private List reachableValueList; + private long entitiesSize; + + public FilteringEntityByEntitySelector(EntitySelector childEntitySelector, + EntitySelector replayingEntitySelector, boolean randomSelection) { + this.replayingEntitySelector = replayingEntitySelector; + this.childEntitySelector = childEntitySelector; + this.randomSelection = randomSelection; + } + + // ************************************************************************ + // Lifecycle methods + // ************************************************************************ + + @Override + public void solvingStarted(SolverScope solverScope) { + super.solvingStarted(solverScope); + this.childEntitySelector.solvingStarted(solverScope); + } + + @Override + public void phaseStarted(AbstractPhaseScope phaseScope) { + super.phaseStarted(phaseScope); + var basicVariableList = childEntitySelector.getEntityDescriptor().getGenuineBasicVariableDescriptorList().stream() + .filter(v -> !v.isListVariable() && !v.canExtractValueRangeFromSolution()) + .map(v -> (BasicVariableDescriptor) v) + .toList(); + if (basicVariableList.isEmpty()) { + throw new IllegalStateException("Impossible state: no basic variable found for the entity %s." + .formatted(childEntitySelector.getEntityDescriptor().getEntityClass())); + } + this.entitiesSize = childEntitySelector.getEntityDescriptor().extractEntities(phaseScope.getWorkingSolution()).size(); + this.basicVariableDescriptorList = basicVariableList; + var valueRangeManager = phaseScope.getScoreDirector().getValueRangeManager(); + this.reachableValueList = basicVariableList.stream() + .map(valueRangeManager::getReachableValues) + .toList(); + this.childEntitySelector.phaseStarted(phaseScope); + } + + @Override + public void stepStarted(AbstractStepScope stepScope) { + super.stepStarted(stepScope); + this.childEntitySelector.stepStarted(stepScope); + } + + @Override + public void phaseEnded(AbstractPhaseScope phaseScope) { + super.phaseEnded(phaseScope); + this.childEntitySelector.phaseEnded(phaseScope); + this.replayedEntity = null; + this.basicVariableDescriptorList = null; + this.reachableValueList = null; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + public EntitySelector getChildEntitySelector() { + return childEntitySelector; + } + + @Override + public EntityDescriptor getEntityDescriptor() { + return childEntitySelector.getEntityDescriptor(); + } + + @Override + public long getSize() { + return entitiesSize; + } + + @Override + public boolean isCountable() { + return childEntitySelector.isCountable(); + } + + @Override + public boolean isNeverEnding() { + return childEntitySelector.isNeverEnding(); + } + + /** + * The expected replayed entity corresponds to the selected entity when the replaying selector has the next value. + * Once it is selected, it will be reused until a new entity is replayed by the recorder selector. + */ + private ReplayedEntity selectReplayedEntity() { + var iterator = replayingEntitySelector.iterator(); + if (iterator.hasNext()) { + var entity = iterator.next(); + this.replayedEntity = new ReplayedEntity(entity, extractAssignedValues(entity)); + } + return replayedEntity; + } + + private List extractAssignedValues(Object entity) { + return basicVariableDescriptorList.stream() + .map(v -> v.getValue(entity)) + .toList(); + } + + @Override + public Iterator endingIterator() { + return new OriginalFilteringValueRangeIterator(this::selectReplayedEntity, this::extractAssignedValues, + childEntitySelector.listIterator(), reachableValueList); + } + + @Override + public Iterator iterator() { + if (randomSelection) { + if (!childEntitySelector.isNeverEnding()) { + throw new IllegalArgumentException( + "Impossible state: childEntitySelector must provide a never ending approach."); + } + return new RandomFilteringValueRangeIterator(this::selectReplayedEntity, this::extractAssignedValues, + childEntitySelector.iterator(), reachableValueList, (int) (entitiesSize * 10)); + } else { + return new OriginalFilteringValueRangeIterator(this::selectReplayedEntity, this::extractAssignedValues, + childEntitySelector.listIterator(), reachableValueList); + } + } + + @Override + public ListIterator listIterator() { + if (!randomSelection) { + return new OriginalFilteringValueRangeIterator(this::selectReplayedEntity, this::extractAssignedValues, + childEntitySelector.listIterator(), reachableValueList); + } else { + throw new IllegalStateException("The selector (%s) does not support a ListIterator with randomSelection (%s)." + .formatted(this, randomSelection)); + } + } + + @Override + public ListIterator listIterator(int index) { + if (!randomSelection) { + return new OriginalFilteringValueRangeIterator(this::selectReplayedEntity, this::extractAssignedValues, + childEntitySelector.listIterator(index), reachableValueList); + } else { + throw new IllegalStateException("The selector (%s) does not support a ListIterator with randomSelection (%s)." + .formatted(this, randomSelection)); + } + } + + @Override + public boolean equals(Object other) { + return other instanceof FilteringEntityByEntitySelector that + && Objects.equals(childEntitySelector, that.childEntitySelector) + && Objects.equals(replayingEntitySelector, that.replayingEntitySelector); + } + + @Override + public int hashCode() { + return Objects.hash(childEntitySelector, replayingEntitySelector); + } + + private record ReplayedEntity(Object entity, List assignedValues) { + } + + private abstract static class AbstractFilteringValueRangeIterator> + extends UpcomingSelectionListIterator { + private final Supplier upcomingEntitySupplier; + private final Function> extractAssignedValuesFunction; + private final List reachableValueList; + Type entityIterator; + private boolean initialized = false; + private ReplayedEntity replayedEntity; + + private AbstractFilteringValueRangeIterator(Supplier upcomingEntitySupplier, + Function> extractAssignedValuesFunction, Type entityIterator, + List reachableValueList) { + this.upcomingEntitySupplier = upcomingEntitySupplier; + this.extractAssignedValuesFunction = extractAssignedValuesFunction; + this.entityIterator = entityIterator; + this.reachableValueList = reachableValueList; + } + + void initialize() { + if (initialized) { + return; + } + var currentUpcomingEntity = upcomingEntitySupplier.get(); + if (currentUpcomingEntity == null) { + this.replayedEntity = null; + entityIterator = (Type) Collections.emptyListIterator(); + } else { + replayedEntity = currentUpcomingEntity; + } + initialized = true; + } + + /** + * The other entity is reachable if it accepts all assigned values from the replayed entity, and vice versa. + */ + boolean isReachable(Object otherEntity) { + if (replayedEntity.entity() == otherEntity) { + // Same entity cannot be swapped + return false; + } + var otherValueAssignedValues = extractAssignedValuesFunction.apply(otherEntity); + if (reachableValueList.size() == 1) { + return isReachable(replayedEntity.entity(), replayedEntity.assignedValues().get(0), otherEntity, + otherValueAssignedValues.get(0), reachableValueList.get(0)); + } else { + for (var i = 0; i < replayedEntity.assignedValues().size(); i++) { + var replayedValue = replayedEntity.assignedValues().get(i); + var otherValue = otherValueAssignedValues.get(i); + var reachableValues = reachableValueList.get(i); + if (!isReachable(replayedEntity.entity(), replayedValue, otherEntity, otherValue, reachableValues)) { + return false; + } + } + } + return true; + } + + private boolean isReachable(Object replayedEntity, Object replayedValue, Object otherEntity, Object otherValue, + ReachableValues reachableValues) { + var replayedValueAccepted = otherValue == null || reachableValues.isEntityReachable(otherValue, replayedEntity); + var otherValueAccepted = replayedValue == null || reachableValues.isEntityReachable(replayedValue, otherEntity); + return replayedValueAccepted && otherValueAccepted; + } + + /** + * @param counter current number of iterations. + * @return true if the iterator should stop; otherwise, it should continue. + */ + abstract boolean stopEarlier(int counter); + + @Override + protected Object createUpcomingSelection() { + initialize(); + if (!entityIterator.hasNext()) { + return noUpcomingSelection(); + } + int counter = 0; + do { + // For random selection, the entity iterator is expected to return a random sequence of values + var entity = entityIterator.next(); + if (isReachable(entity)) { + return entity; + } + } while (entityIterator.hasNext() && !stopEarlier(++counter)); + return noUpcomingSelection(); + } + } + + private static class OriginalFilteringValueRangeIterator extends AbstractFilteringValueRangeIterator> { + + private OriginalFilteringValueRangeIterator(Supplier upcomingEntitySupplier, + Function> extractAssignedValuesFunction, ListIterator entityIterator, + List reachableValueList) { + super(upcomingEntitySupplier, extractAssignedValuesFunction, entityIterator, reachableValueList); + } + + @Override + protected Object createPreviousSelection() { + initialize(); + if (!entityIterator.hasPrevious()) { + return noUpcomingSelection(); + } + int counter = 0; + do { + // For random selection, the entity iterator is expected to return a random sequence of values + var entity = entityIterator.previous(); + if (isReachable(entity)) { + return entity; + } + } while (entityIterator.hasPrevious() && !stopEarlier(++counter)); + return noUpcomingSelection(); + } + + @Override + boolean stopEarlier(int currentCount) { + return false; + } + } + + private static class RandomFilteringValueRangeIterator extends AbstractFilteringValueRangeIterator> { + private final int maxBailoutSize; + + private RandomFilteringValueRangeIterator(Supplier upcomingEntitySupplier, + Function> extractAssignedValuesFunction, Iterator entityIterator, + List reachableValueList, int maxBailoutSize) { + super(upcomingEntitySupplier, extractAssignedValuesFunction, entityIterator, reachableValueList); + this.maxBailoutSize = maxBailoutSize; + } + + @Override + protected Object createPreviousSelection() { + throw new UnsupportedOperationException(); + } + + @Override + boolean stopEarlier(int currentCount) { + return currentCount < maxBailoutSize; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByValueSelector.java similarity index 93% rename from core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java rename to core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByValueSelector.java index 5ea044892e..9bc9941107 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByValueSelector.java @@ -21,10 +21,12 @@ * The decorator returns a list of reachable entities for a specific value. * It enables the creation of a filtering tier when using entity value selectors, * ensuring only valid and reachable entities are returned. - * + *

+ * The decorator can only be applied to list variables. + *

* e1 = entity_range[v1, v2, v3] * e2 = entity_range[v1, v4] - * + *

* v1 = [e1, e2] * v2 = [e1] * v3 = [e1] @@ -32,7 +34,7 @@ * * @param the solution type */ -public final class FilteringEntityValueRangeSelector extends AbstractDemandEnabledSelector +public final class FilteringEntityByValueSelector extends AbstractDemandEnabledSelector implements EntitySelector { private final IterableValueSelector replayingValueSelector; @@ -43,7 +45,7 @@ public final class FilteringEntityValueRangeSelector extends Abstract private ReachableValues reachableValues; private long entitiesSize; - public FilteringEntityValueRangeSelector(EntitySelector childEntitySelector, + public FilteringEntityByValueSelector(EntitySelector childEntitySelector, IterableValueSelector replayingValueSelector, boolean randomSelection) { this.replayingValueSelector = replayingValueSelector; this.childEntitySelector = childEntitySelector; @@ -65,7 +67,9 @@ public void phaseStarted(AbstractPhaseScope phaseScope) { super.phaseStarted(phaseScope); this.entitiesSize = childEntitySelector.getEntityDescriptor().extractEntities(phaseScope.getWorkingSolution()).size(); this.reachableValues = phaseScope.getScoreDirector().getValueRangeManager() - .getReachableValues(phaseScope.getScoreDirector().getSolutionDescriptor().getListVariableDescriptor()); + .getReachableValues(Objects.requireNonNull( + phaseScope.getScoreDirector().getSolutionDescriptor().getListVariableDescriptor(), + "Impossible state: the list variable cannot be null.")); this.childEntitySelector.phaseStarted(phaseScope); } @@ -141,7 +145,7 @@ public ListIterator listIterator(int index) { @Override public boolean equals(Object other) { - return other instanceof FilteringEntityValueRangeSelector that + return other instanceof FilteringEntityByValueSelector that && Objects.equals(childEntitySelector, that.childEntitySelector) && Objects.equals(replayingValueSelector, that.replayingValueSelector); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/DestinationSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/DestinationSelectorFactory.java index be36e4319c..0589ad1999 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/DestinationSelectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/DestinationSelectorFactory.java @@ -10,6 +10,7 @@ import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.AbstractSelectorFactory; +import ai.timefold.solver.core.impl.heuristic.selector.common.ValueRangeRecorderId; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelectorFactory; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.ValueSelector; @@ -35,7 +36,8 @@ public DestinationSelector buildDestinationSelector(HeuristicConfigPo SelectionCacheType minimumCacheType, boolean randomSelection, String entityValueRangeRecorderId) { var selectionOrder = SelectionOrder.fromRandomSelectionBoolean(randomSelection); var entitySelector = EntitySelectorFactory. create(Objects.requireNonNull(config.getEntitySelectorConfig())) - .buildEntitySelector(configPolicy, minimumCacheType, selectionOrder, entityValueRangeRecorderId); + .buildEntitySelector(configPolicy, minimumCacheType, selectionOrder, + new ValueRangeRecorderId(entityValueRangeRecorderId, false)); var valueSelector = buildIterableValueSelector(configPolicy, entitySelector.getEntityDescriptor(), minimumCacheType, selectionOrder, entityValueRangeRecorderId); var baseDestinationSelector = diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactory.java index 3b53534c25..f8e3f6ba7b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorFactory.java @@ -12,9 +12,11 @@ import ai.timefold.solver.core.config.heuristic.selector.move.generic.SwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListSwapMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; +import ai.timefold.solver.core.config.util.ConfigUtils; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.heuristic.selector.common.ValueRangeRecorderId; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelectorFactory; import ai.timefold.solver.core.impl.heuristic.selector.move.AbstractMoveSelectorFactory; import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector; @@ -37,15 +39,36 @@ protected MoveSelector buildBaseMoveSelector(HeuristicConfigPolicy !v.canExtractValueRangeFromSolution()); + // A null ID means to turn off the entity value range filtering + String entityRecorderId = null; + if (enableEntityValueRangeFilter) { + if (entitySelectorConfig.getId() == null && entitySelectorConfig.getMimicSelectorRef() == null) { + var entityName = Objects.requireNonNull(entityDescriptor.getEntityClass().getSimpleName()); + // We set the id to make sure the value selector will use the mimic recorder + entityRecorderId = ConfigUtils.addRandomSuffix(entityName, configPolicy.getRandom()); + entitySelectorConfig.setId(entityRecorderId); + } else { + entityRecorderId = entitySelectorConfig.getId() != null ? entitySelectorConfig.getId() + : entitySelectorConfig.getMimicSelectorRef(); + } + } + var leftEntitySelector = EntitySelectorFactory. create(entitySelectorConfig) .buildEntitySelector(configPolicy, minimumCacheType, selectionOrder); var rightEntitySelector = EntitySelectorFactory. create(secondaryEntitySelectorConfig) - .buildEntitySelector(configPolicy, minimumCacheType, selectionOrder); - var entityDescriptor = leftEntitySelector.getEntityDescriptor(); + .buildEntitySelector(configPolicy, minimumCacheType, selectionOrder, + new ValueRangeRecorderId(entityRecorderId, true)); var variableDescriptorList = deduceBasicVariableDescriptorList(entityDescriptor, config.getVariableNameIncludeList()); return new SwapMoveSelector<>(leftEntitySelector, rightEntitySelector, variableDescriptorList, diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java index ea9da9b96b..5ad89b8622 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/ElementDestinationSelectorTest.java @@ -27,7 +27,7 @@ import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; import ai.timefold.solver.core.impl.heuristic.selector.entity.FromSolutionEntitySelector; -import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityValueRangeSelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityByValueSelector; import ai.timefold.solver.core.impl.heuristic.selector.value.decorator.FilteringValueRangeSelector; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; @@ -178,7 +178,7 @@ void randomWithEntityValueRange() { var valueSelector = mockIterableValueSelector(getEntityRangeListVariableDescriptor(scoreDirector), v3); var filteringValueRangeSelector = mockIterableFromEntityPropertyValueSelector(valueSelector, true); var replayinValueSelector = mockIterableValueSelector(getEntityRangeListVariableDescriptor(scoreDirector), v3); - checkEntityValueRange(new FilteringEntityValueRangeSelector<>(mockEntitySelector(a, b, c), valueSelector, true), + checkEntityValueRange(new FilteringEntityByValueSelector<>(mockEntitySelector(a, b, c), valueSelector, true), new FilteringValueRangeSelector<>(filteringValueRangeSelector, replayinValueSelector, true, false), scoreDirector, new TestRandom(1, 1, 1), "C[0]"); @@ -193,7 +193,7 @@ void randomWithEntityValueRange() { replayinValueSelector = mockIterableValueSelector(getEntityRangeListVariableDescriptor(scoreDirector), v1); // Cause the value iterator return no value at the second call doReturn(List.of(v1).iterator(), Collections.emptyIterator()).when(valueSelector).iterator(); - checkEntityValueRange(new FilteringEntityValueRangeSelector<>(mockEntitySelector(a, b, c), valueSelector, true), + checkEntityValueRange(new FilteringEntityByValueSelector<>(mockEntitySelector(a, b, c), valueSelector, true), new FilteringValueRangeSelector<>(filteringValueRangeSelector, replayinValueSelector, true, false), scoreDirector, new TestRandom(0, 3, 0, 0), "A[2]"); @@ -207,7 +207,7 @@ void randomWithEntityValueRange() { replayinValueSelector = mockIterableValueSelector(getEntityRangeListVariableDescriptor(scoreDirector), v2); // Cause the value iterator return no value at the second call doReturn(List.of(v2).iterator(), Collections.emptyIterator()).when(valueSelector).iterator(); - checkEntityValueRange(new FilteringEntityValueRangeSelector<>(mockEntitySelector(a, b, c), valueSelector, true), + checkEntityValueRange(new FilteringEntityByValueSelector<>(mockEntitySelector(a, b, c), valueSelector, true), new FilteringValueRangeSelector<>(filteringValueRangeSelector, replayinValueSelector, true, false), scoreDirector, new TestRandom(1, 3, 1, 0), "B[1]"); @@ -221,12 +221,12 @@ void randomWithEntityValueRange() { replayinValueSelector = mockIterableValueSelector(getEntityRangeListVariableDescriptor(scoreDirector), v5); // Cause the value iterator return no value at the second call doReturn(List.of(v5).iterator(), Collections.emptyIterator()).when(valueSelector).iterator(); - checkEntityValueRange(new FilteringEntityValueRangeSelector<>(mockEntitySelector(a, b, c), valueSelector, true), + checkEntityValueRange(new FilteringEntityByValueSelector<>(mockEntitySelector(a, b, c), valueSelector, true), new FilteringValueRangeSelector<>(filteringValueRangeSelector, replayinValueSelector, true, false), scoreDirector, new TestRandom(0, 3, 1, 0), "C[1]"); } - private void checkEntityValueRange(FilteringEntityValueRangeSelector entitySelector, + private void checkEntityValueRange(FilteringEntityByValueSelector entitySelector, FilteringValueRangeSelector valueSelector, InnerScoreDirector scoreDirector, TestRandom random, String code) { var selector = new ElementDestinationSelector<>(entitySelector, valueSelector, true); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java index 82017dc991..fef5fb143b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java @@ -1,17 +1,40 @@ package ai.timefold.solver.core.impl.heuristic.selector.move.generic; +import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.mockEntitySelector; +import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.phaseStarted; +import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.solvingStarted; +import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getEntityDescriptor; +import static ai.timefold.solver.core.testutil.PlannerAssert.DO_NOT_ASSERT_SIZE; import static ai.timefold.solver.core.testutil.PlannerAssert.assertAllCodesOfMoveSelector; +import static ai.timefold.solver.core.testutil.PlannerAssert.assertCodesOfNeverEndingIterableSelector; +import static ai.timefold.solver.core.testutil.PlannerAssert.assertEmptyNeverEndingIterableSelector; +import static ai.timefold.solver.core.testutil.PlannerAssert.assertIterableSelectorWithoutSize; import static ai.timefold.solver.core.testutil.PlannerAssert.verifyPhaseLifecycle; +import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.List; +import java.util.Random; + +import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.FromSolutionEntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityByEntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.mimic.MimicRecordingEntitySelector; +import ai.timefold.solver.core.impl.heuristic.selector.entity.mimic.MimicReplayingEntitySelector; 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.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataValue; +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.entityproviding.multivar.TestdataMultiVarEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataMultiVarEntityProvidingSolution; +import ai.timefold.solver.core.testutil.TestRandom; import org.junit.jupiter.api.Test; @@ -269,4 +292,237 @@ void emptyRightOriginalLeftUnequalsRight() { verifyPhaseLifecycle(rightEntitySelector, 1, 2, 5); } + @Test + void singleVarOriginalLeftUnequalsRightWithEntityValueRange() { + var v1 = new TestdataValue("1"); + var v2 = new TestdataValue("2"); + var v3 = new TestdataValue("3"); + var v4 = new TestdataValue("4"); + var e1 = new TestdataEntityProvidingEntity("A", List.of(v1, v4)); + var e2 = new TestdataEntityProvidingEntity("B", List.of(v2, v3)); + var e3 = new TestdataEntityProvidingEntity("C", List.of(v1, v3, v4)); + var solution = new TestdataEntityProvidingSolution("s1"); + solution.setEntityList(List.of(e1, e2, e3)); + + var scoreDirector = mockScoreDirector(TestdataEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var leftEntitySelector = mockEntitySelector(TestdataEntityProvidingEntity.buildEntityDescriptor(), e1, e2, e3); + var entityMimicRecorder = new MimicRecordingEntitySelector<>(leftEntitySelector); + + var entitySelector = mockEntitySelector(TestdataEntityProvidingEntity.buildEntityDescriptor(), e1, e2, e3); + var replayingEntitySelector = new MimicReplayingEntitySelector<>(entityMimicRecorder); + var rightEntitySelector = + new FilteringEntityByEntitySelector<>(entitySelector, replayingEntitySelector, false); + + var moveSelector = new SwapMoveSelector<>(entityMimicRecorder, rightEntitySelector, + leftEntitySelector.getEntityDescriptor().getGenuineVariableDescriptorList(), false); + + var solverScope = solvingStarted(moveSelector, scoreDirector, new Random(0)); + phaseStarted(moveSelector, solverScope); + + // we assume that any entity is reachable from any other entity if the assigned values are null + scoreDirector.setWorkingSolution(solution); + assertIterableSelectorWithoutSize(moveSelector, "A<->B", "A<->C", "B<->A", "B<->C", "C<->A", "C<->B"); + + // e1(v1) can swap with e3(v4) + // e1(v1) cannot swap with e2(v3) because e1 does not accept v3 + // e2(v3) cannot swap with e1(v1) because e2 does not accept v1 + // e2(v3) cannot swap with e3(v4) because e2 does not accept v4 + // e3(v4) can swap with e1(v1) + // e3(v4) cannot swap with e2(v3) because e2 does not accept v4 + e1.setValue(v1); + e2.setValue(v3); + e3.setValue(v4); + scoreDirector.setWorkingSolution(solution); + phaseStarted(moveSelector, solverScope); + assertIterableSelectorWithoutSize(moveSelector, "A<->C", "C<->A"); + } + + @Test + void singleVarRandomSelectionWithEntityValueRange() { + var v1 = new TestdataValue("1"); + var v2 = new TestdataValue("2"); + var v3 = new TestdataValue("3"); + var v4 = new TestdataValue("4"); + var e1 = new TestdataEntityProvidingEntity("A", List.of(v1, v4)); + var e2 = new TestdataEntityProvidingEntity("B", List.of(v2, v3)); + var e3 = new TestdataEntityProvidingEntity("C", List.of(v1, v4)); + var solution = new TestdataEntityProvidingSolution("s1"); + solution.setEntityList(List.of(e1, e2, e3)); + + var scoreDirector = mockScoreDirector(TestdataEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var leftEntitySelector = + new FromSolutionEntitySelector<>(getEntityDescriptor(scoreDirector), SelectionCacheType.JUST_IN_TIME, true); + var entityMimicRecorder = new MimicRecordingEntitySelector<>(leftEntitySelector); + + var replayingEntitySelector = new MimicReplayingEntitySelector<>(entityMimicRecorder); + var rightEntitySelector = + new FilteringEntityByEntitySelector<>(leftEntitySelector, replayingEntitySelector, true); + + var moveSelector = new SwapMoveSelector<>(entityMimicRecorder, rightEntitySelector, + leftEntitySelector.getEntityDescriptor().getGenuineVariableDescriptorList(), true); + + var random = new TestRandom(0); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); + var expectedSize = (long) solution.getEntityList().size() * solution.getEntityList().size(); + + // e1(null) and e2(null) + // all entities are reachable from e1 because their values are null + scoreDirector.setWorkingSolution(solution); + // select left A, select right B + // select left A, select right C + random.reset(0, 1, 0, 2, 0, 2); + assertCodesOfNeverEndingIterableSelector(moveSelector, expectedSize, "A<->B", "A<->C"); + + // e1(v1), e2(v3) and e3(v4) + // e1 does not accepts v3 and e2 does not accepts v1 + // e1 accepts v4, and e3 accepts v1 + e1.setValue(v1); + e2.setValue(v3); + e3.setValue(v4); + // select left A, select right C + // select left A, select right C + random.reset(0, 2, 0, 2); + scoreDirector.setWorkingSolution(solution); + assertCodesOfNeverEndingIterableSelector(moveSelector, expectedSize, "A<->C"); + } + + @Test + void multiVarOriginalLeftUnequalsRightWithEntityValueRange() { + var solution = new TestdataMultiVarEntityProvidingSolution(); + var v1 = new TestdataValue("1"); + var v2 = new TestdataValue("2"); + var v3 = new TestdataValue("3"); + var v4 = new TestdataValue("4"); + var e1 = new TestdataMultiVarEntityProvidingEntity("A", List.of(v1, v4), List.of(v1, v4)); + var e2 = new TestdataMultiVarEntityProvidingEntity("B", List.of(v2, v3), List.of(v2, v3)); + var e3 = new TestdataMultiVarEntityProvidingEntity("C", List.of(v1, v3, v4), List.of(v1, v3, v4)); + solution.setEntityList(List.of(e1, e2, e3)); + + var scoreDirector = mockScoreDirector(TestdataMultiVarEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var leftEntitySelector = mockEntitySelector(TestdataMultiVarEntityProvidingEntity.buildEntityDescriptor(), e1, e2, e3); + var entityMimicRecorder = new MimicRecordingEntitySelector<>(leftEntitySelector); + + var entitySelector = mockEntitySelector(TestdataMultiVarEntityProvidingEntity.buildEntityDescriptor(), e1, e2, e3); + var replayingEntitySelector = new MimicReplayingEntitySelector<>(entityMimicRecorder); + var rightEntitySelector = + new FilteringEntityByEntitySelector<>(entitySelector, replayingEntitySelector, false); + + var moveSelector = new SwapMoveSelector<>(entityMimicRecorder, rightEntitySelector, + leftEntitySelector.getEntityDescriptor().getGenuineVariableDescriptorList(), false); + + var solverScope = solvingStarted(moveSelector, scoreDirector, new Random(0)); + phaseStarted(moveSelector, solverScope); + + // we assume that any entity is reachable from any other entity if the assigned values are null + scoreDirector.setWorkingSolution(solution); + assertIterableSelectorWithoutSize(moveSelector, "A<->B", "A<->C", "B<->A", "B<->C", "C<->A", "C<->B"); + + // e1(v1) cannot swap with e2(v3) because e1 does not accept v3 + // e1(v1) can swap with e3(v4) + // e2(v3) cannot swap with e1(v1) because e2 does not accept v1 + // e2(v3) cannot swap with e3(v4) because e2 does not accept v4 + // e3(v4) can swap with e1(v1) + // e3(v4) cannot swap with e2(v3) because e2 does not accept v4 + e1.setValue(v1); + e1.setSecondValue(v1); + e2.setValue(v3); + e2.setSecondValue(v1); + e3.setValue(v4); + e3.setSecondValue(v1); + scoreDirector.setWorkingSolution(solution); + phaseStarted(moveSelector, solverScope); + assertIterableSelectorWithoutSize(moveSelector, "A<->C", "C<->A"); + + // e1(v4. v4) cannot swap with e2(v3, v3) because e1 does not accept v3 in both variables + // e1(v4, v4) cannot swap with e3(v1, v3) because e1 does not accept v3 in the second variable + // e2(v3, v3) cannot swap with e1(v4. v4) because e2 does not accept v4 in both variables + // e2(v3, v3) cannot swap with e3(v1, v3) because e2 does not accept v1 in the first variable + // e3(v1, v3) cannot swap with e1(v4. v4) because e1 does not accept v3 in both variables + // e3(v1, v3) cannot swap with e2(v3, v3) because e2 does not accept v1 in the first variable + e1.setValue(v1); + e1.setSecondValue(v1); + e2.setValue(v3); + e2.setSecondValue(v3); + e3.setValue(v1); + e3.setSecondValue(v3); + scoreDirector.setWorkingSolution(solution); + phaseStarted(moveSelector, solverScope); + assertIterableSelectorWithoutSize(moveSelector); + } + + @Test + void multiVarRandomSelectionWithEntityValueRange() { + var solution = new TestdataMultiVarEntityProvidingSolution(); + var v1 = new TestdataValue("1"); + var v2 = new TestdataValue("2"); + var v3 = new TestdataValue("3"); + var v4 = new TestdataValue("4"); + var e1 = new TestdataMultiVarEntityProvidingEntity("A", List.of(v1, v4), List.of(v1, v4)); + var e2 = new TestdataMultiVarEntityProvidingEntity("B", List.of(v2, v3), List.of(v2, v3)); + var e3 = new TestdataMultiVarEntityProvidingEntity("C", List.of(v1, v4), List.of(v1, v3, v4)); + solution.setEntityList(List.of(e1, e2, e3)); + + var scoreDirector = mockScoreDirector(TestdataMultiVarEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var leftEntitySelector = + new FromSolutionEntitySelector<>(getEntityDescriptor(scoreDirector), SelectionCacheType.JUST_IN_TIME, true); + var entityMimicRecorder = new MimicRecordingEntitySelector<>(leftEntitySelector); + + var replayingEntitySelector = new MimicReplayingEntitySelector<>(entityMimicRecorder); + var rightEntitySelector = + new FilteringEntityByEntitySelector<>(leftEntitySelector, replayingEntitySelector, true); + + var moveSelector = new SwapMoveSelector<>(entityMimicRecorder, rightEntitySelector, + leftEntitySelector.getEntityDescriptor().getGenuineVariableDescriptorList(), true); + + var random = new TestRandom(0); + var solverScope = solvingStarted(moveSelector, scoreDirector, random); + phaseStarted(moveSelector, solverScope); + var expectedSize = (long) solution.getEntityList().size() * solution.getEntityList().size(); + + // e1(null, null) and e2(null, null) + // all entities are reachable from e1 because their values are null + scoreDirector.setWorkingSolution(solution); + // select left A, select right B + // select left A, select right C + random.reset(0, 1, 0, 2, 0, 2); + assertCodesOfNeverEndingIterableSelector(moveSelector, expectedSize, "A<->B", "A<->C"); + + // e1(v1, v1), e2(v3, v3) and e3(v4, v4) + // e1 does not accepts v3 and e2 does not accepts v1 + // e1 accepts v4, and e3 accepts v1 + e1.setValue(v1); + e1.setSecondValue(v1); + e2.setValue(v3); + e2.setSecondValue(v3); + e3.setValue(v4); + e3.setSecondValue(v4); + // select left A, select right C + // select left A, select right C + random.reset(0, 2, 0, 2); + scoreDirector.setWorkingSolution(solution); + assertCodesOfNeverEndingIterableSelector(moveSelector, expectedSize, "A<->C"); + + // e1(v1, v1), e2(v3, v3) and e3(v3, v4) + // e1 accepts v4 in the first variable, but it does not accept v3 in the second variable + e1.setValue(v1); + e1.setSecondValue(v1); + e2.setValue(v3); + e2.setSecondValue(v3); + e3.setValue(v4); + e3.setSecondValue(v3); + // select left A, select right C + random.reset(0, 2, 0, 2); + scoreDirector.setWorkingSolution(solution); + assertEmptyNeverEndingIterableSelector(moveSelector, DO_NOT_ASSERT_SIZE); + } + } From 6924f3a3cc1acc51eded553b6cdd9f696f946890 Mon Sep 17 00:00:00 2001 From: fred Date: Mon, 25 Aug 2025 18:13:31 -0300 Subject: [PATCH 4/9] chore: enable assertion for swap --- .../core/impl/heuristic/selector/move/generic/SwapMove.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java index b56a34b6d8..f1b85176c1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java @@ -57,7 +57,7 @@ public boolean isMoveDoable(ScoreDirector scoreDirector) { var leftValueRange = extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, leftEntity); if (!leftValueRange.contains(rightValue)) { - return false; + throw new IllegalStateException("Impossible state: no valid move generated."); } } } From a0f3fe4b51663acbe94c7d88ce7ed748fdf7ed7d Mon Sep 17 00:00:00 2001 From: fred Date: Tue, 26 Aug 2025 11:58:19 -0300 Subject: [PATCH 5/9] chore: reachable values accept null values --- .../selector/common/ReachableValues.java | 9 +++- .../score/director/ValueRangeManager.java | 7 ++-- .../selector/common/ReachableValuesTest.java | 42 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java index d62ec0cf94..f46cf62bd5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java @@ -24,10 +24,12 @@ public final class ReachableValues { private final Map values; private final @Nullable Class valueClass; + private final boolean acceptsNullValue; private @Nullable ReachableItemValue cachedObject; - public ReachableValues(Map values) { + public ReachableValues(Map values, boolean acceptsNullValue) { this.values = values; + this.acceptsNullValue = acceptsNullValue; var firstValue = values.entrySet().stream().findFirst(); this.valueClass = firstValue.> map(entry -> entry.getKey().getClass()).orElse(null); } @@ -70,11 +72,14 @@ public boolean isEntityReachable(Object origin, @Nullable Object entity) { return originItemValue.entitySet.contains(entity); } - public boolean isValueReachable(Object origin, Object otherValue) { + public boolean isValueReachable(Object origin, @Nullable Object otherValue) { var originItemValue = fetchItemValue(Objects.requireNonNull(origin)); if (originItemValue == null) { return false; } + if (otherValue == null) { + return acceptsNullValue; + } return originItemValue.valueSet.contains(Objects.requireNonNull(otherValue)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java index 26836966ef..dfeea99169 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeManager.java @@ -442,20 +442,21 @@ public ReachableValues getReachableValues(GenuineVariableDescriptor v var range = getFromEntity(variableDescriptor.getValueRangeDescriptor(), entity); while (valuesIterator.hasNext()) { var value = valuesIterator.next(); - if (range.contains(value)) { + if (value != null && range.contains(value)) { var item = initReachableMap(reachableValuesMap, value, entityList.size(), (int) valuesSize); item.addEntity(entity); var iterator = range.createOriginalIterator(); while (iterator.hasNext()) { var iteratorValue = iterator.next(); - if (!Objects.equals(iteratorValue, value)) { + if (iteratorValue != null && !Objects.equals(iteratorValue, value)) { item.addValue(iteratorValue); } } } } } - values = new ReachableValues(reachableValuesMap); + values = new ReachableValues(reachableValuesMap, + variableDescriptor.getValueRangeDescriptor().acceptsNullInValueRange()); reachableValues[variableDescriptor.getValueRangeDescriptor().getOrdinal()] = values; } return values; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java index 72bd975016..17dec4a754 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValuesTest.java @@ -5,9 +5,12 @@ import java.util.List; +import ai.timefold.solver.core.testdomain.TestdataValue; import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingEntity; import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingSolution; import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingValue; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; import org.junit.jupiter.api.Test; @@ -79,5 +82,44 @@ void testReachableValues() { assertThat(reachableValues.isValueReachable(v1, v2)).isTrue(); assertThat(reachableValues.isValueReachable(v1, v3)).isTrue(); assertThat(reachableValues.isValueReachable(v1, v5)).isFalse(); + + // Null value is not accepted because the setting allowUnassigned is false + assertThat(reachableValues.isValueReachable(v1, null)).isFalse(); + } + + @Test + void testUnassignedReachableValues() { + var v1 = new TestdataValue("V1"); + var v2 = new TestdataValue("V2"); + var v3 = new TestdataValue("V3"); + var v4 = new TestdataValue("V4"); + var v5 = new TestdataValue("V5"); + var a = new TestdataAllowsUnassignedEntityProvidingEntity("A", List.of(v1, v2, v3), v1); + var b = new TestdataAllowsUnassignedEntityProvidingEntity("B", List.of(v2, v3), v3); + var c = new TestdataAllowsUnassignedEntityProvidingEntity("C", List.of(v3, v4, v5), v4); + var solution = new TestdataAllowsUnassignedEntityProvidingSolution(); + solution.setEntityList(List.of(a, b, c)); + + var scoreDirector = mockScoreDirector(TestdataAllowsUnassignedEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var entityDescriptor = solutionDescriptor.findEntityDescriptor(TestdataAllowsUnassignedEntityProvidingEntity.class); + var reachableValues = scoreDirector.getValueRangeManager() + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0)); + + assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v2, v3); + assertThat(reachableValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v1, v3); + assertThat(reachableValues.extractValuesAsList(v3)).containsExactlyInAnyOrder(v1, v2, v4, v5); + assertThat(reachableValues.extractValuesAsList(v4)).containsExactlyInAnyOrder(v3, v5); + assertThat(reachableValues.extractValuesAsList(v5)).containsExactlyInAnyOrder(v3, v4); + + // Only origin + assertThat(reachableValues.isValueReachable(v1, v2)).isTrue(); + assertThat(reachableValues.isValueReachable(v1, v3)).isTrue(); + assertThat(reachableValues.isValueReachable(v1, v5)).isFalse(); + + // Null value is not accepted because the setting allowUnassigned is false + assertThat(reachableValues.isValueReachable(v1, null)).isTrue(); } } From 9e5989e86a2e970a76a50b191b4ddfc4cab7c561 Mon Sep 17 00:00:00 2001 From: fred Date: Tue, 26 Aug 2025 15:50:35 -0300 Subject: [PATCH 6/9] chore: make the entity filtering iterator ensure never-ending approach --- .../FilteringEntityByEntitySelector.java | 28 ++++++++++++++++++- .../selector/move/generic/SwapMove.java | 5 +++- .../FilteringValueRangeSelector.java | 15 +++++++++- .../move/generic/SwapMoveSelectorTest.java | 3 +- .../selector/move/generic/SwapMoveTest.java | 3 -- 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java index 7a613b606d..892028709d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByEntitySelector.java @@ -208,7 +208,7 @@ private abstract static class AbstractFilteringValueRangeIterator reachableValueList; Type entityIterator; private boolean initialized = false; - private ReplayedEntity replayedEntity; + ReplayedEntity replayedEntity; private AbstractFilteringValueRangeIterator(Supplier upcomingEntitySupplier, Function> extractAssignedValuesFunction, Type entityIterator, @@ -219,6 +219,16 @@ private AbstractFilteringValueRangeIterator(Supplier upcomingEnt this.reachableValueList = reachableValueList; } + void checkReplayedEntity() { + var updatedReplayedEntity = upcomingEntitySupplier.get(); + if (replayedEntity == null || replayedEntity.entity() != updatedReplayedEntity.entity()) { + replayedEntity = updatedReplayedEntity; + } + if (replayedEntity == null) { + entityIterator = (Type) Collections.emptyListIterator(); + } + } + void initialize() { if (initialized) { return; @@ -330,6 +340,22 @@ private RandomFilteringValueRangeIterator(Supplier upcomingEntit this.maxBailoutSize = maxBailoutSize; } + @Override + public boolean hasNext() { + checkReplayedEntity(); + var hasNext = super.hasNext(); + if (!hasNext && entityIterator.hasNext()) { + // If a valid move is not found with the given bailout size, + // we can still use the iterator as long as the entityIterator has not been exhausted + this.upcomingCreated = true; + this.hasUpcomingSelection = true; + // We assigned the same entity to the left side, which will result in a non-doable move + this.upcomingSelection = replayedEntity.entity(); + return true; + } + return hasNext; + } + @Override protected Object createPreviousSelection() { throw new UnsupportedOperationException(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java index f1b85176c1..47bd6f4c81 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java @@ -42,6 +42,9 @@ public Object getRightEntity() { @Override public boolean isMoveDoable(ScoreDirector scoreDirector) { + if (leftEntity == rightEntity) { + return false; + } var movable = false; for (var variableDescriptor : variableDescriptorList) { var leftValue = variableDescriptor.getValue(leftEntity); @@ -57,7 +60,7 @@ public boolean isMoveDoable(ScoreDirector scoreDirector) { var leftValueRange = extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, leftEntity); if (!leftValueRange.contains(rightValue)) { - throw new IllegalStateException("Impossible state: no valid move generated."); + return false; } } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java index c85bcca3cb..85996f7c60 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/value/decorator/FilteringValueRangeSelector.java @@ -207,6 +207,7 @@ void loadValues(@Nullable Object upcomingValue) { } currentUpcomingValue = upcomingValue; currentUpcomingEntity = null; + currentUpcomingList = null; if (checkSourceAndDestination) { // Load the current assigned entity of the selected value var position = listVariableStateSupply.getElementPosition(currentUpcomingValue); @@ -313,7 +314,19 @@ public boolean hasNext() { loadValues(updatedUpcomingValue); } } - return super.hasNext(); + var hasNext = super.hasNext(); + if (!hasNext && checkSourceAndDestination && currentUpcomingList != null && !currentUpcomingList.isEmpty()) { + // The checkSourceAndDestination flag is only enabled by swap moves, + // and the following assumption applies: + // if a valid move is not found with the given bailout size, + // we can still use the iterator as long as the currentUpcomingList is not empty + this.upcomingCreated = true; + this.hasUpcomingSelection = true; + // We assigned the same value to the left side, which will result in a non-doable move + this.upcomingSelection = currentUpcomingValue; + return true; + } + return hasNext; } @Override diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java index fef5fb143b..0b38486976 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveSelectorTest.java @@ -7,7 +7,6 @@ import static ai.timefold.solver.core.testutil.PlannerAssert.DO_NOT_ASSERT_SIZE; import static ai.timefold.solver.core.testutil.PlannerAssert.assertAllCodesOfMoveSelector; import static ai.timefold.solver.core.testutil.PlannerAssert.assertCodesOfNeverEndingIterableSelector; -import static ai.timefold.solver.core.testutil.PlannerAssert.assertEmptyNeverEndingIterableSelector; import static ai.timefold.solver.core.testutil.PlannerAssert.assertIterableSelectorWithoutSize; import static ai.timefold.solver.core.testutil.PlannerAssert.verifyPhaseLifecycle; import static ai.timefold.solver.core.testutil.PlannerTestUtils.mockScoreDirector; @@ -522,7 +521,7 @@ void multiVarRandomSelectionWithEntityValueRange() { // select left A, select right C random.reset(0, 2, 0, 2); scoreDirector.setWorkingSolution(solution); - assertEmptyNeverEndingIterableSelector(moveSelector, DO_NOT_ASSERT_SIZE); + assertCodesOfNeverEndingIterableSelector(moveSelector, DO_NOT_ASSERT_SIZE); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java index 2bca93b0bb..aeb431f82b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java @@ -65,9 +65,6 @@ void isMoveDoableValueRangeProviderOnEntity() { a.setValue(v3); b.setValue(v3); assertThat(abMove.isMoveDoable(scoreDirector)).isFalse(); - a.setValue(v2); - b.setValue(v4); - assertThat(abMove.isMoveDoable(scoreDirector)).isFalse(); var acMove = new SwapMove<>(entityDescriptor.getGenuineVariableDescriptorList(), a, c); a.setValue(v1); From 65ca1269c9b2e6b190b16c41893c9fdcd3b7d077 Mon Sep 17 00:00:00 2001 From: fred Date: Tue, 26 Aug 2025 15:51:58 -0300 Subject: [PATCH 7/9] chore: enable assertion --- .../core/impl/heuristic/selector/move/generic/SwapMove.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java index 47bd6f4c81..2a96a888dc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java @@ -55,12 +55,12 @@ public boolean isMoveDoable(ScoreDirector scoreDirector) { var valueRangeDescriptor = variableDescriptor.getValueRangeDescriptor(); var rightValueRange = extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, rightEntity); if (!rightValueRange.contains(leftValue)) { - return false; + throw new IllegalStateException("Impossible state: invalide swap move"); } var leftValueRange = extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, leftEntity); if (!leftValueRange.contains(rightValue)) { - return false; + throw new IllegalStateException("Impossible state: invalide swap move"); } } } From f269afce3568e12e5586ae6523cf841a85b1ae58 Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 19 Feb 2025 09:11:31 -0300 Subject: [PATCH 8/9] feat: add quarkus random seed --- .../TimefoldProcessorOverridePropertiesAtRuntimeTest.java | 6 +++++- .../java/ai/timefold/solver/quarkus/TimefoldRecorder.java | 2 ++ .../timefold/solver/quarkus/config/SolverRuntimeConfig.java | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorOverridePropertiesAtRuntimeTest.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorOverridePropertiesAtRuntimeTest.java index 5dd7f1061b..af33fd9ca2 100644 --- a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorOverridePropertiesAtRuntimeTest.java +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorOverridePropertiesAtRuntimeTest.java @@ -62,6 +62,7 @@ private static String getRequiredProperty(String name) { private static Map getRuntimeProperties() { Map out = new HashMap<>(); out.put("quarkus.timefold.solver.termination.best-score-limit", "7"); + out.put("quarkus.timefold.solver.random-seed", "123"); out.put("quarkus.timefold.solver.move-thread-count", "3"); out.put("quarkus.timefold.solver-manager.parallel-solver-count", "10"); out.put("quarkus.timefold.solver.termination.diminished-returns.enabled", "true"); @@ -90,11 +91,13 @@ public String getSolverConfig() { termination.diminished-returns.minimum-improvement-ratio=%s termination.bestScoreLimit=%s moveThreadCount=%s + randomSeed=%d """ .formatted(diminishedReturnsConfig.getSlidingWindowDuration().toHours(), diminishedReturnsConfig.getMinimumImprovementRatio(), solverConfig.getTerminationConfig().getBestScoreLimit(), - solverConfig.getMoveThreadCount()); + solverConfig.getMoveThreadCount(), + 123); } @GET @@ -120,6 +123,7 @@ void solverConfigPropertiesShouldBeOverwritten() throws IOException { assertEquals("0.5", solverConfigProperties.get("termination.diminished-returns.minimum-improvement-ratio")); assertEquals("7", solverConfigProperties.get("termination.bestScoreLimit")); assertEquals("3", solverConfigProperties.get("moveThreadCount")); + assertEquals("123", solverConfigProperties.get("randomSeed")); } @Test diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java index 04cd3b84ac..f78b2ab59e 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java @@ -136,6 +136,8 @@ public static void updateSolverConfigWithRuntimeProperties(SolverConfig solverCo .ifPresent(solverConfig::setDaemon); maybeSolverRuntimeConfig.flatMap(SolverRuntimeConfig::moveThreadCount) .ifPresent(solverConfig::setMoveThreadCount); + maybeSolverRuntimeConfig.flatMap(SolverRuntimeConfig::randomSeed) + .ifPresent(solverConfig::setRandomSeed); maybeSolverRuntimeConfig.flatMap(config -> config.termination().diminishedReturns()) .ifPresent(diminishedReturnsConfig -> setDiminishedReturns(solverConfig, diminishedReturnsConfig)); } diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java index 671520a238..8507f1f371 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/config/SolverRuntimeConfig.java @@ -43,4 +43,9 @@ public interface SolverRuntimeConfig { * Configuration properties that overwrite {@link TerminationConfig}. */ TerminationRuntimeConfig termination(); + + /** + * Configuration of the random seed. + */ + Optional randomSeed(); } From eb8101fb9f63fcaad2a46f473b1879302a83064d Mon Sep 17 00:00:00 2001 From: fred Date: Tue, 26 Aug 2025 17:23:27 -0300 Subject: [PATCH 9/9] chore: disable entity-rage assertion --- .../selector/move/generic/SwapMove.java | 17 ++--------------- .../selector/move/generic/SwapMoveTest.java | 17 +++-------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java index 2a96a888dc..afbd7eef7b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMove.java @@ -45,27 +45,14 @@ public boolean isMoveDoable(ScoreDirector scoreDirector) { if (leftEntity == rightEntity) { return false; } - var movable = false; for (var variableDescriptor : variableDescriptorList) { var leftValue = variableDescriptor.getValue(leftEntity); var rightValue = variableDescriptor.getValue(rightEntity); if (!Objects.equals(leftValue, rightValue)) { - movable = true; - if (!variableDescriptor.canExtractValueRangeFromSolution()) { - var valueRangeDescriptor = variableDescriptor.getValueRangeDescriptor(); - var rightValueRange = extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, rightEntity); - if (!rightValueRange.contains(leftValue)) { - throw new IllegalStateException("Impossible state: invalide swap move"); - } - var leftValueRange = - extractValueRangeFromEntity(scoreDirector, valueRangeDescriptor, leftEntity); - if (!leftValueRange.contains(rightValue)) { - throw new IllegalStateException("Impossible state: invalide swap move"); - } - } + return true; } } - return movable; + return false; } @Override diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java index aeb431f82b..12cd48f1d2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/SwapMoveTest.java @@ -50,9 +50,6 @@ void isMoveDoableValueRangeProviderOnEntity() { var entityDescriptor = TestdataAllowsUnassignedEntityProvidingEntity.buildEntityDescriptor(); var abMove = new SwapMove<>(entityDescriptor.getGenuineVariableDescriptorList(), a, b); - a.setValue(v1); - b.setValue(v2); - assertThat(abMove.isMoveDoable(scoreDirector)).isFalse(); a.setValue(v2); b.setValue(v2); assertThat(abMove.isMoveDoable(scoreDirector)).isFalse(); @@ -66,18 +63,7 @@ void isMoveDoableValueRangeProviderOnEntity() { b.setValue(v3); assertThat(abMove.isMoveDoable(scoreDirector)).isFalse(); - var acMove = new SwapMove<>(entityDescriptor.getGenuineVariableDescriptorList(), a, c); - a.setValue(v1); - c.setValue(v4); - assertThat(acMove.isMoveDoable(scoreDirector)).isFalse(); - a.setValue(v2); - c.setValue(v5); - assertThat(acMove.isMoveDoable(scoreDirector)).isFalse(); - var bcMove = new SwapMove<>(entityDescriptor.getGenuineVariableDescriptorList(), b, c); - b.setValue(v2); - c.setValue(v4); - assertThat(bcMove.isMoveDoable(scoreDirector)).isFalse(); b.setValue(v4); c.setValue(v5); assertThat(bcMove.isMoveDoable(scoreDirector)).isTrue(); @@ -87,6 +73,9 @@ void isMoveDoableValueRangeProviderOnEntity() { b.setValue(v5); c.setValue(v5); assertThat(bcMove.isMoveDoable(scoreDirector)).isFalse(); + + var aaMove = new SwapMove<>(entityDescriptor.getGenuineVariableDescriptorList(), a, a); + assertThat(aaMove.isMoveDoable(scoreDirector)).isFalse(); } @Test