Skip to content

Commit 7d21b3a

Browse files
authored
fix: avoid removing iterators prematurely (TimefoldAI#1779)
This PR addresses an issue where a random iterator can be removed prematurely if the `hasNext` call returns false. The new approach returns an invalid move when the maximum number of attempts to generate a valid move is not reached.
1 parent b701ef9 commit 7d21b3a

9 files changed

Lines changed: 477 additions & 337 deletions

File tree

core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/common/ReachableValues.java

Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import java.util.ArrayList;
44
import java.util.Collections;
5-
import java.util.IdentityHashMap;
5+
import java.util.LinkedHashSet;
66
import java.util.List;
77
import java.util.Map;
88
import java.util.Objects;
@@ -16,78 +16,117 @@
1616
/**
1717
* This class records the relationship between each planning value and all entities that include the related value
1818
* within its value range.
19-
*
19+
*
2020
* @see FromEntityPropertyValueRangeDescriptor
2121
*/
2222
@NullMarked
2323
public final class ReachableValues {
2424

25-
private final Map<Object, Set<Object>> valueToEntityMap;
26-
private final Map<Object, Set<Object>> valueToValueMap;
27-
private final Map<Object, List<Object>> randomAccessValueToEntityMap;
28-
private final Map<Object, List<Object>> randomAccessValueToValueMap;
25+
private final Map<Object, ReachableItemValue> values;
2926
private final @Nullable Class<?> valueClass;
27+
private final boolean acceptsNullValue;
28+
private @Nullable ReachableItemValue firstCachedObject;
29+
private @Nullable ReachableItemValue secondCachedObject;
3030

31-
public ReachableValues(Map<Object, Set<Object>> valueToEntityMap, Map<Object, Set<Object>> valueToValueMap) {
32-
this.valueToEntityMap = valueToEntityMap;
33-
this.randomAccessValueToEntityMap = new IdentityHashMap<>(this.valueToEntityMap.size());
34-
this.valueToValueMap = valueToValueMap;
35-
this.randomAccessValueToValueMap = new IdentityHashMap<>(this.valueToValueMap.size());
36-
var first = valueToEntityMap.entrySet().stream().findFirst();
37-
this.valueClass = first.<Class<?>> map(entry -> entry.getKey().getClass()).orElse(null);
31+
public ReachableValues(Map<Object, ReachableItemValue> values, boolean acceptsNullValue) {
32+
this.values = values;
33+
this.acceptsNullValue = acceptsNullValue;
34+
var firstValue = values.entrySet().stream().findFirst();
35+
this.valueClass = firstValue.<Class<?>> map(entry -> entry.getKey().getClass()).orElse(null);
3836
}
3937

40-
/**
41-
* @return all reachable values for the given value.
42-
*/
43-
public @Nullable Set<Object> extractEntities(Object value) {
44-
return valueToEntityMap.get(value);
45-
}
46-
47-
/**
48-
* @return all reachable entities for the given value.
49-
*/
50-
public @Nullable Set<Object> extractValues(Object value) {
51-
return valueToValueMap.get(value);
38+
private @Nullable ReachableItemValue fetchItemValue(Object value) {
39+
ReachableItemValue selected = null;
40+
if (firstCachedObject != null && firstCachedObject.value == value) {
41+
selected = firstCachedObject;
42+
} else if (secondCachedObject != null && secondCachedObject.value == value) {
43+
selected = secondCachedObject;
44+
// The most recently used item is moved to the first position.
45+
// The goal is to try to keep recently used items in the cache.
46+
secondCachedObject = firstCachedObject;
47+
firstCachedObject = selected;
48+
}
49+
if (selected == null) {
50+
selected = values.get(value);
51+
secondCachedObject = firstCachedObject;
52+
firstCachedObject = selected;
53+
}
54+
return selected;
5255
}
5356

5457
public List<Object> extractEntitiesAsList(Object value) {
55-
var result = randomAccessValueToEntityMap.get(value);
56-
if (result == null) {
57-
var entitySet = this.valueToEntityMap.get(value);
58-
if (entitySet != null) {
59-
result = new ArrayList<>(entitySet);
60-
} else {
61-
result = Collections.emptyList();
62-
}
63-
randomAccessValueToEntityMap.put(value, result);
58+
var itemValue = fetchItemValue(value);
59+
if (itemValue == null) {
60+
return Collections.emptyList();
6461
}
65-
return result;
62+
return itemValue.randomAccessEntityList;
6663
}
6764

6865
public List<Object> extractValuesAsList(Object value) {
69-
var result = randomAccessValueToValueMap.get(value);
70-
if (result == null) {
71-
var valueSet = this.valueToValueMap.get(value);
72-
if (valueSet != null) {
73-
result = new ArrayList<>(valueSet);
74-
} else {
75-
result = Collections.emptyList();
76-
}
77-
randomAccessValueToValueMap.put(value, result);
66+
var itemValue = fetchItemValue(value);
67+
if (itemValue == null) {
68+
return Collections.emptyList();
7869
}
79-
return result;
70+
return itemValue.randomAccessValueList;
8071
}
8172

8273
public int getSize() {
83-
return valueToEntityMap.size();
74+
return values.size();
8475
}
8576

86-
public boolean isValidValueClass(Object value) {
87-
if (valueToEntityMap.isEmpty()) {
77+
public boolean isEntityReachable(Object origin, @Nullable Object entity) {
78+
if (entity == null) {
79+
return true;
80+
}
81+
var originItemValue = fetchItemValue(Objects.requireNonNull(origin));
82+
if (originItemValue == null) {
83+
return false;
84+
}
85+
return originItemValue.entitySet.contains(entity);
86+
}
87+
88+
public boolean isValueReachable(Object origin, @Nullable Object otherValue) {
89+
var originItemValue = fetchItemValue(Objects.requireNonNull(origin));
90+
if (originItemValue == null) {
8891
return false;
8992
}
90-
return Objects.requireNonNull(value).getClass().equals(valueClass);
93+
if (otherValue == null) {
94+
return acceptsNullValue;
95+
}
96+
return originItemValue.valueSet.contains(Objects.requireNonNull(otherValue));
97+
}
98+
99+
public boolean matchesValueClass(Object value) {
100+
return valueClass != null && valueClass.isAssignableFrom(Objects.requireNonNull(value).getClass());
101+
}
102+
103+
@NullMarked
104+
public static final class ReachableItemValue {
105+
private final Object value;
106+
private final Set<Object> entitySet;
107+
private final Set<Object> valueSet;
108+
private final List<Object> randomAccessEntityList;
109+
private final List<Object> randomAccessValueList;
110+
111+
public ReachableItemValue(Object value, int entityListSize, int valueListSize) {
112+
this.value = value;
113+
this.entitySet = new LinkedHashSet<>(entityListSize);
114+
this.randomAccessEntityList = new ArrayList<>(entityListSize);
115+
this.valueSet = new LinkedHashSet<>(valueListSize);
116+
this.randomAccessValueList = new ArrayList<>(valueListSize);
117+
}
118+
119+
public void addEntity(Object entity) {
120+
if (entitySet.add(entity)) {
121+
randomAccessEntityList.add(entity);
122+
}
123+
}
124+
125+
public void addValue(Object value) {
126+
if (valueSet.add(value)) {
127+
randomAccessValueList.add(value);
128+
}
129+
}
91130
}
92131

93132
}

core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/EntitySelectorFactory.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionSorterWeightFactory;
2525
import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.WeightFactorySelectionSorter;
2626
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.CachingEntitySelector;
27+
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityByValueSelector;
2728
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntitySelector;
28-
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.FilteringEntityValueRangeSelector;
2929
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.ProbabilityEntitySelector;
3030
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.SelectedCountLimitEntitySelector;
3131
import ai.timefold.solver.core.impl.heuristic.selector.entity.decorator.ShufflingEntitySelector;
@@ -195,7 +195,7 @@ private EntitySelector<Solution_> applyEntityValueRangeFiltering(HeuristicConfig
195195
var replayingValueSelector = (IterableValueSelector<Solution_>) ValueSelectorFactory
196196
.<Solution_> create(valueSelectorConfig)
197197
.buildValueSelector(configPolicy, entitySelector.getEntityDescriptor(), minimumCacheType, selectionOrder);
198-
return new FilteringEntityValueRangeSelector<>(entitySelector, replayingValueSelector, randomSelection);
198+
return new FilteringEntityByValueSelector<>(entitySelector, replayingValueSelector, randomSelection);
199199
}
200200

201201
private EntitySelector<Solution_> applyNearbySelection(HeuristicConfigPolicy<Solution_> configPolicy,

core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityValueRangeSelector.java renamed to core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/decorator/FilteringEntityByValueSelector.java

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,64 @@
44
import java.util.Iterator;
55
import java.util.List;
66
import java.util.ListIterator;
7+
import java.util.NoSuchElementException;
78
import java.util.Objects;
89
import java.util.Random;
910
import java.util.function.Supplier;
1011

12+
import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacerFactory;
13+
import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedValuePlacer;
1114
import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor;
1215
import ai.timefold.solver.core.impl.heuristic.selector.AbstractDemandEnabledSelector;
1316
import ai.timefold.solver.core.impl.heuristic.selector.common.ReachableValues;
1417
import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator;
1518
import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector;
19+
import ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ListChangeMoveSelectorFactory;
1620
import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector;
1721
import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope;
1822
import ai.timefold.solver.core.impl.solver.scope.SolverScope;
1923

2024
/**
2125
* The decorator returns a list of reachable entities for a specific value.
22-
* It enables the creation of a filtering tier when using entity value selectors,
26+
* It enables the creation of a filtering tier when using entity-provided value ranges,
2327
* ensuring only valid and reachable entities are returned.
28+
* An entity is considered reachable to a value if its value range includes that value.
29+
* <p>
30+
* The decorator can only be applied to list variables.
31+
* <p>
32+
* <code>
2433
*
2534
* e1 = entity_range[v1, v2, v3]
35+
*
2636
* e2 = entity_range[v1, v4]
2737
*
2838
* v1 = [e1, e2]
39+
*
2940
* v2 = [e1]
41+
*
3042
* v3 = [e1]
43+
*
3144
* v4 = [e2]
3245
*
46+
* </code>
47+
* <p>
48+
* This node is currently used by the {@link QueuedValuePlacer} to build an initial solution.
49+
* To illustrate its usage, let’s assume how moves are generated.
50+
* First, a value is selected using a value selector.
51+
* Then,
52+
* a change move selector generates all possible moves for that value to the available entities
53+
* and selects the entity and position with the best score.
54+
* <p>
55+
* Considering the previous process and the current goal of this node,
56+
* we can observe that once a value is selected, only change moves to reachable entities will be generated.
57+
* This ensures that entities that do not accept the currently selected value will not produce any change moves.
58+
*
59+
* @see ListChangeMoveSelectorFactory
60+
* @see EntityPlacerFactory
61+
*
3362
* @param <Solution_> the solution type
3463
*/
35-
public final class FilteringEntityValueRangeSelector<Solution_> extends AbstractDemandEnabledSelector<Solution_>
64+
public final class FilteringEntityByValueSelector<Solution_> extends AbstractDemandEnabledSelector<Solution_>
3665
implements EntitySelector<Solution_> {
3766

3867
private final IterableValueSelector<Solution_> replayingValueSelector;
@@ -43,7 +72,7 @@ public final class FilteringEntityValueRangeSelector<Solution_> extends Abstract
4372
private ReachableValues reachableValues;
4473
private long entitiesSize;
4574

46-
public FilteringEntityValueRangeSelector(EntitySelector<Solution_> childEntitySelector,
75+
public FilteringEntityByValueSelector(EntitySelector<Solution_> childEntitySelector,
4776
IterableValueSelector<Solution_> replayingValueSelector, boolean randomSelection) {
4877
this.replayingValueSelector = replayingValueSelector;
4978
this.childEntitySelector = childEntitySelector;
@@ -65,7 +94,9 @@ public void phaseStarted(AbstractPhaseScope<Solution_> phaseScope) {
6594
super.phaseStarted(phaseScope);
6695
this.entitiesSize = childEntitySelector.getEntityDescriptor().extractEntities(phaseScope.getWorkingSolution()).size();
6796
this.reachableValues = phaseScope.getScoreDirector().getValueRangeManager()
68-
.getReachableValues(phaseScope.getScoreDirector().getSolutionDescriptor().getListVariableDescriptor());
97+
.getReachableValues(Objects.requireNonNull(
98+
phaseScope.getScoreDirector().getSolutionDescriptor().getListVariableDescriptor(),
99+
"Impossible state: the list variable cannot be null."));
69100
this.childEntitySelector.phaseStarted(phaseScope);
70101
}
71102

@@ -141,7 +172,7 @@ public ListIterator<Object> listIterator(int index) {
141172

142173
@Override
143174
public boolean equals(Object other) {
144-
return other instanceof FilteringEntityValueRangeSelector<?> that
175+
return other instanceof FilteringEntityByValueSelector<?> that
145176
&& Objects.equals(childEntitySelector, that.childEntitySelector)
146177
&& Objects.equals(replayingValueSelector, that.replayingValueSelector);
147178
}
@@ -170,8 +201,7 @@ private void initialize() {
170201
if (currentUpcomingValue == null) {
171202
valueIterator = Collections.emptyIterator();
172203
} else {
173-
var allValues = Objects.requireNonNull(reachableValues)
174-
.extractEntitiesAsList(Objects.requireNonNull(currentUpcomingValue));
204+
var allValues = reachableValues.extractEntitiesAsList(Objects.requireNonNull(currentUpcomingValue));
175205
this.valueIterator = Objects.requireNonNull(allValues).iterator();
176206
}
177207
}
@@ -186,7 +216,7 @@ protected Object createUpcomingSelection() {
186216
}
187217
}
188218

189-
private static class RandomFilteringValueRangeIterator extends UpcomingSelectionIterator<Object> {
219+
private static class RandomFilteringValueRangeIterator implements Iterator<Object> {
190220

191221
private final Supplier<Object> upcomingValueSupplier;
192222
private final ReachableValues reachableValues;
@@ -205,40 +235,29 @@ private void initialize() {
205235
if (entityList != null) {
206236
return;
207237
}
238+
var oldUpcomingValue = currentUpcomingValue;
208239
currentUpcomingValue = upcomingValueSupplier.get();
209240
if (currentUpcomingValue == null) {
210241
entityList = Collections.emptyList();
211-
} else {
242+
} else if (oldUpcomingValue != currentUpcomingValue) {
212243
loadValues();
213244
}
214245
}
215246

216247
private void loadValues() {
217-
upcomingCreated = false;
218248
this.entityList = reachableValues.extractEntitiesAsList(currentUpcomingValue);
219249
}
220250

221251
@Override
222252
public boolean hasNext() {
223-
if (currentUpcomingValue != null) {
224-
var updatedUpcomingValue = upcomingValueSupplier.get();
225-
if (updatedUpcomingValue != currentUpcomingValue) {
226-
// The iterator is reused in the ElementPositionRandomIterator,
227-
// even if the value has changed.
228-
// Therefore,
229-
// we need to update the value list to ensure it is consistent.
230-
currentUpcomingValue = updatedUpcomingValue;
231-
loadValues();
232-
}
233-
}
234-
return super.hasNext();
253+
initialize();
254+
return entityList != null && !entityList.isEmpty();
235255
}
236256

237257
@Override
238-
protected Object createUpcomingSelection() {
239-
initialize();
258+
public Object next() {
240259
if (entityList.isEmpty()) {
241-
return noUpcomingSelection();
260+
throw new NoSuchElementException();
242261
}
243262
var index = workingRandom.nextInt(entityList.size());
244263
return entityList.get(index);

0 commit comments

Comments
 (0)