diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java index c8075352229..7d4c2a94275 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ValueRangeState.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.score.director; +import java.util.ArrayList; import java.util.BitSet; import java.util.HashMap; import java.util.IdentityHashMap; @@ -35,7 +36,6 @@ final class ValueRangeState { // Solution related fields private @Nullable ValueRangeItem, Value_> fromSolutionItem; - private @Nullable Map fromSolutionValueIndexMap; // Entity related fields private @Nullable Map, Value_>> fromEntityMap; @@ -54,7 +54,6 @@ public ValueRange getFromSolution(Solution_ solution, @Nullable Selectio if (fromSolutionItem == null) { var valueRange = fetchValueRangeFromSolution(solution, sorter); fromSolutionItem = ValueRangeItem.ofLeft(null, valueRange, sorter); - fromSolutionValueIndexMap = buildIndexMap(valueRange.createOriginalIterator(), (int) valueRange.getSize()); return valueRange; } var valueRange = pickValueBySorter(fromSolutionItem, sorter, null); @@ -66,9 +65,6 @@ public ValueRange getFromSolution(Solution_ solution, @Nullable Selectio var sortedValueRange = sortValueRange(Objects.requireNonNull(fromSolutionItem.leftItem()), sorter); fromSolutionItem = ValueRangeItem.of(null, sortedValueRange, sorter, fromSolutionItem.rightItem(), fromSolutionItem.rightSorter()); - // We need to update the index map or the positions may become inconsistent - fromSolutionValueIndexMap = - buildIndexMap(sortedValueRange.createOriginalIterator(), (int) sortedValueRange.getSize()); return sortedValueRange; } else if (fromSolutionItem.rightItem() == null) { var sortedValueRange = sortValueRange(Objects.requireNonNull(fromSolutionItem.leftItem()), sorter); @@ -103,14 +99,6 @@ public ValueRange getFromSolution(Solution_ solution, @Nullable Selectio return null; } - private Map getIndexMapFromSolution() { - if (fromSolutionValueIndexMap == null) { - // We call getFromSolution to ensure the solution-range is loaded and the related index map is created - getFromSolution(cachedWorkingSolution, null); - } - return fromSolutionValueIndexMap; - } - private ValueRange fetchValueRangeFromSolution(Solution_ solution, @Nullable SelectionSorter sorter) { var valueRange = extractValueRange(solution); @@ -153,7 +141,7 @@ public ValueRange getFromEntity(Entity_ entity, } return Objects.requireNonNull(newItem.leftItem()); } - var valueRange = pickValueBySorter(item, sorter, (p, s) -> getFromEntity(p, s)); + var valueRange = pickValueBySorter(item, sorter, this::getFromEntity); if (valueRange != null) { return valueRange; } @@ -282,10 +270,14 @@ private ReachableValues fetchReachableValues(GenuineVariableDes var expectedTypeOfValue = valueRangeDescriptor.getVariableDescriptor().getVariableMetaModel().type(); var reachableValueList = initReachableValueList(valueList, entityList.size()); var valueIndexItem = new ReachableValuesIndex<>(valueIndexMap, reachableValueList); + var entityIndicesByRange = new IdentityHashMap, List>(); for (var i = 0; i < entityList.size(); i++) { var entity = entityList.get(i); var valueRange = getFromEntity(entity, null); - loadEntityValueRange(i, valueIndexMap, valueRange, reachableValueList); + entityIndicesByRange.computeIfAbsent(valueRange, k -> new ArrayList<>()).add(i); + } + for (var entry : entityIndicesByRange.entrySet()) { + loadEntityValueRange(entry.getValue(), valueIndexMap, entry.getKey(), reachableValueList); } var sorterAdapter = sorter != null ? SelectionSorterAdapter.of(cachedWorkingSolution, sorter) : null; return new ReachableValues<>(entityIndexItem, valueIndexItem, expectedTypeOfValue, sorterAdapter, @@ -308,31 +300,34 @@ private static Map buildIndexMap(Iterator<@Nullable Type private List> initReachableValueList(ValueRange valueRange, int entityListSize) { var valuesSize = (int) valueRange.getSize(); - Iterator<@Nullable Value_> iterator = valueRange.createOriginalIterator(); + var iterator = valueRange.createOriginalIterator(); var spliterator = Spliterators.spliterator(iterator, valuesSize, Spliterator.ORDERED | Spliterator.IMMUTABLE); var idx = new MutableInt(-1); return StreamSupport.stream(spliterator, false).filter(Objects::nonNull) .map(v -> new ReachableItemValue(idx.increment(), v, entityListSize, valuesSize)).toList(); } - private static void loadEntityValueRange(int entityIndex, Map valueIndexMap, + private static void loadEntityValueRange(List entityIndices, Map valueIndexMap, ValueRange valueRange, List> reachableValueList) { - // We create a bitset containing all possible values from the range to optimize operations + // Build bitset once per distinct range to avoid redundant work for entities sharing a range. var allValuesBitSet = buildBitSetForValueRange(valueRange, valueIndexMap); - // The second pass need only to iterate over the bits we already set. + // The second pass only iterates over the bits we already set. var valueIndex = allValuesBitSet.nextSetBit(0); while (valueIndex >= 0) { var item = reachableValueList.get(valueIndex); - item.addEntity(entityIndex); - // We unset the current value index to import only the values that are reachable + // Co-values populated once per distinct range; addValuesExcept is idempotent across shared ranges. item.addValuesExcept(allValuesBitSet, valueIndex); + // Entity membership registered per entity. + for (var entityIndex : entityIndices) { + item.addEntity(entityIndex); + } valueIndex = allValuesBitSet.nextSetBit(valueIndex + 1); } } private static BitSet buildBitSetForValueRange(ValueRange valueRange, Map valueIndexMap) { var valueBitSet = new BitSet((int) valueRange.getSize()); - Iterator<@Nullable Value_> iterator = valueRange.createOriginalIterator(); + var iterator = valueRange.createOriginalIterator(); while (iterator.hasNext()) { var value = iterator.next(); if (value == null) { @@ -370,7 +365,7 @@ public static ValueRangeItem(ValueRange item, int hash) { + private record HashedValueRange(ValueRange item, int hash) { public static HashedValueRange of(ValueRange valueRange) { return new HashedValueRange<>(valueRange, valueRange.hashCode()); @@ -383,10 +378,9 @@ public int hashCode() { @Override public boolean equals(Object o) { - if (!(o instanceof HashedValueRange that)) { - return false; - } - return hash == that.hash && Objects.equals(item, that.item); + return o instanceof HashedValueRange(var otherItem, var otherHash) + && hash == otherHash + && Objects.equals(item, otherItem); } } 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 393894e0eaf..c87e162b2f1 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 @@ -16,6 +16,43 @@ class ReachableValuesTest { + @Test + void testSharedEntityValueRange() { + var v1 = new TestdataListEntityProvidingValue("V1"); + var v2 = new TestdataListEntityProvidingValue("V2"); + var v3 = new TestdataListEntityProvidingValue("V3"); + var v4 = new TestdataListEntityProvidingValue("V4"); + // A and B share an identical value range; C has a distinct one. + var a = new TestdataListEntityProvidingEntity("A", List.of(v1, v2, v3)); + var b = new TestdataListEntityProvidingEntity("B", List.of(v1, v2, v3)); + var c = new TestdataListEntityProvidingEntity("C", List.of(v3, v4)); + var solution = new TestdataListEntityProvidingSolution(); + solution.setEntityList(List.of(a, b, c)); + + var scoreDirector = mockScoreDirector(TestdataListEntityProvidingSolution.buildSolutionDescriptor()); + scoreDirector.setWorkingSolution(solution); + + var solutionDescriptor = scoreDirector.getSolutionDescriptor(); + var entityDescriptor = solutionDescriptor.findEntityDescriptor(TestdataListEntityProvidingEntity.class); + var reachableValues = scoreDirector.getValueRangeManager() + .getReachableValues(entityDescriptor.getListVariableDescriptor()); + + // Both A and B must appear — addEntity must be called for each entity, not just the canonical one. + assertThat(reachableValues.extractEntitiesAsList(v1)).containsExactlyInAnyOrder(a, b); + assertThat(reachableValues.extractEntitiesAsList(v2)).containsExactlyInAnyOrder(a, b); + assertThat(reachableValues.extractEntitiesAsList(v3)).containsExactlyInAnyOrder(a, b, c); + assertThat(reachableValues.extractEntitiesAsList(v4)).containsExactlyInAnyOrder(c); + + assertThat(reachableValues.isEntityReachable(v1, a)).isTrue(); + assertThat(reachableValues.isEntityReachable(v1, b)).isTrue(); + assertThat(reachableValues.isEntityReachable(v1, c)).isFalse(); + + // v3 appears in both the shared range [v1,v2,v3] and C's range [v3,v4]; co-values = union minus self. + assertThat(reachableValues.extractValuesAsList(v3)).containsExactlyInAnyOrder(v1, v2, v4); + assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v2, v3); + assertThat(reachableValues.extractValuesAsList(v4)).containsExactlyInAnyOrder(v3); + } + @Test void testReachableValuesByEntity() { var v1 = new TestdataListEntityProvidingValue("V1"); @@ -106,7 +143,7 @@ void testUnassignedReachableValues() { var solutionDescriptor = scoreDirector.getSolutionDescriptor(); var entityDescriptor = solutionDescriptor.findEntityDescriptor(TestdataAllowsUnassignedEntityProvidingEntity.class); var reachableValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0)); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst()); assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v2, v3); assertThat(reachableValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v1, v3); @@ -142,7 +179,7 @@ void sortAscendingReachableValues() { var solutionDescriptor = scoreDirector.getSolutionDescriptor(); var entityDescriptor = solutionDescriptor.findEntityDescriptor(TestdataAllowsUnassignedEntityProvidingEntity.class); var reachableValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), new TestdataObjectSorter()); assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v2, v3); @@ -171,7 +208,7 @@ void sortDescendingReachableValues() { var solutionDescriptor = scoreDirector.getSolutionDescriptor(); var entityDescriptor = solutionDescriptor.findEntityDescriptor(TestdataAllowsUnassignedEntityProvidingEntity.class); var reachableValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), new TestdataObjectSorter(false)); assertThat(reachableValues.extractValuesAsList(v1)).containsExactlyInAnyOrder(v3, v2); @@ -203,31 +240,31 @@ void multipleSorters() { // No sorter var noSorterValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), null); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), null); assertThat(noSorterValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v3, v1, v4, v5); // Ascending sorter replaces the no sorter var ascendingSorter = new TestdataObjectSorter(true); var ascendingValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), ascendingSorter); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), ascendingSorter); assertThat(ascendingValues).isNotSameAs(noSorterValues) .isSameAs(scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), ascendingSorter)); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), ascendingSorter)); assertThat(noSorterValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v1, v3, v4, v5); // Descending sorter var descendingSorter = new TestdataObjectSorter(false); var descendingValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), descendingSorter); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), descendingSorter); assertThat(descendingValues).isNotSameAs(ascendingValues) .isNotSameAs(noSorterValues) .isSameAs(scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), descendingSorter)); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), descendingSorter)); assertThat(descendingValues.extractValuesAsList(v2)).containsExactlyInAnyOrder(v5, v4, v3, v1); // Null sorter returns the ascending sorter var otherNoSorterValues = scoreDirector.getValueRangeManager() - .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().get(0), ascendingSorter); + .getReachableValues(entityDescriptor.getGenuineVariableDescriptorList().getFirst(), ascendingSorter); assertThat(otherNoSorterValues).isSameAs(ascendingValues); } }