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. 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..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 @@ -1,14 +1,11 @@ package ai.timefold.solver.core.impl.util; -import java.lang.reflect.Array; import java.util.AbstractList; import java.util.Arrays; -import java.util.ConcurrentModificationException; import java.util.Iterator; 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; @@ -23,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. *

@@ -31,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 */ @@ -38,12 +36,16 @@ 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 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; 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. @@ -51,23 +53,19 @@ 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; + size++; 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) { @@ -85,20 +83,36 @@ 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 (Entry) entries[index]; + } + partialCompact(index); + return (Entry) entries[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); } - return partialCompact(index); } /** * 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; + } 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 = (Entry) entries[currentPosition]; if (entry == null) { encounteredGaps++; } else { @@ -108,16 +122,17 @@ 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, 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; + return; } } } @@ -133,7 +148,8 @@ private void truncateTo(int newLastPosition) { Arrays.fill(entries, newLastPosition + 1, lastElementPosition + 1, null); lastElementPosition = newLastPosition; gapCount = 0; - modCount++; + firstGapPosition = lastElementPosition + 1; // [0, lastElementPosition] are all non-null. + size = newLastPosition + 1; } @Override @@ -143,14 +159,13 @@ public boolean add(T element) { } @Override - @SuppressWarnings("DataFlowIssue") 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; } @@ -160,12 +175,13 @@ 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. 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. @@ -174,27 +190,27 @@ 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--) { - var shifted = entries[i]; + var shifted = (Entry) entries[i]; entries[i + 1] = shifted; shifted.moveTo(i + 1); } entries[index] = newEntry; lastElementPosition++; + size++; } private void addWithGaps(int index, Entry newEntry) { - modCount++; var displaced = newEntry; for (var i = index; i <= lastElementPosition; i++) { - var current = entries[i]; + var current = (Entry) entries[i]; displaced.moveTo(i); entries[i] = displaced; if (current == null) { gapCount--; + size++; break; } displaced = current; @@ -220,44 +236,51 @@ 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; just trim the list. - entries[lastElementPosition--] = null; - } else { - entries[positionPreRemoval] = null; - gapCount++; - } entry.moveTo(REMOVED_POSITION); // Mark the entry as removed. - modCount++; - clearIfPossible(); - } - - private void clearIfPossible() { - if (isEmpty()) { - // All positions, if any, are gaps. Clear the list entirely. - innerClear(); + size--; + entries[position] = null; + if (position == lastElementPosition) { // Removing the last element; trim and retract trailing gaps. + lastElementPosition--; + while (lastElementPosition >= 0 && entries[lastElementPosition] == null) { + lastElementPosition--; + gapCount--; + } + 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. + gapCount++; + if (position < firstGapPosition) { + firstGapPosition = position; + } } } @Override public void clear() { innerClear(); - modCount++; } private void innerClear() { - entries = null; + entries = EMPTY_ARRAY; gapCount = 0; lastElementPosition = -1; + firstGapPosition = 0; + size = 0; } @Override public int size() { - return lastElementPosition - gapCount + 1; + return size; } /** @@ -281,7 +304,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(((Entry) entries[currentPosition]).element); // entries[i] is provably non-null (gapCount==0) } } @@ -301,16 +324,15 @@ private void forEachCompacting(Consumer elementConsumer) { } var compactPosition = 0; for (var currentPosition = 0; currentPosition <= lastElementPosition; currentPosition++) { - var entry = entries[currentPosition]; + var entry = (Entry) entries[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; entries[currentPosition] = null; // Prevent stale data. - modCount++; } if (++compactPosition == liveCount) { break; @@ -324,8 +346,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); } @@ -350,7 +378,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(); @@ -358,13 +385,9 @@ private ElementAwareListIterator(int startingPosition) { throw new IndexOutOfBoundsException( "The index (%d) must be >= 0 and <= size (%d).".formatted(startingPosition, currentSize)); } - if (startingPosition > 0 && gapCount > 0) { - currentPosition = partialCompact(startingPosition - 1).position + 1; - } else { - currentPosition = startingPosition; - } + // listIterator() compacts before construction ⟹ gapless: logical position == physical position. + currentPosition = startingPosition; logicalPosition = startingPosition; - expectedModCount = modCount; } @Override @@ -389,35 +412,35 @@ public int previousIndex() { @Override public T next() { - checkModCount(); if (logicalPosition >= size()) { throw new NoSuchElementException(); } - var entry = entries[currentPosition]; + var entry = (Entry) entries[currentPosition]; while (entry == null) { - entry = entries[++currentPosition]; + var position = ++currentPosition; + entry = (Entry) entries[position]; } 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 public T previous() { - checkModCount(); if (logicalPosition <= 0) { throw new NoSuchElementException(); } Entry entry = null; while (entry == null) { - entry = entries[--currentPosition]; + var position = --currentPosition; + entry = (Entry) entries[position]; } 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 @@ -426,12 +449,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; } @@ -440,26 +461,20 @@ 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(); + var appending = logicalPosition == size(); ElementAwareArrayList.this.add(logicalPosition, element); logicalPosition++; - currentPosition = logicalPosition; - expectedModCount = modCount; + // 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; } - 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 4cdfbf15a71..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 @@ -5,16 +5,22 @@ 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; 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 { @@ -102,22 +108,40 @@ 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("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 @@ -214,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 { @@ -651,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 @@ -1067,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() { @@ -1097,10 +1052,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 @@ -1172,29 +1127,38 @@ void addWithGaps() { } @Test - @DisplayName("next() past end throws NoSuchElementException") - void noSuchElement() { + @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(); - assertThatExceptionOfType(NoSuchElementException.class) - .isThrownBy(it::next); + 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("external remove(Entry) causes CME on subsequent iterator call") - void cmeOnExternalModify() { + @DisplayName("next() past end throws NoSuchElementException") + void noSuchElement() { var list = new ElementAwareArrayList(); - var entry = list.addEntry("a"); - list.add("b"); + list.add("a"); var it = list.listIterator(); it.next(); - entry.remove(); - - assertThatExceptionOfType(ConcurrentModificationException.class) + assertThatExceptionOfType(NoSuchElementException.class) .isThrownBy(it::next); } @@ -1225,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() { @@ -1295,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"); @@ -1319,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"); @@ -1363,52 +1266,152 @@ void setWithoutNextOrPrevious() { } @Nested - @DisplayName("Fail-fast behavior tests (implementation-specific)") - class FailFastBehaviorTests { + @DisplayName("compact() and firstGapPosition tests") + class CompactAndFirstGapPositionTests { @Test - @DisplayName("listIterator fail-fast on add (implementation-specific)") - void cmeOnAdd() { + @DisplayName("compact() removes all gaps and preserves insertion order") + void compactRemovesAllGaps() { var list = new ElementAwareArrayList(); - list.add("a"); - list.add("b"); + list.addEntry("a"); + var e1 = list.addEntry("b"); + list.addEntry("c"); + var e2 = list.addEntry("d"); + list.addEntry("e"); + e1.remove(); + e2.remove(); - var it = list.listIterator(); - it.next(); - list.add("c"); + list.compact(); - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); + 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("listIterator fail-fast on remove(int) (implementation-specific)") - void cmeOnRemove() { + @DisplayName("compact() on already-compact list is a no-op") + void compactNoOpWhenAlreadyCompact() { var list = new ElementAwareArrayList(); - list.add("a"); - list.add("b"); + list.addEntry("a"); + list.addEntry("b"); + list.addEntry("c"); - var it = list.listIterator(); - it.next(); - list.removeFirst(); + list.compact(); - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); + assertThat(list).containsExactly("a", "b", "c"); } @Test - @DisplayName("listIterator fail-fast on remove(Entry) (implementation-specific)") - void cmeOnEntryRemove() { + @DisplayName("compact() on empty list is a no-op") + void compactEmptyList() { var list = new ElementAwareArrayList(); - var entry = list.addEntry("a"); - list.add("b"); - var it = list.listIterator(); - it.next(); - entry.remove(); + assertThatCode(list::compact).doesNotThrowAnyException(); + assertThat(list).isEmpty(); + } - assertThatExceptionOfType(ConcurrentModificationException.class) - .isThrownBy(it::next); + @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); + } + default -> { + throw new IllegalStateException("Unexpected operation code: %d".formatted(op)); + } + } + assertThat(list).hasSameSizeAs(reference); + } + assertThat(list).containsExactlyElementsOf(reference); + } + + static Stream randomSeeds() { + var random = new Random(0xCAFEBABEL); + return random.longs(10) + .mapToObj(Arguments::of); } }