From cc75c64546819d502c841f2b5fac4daba563186e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 12 Jun 2026 13:17:11 +0200 Subject: [PATCH 01/12] perf: improve iteration speed --- .../core/impl/util/ElementAwareArrayList.java | 106 ++++++++---- .../impl/util/ElementAwareArrayListTest.java | 159 +++++++++++++++++- 2 files changed, 234 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index c8fc185e1a2..98a68fa4e93 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.impl.util; -import java.lang.reflect.Array; import java.util.AbstractList; import java.util.Arrays; import java.util.ConcurrentModificationException; @@ -8,7 +7,6 @@ import java.util.List; import java.util.ListIterator; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.function.Consumer; import org.jspecify.annotations.NullMarked; @@ -38,12 +36,15 @@ public final class ElementAwareArrayList extends AbstractList { + private static final Object[] EMPTY_ARRAY = new Object[0]; private static final int REMOVED_POSITION = -1; private static final int DEFAULT_CAPACITY = 16; - private @Nullable Entry @Nullable [] entries; + private static final int RETAIN_THRESHOLD = DEFAULT_CAPACITY; // Retain backing array when length <= this. + private Object @Nullable [] entries = EMPTY_ARRAY; private int lastElementPosition = -1; private int gapCount = 0; // Always equals the total number of null slots in entryList. + private int firstGapPosition = 0; // Pessimistic lower bound: positions [0, firstGapPosition) are guaranteed gap-free and positionally compact (logical i == physical i). /** * Appends the specified element to the end of this list. @@ -64,10 +65,9 @@ public Entry addEntry(T element) { return newEntry; } - @SuppressWarnings("unchecked") private void resize(int minCapacity) { - if (entries == null) { - entries = (Entry[]) Array.newInstance(Entry.class, Math.max(DEFAULT_CAPACITY, minCapacity)); + if (entries.length == 0) { + entries = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)]; return; } if (minCapacity <= entries.length) { @@ -76,6 +76,15 @@ private void resize(int minCapacity) { entries = Arrays.copyOf(entries, Math.max(entries.length * 2, minCapacity)); } + /** + * Returns the entry at the given physical position, or {@code null} if the slot is a gap. + * Callers in fast-path loops can skip calling this and check {@code entries[i] == null} directly. + */ + @SuppressWarnings("unchecked") + private @Nullable Entry entryAt(int position) { + return (Entry) entries[position]; + } + @Override public T get(int index) { return getEntry(index).element(); @@ -85,20 +94,35 @@ private Entry getEntry(int index) { if (index < 0 || index >= size()) { throw new IndexOutOfBoundsException( "The index (%d) must be >= 0 and < size (%d).".formatted(index, size())); - } else if (gapCount == 0) { - return Objects.requireNonNull(entries[index]); + } else if (gapCount == 0 || index < firstGapPosition) { + return entryAt(index); } return partialCompact(index); } + /** + * Removes all gaps from the list in O(n), preserving insertion order. + * After this call, {@code gapCount == 0} and every subsequent {@code get(int)} runs in O(1). + * No-op when the list is already compact or empty. + */ + void compact() { + if (gapCount > 0 && !isEmpty()) { + partialCompact(size() - 1); + } + } + /** * Avoid calling this when {@code gapCount == 0}. */ private Entry partialCompact(int rightBoundaryPosition) { + if (rightBoundaryPosition < firstGapPosition) { + // The entire target range is in the already-compacted prefix; no work needed. + return entryAt(rightBoundaryPosition); + } var encounteredGaps = 0; - var lastNonNullPosition = -1; - for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - var entry = entries[currentPosition]; + var lastNonNullPosition = firstGapPosition - 1; // firstGapPosition non-nulls are already in place before us. + for (var currentPosition = firstGapPosition; currentPosition <= lastElementPosition; currentPosition++) { + var entry = entryAt(currentPosition); if (entry == null) { encounteredGaps++; } else { @@ -111,11 +135,13 @@ private Entry partialCompact(int rightBoundaryPosition) { modCount++; } if (lastNonNullPosition == rightBoundaryPosition) { - // Invariant: positions [0, index] are all non-null, - // so all gapCount nulls lie in [index+1, lastElementPosition]. - // If that suffix is entirely nulls (equivalent to index == size()-1), trim it now. + // Invariant: positions [0, rightBoundaryPosition] are all non-null, + // so all gapCount nulls lie in [rightBoundaryPosition+1, lastElementPosition]. + // If that suffix is entirely nulls (equivalent to rightBoundaryPosition == size()-1), trim it now. if (gapCount == lastElementPosition - rightBoundaryPosition) { truncateTo(rightBoundaryPosition); + } else { + firstGapPosition = rightBoundaryPosition + 1; } return entry; } @@ -133,6 +159,7 @@ private void truncateTo(int newLastPosition) { Arrays.fill(entries, newLastPosition + 1, lastElementPosition + 1, null); lastElementPosition = newLastPosition; gapCount = 0; + firstGapPosition = lastElementPosition + 1; // [0, lastElementPosition] are all non-null. modCount++; } @@ -143,7 +170,6 @@ public boolean add(T element) { } @Override - @SuppressWarnings("DataFlowIssue") public void add(int index, T element) { var size = size(); if (index < 0 || index > size) { @@ -178,7 +204,7 @@ private void addWithoutGaps(int index, T element) { var newEntry = new Entry(element, index); resize(lastElementPosition + 2); for (var i = lastElementPosition; i >= index; i--) { - var shifted = entries[i]; + var shifted = entryAt(i); entries[i + 1] = shifted; shifted.moveTo(i + 1); } @@ -190,7 +216,7 @@ private void addWithGaps(int index, Entry newEntry) { modCount++; var displaced = newEntry; for (var i = index; i <= lastElementPosition; i++) { - var current = entries[i]; + var current = entryAt(i); displaced.moveTo(i); entries[i] = displaced; if (current == null) { @@ -225,11 +251,16 @@ private void remove(Entry entry) { .formatted(entry)); } var positionPreRemoval = entry.position; - if (positionPreRemoval == lastElementPosition) { // Removing the last element; just trim the list. + if (positionPreRemoval == lastElementPosition) { // Removing the last element; trim list and retract trailing gaps. entries[lastElementPosition--] = null; + while (lastElementPosition >= 0 && entries[lastElementPosition] == null) { + lastElementPosition--; + gapCount--; + } } else { entries[positionPreRemoval] = null; gapCount++; + firstGapPosition = Math.min(firstGapPosition, positionPreRemoval); } entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. modCount++; @@ -237,8 +268,18 @@ private void remove(Entry entry) { } private void clearIfPossible() { - if (isEmpty()) { - // All positions, if any, are gaps. Clear the list entirely. + if (!isEmpty()) { + return; + } + // List is empty: either retain the backing array (cheap re-add) or free it (large arrays). + // Trailing-gap retraction in remove() guarantees lastElementPosition == -1, gapCount == 0, + // and every slot already null — no fill is needed. + var length = entries.length; + if (length > 0 && length <= RETAIN_THRESHOLD) { + lastElementPosition = -1; // Already -1; explicit for clarity. + gapCount = 0; // Required: guards addEntry's gap-reuse branch (entries[-1]). + firstGapPosition = 0; // Already 0; explicit for clarity. + } else { innerClear(); } } @@ -250,9 +291,10 @@ public void clear() { } private void innerClear() { - entries = null; + entries = EMPTY_ARRAY; gapCount = 0; lastElementPosition = -1; + firstGapPosition = 0; } @Override @@ -281,7 +323,7 @@ public void forEach(Consumer action) { @SuppressWarnings("DataFlowIssue") private void forEachWithoutGaps(Consumer elementConsumer) { for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - elementConsumer.accept(entries[currentPosition].element()); + elementConsumer.accept(entryAt(currentPosition).element); // entries[i] is provably non-null (gapCount==0) } } @@ -301,11 +343,11 @@ private void forEachCompacting(Consumer elementConsumer) { } var compactPosition = 0; for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - var entry = entries[currentPosition]; + var entry = entryAt(currentPosition); if (entry == null) { continue; } - elementConsumer.accept(entry.element()); + elementConsumer.accept(entry.element); // entry is provably live (post null-skip) if (currentPosition != compactPosition) { entry.moveTo(compactPosition); entries[compactPosition] = entry; @@ -324,8 +366,14 @@ public Iterator iterator() { return listIterator(0); } + @Override + public ListIterator listIterator() { + return this.listIterator(0); + } + @Override public ListIterator listIterator(int index) { + compact(); // Ensure fast-path iteration; remove all gaps at once. return new ElementAwareListIterator(index); } @@ -393,15 +441,15 @@ public T next() { if (logicalPosition >= size()) { throw new NoSuchElementException(); } - var entry = entries[currentPosition]; + var entry = entryAt(currentPosition); while (entry == null) { - entry = entries[++currentPosition]; + entry = entryAt(++currentPosition); } currentPosition++; logicalPosition++; lastEntry = entry; lastWasFwd = true; - return entry.element(); + return entry.element; // provably live: entry is from a non-null slot after the null-skip loop } @Override @@ -412,12 +460,12 @@ public T previous() { } Entry entry = null; while (entry == null) { - entry = entries[--currentPosition]; + entry = entryAt(--currentPosition); } logicalPosition--; lastEntry = entry; lastWasFwd = false; - return entry.element(); + return entry.element; // provably live: entry is from a non-null slot after the null-skip loop } @Override diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java index 4cdfbf15a71..13ce3d9207a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java @@ -8,13 +8,20 @@ import java.util.ConcurrentModificationException; import java.util.List; import java.util.NoSuchElementException; +import java.util.Random; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; @NullMarked class ElementAwareArrayListTest { @@ -1097,10 +1104,10 @@ void removeFwd() { var it = list.listIterator(); assertThat(it.next()).isEqualTo("a"); it.remove(); - assertThat(list).containsExactly("b", "c"); assertThat(it.hasPrevious()).isFalse(); - assertThat(it.hasNext()).isTrue(); + assertThat(it).hasNext(); assertThat(it.next()).isEqualTo("b"); + assertThat(list).containsExactly("b", "c"); } @Test @@ -1413,6 +1420,154 @@ void cmeOnEntryRemove() { } + @Nested + @DisplayName("compact() and firstGapPosition tests") + class CompactAndFirstGapPositionTests { + + @Test + @DisplayName("compact() removes all gaps and preserves insertion order") + void compactRemovesAllGaps() { + var list = new ElementAwareArrayList(); + list.addEntry("a"); + var e1 = list.addEntry("b"); + list.addEntry("c"); + var e2 = list.addEntry("d"); + list.addEntry("e"); + e1.remove(); + e2.remove(); + + list.compact(); + + assertThat(list).containsExactly("a", "c", "e"); + assertThat(list.get(0)).isEqualTo("a"); + assertThat(list.get(1)).isEqualTo("c"); + assertThat(list.get(2)).isEqualTo("e"); + } + + @Test + @DisplayName("compact() on already-compact list is a no-op") + void compactNoOpWhenAlreadyCompact() { + var list = new ElementAwareArrayList(); + list.addEntry("a"); + list.addEntry("b"); + list.addEntry("c"); + + list.compact(); + + assertThat(list).containsExactly("a", "b", "c"); + } + + @Test + @DisplayName("compact() on empty list is a no-op") + void compactEmptyList() { + var list = new ElementAwareArrayList(); + + assertThatCode(list::compact).doesNotThrowAnyException(); + assertThat(list).isEmpty(); + } + + @Test + @DisplayName("get() indices below the first gap return correct elements without compacting suffix") + void getBeforeFirstGapFastPath() { + var list = new ElementAwareArrayList(); + list.addEntry("a"); + list.addEntry("b"); + var e2 = list.addEntry("c"); + list.addEntry("d"); + e2.remove(); // gap at physical 2; firstGapPosition ≤ 2 + + // get(0) and get(1) are below the boundary — return correct values + assertThat(list.get(0)).isEqualTo("a"); + assertThat(list.get(1)).isEqualTo("b"); + // get(2) is at/above the boundary — triggers compaction + assertThat(list.get(2)).isEqualTo("d"); + } + + @Test + @DisplayName("firstGapPosition advances after partialCompact; prefix stays fast-path-valid") + void firstGapAdvancesAfterPartialCompact() { + var list = new ElementAwareArrayList(); + list.addEntry("a"); + var e1 = list.addEntry("b"); + list.addEntry("c"); + var e2 = list.addEntry("d"); + list.addEntry("e"); + e1.remove(); + e2.remove(); + + assertThat(list.get(1)).isEqualTo("c"); // triggers partialCompact(1), firstGapPosition advances + assertThat(list.get(0)).isEqualTo("a"); // still correct after boundary move + assertThat(list.get(1)).isEqualTo("c"); + assertThat(list.get(2)).isEqualTo("e"); // next compaction round + assertThat(list).containsExactly("a", "c", "e"); + } + + @Test + @DisplayName("remove that lowers the boundary still compacts correctly on next get") + void removeLoweringBoundaryStillCompacts() { + var list = new ElementAwareArrayList(); + var e0 = list.addEntry("a"); + list.addEntry("b"); + var e2 = list.addEntry("c"); + list.addEntry("d"); + + e2.remove(); // gap at 2; firstGapPosition ≤ 2 + list.get(2); // compact to index 2; firstGapPosition = 3 + e0.remove(); // gap at 0; firstGapPosition must drop back to ≤ 0 + + // After removing a0, logical list = [b, d]; get(0) must still work + assertThat(list.get(0)).isEqualTo("b"); + assertThat(list.get(1)).isEqualTo("d"); + assertThat(list).containsExactly("b", "d"); + } + + @Execution(ExecutionMode.CONCURRENT) + @ParameterizedTest + @MethodSource("randomSeeds") + @DisplayName("randomized stress test: EAAL matches ArrayList reference under mixed add/remove/get/compact") + void stressTestAgainstReferenceArrayList(long seed) { + var random = new Random(seed); + var reference = new ArrayList(); + var list = new ElementAwareArrayList(); + var liveEntries = new ArrayList.Entry>(); + var counter = new AtomicInteger(0); + + for (var i = 0; i < 10_000; i++) { + var op = random.nextInt(reference.isEmpty() ? 2 : 5); + switch (op) { + case 0, 1 -> { // addEntry (weighted so the list doesn't starve) + var element = "e" + counter.getAndIncrement(); + reference.add(element); + liveEntries.add(list.addEntry(element)); + } + case 2 -> { // remove(Entry) + var idx = random.nextInt(liveEntries.size()); + var entry = liveEntries.remove(idx); + reference.remove(entry.element()); + entry.remove(); + } + case 3 -> { // get(int) — exercises firstGapPosition fast-path + partialCompact + var idx = random.nextInt(reference.size()); + assertThat(list.get(idx)).isEqualTo(reference.get(idx)); + } + case 4 -> { // compact() then full equality check + list.compact(); + assertThat(list).containsExactlyElementsOf(reference); + } + } + assertThat(list.size()).isEqualTo(reference.size()); + } + assertThat(list).containsExactlyElementsOf(reference); + } + + static Stream randomSeeds() { + var random = new Random(0xCAFEBABEL); + return random.longs(10) + .mapToObj(Arguments::of); + } + + } + private static List copyUsingForEach(ElementAwareArrayList list) { var result = new ArrayList(); list.forEach(result::add); From b23704547ac089eb2956e46f64e22b70ce674b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 12 Jun 2026 13:28:17 +0200 Subject: [PATCH 02/12] keep just one leaf --- .../bavet/common/index/IndexerFactory.java | 13 ++-- .../impl/bavet/common/index/LeafIndexer.java | 2 +- .../common/index/LinkedListLeafIndexer.java | 68 ------------------- .../common/index/FusedEqualIndexTest.java | 4 +- 4 files changed, 9 insertions(+), 78 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java index 54d94541209..c20ec88270f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java @@ -483,8 +483,7 @@ public UniKeysExtractor buildRightKeysExtractor() { } public Indexer buildIndexer(boolean isLeftBridge) { - Supplier> backendSupplier = - requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new; + Supplier> backendSupplier = RandomAccessLeafIndexer::new; if (!hasJoiners()) { // NoneJoiner results in a bare backend (NoneIndexer). return backendSupplier.get(); } @@ -517,8 +516,7 @@ private Supplier> buildIndexerChain(boolean isLeftBridge, int fro // Leaf-most level whose index key equals the whole composite key: no KeyUnpacker indirection. if (joinerType == JoinerType.EQUAL) { // Fuse the leaf-most equal indexer with its backend. - downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), - requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new); + downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), RandomAccessLeafIndexer::new); } else { KeyUnpacker keyUnpacker = KeyUnpacker.single(); downstreamIndexerSupplier = () -> buildIndexerPart(isLeftBridge, joinerType, keyUnpacker, backendSupplier); @@ -561,11 +559,12 @@ public FusedEqualIndex buildFusedEqualIndex() { equalPrefixLength == joinerCount ? KeyUnpacker.single() : KeyUnpacker.composite(0); if (equalPrefixLength == joinerCount) { // Pure equal: the per-side downstream is just the tuple list; the bucket is the equal-key group. - return new FusedEqualIndex<>(topEqualKeyUnpacker, false, LinkedListLeafIndexer::new, LinkedListLeafIndexer::new); + return new FusedEqualIndex<>(topEqualKeyUnpacker, false, RandomAccessLeafIndexer::new, + RandomAccessLeafIndexer::new); } else { // Equal prefix + suffix: build the per-side suffix sub-chain (the right side flips comparisons). - var leftDownstreamSupplier = this. buildIndexerChain(true, 1, LinkedListLeafIndexer::new); - var rightDownstreamSupplier = this. buildIndexerChain(false, 1, LinkedListLeafIndexer::new); + var leftDownstreamSupplier = this. buildIndexerChain(true, 1, RandomAccessLeafIndexer::new); + var rightDownstreamSupplier = this. buildIndexerChain(false, 1, RandomAccessLeafIndexer::new); return new FusedEqualIndex<>(topEqualKeyUnpacker, true, leftDownstreamSupplier, rightDownstreamSupplier); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java index 605e3feff49..6e87e8d7c71 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java @@ -12,6 +12,6 @@ @NullMarked public sealed interface LeafIndexer extends Indexer - permits RandomAccessLeafIndexer, LinkedListLeafIndexer { + permits RandomAccessLeafIndexer { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java deleted file mode 100644 index 19b16e71ce2..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java +++ /dev/null @@ -1,68 +0,0 @@ -package ai.timefold.solver.core.impl.bavet.common.index; - -import java.util.Iterator; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.random.RandomGenerator; - -import ai.timefold.solver.core.impl.util.ElementAwareLinkedList; -import ai.timefold.solver.core.impl.util.ListEntry; - -import org.jspecify.annotations.NullMarked; - -/** - * Super-fast, but doesn't support random access. - * - * @param - */ -@NullMarked -public final class LinkedListLeafIndexer implements LeafIndexer { - - private final ElementAwareLinkedList tupleList = new ElementAwareLinkedList<>(); - - @Override - public ListEntry put(Object compositeKey, T tuple) { - return tupleList.add(tuple); - } - - @Override - public void remove(Object compositeKey, ListEntry entry) { - tupleList.remove((ElementAwareLinkedList.Entry) entry); - } - - @Override - public int size(Object compositeKey) { - return tupleList.size(); - } - - @Override - public void forEach(Object compositeKey, Consumer tupleConsumer) { - tupleList.forEach(tupleConsumer); - } - - @Override - public Iterator iterator(Object queryCompositeKey) { - return tupleList.iterator(); - } - - @Override - public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom) { // Neighborhoods will not get here. - throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); - } - - @Override - public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom, Predicate filter) { // Neighborhoods will not get here. - throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); - } - - @Override - public boolean isRemovable() { - return tupleList.size() == 0; - } - - @Override - public String toString() { - return "size = " + tupleList.size(); - } - -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java index 06e90b182c0..307b9b6287d 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java @@ -127,10 +127,10 @@ void lazyAllocation_leftOnlyKey_rightNotAllocated() { var index = new FusedEqualIndex( KeyUnpacker.single(), // identity: pure-equal, single-component key false, // hasSuffix - LinkedListLeafIndexer::new, + RandomAccessLeafIndexer::new, () -> { rightInitCount.incrementAndGet(); - return new LinkedListLeafIndexer<>(); + return new RandomAccessLeafIndexer<>(); }); // right downstream must NOT be allocated on bucket creation From d911ea5002ca092d2af5356b04e312c97332810e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 12 Jun 2026 18:20:57 +0200 Subject: [PATCH 03/12] dramatically cut add/remove overhead --- .../core/impl/util/ElementAwareArrayList.java | 82 ++++--------- .../impl/util/ElementAwareArrayListTest.java | 110 ++++++------------ 2 files changed, 62 insertions(+), 130 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index 98a68fa4e93..06728fb4b77 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -2,7 +2,6 @@ import java.util.AbstractList; import java.util.Arrays; -import java.util.ConcurrentModificationException; import java.util.Iterator; import java.util.List; import java.util.ListIterator; @@ -29,6 +28,7 @@ * All standard {@link List} methods are also available and may run in O(n) or worse. *

* This class is so very not thread safe. + * {@code modCount} is intentionally not maintained; iteration is not fail-fast (matches {@link ElementAwareLinkedList}). * * @param */ @@ -52,16 +52,12 @@ public final class ElementAwareArrayList * @return the entry for later O(1) removal via {@link Entry#remove()} */ public Entry addEntry(T element) { - modCount++; - if (gapCount > 0 && entries[lastElementPosition] == null) { // Reuse a gap if it exists. - var newEntry = new Entry(element, lastElementPosition); - entries[lastElementPosition] = newEntry; - gapCount--; - return newEntry; + var newPosition = ++lastElementPosition; + if (newPosition == entries.length) { // Full (also covers EMPTY_ARRAY); grow on the cold path only. + resize(newPosition + 1); } - var newEntry = new Entry(element, ++lastElementPosition); - resize(lastElementPosition + 1); - entries[lastElementPosition] = newEntry; + var newEntry = new Entry(element, newPosition); + entries[newPosition] = newEntry; return newEntry; } @@ -132,7 +128,6 @@ private Entry partialCompact(int rightBoundaryPosition) { entry.moveTo(targetPosition); entries[targetPosition] = entry; entries[currentPosition] = null; // For consistency; the list is never in an invalid state. - modCount++; } if (lastNonNullPosition == rightBoundaryPosition) { // Invariant: positions [0, rightBoundaryPosition] are all non-null, @@ -160,7 +155,6 @@ private void truncateTo(int newLastPosition) { lastElementPosition = newLastPosition; gapCount = 0; firstGapPosition = lastElementPosition + 1; // [0, lastElementPosition] are all non-null. - modCount++; } @Override @@ -200,7 +194,6 @@ public void add(int index, T element) { } private void addWithoutGaps(int index, T element) { - modCount++; var newEntry = new Entry(element, index); resize(lastElementPosition + 2); for (var i = lastElementPosition; i >= index; i--) { @@ -213,7 +206,6 @@ private void addWithoutGaps(int index, T element) { } private void addWithGaps(int index, Entry newEntry) { - modCount++; var displaced = newEntry; for (var i = index; i <= lastElementPosition; i++) { var current = entryAt(i); @@ -246,48 +238,38 @@ public T remove(int index) { * @throws IllegalStateException if the entry was already removed */ private void remove(Entry entry) { - if (entry.isRemoved()) { + var position = entry.position; + if (position == REMOVED_POSITION) { throw new IllegalStateException("The entry (%s) was already removed." .formatted(entry)); } - var positionPreRemoval = entry.position; - if (positionPreRemoval == lastElementPosition) { // Removing the last element; trim list and retract trailing gaps. - entries[lastElementPosition--] = null; + entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. + if (position == lastElementPosition) { // Removing the last element; trim and retract trailing gaps. + entries[position] = null; + lastElementPosition--; while (lastElementPosition >= 0 && entries[lastElementPosition] == null) { lastElementPosition--; gapCount--; } - } else { - entries[positionPreRemoval] = null; + if (lastElementPosition < 0) { // List now empty: retain a small backing array, free a large one. + gapCount = 0; // Already 0 after retraction; explicit for clarity. + firstGapPosition = 0; + if (entries.length > RETAIN_THRESHOLD) { + entries = EMPTY_ARRAY; + } + } + } else { // Interior removal; cannot empty the list, so no empty-handling needed. + entries[position] = null; gapCount++; - firstGapPosition = Math.min(firstGapPosition, positionPreRemoval); - } - entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. - modCount++; - clearIfPossible(); - } - - private void clearIfPossible() { - if (!isEmpty()) { - return; - } - // List is empty: either retain the backing array (cheap re-add) or free it (large arrays). - // Trailing-gap retraction in remove() guarantees lastElementPosition == -1, gapCount == 0, - // and every slot already null — no fill is needed. - var length = entries.length; - if (length > 0 && length <= RETAIN_THRESHOLD) { - lastElementPosition = -1; // Already -1; explicit for clarity. - gapCount = 0; // Required: guards addEntry's gap-reuse branch (entries[-1]). - firstGapPosition = 0; // Already 0; explicit for clarity. - } else { - innerClear(); + if (position < firstGapPosition) { + firstGapPosition = position; + } } } @Override public void clear() { innerClear(); - modCount++; } private void innerClear() { @@ -352,7 +334,6 @@ private void forEachCompacting(Consumer elementConsumer) { entry.moveTo(compactPosition); entries[compactPosition] = entry; entries[currentPosition] = null; // Prevent stale data. - modCount++; } if (++compactPosition == liveCount) { break; @@ -398,7 +379,6 @@ private final class ElementAwareListIterator implements ListIterator { private int logicalPosition; private @Nullable Entry lastEntry; private boolean lastWasFwd; - private int expectedModCount; private ElementAwareListIterator(int startingPosition) { var currentSize = size(); @@ -412,7 +392,6 @@ private ElementAwareListIterator(int startingPosition) { currentPosition = startingPosition; } logicalPosition = startingPosition; - expectedModCount = modCount; } @Override @@ -437,7 +416,6 @@ public int previousIndex() { @Override public T next() { - checkModCount(); if (logicalPosition >= size()) { throw new NoSuchElementException(); } @@ -454,7 +432,6 @@ public T next() { @Override public T previous() { - checkModCount(); if (logicalPosition <= 0) { throw new NoSuchElementException(); } @@ -474,12 +451,10 @@ public void remove() { throw new IllegalStateException( "remove() called without a preceding next() or previous()."); } - checkModCount(); lastEntry.remove(); // Adjusts lastElementPosition. if (lastWasFwd) { logicalPosition--; } - expectedModCount = modCount; lastEntry = null; } @@ -488,26 +463,17 @@ public void set(T element) { if (lastEntry == null) { throw new IllegalStateException("set() called without a preceding next() or previous()."); } - checkModCount(); lastEntry.replaceElement(element); } @Override public void add(T element) { - checkModCount(); ElementAwareArrayList.this.add(logicalPosition, element); logicalPosition++; currentPosition = logicalPosition; - expectedModCount = modCount; lastEntry = null; } - private void checkModCount() { - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - } - } public final class Entry implements ListEntry { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java index 13ce3d9207a..b8053f21242 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java @@ -5,7 +5,6 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import java.util.ArrayList; -import java.util.ConcurrentModificationException; import java.util.List; import java.util.NoSuchElementException; import java.util.Random; @@ -109,22 +108,55 @@ void removeAllElements() { } @Test - @DisplayName("addEntry reuses the null slot at lastElementPosition when it is a gap") - void addEntryReusesGapAtTail() { + @DisplayName("addEntry after churn appends in insertion order") + void addEntryAfterChurnAppendsInOrder() { var list = new ElementAwareArrayList(); list.addEntry("a"); var entryB = list.addEntry("b"); var entryC = list.addEntry("c"); - entryB.remove(); // gap at slot 1; physical [a@0, null, c@2] - entryC.remove(); // c is last element → trim; physical [a@0, null], gapCount=1, lastElementPosition=1 + entryB.remove(); // interior gap at slot 1 + entryC.remove(); // last element: trim + retract trailing null → lastElementPosition=0, gapCount=0 - var entryX = list.addEntry("x"); // reuses slot 1 (null at lastElementPosition) + // addEntry appends at lastElementPosition+1 (==1); insertion order a, x preserved. + var entryX = list.addEntry("x"); assertThat(list).containsExactly("a", "x"); assertThat(entryX.toString()).contains("@1"); } + @Test + @DisplayName("remove-to-empty frees large backing array (length > RETAIN_THRESHOLD)") + void removeToEmptyFreesLargeBackingArray() { + var list = new ElementAwareArrayList(); + // 30 elements force entries.length to 32 (> RETAIN_THRESHOLD=16), triggering the free path on empty. + List.Entry> entryList = new ArrayList<>(); + for (var i = 0; i < 30; i++) { + entryList.add(list.addEntry("e" + i)); + } + for (var entry : entryList) { + entry.remove(); + } + assertThat(list).isEmpty(); + } + + @Test + @DisplayName("add after free-path empty re-allocates and preserves insertion order") + void addAfterLargeArrayFreePathPreservesOrder() { + var list = new ElementAwareArrayList(); + List.Entry> entryList = new ArrayList<>(); + for (var i = 0; i < 30; i++) { + entryList.add(list.addEntry("e" + i)); + } + for (var entry : entryList) { + entry.remove(); + } + list.addEntry("x"); + list.addEntry("y"); + list.addEntry("z"); + assertThat(list).containsExactly("x", "y", "z"); + } + } @Nested @@ -1190,21 +1222,6 @@ void noSuchElement() { .isThrownBy(it::next); } - @Test - @DisplayName("external remove(Entry) causes CME on subsequent iterator call") - void cmeOnExternalModify() { - var list = new ElementAwareArrayList(); - var entry = list.addEntry("a"); - list.add("b"); - - var it = list.listIterator(); - it.next(); - entry.remove(); - - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); - } - @Test @DisplayName("remove() without preceding next() or previous() throws IllegalStateException") void removeWithoutNext() { @@ -1369,57 +1386,6 @@ void setWithoutNextOrPrevious() { } - @Nested - @DisplayName("Fail-fast behavior tests (implementation-specific)") - class FailFastBehaviorTests { - - @Test - @DisplayName("listIterator fail-fast on add (implementation-specific)") - void cmeOnAdd() { - var list = new ElementAwareArrayList(); - list.add("a"); - list.add("b"); - - var it = list.listIterator(); - it.next(); - list.add("c"); - - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); - } - - @Test - @DisplayName("listIterator fail-fast on remove(int) (implementation-specific)") - void cmeOnRemove() { - var list = new ElementAwareArrayList(); - list.add("a"); - list.add("b"); - - var it = list.listIterator(); - it.next(); - list.removeFirst(); - - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); - } - - @Test - @DisplayName("listIterator fail-fast on remove(Entry) (implementation-specific)") - void cmeOnEntryRemove() { - var list = new ElementAwareArrayList(); - var entry = list.addEntry("a"); - list.add("b"); - - var it = list.listIterator(); - it.next(); - entry.remove(); - - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); - } - - } - @Nested @DisplayName("compact() and firstGapPosition tests") class CompactAndFirstGapPositionTests { From 06144f9d1cd0905fd313f209a2efcf5c62093306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 12 Jun 2026 21:28:28 +0200 Subject: [PATCH 04/12] remove more overhead --- .../core/impl/util/ElementAwareArrayList.java | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index 06728fb4b77..93bcfdc0eef 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -39,7 +39,7 @@ public final class ElementAwareArrayList private static final Object[] EMPTY_ARRAY = new Object[0]; private static final int REMOVED_POSITION = -1; - private static final int DEFAULT_CAPACITY = 16; + private static final int DEFAULT_CAPACITY = 8; private static final int RETAIN_THRESHOLD = DEFAULT_CAPACITY; // Retain backing array when length <= this. private Object @Nullable [] entries = EMPTY_ARRAY; private int lastElementPosition = -1; @@ -72,15 +72,6 @@ private void resize(int minCapacity) { entries = Arrays.copyOf(entries, Math.max(entries.length * 2, minCapacity)); } - /** - * Returns the entry at the given physical position, or {@code null} if the slot is a gap. - * Callers in fast-path loops can skip calling this and check {@code entries[i] == null} directly. - */ - @SuppressWarnings("unchecked") - private @Nullable Entry entryAt(int position) { - return (Entry) entries[position]; - } - @Override public T get(int index) { return getEntry(index).element(); @@ -91,7 +82,7 @@ private Entry getEntry(int index) { throw new IndexOutOfBoundsException( "The index (%d) must be >= 0 and < size (%d).".formatted(index, size())); } else if (gapCount == 0 || index < firstGapPosition) { - return entryAt(index); + return (Entry) entries[index]; } return partialCompact(index); } @@ -113,12 +104,12 @@ void compact() { private Entry partialCompact(int rightBoundaryPosition) { if (rightBoundaryPosition < firstGapPosition) { // The entire target range is in the already-compacted prefix; no work needed. - return entryAt(rightBoundaryPosition); + return (Entry) entries[rightBoundaryPosition]; } var encounteredGaps = 0; var lastNonNullPosition = firstGapPosition - 1; // firstGapPosition non-nulls are already in place before us. for (var currentPosition = firstGapPosition; currentPosition <= lastElementPosition; currentPosition++) { - var entry = entryAt(currentPosition); + var entry = (Entry) entries[currentPosition]; if (entry == null) { encounteredGaps++; } else { @@ -197,7 +188,7 @@ private void addWithoutGaps(int index, T element) { var newEntry = new Entry(element, index); resize(lastElementPosition + 2); for (var i = lastElementPosition; i >= index; i--) { - var shifted = entryAt(i); + var shifted = (Entry) entries[i]; entries[i + 1] = shifted; shifted.moveTo(i + 1); } @@ -208,7 +199,7 @@ private void addWithoutGaps(int index, T element) { private void addWithGaps(int index, Entry newEntry) { var displaced = newEntry; for (var i = index; i <= lastElementPosition; i++) { - var current = entryAt(i); + var current = (Entry) entries[i]; displaced.moveTo(i); entries[i] = displaced; if (current == null) { @@ -244,8 +235,8 @@ private void remove(Entry entry) { .formatted(entry)); } entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. + entries[position] = null; if (position == lastElementPosition) { // Removing the last element; trim and retract trailing gaps. - entries[position] = null; lastElementPosition--; while (lastElementPosition >= 0 && entries[lastElementPosition] == null) { lastElementPosition--; @@ -259,7 +250,6 @@ private void remove(Entry entry) { } } } else { // Interior removal; cannot empty the list, so no empty-handling needed. - entries[position] = null; gapCount++; if (position < firstGapPosition) { firstGapPosition = position; @@ -305,7 +295,7 @@ public void forEach(Consumer action) { @SuppressWarnings("DataFlowIssue") private void forEachWithoutGaps(Consumer elementConsumer) { for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - elementConsumer.accept(entryAt(currentPosition).element); // entries[i] is provably non-null (gapCount==0) + elementConsumer.accept(((Entry) entries[currentPosition]).element); // entries[i] is provably non-null (gapCount==0) } } @@ -325,7 +315,7 @@ private void forEachCompacting(Consumer elementConsumer) { } var compactPosition = 0; for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - var entry = entryAt(currentPosition); + var entry = (Entry) entries[currentPosition]; if (entry == null) { continue; } @@ -419,9 +409,10 @@ public T next() { if (logicalPosition >= size()) { throw new NoSuchElementException(); } - var entry = entryAt(currentPosition); + var entry = (Entry) entries[currentPosition]; while (entry == null) { - entry = entryAt(++currentPosition); + var position = ++currentPosition; + entry = (Entry) entries[position]; } currentPosition++; logicalPosition++; @@ -437,7 +428,8 @@ public T previous() { } Entry entry = null; while (entry == null) { - entry = entryAt(--currentPosition); + var position = --currentPosition; + entry = (Entry) entries[position]; } logicalPosition--; lastEntry = entry; From 3fcab47981dc9e57a811bd1e4793655b9ae24f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 13 Jun 2026 06:43:44 +0200 Subject: [PATCH 05/12] even less overhead --- .../core/impl/util/ElementAwareArrayList.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index 93bcfdc0eef..bff1df06b5c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -45,6 +45,7 @@ public final class ElementAwareArrayList private int lastElementPosition = -1; private int gapCount = 0; // Always equals the total number of null slots in entryList. private int firstGapPosition = 0; // Pessimistic lower bound: positions [0, firstGapPosition) are guaranteed gap-free and positionally compact (logical i == physical i). + private int size = 0; /** * Appends the specified element to the end of this list. @@ -58,6 +59,7 @@ public Entry addEntry(T element) { } var newEntry = new Entry(element, newPosition); entries[newPosition] = newEntry; + size++; return newEntry; } @@ -146,6 +148,7 @@ private void truncateTo(int newLastPosition) { lastElementPosition = newLastPosition; gapCount = 0; firstGapPosition = lastElementPosition + 1; // [0, lastElementPosition] are all non-null. + size = newLastPosition + 1; } @Override @@ -156,12 +159,12 @@ public boolean add(T element) { @Override public void add(int index, T element) { - var size = size(); - if (index < 0 || index > size) { + var currentSize = size; + if (index < 0 || index > currentSize) { throw new IndexOutOfBoundsException( - "The index (%d) must be >= 0 and <= size (%d).".formatted(index, size)); + "The index (%d) must be >= 0 and <= size (%d).".formatted(index, currentSize)); } - if (index == size) { + if (index == currentSize) { addEntry(element); return; } @@ -177,6 +180,7 @@ public void add(int index, T element) { // Gap at the target position: fill it directly without shifting the array. entries[index] = new Entry(element, index); gapCount--; + size++; } else { // No gap at the target position: rotate entries rightward into the nearest gap in the suffix, // consuming that gap rather than growing the backing list. @@ -194,6 +198,7 @@ private void addWithoutGaps(int index, T element) { } entries[index] = newEntry; lastElementPosition++; + size++; } private void addWithGaps(int index, Entry newEntry) { @@ -204,6 +209,7 @@ private void addWithGaps(int index, Entry newEntry) { entries[i] = displaced; if (current == null) { gapCount--; + size++; break; } displaced = current; @@ -235,6 +241,7 @@ private void remove(Entry entry) { .formatted(entry)); } entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. + size--; entries[position] = null; if (position == lastElementPosition) { // Removing the last element; trim and retract trailing gaps. lastElementPosition--; @@ -267,11 +274,12 @@ private void innerClear() { gapCount = 0; lastElementPosition = -1; firstGapPosition = 0; + size = 0; } @Override public int size() { - return lastElementPosition - gapCount + 1; + return size; } /** From 5fe1b7712114d2f5f5eb388b64a2a61dcda76f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 13 Jun 2026 06:44:03 +0200 Subject: [PATCH 06/12] Revert "keep just one leaf" This reverts commit 4ad849b208f0cca09fef9c09608f490179221853. --- .../bavet/common/index/IndexerFactory.java | 13 ++-- .../impl/bavet/common/index/LeafIndexer.java | 2 +- .../common/index/LinkedListLeafIndexer.java | 68 +++++++++++++++++++ .../common/index/FusedEqualIndexTest.java | 4 +- 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java index c20ec88270f..54d94541209 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java @@ -483,7 +483,8 @@ public UniKeysExtractor buildRightKeysExtractor() { } public Indexer buildIndexer(boolean isLeftBridge) { - Supplier> backendSupplier = RandomAccessLeafIndexer::new; + Supplier> backendSupplier = + requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new; if (!hasJoiners()) { // NoneJoiner results in a bare backend (NoneIndexer). return backendSupplier.get(); } @@ -516,7 +517,8 @@ private Supplier> buildIndexerChain(boolean isLeftBridge, int fro // Leaf-most level whose index key equals the whole composite key: no KeyUnpacker indirection. if (joinerType == JoinerType.EQUAL) { // Fuse the leaf-most equal indexer with its backend. - downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), RandomAccessLeafIndexer::new); + downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), + requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new); } else { KeyUnpacker keyUnpacker = KeyUnpacker.single(); downstreamIndexerSupplier = () -> buildIndexerPart(isLeftBridge, joinerType, keyUnpacker, backendSupplier); @@ -559,12 +561,11 @@ public FusedEqualIndex buildFusedEqualIndex() { equalPrefixLength == joinerCount ? KeyUnpacker.single() : KeyUnpacker.composite(0); if (equalPrefixLength == joinerCount) { // Pure equal: the per-side downstream is just the tuple list; the bucket is the equal-key group. - return new FusedEqualIndex<>(topEqualKeyUnpacker, false, RandomAccessLeafIndexer::new, - RandomAccessLeafIndexer::new); + return new FusedEqualIndex<>(topEqualKeyUnpacker, false, LinkedListLeafIndexer::new, LinkedListLeafIndexer::new); } else { // Equal prefix + suffix: build the per-side suffix sub-chain (the right side flips comparisons). - var leftDownstreamSupplier = this. buildIndexerChain(true, 1, RandomAccessLeafIndexer::new); - var rightDownstreamSupplier = this. buildIndexerChain(false, 1, RandomAccessLeafIndexer::new); + var leftDownstreamSupplier = this. buildIndexerChain(true, 1, LinkedListLeafIndexer::new); + var rightDownstreamSupplier = this. buildIndexerChain(false, 1, LinkedListLeafIndexer::new); return new FusedEqualIndex<>(topEqualKeyUnpacker, true, leftDownstreamSupplier, rightDownstreamSupplier); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java index 6e87e8d7c71..605e3feff49 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java @@ -12,6 +12,6 @@ @NullMarked public sealed interface LeafIndexer extends Indexer - permits RandomAccessLeafIndexer { + permits RandomAccessLeafIndexer, LinkedListLeafIndexer { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java new file mode 100644 index 00000000000..19b16e71ce2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java @@ -0,0 +1,68 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +import java.util.Iterator; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.util.ElementAwareLinkedList; +import ai.timefold.solver.core.impl.util.ListEntry; + +import org.jspecify.annotations.NullMarked; + +/** + * Super-fast, but doesn't support random access. + * + * @param + */ +@NullMarked +public final class LinkedListLeafIndexer implements LeafIndexer { + + private final ElementAwareLinkedList tupleList = new ElementAwareLinkedList<>(); + + @Override + public ListEntry put(Object compositeKey, T tuple) { + return tupleList.add(tuple); + } + + @Override + public void remove(Object compositeKey, ListEntry entry) { + tupleList.remove((ElementAwareLinkedList.Entry) entry); + } + + @Override + public int size(Object compositeKey) { + return tupleList.size(); + } + + @Override + public void forEach(Object compositeKey, Consumer tupleConsumer) { + tupleList.forEach(tupleConsumer); + } + + @Override + public Iterator iterator(Object queryCompositeKey) { + return tupleList.iterator(); + } + + @Override + public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom) { // Neighborhoods will not get here. + throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); + } + + @Override + public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom, Predicate filter) { // Neighborhoods will not get here. + throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); + } + + @Override + public boolean isRemovable() { + return tupleList.size() == 0; + } + + @Override + public String toString() { + return "size = " + tupleList.size(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java index 307b9b6287d..06e90b182c0 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java @@ -127,10 +127,10 @@ void lazyAllocation_leftOnlyKey_rightNotAllocated() { var index = new FusedEqualIndex( KeyUnpacker.single(), // identity: pure-equal, single-component key false, // hasSuffix - RandomAccessLeafIndexer::new, + LinkedListLeafIndexer::new, () -> { rightInitCount.incrementAndGet(); - return new RandomAccessLeafIndexer<>(); + return new LinkedListLeafIndexer<>(); }); // right downstream must NOT be allocated on bucket creation From 9ba4ff6bb6b1ea019c6507aed55b9556b23b0864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 15 Jun 2026 16:37:44 +0200 Subject: [PATCH 07/12] revert last commit --- .../bavet/common/index/IndexerFactory.java | 13 ++-- .../impl/bavet/common/index/LeafIndexer.java | 2 +- .../common/index/LinkedListLeafIndexer.java | 68 ------------------- .../common/index/FusedEqualIndexTest.java | 4 +- 4 files changed, 9 insertions(+), 78 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java index 54d94541209..c20ec88270f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java @@ -483,8 +483,7 @@ public UniKeysExtractor buildRightKeysExtractor() { } public Indexer buildIndexer(boolean isLeftBridge) { - Supplier> backendSupplier = - requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new; + Supplier> backendSupplier = RandomAccessLeafIndexer::new; if (!hasJoiners()) { // NoneJoiner results in a bare backend (NoneIndexer). return backendSupplier.get(); } @@ -517,8 +516,7 @@ private Supplier> buildIndexerChain(boolean isLeftBridge, int fro // Leaf-most level whose index key equals the whole composite key: no KeyUnpacker indirection. if (joinerType == JoinerType.EQUAL) { // Fuse the leaf-most equal indexer with its backend. - downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), - requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new); + downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), RandomAccessLeafIndexer::new); } else { KeyUnpacker keyUnpacker = KeyUnpacker.single(); downstreamIndexerSupplier = () -> buildIndexerPart(isLeftBridge, joinerType, keyUnpacker, backendSupplier); @@ -561,11 +559,12 @@ public FusedEqualIndex buildFusedEqualIndex() { equalPrefixLength == joinerCount ? KeyUnpacker.single() : KeyUnpacker.composite(0); if (equalPrefixLength == joinerCount) { // Pure equal: the per-side downstream is just the tuple list; the bucket is the equal-key group. - return new FusedEqualIndex<>(topEqualKeyUnpacker, false, LinkedListLeafIndexer::new, LinkedListLeafIndexer::new); + return new FusedEqualIndex<>(topEqualKeyUnpacker, false, RandomAccessLeafIndexer::new, + RandomAccessLeafIndexer::new); } else { // Equal prefix + suffix: build the per-side suffix sub-chain (the right side flips comparisons). - var leftDownstreamSupplier = this. buildIndexerChain(true, 1, LinkedListLeafIndexer::new); - var rightDownstreamSupplier = this. buildIndexerChain(false, 1, LinkedListLeafIndexer::new); + var leftDownstreamSupplier = this. buildIndexerChain(true, 1, RandomAccessLeafIndexer::new); + var rightDownstreamSupplier = this. buildIndexerChain(false, 1, RandomAccessLeafIndexer::new); return new FusedEqualIndex<>(topEqualKeyUnpacker, true, leftDownstreamSupplier, rightDownstreamSupplier); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java index 605e3feff49..6e87e8d7c71 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java @@ -12,6 +12,6 @@ @NullMarked public sealed interface LeafIndexer extends Indexer - permits RandomAccessLeafIndexer, LinkedListLeafIndexer { + permits RandomAccessLeafIndexer { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java deleted file mode 100644 index 19b16e71ce2..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java +++ /dev/null @@ -1,68 +0,0 @@ -package ai.timefold.solver.core.impl.bavet.common.index; - -import java.util.Iterator; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.random.RandomGenerator; - -import ai.timefold.solver.core.impl.util.ElementAwareLinkedList; -import ai.timefold.solver.core.impl.util.ListEntry; - -import org.jspecify.annotations.NullMarked; - -/** - * Super-fast, but doesn't support random access. - * - * @param - */ -@NullMarked -public final class LinkedListLeafIndexer implements LeafIndexer { - - private final ElementAwareLinkedList tupleList = new ElementAwareLinkedList<>(); - - @Override - public ListEntry put(Object compositeKey, T tuple) { - return tupleList.add(tuple); - } - - @Override - public void remove(Object compositeKey, ListEntry entry) { - tupleList.remove((ElementAwareLinkedList.Entry) entry); - } - - @Override - public int size(Object compositeKey) { - return tupleList.size(); - } - - @Override - public void forEach(Object compositeKey, Consumer tupleConsumer) { - tupleList.forEach(tupleConsumer); - } - - @Override - public Iterator iterator(Object queryCompositeKey) { - return tupleList.iterator(); - } - - @Override - public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom) { // Neighborhoods will not get here. - throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); - } - - @Override - public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom, Predicate filter) { // Neighborhoods will not get here. - throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); - } - - @Override - public boolean isRemovable() { - return tupleList.size() == 0; - } - - @Override - public String toString() { - return "size = " + tupleList.size(); - } - -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java index 06e90b182c0..307b9b6287d 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java @@ -127,10 +127,10 @@ void lazyAllocation_leftOnlyKey_rightNotAllocated() { var index = new FusedEqualIndex( KeyUnpacker.single(), // identity: pure-equal, single-component key false, // hasSuffix - LinkedListLeafIndexer::new, + RandomAccessLeafIndexer::new, () -> { rightInitCount.incrementAndGet(); - return new LinkedListLeafIndexer<>(); + return new RandomAccessLeafIndexer<>(); }); // right downstream must NOT be allocated on bucket creation From 537bcf0e243268783ef16faa7f9d02804df22b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 15 Jun 2026 17:17:02 +0200 Subject: [PATCH 08/12] improve alloc --- .../timefold/solver/core/impl/util/ElementAwareArrayList.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index bff1df06b5c..1ddd3165059 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -39,7 +39,7 @@ public final class ElementAwareArrayList private static final Object[] EMPTY_ARRAY = new Object[0]; private static final int REMOVED_POSITION = -1; - private static final int DEFAULT_CAPACITY = 8; + private static final int DEFAULT_CAPACITY = 2; private static final int RETAIN_THRESHOLD = DEFAULT_CAPACITY; // Retain backing array when length <= this. private Object @Nullable [] entries = EMPTY_ARRAY; private int lastElementPosition = -1; From b502828702404fe7ba9034ea69b7b2d2c9e3fe9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 19 Jun 2026 08:15:47 +0200 Subject: [PATCH 09/12] review --- .../core/impl/util/ElementAwareArrayList.java | 15 ++++++++------ .../impl/util/ElementAwareArrayListTest.java | 20 ++++--------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index 1ddd3165059..8c72bb967e8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -86,7 +86,8 @@ private Entry getEntry(int index) { } else if (gapCount == 0 || index < firstGapPosition) { return (Entry) entries[index]; } - return partialCompact(index); + partialCompact(index); + return (Entry) entries[index]; } /** @@ -103,10 +104,10 @@ void compact() { /** * Avoid calling this when {@code gapCount == 0}. */ - private Entry partialCompact(int rightBoundaryPosition) { + private void partialCompact(int rightBoundaryPosition) { if (rightBoundaryPosition < firstGapPosition) { // The entire target range is in the already-compacted prefix; no work needed. - return (Entry) entries[rightBoundaryPosition]; + return; } var encounteredGaps = 0; var lastNonNullPosition = firstGapPosition - 1; // firstGapPosition non-nulls are already in place before us. @@ -131,7 +132,7 @@ private Entry partialCompact(int rightBoundaryPosition) { } else { firstGapPosition = rightBoundaryPosition + 1; } - return entry; + return; } } } @@ -174,7 +175,7 @@ public void add(int index, T element) { } // Compact prefix [0, index-1] so physical position k == logical position k for all k < index. if (index > 0) { - partialCompact(index - 1); // Increases modCount. + partialCompact(index - 1); } if (entries[index] == null) { // Gap at the target position: fill it directly without shifting the array. @@ -385,7 +386,9 @@ private ElementAwareListIterator(int startingPosition) { "The index (%d) must be >= 0 and <= size (%d).".formatted(startingPosition, currentSize)); } if (startingPosition > 0 && gapCount > 0) { - currentPosition = partialCompact(startingPosition - 1).position + 1; + var index = startingPosition - 1; + partialCompact(index); + currentPosition = ((Entry) entries[index]).position + 1; } else { currentPosition = startingPosition; } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java index b8053f21242..679580a0896 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java @@ -125,21 +125,6 @@ void addEntryAfterChurnAppendsInOrder() { assertThat(entryX.toString()).contains("@1"); } - @Test - @DisplayName("remove-to-empty frees large backing array (length > RETAIN_THRESHOLD)") - void removeToEmptyFreesLargeBackingArray() { - var list = new ElementAwareArrayList(); - // 30 elements force entries.length to 32 (> RETAIN_THRESHOLD=16), triggering the free path on empty. - List.Entry> entryList = new ArrayList<>(); - for (var i = 0; i < 30; i++) { - entryList.add(list.addEntry("e" + i)); - } - for (var entry : entryList) { - entry.remove(); - } - assertThat(list).isEmpty(); - } - @Test @DisplayName("add after free-path empty re-allocates and preserves insertion order") void addAfterLargeArrayFreePathPreservesOrder() { @@ -1520,8 +1505,11 @@ void stressTestAgainstReferenceArrayList(long seed) { list.compact(); assertThat(list).containsExactlyElementsOf(reference); } + default -> { + throw new IllegalStateException("Unexpected operation code: %d".formatted(op)); + } } - assertThat(list.size()).isEqualTo(reference.size()); + assertThat(list).hasSameSizeAs(reference); } assertThat(list).containsExactlyElementsOf(reference); } From 33db0b4e1018bf422c76cf273ac606eef22e0a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 19 Jun 2026 08:39:36 +0200 Subject: [PATCH 10/12] fix CI --- .mvn/maven.config | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.mvn/maven.config b/.mvn/maven.config index bc64d227cc1..26daffc4258 100644 --- a/.mvn/maven.config +++ b/.mvn/maven.config @@ -1,3 +1,6 @@ +-Dmaven.wagon.httpconnectionManager.ttlSeconds=120 +-Dmaven.wagon.http.retryHandler.requestSentEnabled=true +-Dmaven.wagon.http.retryHandler.count=10 -ntp # timefold-solver-service-parent configures maven plugins required for Timefold models, but failing the build # of that module itself. This way, we disable the plugin execution, while it remains active by default for models. From 40b36baaee28a80afb2377facc93eece98afc3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 19 Jun 2026 08:44:40 +0200 Subject: [PATCH 11/12] Revert "revert last commit" This reverts commit 9ba4ff6bb6b1ea019c6507aed55b9556b23b0864. --- .../bavet/common/index/IndexerFactory.java | 13 ++-- .../impl/bavet/common/index/LeafIndexer.java | 2 +- .../common/index/LinkedListLeafIndexer.java | 68 +++++++++++++++++++ .../common/index/FusedEqualIndexTest.java | 4 +- 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java index c20ec88270f..54d94541209 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexerFactory.java @@ -483,7 +483,8 @@ public UniKeysExtractor buildRightKeysExtractor() { } public Indexer buildIndexer(boolean isLeftBridge) { - Supplier> backendSupplier = RandomAccessLeafIndexer::new; + Supplier> backendSupplier = + requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new; if (!hasJoiners()) { // NoneJoiner results in a bare backend (NoneIndexer). return backendSupplier.get(); } @@ -516,7 +517,8 @@ private Supplier> buildIndexerChain(boolean isLeftBridge, int fro // Leaf-most level whose index key equals the whole composite key: no KeyUnpacker indirection. if (joinerType == JoinerType.EQUAL) { // Fuse the leaf-most equal indexer with its backend. - downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), RandomAccessLeafIndexer::new); + downstreamIndexerSupplier = () -> new EqualIndexer<>(KeyUnpacker.single(), + requiresRandomAccess ? RandomAccessLeafIndexer::new : LinkedListLeafIndexer::new); } else { KeyUnpacker keyUnpacker = KeyUnpacker.single(); downstreamIndexerSupplier = () -> buildIndexerPart(isLeftBridge, joinerType, keyUnpacker, backendSupplier); @@ -559,12 +561,11 @@ public FusedEqualIndex buildFusedEqualIndex() { equalPrefixLength == joinerCount ? KeyUnpacker.single() : KeyUnpacker.composite(0); if (equalPrefixLength == joinerCount) { // Pure equal: the per-side downstream is just the tuple list; the bucket is the equal-key group. - return new FusedEqualIndex<>(topEqualKeyUnpacker, false, RandomAccessLeafIndexer::new, - RandomAccessLeafIndexer::new); + return new FusedEqualIndex<>(topEqualKeyUnpacker, false, LinkedListLeafIndexer::new, LinkedListLeafIndexer::new); } else { // Equal prefix + suffix: build the per-side suffix sub-chain (the right side flips comparisons). - var leftDownstreamSupplier = this. buildIndexerChain(true, 1, RandomAccessLeafIndexer::new); - var rightDownstreamSupplier = this. buildIndexerChain(false, 1, RandomAccessLeafIndexer::new); + var leftDownstreamSupplier = this. buildIndexerChain(true, 1, LinkedListLeafIndexer::new); + var rightDownstreamSupplier = this. buildIndexerChain(false, 1, LinkedListLeafIndexer::new); return new FusedEqualIndex<>(topEqualKeyUnpacker, true, leftDownstreamSupplier, rightDownstreamSupplier); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java index 6e87e8d7c71..605e3feff49 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LeafIndexer.java @@ -12,6 +12,6 @@ @NullMarked public sealed interface LeafIndexer extends Indexer - permits RandomAccessLeafIndexer { + permits RandomAccessLeafIndexer, LinkedListLeafIndexer { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java new file mode 100644 index 00000000000..19b16e71ce2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/LinkedListLeafIndexer.java @@ -0,0 +1,68 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +import java.util.Iterator; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.util.ElementAwareLinkedList; +import ai.timefold.solver.core.impl.util.ListEntry; + +import org.jspecify.annotations.NullMarked; + +/** + * Super-fast, but doesn't support random access. + * + * @param + */ +@NullMarked +public final class LinkedListLeafIndexer implements LeafIndexer { + + private final ElementAwareLinkedList tupleList = new ElementAwareLinkedList<>(); + + @Override + public ListEntry put(Object compositeKey, T tuple) { + return tupleList.add(tuple); + } + + @Override + public void remove(Object compositeKey, ListEntry entry) { + tupleList.remove((ElementAwareLinkedList.Entry) entry); + } + + @Override + public int size(Object compositeKey) { + return tupleList.size(); + } + + @Override + public void forEach(Object compositeKey, Consumer tupleConsumer) { + tupleList.forEach(tupleConsumer); + } + + @Override + public Iterator iterator(Object queryCompositeKey) { + return tupleList.iterator(); + } + + @Override + public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom) { // Neighborhoods will not get here. + throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); + } + + @Override + public Iterator randomIterator(Object queryCompositeKey, RandomGenerator workingRandom, Predicate filter) { // Neighborhoods will not get here. + throw new UnsupportedOperationException("Impossible state: This backend does not support random access."); + } + + @Override + public boolean isRemovable() { + return tupleList.size() == 0; + } + + @Override + public String toString() { + return "size = " + tupleList.size(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java index 307b9b6287d..06e90b182c0 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/FusedEqualIndexTest.java @@ -127,10 +127,10 @@ void lazyAllocation_leftOnlyKey_rightNotAllocated() { var index = new FusedEqualIndex( KeyUnpacker.single(), // identity: pure-equal, single-component key false, // hasSuffix - RandomAccessLeafIndexer::new, + LinkedListLeafIndexer::new, () -> { rightInitCount.incrementAndGet(); - return new RandomAccessLeafIndexer<>(); + return new LinkedListLeafIndexer<>(); }); // right downstream must NOT be allocated on bucket creation From 0dabfcd64e24ff543852f759e6ce3619ba0839f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Fri, 19 Jun 2026 09:54:54 +0200 Subject: [PATCH 12/12] copilot --- .../core/impl/util/ElementAwareArrayList.java | 16 +- .../impl/util/ElementAwareArrayListTest.java | 206 +++++------------- 2 files changed, 57 insertions(+), 165 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java index 8c72bb967e8..5ddd3048a0f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareArrayList.java @@ -20,7 +20,7 @@ * It uses internal state of the entry to track insertion position of the element. * When an entry is removed, its slot in the underlying collection is replaced with {@code null} (a gap); * therefore, the insertion position of later elements isn't changed. - * Gaps are removed (the list is fully compacted) when {@link #forEach(Consumer)} or {@link #add(int, Object)} is called. + * Gaps are removed (the list is fully compacted) when {@link #listIterator()} is called. * {@link #get(int)} and related index-based operations compact only the prefix up to the requested index. * This keeps the overhead low while giving us most benefits of an array-backed list. *

@@ -385,13 +385,8 @@ private ElementAwareListIterator(int startingPosition) { throw new IndexOutOfBoundsException( "The index (%d) must be >= 0 and <= size (%d).".formatted(startingPosition, currentSize)); } - if (startingPosition > 0 && gapCount > 0) { - var index = startingPosition - 1; - partialCompact(index); - currentPosition = ((Entry) entries[index]).position + 1; - } else { - currentPosition = startingPosition; - } + // listIterator() compacts before construction ⟹ gapless: logical position == physical position. + currentPosition = startingPosition; logicalPosition = startingPosition; } @@ -471,9 +466,12 @@ public void set(T element) { @Override public void add(T element) { + var appending = logicalPosition == size(); ElementAwareArrayList.this.add(logicalPosition, element); logicalPosition++; - currentPosition = logicalPosition; + // Appended entries land at physical lastElementPosition (may exceed logicalPosition when gaps exist); + // interior inserts land at physical logicalPosition (prefix is compacted by add(int,T)). + currentPosition = appending ? lastElementPosition + 1 : logicalPosition; lastEntry = null; } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java index 679580a0896..b0d4879976b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareArrayListTest.java @@ -238,77 +238,6 @@ void forEachCompactsWithTailGaps() { } } - @Nested - @DisplayName("Compaction tests") - class CompactionTests { - - @Test - @DisplayName("Access compacts the list when gaps exist") - void accessCompactsWithGaps() { - var list = new ElementAwareArrayList(); - list.addEntry("first"); - var entry2 = list.addEntry("second"); - list.addEntry("third"); - - entry2.remove(); - - assertThat(list).hasSize(2); - assertThat(list.get(0)).isEqualTo("first"); - assertThat(list.get(1)).isEqualTo("third"); - } - - @Test - @DisplayName("Access returns empty when all elements removed") - void accessWhenAllRemoved() { - var list = new ElementAwareArrayList(); - var entry1 = list.addEntry("first"); - - entry1.remove(); - - assertThat(list).isEmpty(); - } - - @Test - @DisplayName("Access compacts with tail gaps") - void accessCompactsWithTailGaps() { - var list = new ElementAwareArrayList(); - list.add("first"); - list.add("second"); - var entry3 = list.addEntry("third"); - var entry4 = list.addEntry("fourth"); - var entry5 = list.addEntry("fifth"); - - entry3.remove(); - entry4.remove(); - entry5.remove(); - - assertThat(list).hasSize(2); - assertThat(entry3.isRemoved()).isTrue(); - assertThat(entry4.isRemoved()).isTrue(); - assertThat(entry5.isRemoved()).isTrue(); - } - - @Test - @DisplayName("addAt with trailing gaps only rotates entry into first suffix gap") - void addAtAfterTrailingGapOnly() { - var list = new ElementAwareArrayList(); - var e1 = list.addEntry("a"); - var e2 = list.addEntry("b"); - var e3 = list.addEntry("c"); - var e4 = list.addEntry("d"); - - e3.remove(); - e4.remove(); - // Physical: [a, b, null], gapCount=1, size=2 (d trim reduced lastElementPosition) - - list.add(1, "x"); // partialCompact(0): no prefix gap; slot 1 non-null → rotate b into gap at slot 2 - assertThat(list).hasSize(3); - assertThat(copyUsingForEach(list)).containsExactly("a", "x", "b"); - assertThat(e1.toString()).contains("@0"); - assertThat(e2.toString()).contains("@2"); - } - } - @Nested @DisplayName("Partial compaction tests") class PartialCompactionTests { @@ -675,14 +604,35 @@ void removeNullEntry() { } @Test - @DisplayName("iterator visits null elements") + @DisplayName("listIterator visits null elements in forward and backward order") void iterator() { var list = new ElementAwareArrayList<@Nullable String>(); list.addEntry(null); list.addEntry("a"); list.addEntry(null); - var result = copyUsingForEach(list); - assertThat(result).containsExactly(null, "a", null); + + // Forward traversal returns null elements. + var it = list.listIterator(); + assertThat(it.next()).isNull(); + assertThat(it.next()).isEqualTo("a"); + assertThat(it.next()).isNull(); + assertThat(it.hasNext()).isFalse(); + + // Backward traversal returns null elements. + assertThat(it.previous()).isNull(); + assertThat(it.previous()).isEqualTo("a"); + assertThat(it.previous()).isNull(); + assertThat(it.hasPrevious()).isFalse(); + + // remove() creates a gap (Entry == null) adjacent to a live null element (Entry != null, element == null); + // next() must skip the former without skipping the latter. + it = list.listIterator(); + assertThat(it.next()).isNull(); // null element at logical 0 + it.remove(); // slot 0 becomes a removed gap; live null element remains at slot 2 + assertThat(it.next()).isEqualTo("a"); + assertThat(it.next()).isNull(); // live null element still reachable + assertThat(it.hasNext()).isFalse(); + assertThat(list).containsExactly("a", null); } @Test @@ -1091,25 +1041,6 @@ void backwardIteration() { assertThat(it.hasPrevious()).isFalse(); } - @Test - @DisplayName("forward iteration skips null slots") - void forwardWithGaps() { - var list = new ElementAwareArrayList(); - list.add("a"); - var entryB = list.addEntry("b"); - list.add("c"); - var entryD = list.addEntry("d"); - list.add("e"); - entryB.remove(); - entryD.remove(); - - var it = list.listIterator(); - assertThat(it.next()).isEqualTo("a"); - assertThat(it.next()).isEqualTo("c"); - assertThat(it.next()).isEqualTo("e"); - assertThat(it.hasNext()).isFalse(); - } - @Test @DisplayName("remove after next() removes correct element, adjusts cursor") void removeFwd() { @@ -1195,6 +1126,30 @@ void addWithGaps() { assertThat(list).containsExactly("a", "x", "c"); } + @Test + @DisplayName("add() appending to list made gappy mid-iteration positions cursor for previous()") + void addAtEndAfterMidIterationRemoval() { + var list = new ElementAwareArrayList(); + list.add("a"); + list.add("b"); + list.add("c"); + list.add("d"); + + var it = list.listIterator(); + it.next(); // a + it.next(); // b + it.remove(); // gap at physical 1; logical [a, c, d] + it.next(); // c + it.next(); // d -> logical end, interior gap still present + it.add("e"); // append via addEntry while gap exists + + assertThat(it.hasNext()).isFalse(); + assertThat(it.previous()).isEqualTo("e"); + assertThat(it.previous()).isEqualTo("d"); + assertThat(it.previous()).isEqualTo("c"); + assertThat(list).containsExactly("a", "c", "d", "e"); + } + @Test @DisplayName("next() past end throws NoSuchElementException") void noSuchElement() { @@ -1234,67 +1189,6 @@ void startAtIndex() { assertThat(it.previous()).isEqualTo("b"); } - @Test - @DisplayName("listIterator(index) on gappy list compacts before starting") - void startAtIndexWithGaps() { - var list = new ElementAwareArrayList(); - list.add("a"); - var entry = list.addEntry("b"); - list.add("c"); - list.add("d"); - entry.remove(); - - var it = list.listIterator(1); - assertThat(it.next()).isEqualTo("c"); - assertThat(it.previous()).isEqualTo("c"); - assertThat(it.previous()).isEqualTo("a"); - } - - @Test - @DisplayName("forward iteration through gaps then full backward traversal") - void forwardWithGapsThenBackward() { - var list = new ElementAwareArrayList(); - list.add("a"); - var entryB = list.addEntry("b"); - list.add("c"); - var entryD = list.addEntry("d"); - list.add("e"); - entryB.remove(); - entryD.remove(); - // Physical: [a, null, c, null, e]; gaps never compacted during forward scan. - - var it = list.listIterator(); - assertThat(it.next()).isEqualTo("a"); - assertThat(it.next()).isEqualTo("c"); - assertThat(it.next()).isEqualTo("e"); - assertThat(it.hasNext()).isFalse(); - - // previous() must traverse back through the uncompacted gaps. - assertThat(it.previous()).isEqualTo("e"); - assertThat(it.previous()).isEqualTo("c"); - assertThat(it.previous()).isEqualTo("a"); - assertThat(it.hasPrevious()).isFalse(); - } - - @Test - @DisplayName("backward iteration from end of gappy list (gaps at head and middle)") - void backwardFromEndWithGaps() { - var list = new ElementAwareArrayList(); - var entryA = list.addEntry("a"); - list.add("b"); - var entryC = list.addEntry("c"); - list.add("d"); - entryA.remove(); - entryC.remove(); - // Physical: [null, b, null, d], logical: [b, d]. - - var it = list.listIterator(list.size()); - assertThat(it.hasPrevious()).isTrue(); - assertThat(it.previous()).isEqualTo("d"); - assertThat(it.previous()).isEqualTo("b"); - assertThat(it.hasPrevious()).isFalse(); - } - @Test @DisplayName("iterator remove on list that already has gaps") void removeViaIteratorOnGappyList() { @@ -1304,7 +1198,7 @@ void removeViaIteratorOnGappyList() { list.add("c"); list.add("d"); entryB.remove(); - // Physical: [a, null, c, d], logical: [a, c, d]. + // listIterator() compacts the b-gap away first; the gap under test is the one it.remove() creates mid-iteration. var it = list.listIterator(); assertThat(it.next()).isEqualTo("a"); @@ -1328,7 +1222,7 @@ void nextRemoveNextPreviousCycle() { list.add("b"); list.add("c"); entryX.remove(); - // Physical: [a, null, b, c], logical: [a, b, c]. + // listIterator() compacts the x-gap away first; the gap under test is the one it.remove() creates mid-iteration. var it = list.listIterator(); assertThat(it.next()).isEqualTo("a");