From 276d0cfbaa1dce6178cbfb0ee4c0a818dbcec5ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 6 Nov 2025 16:20:10 +0100 Subject: [PATCH 01/21] chore: refactor CS index to support random access --- .../impl/bavet/bi/IndexedIfExistsBiNode.java | 20 +- .../core/impl/bavet/bi/IndexedJoinBiNode.java | 23 +- .../bavet/bi/UnindexedIfExistsBiNode.java | 18 +- .../impl/bavet/bi/UnindexedJoinBiNode.java | 23 +- .../bavet/common/AbstractIfExistsNode.java | 86 +++--- .../common/AbstractIndexedIfExistsNode.java | 110 ++++---- .../bavet/common/AbstractIndexedJoinNode.java | 93 ++++--- .../impl/bavet/common/AbstractJoinNode.java | 96 +++---- .../common/AbstractUnindexedIfExistsNode.java | 117 ++++----- .../common/AbstractUnindexedJoinNode.java | 98 ++++--- .../bavet/common/ExistsCounterHandle.java | 36 +++ .../ExistsCounterHandlePositionTracker.java | 88 +++++++ .../common/ExistsCounterPositionTracker.java | 34 +++ .../bavet/common/TuplePositionTracker.java | 32 +++ .../bavet/common/index/ComparisonIndexer.java | 19 +- .../common/index/ElementPositionTracker.java | 36 +++ .../bavet/common/index/EqualsIndexer.java | 20 +- .../impl/bavet/common/index/IndexedSet.java | 176 +++++++++++++ .../core/impl/bavet/common/index/Indexer.java | 5 +- .../bavet/common/index/IndexerFactory.java | 20 +- .../impl/bavet/common/index/NoneIndexer.java | 25 +- .../common/tuple/OutputStoreSizeTracker.java | 46 ++++ .../tuple/TupleStorePositionTracker.java | 7 + .../bavet/quad/IndexedIfExistsQuadNode.java | 20 +- .../impl/bavet/quad/IndexedJoinQuadNode.java | 23 +- .../bavet/quad/UnindexedIfExistsQuadNode.java | 15 +- .../bavet/quad/UnindexedJoinQuadNode.java | 23 +- .../bavet/tri/IndexedIfExistsTriNode.java | 20 +- .../impl/bavet/tri/IndexedJoinTriNode.java | 24 +- .../bavet/tri/UnindexedIfExistsTriNode.java | 18 +- .../impl/bavet/tri/UnindexedJoinTriNode.java | 24 +- .../bavet/uni/IndexedIfExistsUniNode.java | 20 +- .../bavet/uni/UnindexedIfExistsUniNode.java | 18 +- .../bi/JoinBiEnumeratingStream.java | 28 +- .../uni/IfExistsUniEnumeratingStream.java | 38 +-- .../bi/BavetIfExistsBiConstraintStream.java | 32 +-- .../bavet/bi/BavetJoinBiConstraintStream.java | 28 +- .../BavetIfExistsQuadConstraintStream.java | 32 +-- .../quad/BavetJoinQuadConstraintStream.java | 28 +- .../tri/BavetIfExistsTriConstraintStream.java | 32 +-- .../tri/BavetJoinTriConstraintStream.java | 28 +- .../uni/BavetIfExistsUniConstraintStream.java | 32 +-- .../index/EqualsAndComparisonIndexerTest.java | 18 +- .../bavet/common/index/EqualsIndexerTest.java | 18 +- .../bavet/common/index/IndexedSetTest.java | 245 ++++++++++++++++++ .../bavet/common/index/NoneIndexerTest.java | 19 +- 46 files changed, 1230 insertions(+), 781 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/OutputStoreSizeTracker.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/TupleStorePositionTracker.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedIfExistsBiNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedIfExistsBiNode.java index 50b0239e2b..bb83700435 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedIfExistsBiNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedIfExistsBiNode.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedIfExistsBiNode extends AbstractIndexedIfExistsNode, C> { @@ -12,23 +13,10 @@ public final class IndexedIfExistsBiNode extends AbstractIndexedIfExist private final TriPredicate filtering; public IndexedIfExistsBiNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, -1, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public IndexedIfExistsBiNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering) { - super(shouldExist, indexerFactory.buildBiLeftKeysExtractor(), indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, indexerFactory.buildBiLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedJoinBiNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedJoinBiNode.java index 86eddba25f..09d077fd11 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedJoinBiNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/IndexedJoinBiNode.java @@ -5,31 +5,26 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractIndexedJoinNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedJoinBiNode extends AbstractIndexedJoinNode, B, BiTuple> { private final BiPredicate filtering; - private final int outputStoreSize; - - public IndexedJoinBiNode(IndexerFactory indexerFactory, - int inputStoreIndexA, int inputStoreIndexEntryA, int inputStoreIndexOutTupleListA, - int inputStoreIndexB, int inputStoreIndexEntryB, int inputStoreIndexOutTupleListB, - TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering, - int outputStoreSize, int outputStoreIndexOutEntryA, int outputStoreIndexOutEntryB) { - super(indexerFactory.buildUniLeftKeysExtractor(), indexerFactory, - inputStoreIndexA, inputStoreIndexEntryA, inputStoreIndexOutTupleListA, - inputStoreIndexB, inputStoreIndexEntryB, inputStoreIndexOutTupleListB, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexOutEntryA, outputStoreIndexOutEntryB); + + public IndexedJoinBiNode(IndexerFactory indexerFactory, TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering) { + super(indexerFactory.buildUniLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected BiTuple createOutTuple(UniTuple leftTuple, UniTuple rightTuple) { - return new BiTuple<>(leftTuple.factA, rightTuple.factA, outputStoreSize); + return new BiTuple<>(leftTuple.factA, rightTuple.factA, outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedIfExistsBiNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedIfExistsBiNode.java index 7316766738..a54170598a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedIfExistsBiNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedIfExistsBiNode.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedIfExistsBiNode extends AbstractUnindexedIfExistsNode, C> { @@ -11,22 +12,11 @@ public final class UnindexedIfExistsBiNode extends AbstractUnindexedIfE private final TriPredicate filtering; public UnindexedIfExistsBiNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, - inputStoreIndexLeftCounterEntry, -1, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public UnindexedIfExistsBiNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, int inputStoreIndexRightEntry, - int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering) { - super(shouldExist, - inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, inputStoreIndexRightEntry, - inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedJoinBiNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedJoinBiNode.java index ebd0cd9c72..74e3e55588 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedJoinBiNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/UnindexedJoinBiNode.java @@ -4,32 +4,27 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedJoinNode; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedJoinBiNode extends AbstractUnindexedJoinNode, B, BiTuple> { private final BiPredicate filtering; - private final int outputStoreSize; - - public UnindexedJoinBiNode( - int inputStoreIndexLeftEntry, int inputStoreIndexLeftOutTupleList, - int inputStoreIndexRightEntry, int inputStoreIndexRightOutTupleList, - TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering, - int outputStoreSize, - int outputStoreIndexLeftOutEntry, int outputStoreIndexRightOutEntry) { - super(inputStoreIndexLeftEntry, inputStoreIndexLeftOutTupleList, - inputStoreIndexRightEntry, inputStoreIndexRightOutTupleList, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexLeftOutEntry, outputStoreIndexRightOutEntry); + + public UnindexedJoinBiNode(TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering) { + super(leftTupleStorePositionTracker, rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected BiTuple createOutTuple(UniTuple leftTuple, UniTuple rightTuple) { - return new BiTuple<>(leftTuple.factA, rightTuple.factA, outputStoreSize); + return new BiTuple<>(leftTuple.factA, rightTuple.factA, outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index 26aecf2ab1..b73360c97a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -1,11 +1,11 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * This class has two direct children: {@link AbstractIndexedIfExistsNode} and {@link AbstractUnindexedIfExistsNode}. @@ -20,20 +20,17 @@ public abstract class AbstractIfExistsNode> { protected final boolean shouldExist; - - protected final int inputStoreIndexLeftTrackerList; // -1 if !isFiltering - protected final int inputStoreIndexRightTrackerList; // -1 if !isFiltering - + protected final int inputStoreIndexLeftTrackerSet; // -1 if !isFiltering + protected final int inputStoreIndexRightTrackerSet; // -1 if !isFiltering protected final boolean isFiltering; private final DynamicPropagationQueue> propagationQueue; - protected AbstractIfExistsNode(boolean shouldExist, - int inputStoreIndexLeftTrackerList, int inputStoreIndexRightTrackerList, - TupleLifecycle nextNodesTupleLifecycle, + protected AbstractIfExistsNode(boolean shouldExist, TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { this.shouldExist = shouldExist; - this.inputStoreIndexLeftTrackerList = inputStoreIndexLeftTrackerList; - this.inputStoreIndexRightTrackerList = inputStoreIndexRightTrackerList; + this.inputStoreIndexLeftTrackerSet = isFiltering ? leftTupleStorePositionTracker.reserveNextAvailablePosition() : -1; + this.inputStoreIndexRightTrackerSet = isFiltering ? rightTupleStorePositionTracker.reserveNextAvailablePosition() : -1; this.isFiltering = isFiltering; this.propagationQueue = new DynamicPropagationQueue<>(nextNodesTupleLifecycle); } @@ -66,8 +63,9 @@ protected void updateCounterLeft(ExistsCounter counter) { } case OK, DYING -> propagationQueue.update(counter); case DEAD, ABORTING -> propagationQueue.insert(counter); - default -> throw new IllegalStateException("Impossible state: the counter (" + counter - + ") has an impossible insert state (" + state + ")."); + default -> + throw new IllegalStateException("Impossible state: the counter (%s) has an impossible insert state (%s)." + .formatted(counter, state)); } } else { // Retract or remain dead @@ -80,8 +78,9 @@ protected void updateCounterLeft(ExistsCounter counter) { propagationQueue.retract(counter, TupleState.ABORTING); case OK, UPDATING -> // Kill the original propagation. propagationQueue.retract(counter, TupleState.DYING); - default -> throw new IllegalStateException("Impossible state: The counter (" + counter - + ") has an impossible retract state (" + state + ")."); + default -> + throw new IllegalStateException("Impossible state: The counter (%s) has an impossible retract state (%s)." + .formatted(counter, state)); } } @@ -115,27 +114,26 @@ protected void decrementCounterRight(ExistsCounter counter) { } // Else do not even propagate an update } - protected ElementAwareList> updateRightTrackerList(UniTuple rightTuple) { - ElementAwareList> rightTrackerList = rightTuple.getStore(inputStoreIndexRightTrackerList); - for (FilteringTracker tuple : rightTrackerList) { + IndexedSet> updateRightTrackerSet(UniTuple rightTuple) { + IndexedSet> rightTrackerSet = rightTuple.getStore(inputStoreIndexRightTrackerSet); + rightTrackerSet.forEach(tuple -> { decrementCounterRight(tuple.counter); tuple.remove(); - } - return rightTrackerList; + }); + return rightTrackerSet; } - protected void updateCounterFromLeft(LeftTuple_ leftTuple, UniTuple rightTuple, ExistsCounter counter, - ElementAwareList> leftTrackerList) { + void updateCounterFromLeft(LeftTuple_ leftTuple, UniTuple rightTuple, ExistsCounter counter, + IndexedSet> leftTrackerSet) { if (testFiltering(leftTuple, rightTuple)) { counter.countRight++; - ElementAwareList> rightTrackerList = - rightTuple.getStore(inputStoreIndexRightTrackerList); - new FilteringTracker<>(counter, leftTrackerList, rightTrackerList); + IndexedSet> rightTrackerSet = rightTuple.getStore(inputStoreIndexRightTrackerSet); + new ExistsCounterHandle<>(counter, leftTrackerSet, rightTrackerSet); } } - protected void updateCounterFromRight(UniTuple rightTuple, ExistsCounter counter, - ElementAwareList> rightTrackerList) { + void updateCounterFromRight(UniTuple rightTuple, ExistsCounter counter, + IndexedSet> rightTrackerSet) { var leftTuple = counter.leftTuple; if (!leftTuple.state.isActive()) { // Assume the following scenario: @@ -156,9 +154,9 @@ protected void updateCounterFromRight(UniTuple rightTuple, ExistsCounter } if (testFiltering(counter.leftTuple, rightTuple)) { incrementCounterRight(counter); - ElementAwareList> leftTrackerList = - counter.leftTuple.getStore(inputStoreIndexLeftTrackerList); - new FilteringTracker<>(counter, leftTrackerList, rightTrackerList); + IndexedSet> leftTrackerSet = + counter.leftTuple.getStore(inputStoreIndexLeftTrackerSet); + new ExistsCounterHandle<>(counter, leftTrackerSet, rightTrackerSet); } } @@ -166,8 +164,8 @@ private void doInsertCounter(ExistsCounter counter) { switch (counter.state) { case DYING -> propagationQueue.update(counter); case DEAD, ABORTING -> propagationQueue.insert(counter); - default -> throw new IllegalStateException("Impossible state: the counter (" + counter - + ") has an impossible insert state (" + counter.state + ")."); + default -> throw new IllegalStateException("Impossible state: the counter (%s) has an impossible insert state (%s)." + .formatted(counter, counter.state)); } } @@ -177,8 +175,9 @@ private void doRetractCounter(ExistsCounter counter) { propagationQueue.retract(counter, TupleState.ABORTING); case OK, UPDATING -> // Kill the original propagation. propagationQueue.retract(counter, TupleState.DYING); - default -> throw new IllegalStateException("Impossible state: The counter (" + counter - + ") has an impossible retract state (" + counter.state + ")."); + default -> + throw new IllegalStateException("Impossible state: The counter (%s) has an impossible retract state (%s)." + .formatted(counter, counter.state)); } } @@ -187,23 +186,4 @@ public Propagator getPropagator() { return propagationQueue; } - protected static final class FilteringTracker { - final ExistsCounter counter; - private final ElementAwareListEntry> leftTrackerEntry; - private final ElementAwareListEntry> rightTrackerEntry; - - FilteringTracker(ExistsCounter counter, ElementAwareList> leftTrackerList, - ElementAwareList> rightTrackerList) { - this.counter = counter; - leftTrackerEntry = leftTrackerList.add(this); - rightTrackerEntry = rightTrackerList.add(this); - } - - public void remove() { - leftTrackerEntry.remove(); - rightTrackerEntry.remove(); - } - - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index 1f12232e6a..bd174958b4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.index.Indexer; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory.KeysExtractor; @@ -8,9 +9,8 @@ import ai.timefold.solver.core.impl.bavet.common.tuple.LeftTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.RightTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * There is a strong likelihood that any change to this class, which is not related to indexing, @@ -26,56 +26,53 @@ public abstract class AbstractIndexedIfExistsNode keysExtractorLeft; private final UniKeysExtractor keysExtractorRight; private final int inputStoreIndexLeftKeys; - private final int inputStoreIndexLeftCounterEntry; + private final int inputStoreIndexLeftCounter; private final int inputStoreIndexRightKeys; - private final int inputStoreIndexRightEntry; private final Indexer> indexerLeft; private final Indexer> indexerRight; protected AbstractIndexedIfExistsNode(boolean shouldExist, KeysExtractor keysExtractorLeft, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { - super(shouldExist, - inputStoreIndexLeftTrackerList, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, isFiltering); + super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); this.keysExtractorLeft = keysExtractorLeft; this.keysExtractorRight = indexerFactory.buildRightKeysExtractor(); - this.inputStoreIndexLeftKeys = inputStoreIndexLeftKeys; - this.inputStoreIndexLeftCounterEntry = inputStoreIndexLeftCounterEntry; - this.inputStoreIndexRightKeys = inputStoreIndexRightKeys; - this.inputStoreIndexRightEntry = inputStoreIndexRightEntry; - this.indexerLeft = indexerFactory.buildIndexer(true); - this.indexerRight = indexerFactory.buildIndexer(false); + this.inputStoreIndexLeftKeys = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexRightKeys = rightTupleStorePositionTracker.reserveNextAvailablePosition(); + this.indexerLeft = indexerFactory.buildIndexer(true, + new ExistsCounterPositionTracker<>(leftTupleStorePositionTracker.reserveNextAvailablePosition())); + this.indexerRight = indexerFactory.buildIndexer(false, + new TuplePositionTracker<>(rightTupleStorePositionTracker.reserveNextAvailablePosition())); } @Override public final void insertLeft(LeftTuple_ leftTuple) { if (leftTuple.getStore(inputStoreIndexLeftKeys) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + leftTuple - + ") was already added in the tupleStore."); + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(leftTuple)); } var indexKeys = keysExtractorLeft.apply(leftTuple); leftTuple.setStore(inputStoreIndexLeftKeys, indexKeys); var counter = new ExistsCounter<>(leftTuple); - var counterEntry = indexerLeft.put(indexKeys, counter); - updateCounterRight(leftTuple, indexKeys, counter, counterEntry); + indexerLeft.put(indexKeys, counter); + updateCounterRight(leftTuple, indexKeys, counter); initCounterLeft(counter); } - private void updateCounterRight(LeftTuple_ leftTuple, Object indexKeys, ExistsCounter counter, - ElementAwareListEntry> counterEntry) { - leftTuple.setStore(inputStoreIndexLeftCounterEntry, counterEntry); + private void updateCounterRight(LeftTuple_ leftTuple, Object indexKeys, ExistsCounter counter) { + leftTuple.setStore(inputStoreIndexLeftCounter, counter); if (!isFiltering) { counter.countRight = indexerRight.size(indexKeys); } else { - var leftTrackerList = new ElementAwareList>(); + var leftTrackerSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); indexerRight.forEach(indexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerList)); - leftTuple.setStore(inputStoreIndexLeftTrackerList, leftTrackerList); + rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerSet)); + leftTuple.setStore(inputStoreIndexLeftTrackerSet, leftTrackerSet); } } @@ -88,8 +85,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { return; } var newIndexKeys = keysExtractorLeft.apply(leftTuple); - ElementAwareListEntry> counterEntry = leftTuple.getStore(inputStoreIndexLeftCounterEntry); - var counter = counterEntry.getElement(); + ExistsCounter counter = leftTuple.getStore(inputStoreIndexLeftCounter); if (oldIndexKeys.equals(newIndexKeys)) { // No need for re-indexing because the index keys didn't change @@ -98,19 +94,20 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - ElementAwareList> leftTrackerList = - leftTuple.getStore(inputStoreIndexLeftTrackerList); - leftTrackerList.forEach(FilteringTracker::remove); + IndexedSet> leftTrackerSet = + leftTuple.getStore(inputStoreIndexLeftTrackerSet); + leftTrackerSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; indexerRight.forEach(oldIndexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerList)); + rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerSet)); updateCounterLeft(counter); } } else { - updateIndexerLeft(oldIndexKeys, counterEntry, leftTuple); + updateIndexerLeft(oldIndexKeys, counter, leftTuple); counter.countRight = 0; leftTuple.setStore(inputStoreIndexLeftKeys, newIndexKeys); - updateCounterRight(leftTuple, newIndexKeys, counter, indexerLeft.put(newIndexKeys, counter)); + indexerLeft.put(newIndexKeys, counter); + updateCounterRight(leftTuple, newIndexKeys, counter); updateCounterLeft(counter); } } @@ -122,32 +119,31 @@ public final void retractLeft(LeftTuple_ leftTuple) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareListEntry> counterEntry = leftTuple.getStore(inputStoreIndexLeftCounterEntry); - var counter = counterEntry.getElement(); - updateIndexerLeft(indexKeys, counterEntry, leftTuple); + ExistsCounter counter = leftTuple.getStore(inputStoreIndexLeftCounter); + updateIndexerLeft(indexKeys, counter, leftTuple); killCounterLeft(counter); } - private void updateIndexerLeft(Object indexKeys, ElementAwareListEntry> counterEntry, - LeftTuple_ leftTuple) { - indexerLeft.remove(indexKeys, counterEntry); + private void updateIndexerLeft(Object indexKeys, ExistsCounter counter, LeftTuple_ leftTuple) { + indexerLeft.remove(indexKeys, counter); if (isFiltering) { - ElementAwareList> leftTrackerList = leftTuple.getStore(inputStoreIndexLeftTrackerList); - leftTrackerList.forEach(FilteringTracker::remove); + IndexedSet> leftTrackerSet = + leftTuple.getStore(inputStoreIndexLeftTrackerSet); + leftTrackerSet.forEach(ExistsCounterHandle::remove); } } @Override public final void insertRight(UniTuple rightTuple) { if (rightTuple.getStore(inputStoreIndexRightKeys) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + rightTuple - + ") was already added in the tupleStore."); + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(rightTuple)); } var indexKeys = keysExtractorRight.apply(rightTuple); rightTuple.setStore(inputStoreIndexRightKeys, indexKeys); - var rightEntry = indexerRight.put(indexKeys, rightTuple); - rightTuple.setStore(inputStoreIndexRightEntry, rightEntry); + indexerRight.put(indexKeys, rightTuple); updateCounterLeft(rightTuple, indexKeys); } @@ -155,9 +151,10 @@ private void updateCounterLeft(UniTuple rightTuple, Object indexKeys) { if (!isFiltering) { indexerLeft.forEach(indexKeys, this::incrementCounterRight); } else { - var rightTrackerList = new ElementAwareList>(); - indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(rightTuple, counter, rightTrackerList)); - rightTuple.setStore(inputStoreIndexRightTrackerList, rightTrackerList); + var rightTrackerSet = + new IndexedSet>(ExistsCounterHandlePositionTracker.right()); + indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(rightTuple, counter, rightTrackerSet)); + rightTuple.setStore(inputStoreIndexRightTrackerSet, rightTrackerSet); } } @@ -173,21 +170,19 @@ public final void updateRight(UniTuple rightTuple) { if (oldIndexKeys.equals(newIndexKeys)) { // No need for re-indexing because the index keys didn't change if (isFiltering) { - var rightTrackerList = updateRightTrackerList(rightTuple); + var rightTrackerSet = updateRightTrackerSet(rightTuple); indexerLeft.forEach(oldIndexKeys, - counter -> updateCounterFromRight(rightTuple, counter, rightTrackerList)); + counter -> updateCounterFromRight(rightTuple, counter, rightTrackerSet)); } } else { - ElementAwareListEntry> rightEntry = rightTuple.getStore(inputStoreIndexRightEntry); - indexerRight.remove(oldIndexKeys, rightEntry); + indexerRight.remove(oldIndexKeys, rightTuple); if (!isFiltering) { indexerLeft.forEach(oldIndexKeys, this::decrementCounterRight); } else { - updateRightTrackerList(rightTuple); + updateRightTrackerSet(rightTuple); } rightTuple.setStore(inputStoreIndexRightKeys, newIndexKeys); - rightEntry = indexerRight.put(newIndexKeys, rightTuple); - rightTuple.setStore(inputStoreIndexRightEntry, rightEntry); + indexerRight.put(newIndexKeys, rightTuple); updateCounterLeft(rightTuple, newIndexKeys); } } @@ -199,12 +194,11 @@ public final void retractRight(UniTuple rightTuple) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareListEntry> rightEntry = rightTuple.removeStore(inputStoreIndexRightEntry); - indexerRight.remove(indexKeys, rightEntry); + indexerRight.remove(indexKeys, rightTuple); if (!isFiltering) { indexerLeft.forEach(indexKeys, this::decrementCounterRight); } else { - updateRightTrackerList(rightTuple); + updateRightTrackerSet(rightTuple); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java index 2e050483cf..398abae78c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java @@ -1,16 +1,17 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.index.Indexer; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory.KeysExtractor; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory.UniKeysExtractor; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.LeftTupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.RightTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * There is a strong likelihood that any change to this class, which is not related to indexing, @@ -26,9 +27,10 @@ public abstract class AbstractIndexedJoinNode keysExtractorLeft; private final UniKeysExtractor keysExtractorRight; private final int inputStoreIndexLeftKeys; - private final int inputStoreIndexLeftEntry; private final int inputStoreIndexRightKeys; - private final int inputStoreIndexRightEntry; + private final int outputStoreIndexLeftPosition; + private final int outputStoreIndexRightPosition; + /** * Calls for example {@link AbstractScorer#insert(AbstractTuple)} and/or ... */ @@ -36,31 +38,33 @@ public abstract class AbstractIndexedJoinNode> indexerRight; protected AbstractIndexedJoinNode(KeysExtractor keysExtractorLeft, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftEntry, int inputStoreIndexLeftOutTupleList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightOutTupleList, - TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering, int outputStoreIndexLeftOutEntry, - int outputStoreIndexRightOutEntry) { - super(inputStoreIndexLeftOutTupleList, inputStoreIndexRightOutTupleList, nextNodesTupleLifecycle, isFiltering, - outputStoreIndexLeftOutEntry, outputStoreIndexRightOutEntry); + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, + OutputStoreSizeTracker outputStoreSizeTracker, TupleLifecycle nextNodesTupleLifecycle, + boolean isFiltering) { + super(leftTupleStorePositionTracker, rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, + isFiltering); this.keysExtractorLeft = keysExtractorLeft; this.keysExtractorRight = indexerFactory.buildRightKeysExtractor(); - this.inputStoreIndexLeftKeys = inputStoreIndexLeftKeys; - this.inputStoreIndexLeftEntry = inputStoreIndexLeftEntry; - this.inputStoreIndexRightKeys = inputStoreIndexRightKeys; - this.inputStoreIndexRightEntry = inputStoreIndexRightEntry; - this.indexerLeft = indexerFactory.buildIndexer(true); - this.indexerRight = indexerFactory.buildIndexer(false); + this.inputStoreIndexLeftKeys = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexRightKeys = rightTupleStorePositionTracker.reserveNextAvailablePosition(); + this.outputStoreIndexLeftPosition = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.outputStoreIndexRightPosition = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.indexerLeft = indexerFactory.buildIndexer(true, + new TuplePositionTracker<>(leftTupleStorePositionTracker.reserveNextAvailablePosition())); + this.indexerRight = indexerFactory.buildIndexer(false, + new TuplePositionTracker<>(rightTupleStorePositionTracker.reserveNextAvailablePosition())); } @Override public final void insertLeft(LeftTuple_ leftTuple) { if (leftTuple.getStore(inputStoreIndexLeftKeys) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + leftTuple - + ") was already added in the tupleStore."); + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(leftTuple)); } var indexKeys = keysExtractorLeft.apply(leftTuple); - var outTupleListLeft = new ElementAwareList(); - leftTuple.setStore(inputStoreIndexLeftOutTupleList, outTupleListLeft); + var outTupleSetLeft = new IndexedSet<>(new TuplePositionTracker<>(outputStoreIndexLeftPosition)); + leftTuple.setStore(inputStoreIndexLeftOutTupleSet, outTupleSetLeft); indexAndPropagateLeft(leftTuple, indexKeys); } @@ -78,20 +82,17 @@ public final void updateLeft(LeftTuple_ leftTuple) { // Prefer an update over retract-insert if possible innerUpdateLeft(leftTuple, consumer -> indexerRight.forEach(oldIndexKeys, consumer)); } else { - ElementAwareListEntry leftEntry = leftTuple.getStore(inputStoreIndexLeftEntry); - ElementAwareList outTupleListLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleList); - indexerLeft.remove(oldIndexKeys, leftEntry); - outTupleListLeft.forEach(this::retractOutTuple); - // outTupleListLeft is now empty - // No need for leftTuple.setStore(inputStoreIndexLeftOutTupleList, outTupleListLeft); + IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); + indexerLeft.remove(oldIndexKeys, leftTuple); + outTupleSetLeft.forEach(this::retractOutTuple); + // outTupleSetLeft is now empty, no need for leftTuple.setStore(...); indexAndPropagateLeft(leftTuple, newIndexKeys); } } private void indexAndPropagateLeft(LeftTuple_ leftTuple, Object indexKeys) { leftTuple.setStore(inputStoreIndexLeftKeys, indexKeys); - var leftEntry = indexerLeft.put(indexKeys, leftTuple); - leftTuple.setStore(inputStoreIndexLeftEntry, leftEntry); + indexerLeft.put(indexKeys, leftTuple); indexerRight.forEach(indexKeys, rightTuple -> insertOutTupleFiltered(leftTuple, rightTuple)); } @@ -102,21 +103,21 @@ public final void retractLeft(LeftTuple_ leftTuple) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareListEntry leftEntry = leftTuple.removeStore(inputStoreIndexLeftEntry); - ElementAwareList outTupleListLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleList); - indexerLeft.remove(indexKeys, leftEntry); - outTupleListLeft.forEach(this::retractOutTuple); + IndexedSet outTupleSetLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleSet); + indexerLeft.remove(indexKeys, leftTuple); + outTupleSetLeft.forEach(this::retractOutTuple); } @Override public final void insertRight(UniTuple rightTuple) { if (rightTuple.getStore(inputStoreIndexRightKeys) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + rightTuple - + ") was already added in the tupleStore."); + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(rightTuple)); } var indexKeys = keysExtractorRight.apply(rightTuple); - var outTupleListRight = new ElementAwareList(); - rightTuple.setStore(inputStoreIndexRightOutTupleList, outTupleListRight); + var outTupleSetRight = new IndexedSet<>(new TuplePositionTracker<>(outputStoreIndexRightPosition)); + rightTuple.setStore(inputStoreIndexRightOutTupleSet, outTupleSetRight); indexAndPropagateRight(rightTuple, indexKeys); } @@ -134,20 +135,17 @@ public final void updateRight(UniTuple rightTuple) { // Prefer an update over retract-insert if possible innerUpdateRight(rightTuple, consumer -> indexerLeft.forEach(oldIndexKeys, consumer)); } else { - ElementAwareListEntry> rightEntry = rightTuple.getStore(inputStoreIndexRightEntry); - ElementAwareList outTupleListRight = rightTuple.getStore(inputStoreIndexRightOutTupleList); - indexerRight.remove(oldIndexKeys, rightEntry); - outTupleListRight.forEach(this::retractOutTuple); - // outTupleListRight is now empty - // No need for rightTuple.setStore(inputStoreIndexRightOutTupleList, outTupleListRight); + IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); + indexerRight.remove(oldIndexKeys, rightTuple); + outTupleSetRight.forEach(this::retractOutTuple); + // outTupleSetRight is now empty, no need for rightTuple.setStore(...); indexAndPropagateRight(rightTuple, newIndexKeys); } } private void indexAndPropagateRight(UniTuple rightTuple, Object indexKeys) { rightTuple.setStore(inputStoreIndexRightKeys, indexKeys); - var rightEntry = indexerRight.put(indexKeys, rightTuple); - rightTuple.setStore(inputStoreIndexRightEntry, rightEntry); + indexerRight.put(indexKeys, rightTuple); indexerLeft.forEach(indexKeys, leftTuple -> insertOutTupleFiltered(leftTuple, rightTuple)); } @@ -158,10 +156,9 @@ public final void retractRight(UniTuple rightTuple) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareListEntry> rightEntry = rightTuple.removeStore(inputStoreIndexRightEntry); - ElementAwareList outTupleListRight = rightTuple.removeStore(inputStoreIndexRightOutTupleList); - indexerRight.remove(indexKeys, rightEntry); - outTupleListRight.forEach(this::retractOutTuple); + IndexedSet outTupleSetRight = rightTuple.removeStore(inputStoreIndexRightOutTupleSet); + indexerRight.remove(indexKeys, rightTuple); + outTupleSetRight.forEach(this::retractOutTuple); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java index 5303ce47e0..cfc660e8cb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java @@ -2,12 +2,15 @@ import java.util.function.Consumer; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; + +import org.jspecify.annotations.Nullable; /** * This class has two direct children: {@link AbstractIndexedJoinNode} and {@link AbstractUnindexedJoinNode}. @@ -21,21 +24,23 @@ public abstract class AbstractJoinNode extends AbstractTwoInputNode> { - protected final int inputStoreIndexLeftOutTupleList; - protected final int inputStoreIndexRightOutTupleList; + protected final int inputStoreIndexLeftOutTupleSet; + protected final int inputStoreIndexRightOutTupleSet; private final boolean isFiltering; - private final int outputStoreIndexLeftOutEntry; - private final int outputStoreIndexRightOutEntry; + private final int outputStoreIndexLeftOutSet; + private final int outputStoreIndexRightOutSet; + protected final OutputStoreSizeTracker outputStoreSizeTracker; private final StaticPropagationQueue propagationQueue; - protected AbstractJoinNode(int inputStoreIndexLeftOutTupleList, int inputStoreIndexRightOutTupleList, - TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering, - int outputStoreIndexLeftOutEntry, int outputStoreIndexRightOutEntry) { - this.inputStoreIndexLeftOutTupleList = inputStoreIndexLeftOutTupleList; - this.inputStoreIndexRightOutTupleList = inputStoreIndexRightOutTupleList; + protected AbstractJoinNode(TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { + this.inputStoreIndexLeftOutTupleSet = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexRightOutTupleSet = rightTupleStorePositionTracker.reserveNextAvailablePosition(); this.isFiltering = isFiltering; - this.outputStoreIndexLeftOutEntry = outputStoreIndexLeftOutEntry; - this.outputStoreIndexRightOutEntry = outputStoreIndexRightOutEntry; + this.outputStoreIndexLeftOutSet = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.outputStoreIndexRightOutSet = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.outputStoreSizeTracker = outputStoreSizeTracker; this.propagationQueue = new StaticPropagationQueue<>(nextNodesTupleLifecycle); } @@ -49,12 +54,12 @@ protected AbstractJoinNode(int inputStoreIndexLeftOutTupleList, int inputStoreIn protected final void insertOutTuple(LeftTuple_ leftTuple, UniTuple rightTuple) { var outTuple = createOutTuple(leftTuple, rightTuple); - ElementAwareList outTupleListLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleList); - var outEntryLeft = outTupleListLeft.add(outTuple); - outTuple.setStore(outputStoreIndexLeftOutEntry, outEntryLeft); - ElementAwareList outTupleListRight = rightTuple.getStore(inputStoreIndexRightOutTupleList); - var outEntryRight = outTupleListRight.add(outTuple); - outTuple.setStore(outputStoreIndexRightOutEntry, outEntryRight); + IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); + outTupleSetLeft.add(outTuple); + outTuple.setStore(outputStoreIndexLeftOutSet, outTupleSetLeft); + IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); + outTupleSetRight.add(outTuple); + outTuple.setStore(outputStoreIndexRightOutSet, outTupleSetRight); propagationQueue.insert(outTuple); } @@ -82,16 +87,14 @@ protected final void insertOutTupleFiltered(LeftTuple_ leftTuple, UniTuple>> rightTupleConsumer) { // Prefer an update over retract-insert if possible - ElementAwareList outTupleListLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleList); + IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); // Propagate the update for downstream filters, matchWeighers, ... if (!isFiltering) { - for (var outTuple : outTupleListLeft) { - updateOutTupleLeft(outTuple, leftTuple); - } + outTupleSetLeft.forEach(outTuple -> updateOutTupleLeft(outTuple, leftTuple)); } else { rightTupleConsumer.accept(rightTuple -> { - ElementAwareList rightOutList = rightTuple.getStore(inputStoreIndexRightOutTupleList); - processOutTupleUpdate(leftTuple, rightTuple, rightOutList, outTupleListLeft, outputStoreIndexRightOutEntry); + IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); + processOutTupleUpdate(leftTuple, rightTuple, outTupleSetRight, outTupleSetLeft, outputStoreIndexRightOutSet); }); } } @@ -114,23 +117,24 @@ private void doUpdateOutTuple(OutTuple_ outTuple) { protected final void innerUpdateRight(UniTuple rightTuple, Consumer> leftTupleConsumer) { // Prefer an update over retract-insert if possible - ElementAwareList outTupleListRight = rightTuple.getStore(inputStoreIndexRightOutTupleList); + IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); if (!isFiltering) { // Propagate the update for downstream filters, matchWeighers, ... - for (var outTuple : outTupleListRight) { + outTupleSetRight.forEach(outTuple -> { setOutTupleRightFact(outTuple, rightTuple); doUpdateOutTuple(outTuple); - } + }); } else { leftTupleConsumer.accept(leftTuple -> { - ElementAwareList leftOutList = leftTuple.getStore(inputStoreIndexLeftOutTupleList); - processOutTupleUpdate(leftTuple, rightTuple, leftOutList, outTupleListRight, outputStoreIndexLeftOutEntry); + IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); + processOutTupleUpdate(leftTuple, rightTuple, outTupleSetLeft, outTupleSetRight, outputStoreIndexLeftOutSet); }); } } - private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightTuple, ElementAwareList outList, - ElementAwareList outTupleList, int outputStoreIndexOutEntry) { + private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightTuple, + IndexedSet referenceOutTupleSet, IndexedSet outTupleSet, + int outputStoreIndexOutSet) { if (!leftTuple.state.isActive()) { // Assume the following scenario: // - The join is of two entities of the same type, both filtering out unassigned. @@ -148,7 +152,7 @@ private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightT // However, no such issue could have been reproduced; when in doubt, leave it out. return; } - var outTuple = findOutTuple(outTupleList, outList, outputStoreIndexOutEntry); + var outTuple = findOutTuple(outTupleSet, referenceOutTupleSet, outputStoreIndexOutSet); if (testFiltering(leftTuple, rightTuple)) { if (outTuple == null) { insertOutTuple(leftTuple, rightTuple); @@ -162,28 +166,24 @@ private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightT } } - private static Tuple_ findOutTuple(ElementAwareList outTupleList, - ElementAwareList outList, int outputStoreIndexOutEntry) { - // Hack: the outTuple has no left/right input tuple reference, use the left/right outList reference instead. - var item = outTupleList.first(); - while (item != null) { - // Creating list iterators here caused major GC pressure; therefore, we iterate over the entries directly. - var outTuple = item.getElement(); - ElementAwareListEntry outEntry = outTuple.getStore(outputStoreIndexOutEntry); - var outEntryList = outEntry.getList(); - if (outList == outEntryList) { + private static @Nullable Tuple_ findOutTuple(IndexedSet outTupleSet, + IndexedSet referenceOutTupleSet, int outputStoreIndexOutSet) { + // Hack: the outTuple has no left/right input tuple reference, use the left/right outSet reference instead. + var list = outTupleSet.asList(); + for (var i = 0; i < list.size(); i++) { // Avoid allocating iterators. + var outTuple = list.get(i); + if (referenceOutTupleSet == outTuple.getStore(outputStoreIndexOutSet)) { return outTuple; } - item = item.next(); } return null; } protected final void retractOutTuple(OutTuple_ outTuple) { - ElementAwareListEntry outEntryLeft = outTuple.removeStore(outputStoreIndexLeftOutEntry); - outEntryLeft.remove(); - ElementAwareListEntry outEntryRight = outTuple.removeStore(outputStoreIndexRightOutEntry); - outEntryRight.remove(); + IndexedSet outSetLeft = outTuple.removeStore(outputStoreIndexLeftOutSet); + outSetLeft.remove(outTuple); + IndexedSet outSetRight = outTuple.removeStore(outputStoreIndexRightOutSet); + outSetRight.remove(outTuple); var state = outTuple.state; if (!state.isActive()) { // Impossible because they shouldn't linger in the indexes. throw new IllegalStateException("Impossible state: The tuple (%s) in node (%s) is in an unexpected state (%s)." diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index 9709dd62a1..2ade3386dc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.LeftTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.RightTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * There is a strong likelihood that any change made to this class @@ -19,134 +19,123 @@ public abstract class AbstractUnindexedIfExistsNode implements LeftTupleLifecycle, RightTupleLifecycle> { - private final int inputStoreIndexLeftCounterEntry; + private final int inputStoreIndexLeftCounter; + private final int inputStoreIndexRightTuple; - private final int inputStoreIndexRightEntry; + private final IndexedSet> leftCounterSet; + private final IndexedSet> rightTupleSet; - // Acts as a leftTupleList too - private final ElementAwareList> leftCounterList = new ElementAwareList<>(); - private final ElementAwareList> rightTupleList = new ElementAwareList<>(); - - protected AbstractUnindexedIfExistsNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, int inputStoreIndexRightEntry, - int inputStoreIndexRightTrackerList, - TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { - super(shouldExist, - inputStoreIndexLeftTrackerList, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, isFiltering); - this.inputStoreIndexLeftCounterEntry = inputStoreIndexLeftCounterEntry; - this.inputStoreIndexRightEntry = inputStoreIndexRightEntry; + protected AbstractUnindexedIfExistsNode(boolean shouldExist, TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, + boolean isFiltering) { + super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); + this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexRightTuple = rightTupleStorePositionTracker.reserveNextAvailablePosition(); + this.leftCounterSet = new IndexedSet<>( + new ExistsCounterPositionTracker<>(leftTupleStorePositionTracker.reserveNextAvailablePosition())); + this.rightTupleSet = new IndexedSet<>(new TuplePositionTracker<>(inputStoreIndexRightTuple)); } @Override public final void insertLeft(LeftTuple_ leftTuple) { - if (leftTuple.getStore(inputStoreIndexLeftCounterEntry) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + leftTuple - + ") was already added in the tupleStore."); + if (leftTuple.getStore(inputStoreIndexLeftCounter) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(leftTuple)); } var counter = new ExistsCounter<>(leftTuple); - var counterEntry = leftCounterList.add(counter); - leftTuple.setStore(inputStoreIndexLeftCounterEntry, counterEntry); + leftCounterSet.add(counter); + leftTuple.setStore(inputStoreIndexLeftCounter, counter); if (!isFiltering) { - counter.countRight = rightTupleList.size(); + counter.countRight = rightTupleSet.size(); } else { - var leftTrackerList = new ElementAwareList>(); - for (var tuple : rightTupleList) { - updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerList); - } - leftTuple.setStore(inputStoreIndexLeftTrackerList, leftTrackerList); + var leftTrackerSet = + new IndexedSet>(ExistsCounterHandlePositionTracker.left()); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerSet)); + leftTuple.setStore(inputStoreIndexLeftTrackerSet, leftTrackerSet); } initCounterLeft(counter); } @Override public final void updateLeft(LeftTuple_ leftTuple) { - ElementAwareListEntry> counterEntry = leftTuple.getStore(inputStoreIndexLeftCounterEntry); - if (counterEntry == null) { + ExistsCounter counter = leftTuple.getStore(inputStoreIndexLeftCounter); + if (counter == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) insertLeft(leftTuple); return; } - var counter = counterEntry.getElement(); // The indexers contain counters in the DEAD state, to track the rightCount. if (!isFiltering) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - ElementAwareList> leftTrackerList = leftTuple.getStore(inputStoreIndexLeftTrackerList); - leftTrackerList.forEach(FilteringTracker::remove); + IndexedSet> leftTrackerSet = + leftTuple.getStore(inputStoreIndexLeftTrackerSet); + leftTrackerSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; - for (var tuple : rightTupleList) { - updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerList); - } + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerSet)); updateCounterLeft(counter); } } @Override public final void retractLeft(LeftTuple_ leftTuple) { - ElementAwareListEntry> counterEntry = leftTuple.removeStore(inputStoreIndexLeftCounterEntry); - if (counterEntry == null) { + ExistsCounter counter = leftTuple.removeStore(inputStoreIndexLeftCounter); + if (counter == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - var counter = counterEntry.getElement(); - counterEntry.remove(); + leftCounterSet.remove(counter); if (isFiltering) { - ElementAwareList> leftTrackerList = leftTuple.getStore(inputStoreIndexLeftTrackerList); - leftTrackerList.forEach(FilteringTracker::remove); + IndexedSet> leftTrackerSet = leftTuple.getStore(inputStoreIndexLeftTrackerSet); + leftTrackerSet.forEach(ExistsCounterHandle::remove); } killCounterLeft(counter); } @Override public final void insertRight(UniTuple rightTuple) { - if (rightTuple.getStore(inputStoreIndexRightEntry) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + rightTuple - + ") was already added in the tupleStore."); + if (rightTuple.getStore(inputStoreIndexRightTuple) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(rightTuple)); } - var rightEntry = rightTupleList.add(rightTuple); - rightTuple.setStore(inputStoreIndexRightEntry, rightEntry); + rightTupleSet.add(rightTuple); if (!isFiltering) { - leftCounterList.forEach(this::incrementCounterRight); + leftCounterSet.forEach(this::incrementCounterRight); } else { - var rightTrackerList = new ElementAwareList>(); - for (var tuple : leftCounterList) { - updateCounterFromRight(rightTuple, tuple, rightTrackerList); - } - rightTuple.setStore(inputStoreIndexRightTrackerList, rightTrackerList); + var rightTrackerSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); + leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightTrackerSet)); + rightTuple.setStore(inputStoreIndexRightTrackerSet, rightTrackerSet); } } @Override public final void updateRight(UniTuple rightTuple) { - ElementAwareListEntry> rightEntry = rightTuple.getStore(inputStoreIndexRightEntry); - if (rightEntry == null) { + if (rightTuple.getStore(inputStoreIndexRightTuple) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) insertRight(rightTuple); return; } if (isFiltering) { - var rightTrackerList = updateRightTrackerList(rightTuple); - for (var tuple : leftCounterList) { - updateCounterFromRight(rightTuple, tuple, rightTrackerList); - } + var rightTrackerSet = updateRightTrackerSet(rightTuple); + leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightTrackerSet)); } } @Override public final void retractRight(UniTuple rightTuple) { - ElementAwareListEntry> rightEntry = rightTuple.removeStore(inputStoreIndexRightEntry); - if (rightEntry == null) { + if (rightTuple.getStore(inputStoreIndexRightTuple) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - rightEntry.remove(); + rightTupleSet.remove(rightTuple); if (!isFiltering) { - leftCounterList.forEach(this::decrementCounterRight); + leftCounterSet.forEach(this::decrementCounterRight); } else { - updateRightTrackerList(rightTuple); + updateRightTrackerSet(rightTuple); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java index 971b569288..22cba406ac 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java @@ -1,12 +1,13 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.LeftTupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.RightTupleLifecycle; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * There is a strong likelihood that any change made to this class @@ -19,95 +20,92 @@ public abstract class AbstractUnindexedJoinNode implements LeftTupleLifecycle, RightTupleLifecycle> { - private final int inputStoreIndexLeftEntry; - private final int inputStoreIndexRightEntry; - private final ElementAwareList leftTupleList = new ElementAwareList<>(); - private final ElementAwareList> rightTupleList = new ElementAwareList<>(); + private final int inputStoreIndexLeftPosition; + private final int inputStoreIndexRightPosition; + private final int outputStoreIndexLeftPosition; + private final int outputStoreIndexRightPosition; + private final IndexedSet leftTupleSet; + private final IndexedSet> rightTupleSet; - protected AbstractUnindexedJoinNode(int inputStoreIndexLeftEntry, int inputStoreIndexLeftOutTupleList, - int inputStoreIndexRightEntry, int inputStoreIndexRightOutTupleList, - TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering, int outputStoreIndexLeftOutEntry, - int outputStoreIndexRightOutEntry) { - super(inputStoreIndexLeftOutTupleList, inputStoreIndexRightOutTupleList, nextNodesTupleLifecycle, isFiltering, - outputStoreIndexLeftOutEntry, outputStoreIndexRightOutEntry); - this.inputStoreIndexLeftEntry = inputStoreIndexLeftEntry; - this.inputStoreIndexRightEntry = inputStoreIndexRightEntry; + protected AbstractUnindexedJoinNode(TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { + super(leftTupleStorePositionTracker, rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, + isFiltering); + this.inputStoreIndexLeftPosition = leftTupleStorePositionTracker.reserveNextAvailablePosition(); + this.inputStoreIndexRightPosition = rightTupleStorePositionTracker.reserveNextAvailablePosition(); + this.outputStoreIndexLeftPosition = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.outputStoreIndexRightPosition = outputStoreSizeTracker.reserveNextAvailablePosition(); + this.leftTupleSet = new IndexedSet<>(new TuplePositionTracker<>(inputStoreIndexLeftPosition)); + this.rightTupleSet = new IndexedSet<>(new TuplePositionTracker<>(inputStoreIndexRightPosition)); } @Override public final void insertLeft(LeftTuple_ leftTuple) { - if (leftTuple.getStore(inputStoreIndexLeftEntry) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + leftTuple - + ") was already added in the tupleStore."); - } - var leftEntry = leftTupleList.add(leftTuple); - leftTuple.setStore(inputStoreIndexLeftEntry, leftEntry); - var outTupleListLeft = new ElementAwareList(); - leftTuple.setStore(inputStoreIndexLeftOutTupleList, outTupleListLeft); - for (var tuple : rightTupleList) { - insertOutTupleFiltered(leftTuple, tuple); + if (leftTuple.getStore(inputStoreIndexLeftPosition) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(leftTuple)); } + leftTupleSet.add(leftTuple); + var outTupleSetLeft = new IndexedSet<>(new TuplePositionTracker<>(outputStoreIndexLeftPosition)); + leftTuple.setStore(inputStoreIndexLeftOutTupleSet, outTupleSetLeft); + rightTupleSet.forEach(tuple -> insertOutTupleFiltered(leftTuple, tuple)); } @Override public final void updateLeft(LeftTuple_ leftTuple) { - ElementAwareListEntry leftEntry = leftTuple.getStore(inputStoreIndexLeftEntry); - if (leftEntry == null) { + if (leftTuple.getStore(inputStoreIndexLeftPosition) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) insertLeft(leftTuple); return; } - innerUpdateLeft(leftTuple, rightTupleList::forEach); + innerUpdateLeft(leftTuple, rightTupleSet::forEach); } @Override public final void retractLeft(LeftTuple_ leftTuple) { - ElementAwareListEntry leftEntry = leftTuple.removeStore(inputStoreIndexLeftEntry); - if (leftEntry == null) { + if (leftTuple.getStore(inputStoreIndexLeftPosition) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareList outTupleListLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleList); - leftEntry.remove(); - outTupleListLeft.forEach(this::retractOutTuple); + IndexedSet outTupleSetLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleSet); + leftTupleSet.remove(leftTuple); + outTupleSetLeft.forEach(this::retractOutTuple); } @Override public final void insertRight(UniTuple rightTuple) { - if (rightTuple.getStore(inputStoreIndexRightEntry) != null) { - throw new IllegalStateException("Impossible state: the input for the tuple (" + rightTuple - + ") was already added in the tupleStore."); - } - var rightEntry = rightTupleList.add(rightTuple); - rightTuple.setStore(inputStoreIndexRightEntry, rightEntry); - var outTupleListRight = new ElementAwareList(); - rightTuple.setStore(inputStoreIndexRightOutTupleList, outTupleListRight); - for (var tuple : leftTupleList) { - insertOutTupleFiltered(tuple, rightTuple); + if (rightTuple.getStore(inputStoreIndexRightPosition) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(rightTuple)); } + rightTupleSet.add(rightTuple); + var outTupleSetRight = new IndexedSet(new TuplePositionTracker<>(outputStoreIndexRightPosition)); + rightTuple.setStore(inputStoreIndexRightOutTupleSet, outTupleSetRight); + leftTupleSet.forEach(tuple -> insertOutTupleFiltered(tuple, rightTuple)); } @Override public final void updateRight(UniTuple rightTuple) { - ElementAwareListEntry> rightEntry = rightTuple.getStore(inputStoreIndexRightEntry); - if (rightEntry == null) { + if (rightTuple.getStore(inputStoreIndexRightPosition) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) insertRight(rightTuple); return; } - innerUpdateRight(rightTuple, leftTupleList::forEach); + innerUpdateRight(rightTuple, leftTupleSet::forEach); } @Override public final void retractRight(UniTuple rightTuple) { - ElementAwareListEntry> rightEntry = rightTuple.removeStore(inputStoreIndexRightEntry); - if (rightEntry == null) { + if (rightTuple.getStore(inputStoreIndexRightPosition) == null) { // No fail fast if null because we don't track which tuples made it through the filter predicate(s) return; } - ElementAwareList outTupleListRight = rightTuple.removeStore(inputStoreIndexRightOutTupleList); - rightEntry.remove(); - outTupleListRight.forEach(this::retractOutTuple); + IndexedSet outTupleSetRight = rightTuple.removeStore(inputStoreIndexRightOutTupleSet); + rightTupleSet.remove(rightTuple); + outTupleSetRight.forEach(this::retractOutTuple); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java new file mode 100644 index 0000000000..f6e40c734c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -0,0 +1,36 @@ +package ai.timefold.solver.core.impl.bavet.common; + +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; +import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; + +/** + * Used for filtering in {@link AbstractIfExistsNode}. + * There is no place where both left and right sets for each counter would be kept together, + * therefore we create this handle to avoid expensive iteration. + * (The alternative would be to look up things on the left when we have the right, and vice versa.) + * + * @param + */ +final class ExistsCounterHandle { + + final ExistsCounter counter; + private final IndexedSet> leftSet; + private final IndexedSet> rightSet; + int leftPosition = -1; + int rightPosition = -1; + + ExistsCounterHandle(ExistsCounter counter, IndexedSet> leftSet, + IndexedSet> rightSet) { + this.counter = counter; + this.leftSet = leftSet; + leftSet.add(this); + this.rightSet = rightSet; + rightSet.add(this); + } + + public void remove() { + leftSet.remove(this); + rightSet.remove(this); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java new file mode 100644 index 0000000000..d204fed047 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java @@ -0,0 +1,88 @@ +package ai.timefold.solver.core.impl.bavet.common; + +import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; +import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; + +import org.jspecify.annotations.NullMarked; + +@SuppressWarnings({ "rawtypes", "unchecked" }) +@NullMarked +record ExistsCounterHandlePositionTracker(PositionGetter positionGetter, + PositionClearer positionClearer, + PositionSetter positionSetter) + implements + ElementPositionTracker> { + + private static final ExistsCounterHandlePositionTracker LEFT = new ExistsCounterHandlePositionTracker( + tracker -> tracker.leftPosition, + tracker -> { + var result = tracker.leftPosition; + tracker.leftPosition = -1; + return result; + }, + (tracker, position) -> { + var oldValue = tracker.leftPosition; + tracker.leftPosition = position; + return oldValue; + }); + private static final ExistsCounterHandlePositionTracker RIGHT = new ExistsCounterHandlePositionTracker( + tracker -> tracker.rightPosition, + tracker -> { + var result = tracker.rightPosition; + tracker.rightPosition = -1; + return result; + }, + (tracker, position) -> { + var oldValue = tracker.rightPosition; + tracker.rightPosition = position; + return oldValue; + }); + + public static ExistsCounterHandlePositionTracker left() { + return LEFT; + } + + public static ExistsCounterHandlePositionTracker right() { + return RIGHT; + } + + @Override + public int getPosition(ExistsCounterHandle element) { + return positionGetter.apply(element); + } + + @Override + public int setPosition(ExistsCounterHandle element, int position) { + return positionSetter.apply(element, position); + } + + @Override + public int clearPosition(ExistsCounterHandle element) { + return positionClearer.apply(element); + } + + @FunctionalInterface + @NullMarked + interface PositionGetter { + + int apply(ExistsCounterHandle tracker); + + } + + @FunctionalInterface + @NullMarked + interface PositionClearer { + + int apply(ExistsCounterHandle tracker); + + } + + @FunctionalInterface + @NullMarked + interface PositionSetter { + + int apply(ExistsCounterHandle tracker, int position); + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java new file mode 100644 index 0000000000..0d52a39c73 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java @@ -0,0 +1,34 @@ +package ai.timefold.solver.core.impl.bavet.common; + +import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; +import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; + +record ExistsCounterPositionTracker(int inputStorePosition) + implements + ElementPositionTracker> { + + @Override + public int getPosition(ExistsCounter element) { + var tuple = element.getTuple(); + var value = tuple.getStore(inputStorePosition); + return value == null ? -1 : (int) value; + } + + @Override + public int setPosition(ExistsCounter element, int position) { + var tuple = element.getTuple(); + var oldValue = getPosition(element); + tuple.setStore(inputStorePosition, position); + return oldValue; + } + + @Override + public int clearPosition(ExistsCounter element) { + try { + return element.getTuple().removeStore(inputStorePosition); + } catch (NullPointerException e) { + return -1; + } + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java new file mode 100644 index 0000000000..a6e801db41 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.core.impl.bavet.common; + +import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; +import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; + +public record TuplePositionTracker(int inputStorePosition) + implements + ElementPositionTracker { + + @Override + public int getPosition(Tuple_ element) { + var value = element.getStore(inputStorePosition); + return value == null ? -1 : (int) value; + } + + @Override + public int setPosition(Tuple_ element, int position) { + var oldValue = getPosition(element); + element.setStore(inputStorePosition, position); + return oldValue; + } + + @Override + public int clearPosition(Tuple_ element) { + try { + return element.removeStore(inputStorePosition); + } catch (NullPointerException e) { + return -1; + } + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ComparisonIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ComparisonIndexer.java index 09c5102468..a783a10d31 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ComparisonIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ComparisonIndexer.java @@ -9,7 +9,6 @@ import java.util.function.Supplier; import ai.timefold.solver.core.impl.bavet.common.joiner.JoinerType; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; final class ComparisonIndexer> implements Indexer { @@ -26,8 +25,8 @@ final class ComparisonIndexer> * * @param comparisonJoinerType the type of comparison to use */ - public ComparisonIndexer(JoinerType comparisonJoinerType) { - this(comparisonJoinerType, new SingleKeyRetriever<>(), NoneIndexer::new); + public ComparisonIndexer(JoinerType comparisonJoinerType, ElementPositionTracker elementPositionTracker) { + this(comparisonJoinerType, new SingleKeyRetriever<>(), () -> new NoneIndexer<>(elementPositionTracker)); } /** @@ -61,7 +60,7 @@ private ComparisonIndexer(JoinerType comparisonJoinerType, KeyRetriever ke } @Override - public ElementAwareListEntry put(Object indexKeys, T tuple) { + public void put(Object indexKeys, T element) { Key_ indexKey = keyRetriever.apply(indexKeys); // Avoids computeIfAbsent in order to not create lambdas on the hot path. var downstreamIndexer = comparisonMap.get(indexKey); @@ -69,25 +68,25 @@ public ElementAwareListEntry put(Object indexKeys, T tuple) { downstreamIndexer = downstreamIndexerSupplier.get(); comparisonMap.put(indexKey, downstreamIndexer); } - return downstreamIndexer.put(indexKeys, tuple); + downstreamIndexer.put(indexKeys, element); } @Override - public void remove(Object indexKeys, ElementAwareListEntry entry) { + public void remove(Object indexKeys, T element) { Key_ indexKey = keyRetriever.apply(indexKeys); - var downstreamIndexer = getDownstreamIndexer(indexKeys, indexKey, entry); - downstreamIndexer.remove(indexKeys, entry); + var downstreamIndexer = getDownstreamIndexer(indexKeys, indexKey, element); + downstreamIndexer.remove(indexKeys, element); if (downstreamIndexer.isEmpty()) { comparisonMap.remove(indexKey); } } - private Indexer getDownstreamIndexer(Object indexKeys, Key_ indexerKey, ElementAwareListEntry entry) { + private Indexer getDownstreamIndexer(Object indexKeys, Key_ indexerKey, T entry) { var downstreamIndexer = comparisonMap.get(indexerKey); if (downstreamIndexer == null) { throw new IllegalStateException( "Impossible state: the tuple (%s) with indexKeys (%s) doesn't exist in the indexer %s." - .formatted(entry.getElement(), indexKeys, this)); + .formatted(entry, indexKeys, this)); } return downstreamIndexer; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java new file mode 100644 index 0000000000..f0c50f848b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java @@ -0,0 +1,36 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +/** + * Allows to read and modify the position of an element in an {@link IndexedSet}. + * Typically points to a field in the element itself. + * + * @param + */ +public interface ElementPositionTracker { + + /** + * Gets the position of the given element. + * + * @param element never null + * @return >= 0 if the element is tracked, or -1 if it is not tracked + */ + int getPosition(T element); + + /** + * Sets the position of the given element. + * + * @param element never null + * @param position >= 0 + * @return the previous position of the element, or -1 if it was not tracked before + */ + int setPosition(T element, int position); + + /** + * Clears the position of the given element. + * + * @param element never null + * @return the previous position of the element, or -1 if it was not tracked before + */ + int clearPosition(T element); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexer.java index f81d90c82a..365b4faf95 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexer.java @@ -6,8 +6,6 @@ import java.util.function.Consumer; import java.util.function.Supplier; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; - final class EqualsIndexer implements Indexer { private final KeyRetriever keyRetriever; @@ -18,9 +16,9 @@ final class EqualsIndexer implements Indexer { * Construct an {@link EqualsIndexer} which immediately ends in a {@link NoneIndexer}. * This means {@code indexKeys} must be a single key. */ - public EqualsIndexer() { + public EqualsIndexer(ElementPositionTracker elementPositionTracker) { this.keyRetriever = new SingleKeyRetriever<>(); - this.downstreamIndexerSupplier = NoneIndexer::new; + this.downstreamIndexerSupplier = () -> new NoneIndexer<>(elementPositionTracker); } /** @@ -36,7 +34,7 @@ public EqualsIndexer(int keyIndex, Supplier> downstreamIndexerSupplie } @Override - public ElementAwareListEntry put(Object indexKeys, T tuple) { + public void put(Object indexKeys, T element) { Key_ indexKey = keyRetriever.apply(indexKeys); // Avoids computeIfAbsent in order to not create lambdas on the hot path. Indexer downstreamIndexer = downstreamIndexerMap.get(indexKey); @@ -44,25 +42,25 @@ public ElementAwareListEntry put(Object indexKeys, T tuple) { downstreamIndexer = downstreamIndexerSupplier.get(); downstreamIndexerMap.put(indexKey, downstreamIndexer); } - return downstreamIndexer.put(indexKeys, tuple); + downstreamIndexer.put(indexKeys, element); } @Override - public void remove(Object indexKeys, ElementAwareListEntry entry) { + public void remove(Object indexKeys, T element) { Key_ indexKey = keyRetriever.apply(indexKeys); - Indexer downstreamIndexer = getDownstreamIndexer(indexKeys, indexKey, entry); - downstreamIndexer.remove(indexKeys, entry); + Indexer downstreamIndexer = getDownstreamIndexer(indexKeys, indexKey, element); + downstreamIndexer.remove(indexKeys, element); if (downstreamIndexer.isEmpty()) { downstreamIndexerMap.remove(indexKey); } } - private Indexer getDownstreamIndexer(Object indexKeys, Key_ indexerKey, ElementAwareListEntry entry) { + private Indexer getDownstreamIndexer(Object indexKeys, Key_ indexerKey, T entry) { Indexer downstreamIndexer = downstreamIndexerMap.get(indexerKey); if (downstreamIndexer == null) { throw new IllegalStateException( "Impossible state: the tuple (%s) with indexKey (%s) doesn't exist in the indexer %s." - .formatted(entry.getElement(), indexKeys, this)); + .formatted(entry, indexKeys, this)); } return downstreamIndexer; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java new file mode 100644 index 0000000000..e2bab41547 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -0,0 +1,176 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import ai.timefold.solver.core.impl.util.ElementAwareList; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element without knowing its position + * and without an expensive lookup. + *

+ * It uses an {@link ElementPositionTracker} to track the insertion position of each element. + * When an element is removed, the insertion position of later elements is not changed. + * Instead, when the next element is removed, the search starts from its last known insertion position, + * iterating backwards until the element is found. + * We also keep a counter of deleted elements to avoid excessive iteration; + * we can guarantee that the current position of an element will not be further away + * than the number of earlier deletions. + *

+ * Together with the fact that removals are relatively rare, + * this keeps the average removal cost low while giving us all benefits of {@link ArrayList}, + * such as memory efficiency, random access, and fast iteration. + * Random access is not required for Constraint Streams, but Neighborhoods make heavy use of it; + * if we used the {@link ElementAwareList} implementation instead, + * we would have to copy the elements to an array every time we need to access them randomly during move generation. + * + * + * @param + */ +@NullMarked +public final class IndexedSet { + + private final ElementPositionTracker elementPositionTracker; + private @Nullable ArrayList elementList; // Lazily initialized, so that empty indexes use no memory. + private int removalCount = 0; + + public IndexedSet(ElementPositionTracker elementPositionTracker) { + this.elementPositionTracker = Objects.requireNonNull(elementPositionTracker); + } + + private List getElementList() { + if (elementList == null) { + elementList = new ArrayList<>(); + } + return elementList; + } + + /** + * Appends the specified element to the end of this collection, if not already present. + * Will use identity comparison to check for presence; + * two different instances which {@link Object#equals(Object) equal} are considered different elements. + * + * @param element element to be appended to this collection + * @throws IllegalStateException if the element was already present in this collection + */ + public void add(T element) { + var actualElementList = getElementList(); + actualElementList.add(element); + if (elementPositionTracker.setPosition(element, actualElementList.size() - 1) >= 0) { + throw new IllegalStateException("Impossible state: the element (%s) was already added to the IndexedSet." + .formatted(element)); + } + } + + /** + * Removes the first occurrence of the specified element from this collection, if it is present. + * Will use identity comparison to check for presence; + * two different instances which {@link Object#equals(Object) equal} are considered different elements. + * + * @param element element to be removed from this collection + * @throws IllegalStateException if the element was not found in this collection + */ + public void remove(T element) { + if (!innerRemove(element)) { + throw new IllegalStateException("Impossible state: the element (%s) was not found in the IndexedSet." + .formatted(element)); + } + } + + private boolean innerRemove(T element) { + if (isEmpty()) { + return false; + } + var insertionPosition = elementPositionTracker.clearPosition(element); + if (insertionPosition < 0) { + return false; + } + var actualElementList = getElementList(); + var upperBound = Math.min(insertionPosition, actualElementList.size() - 1); + var lowerBound = Math.max(0, insertionPosition - removalCount); + var actualPosition = findElement(actualElementList, element, lowerBound, upperBound); + if (actualPosition < 0) { + return false; + } + actualElementList.remove(actualPosition); + if (isEmpty()) { + removalCount = 0; + } else if (actualElementList.size() > actualPosition) { + // We only mark removals that actually affect later elements. + // Removing the last element does not affect any other element. + removalCount++; + } + return true; + } + + /** + * Search for the element in the given range. + * + * @param actualElementList the list to search in + * @param element the element to search for + * @param startIndex start of the range we are currently considering (inclusive) + * @param endIndex end of the range we are currently considering (inclusive) + * @return the index of the element if found, -1 otherwise + */ + private static int findElement(List actualElementList, T element, int startIndex, int endIndex) { + for (var i = endIndex; i >= startIndex; i--) { + // Iterating backwards as the element is more likely to be closer to the end of the range, + // which is where it was originally inserted. + var maybeElement = actualElementList.get(i); + if (maybeElement == element) { + return i; + } + } + return -1; + } + + public int size() { + return elementList == null ? 0 : elementList.size(); + } + + /** + * Performs the given action for each element of the collection + * until all elements have been processed. + * + * @param tupleConsumer the action to be performed for each element + */ + public void forEach(Consumer tupleConsumer) { + if (elementList == null) { + return; + } + var i = 0; + while (i < elementList.size()) { + var oldRemovalCount = removalCount; // The consumer may remove some elements, shifting others forward. + tupleConsumer.accept(elementList.get(i)); + var elementDrift = removalCount - oldRemovalCount; + // Move to the next element, adjusting for any shifts due to removals. + // If no elements were removed by the consumer, we simply move to the next index. + i -= elementDrift - 1; + } + } + + public boolean isEmpty() { + return elementList == null || elementList.isEmpty(); + } + + /** + * Returns a standard {@link List} view of this collection. + * Users must not modify the returned list, as that would also change the underlying data structure. + * + * @return a standard list view of this element-aware list + */ + public List asList() { + return elementList == null ? Collections.emptyList() : elementList; + } + + public String toString() { + return elementList == null ? "[]" : elementList.toString(); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/Indexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/Indexer.java index add7191cf1..b1375e5d78 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/Indexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/Indexer.java @@ -3,7 +3,6 @@ import java.util.function.Consumer; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; /** * An indexer for entity or fact {@code X}, @@ -22,9 +21,9 @@ */ public sealed interface Indexer permits ComparisonIndexer, EqualsIndexer, NoneIndexer { - ElementAwareListEntry put(Object indexKeys, T tuple); + void put(Object indexKeys, T element); - void remove(Object indexKeys, ElementAwareListEntry entry); + void remove(Object indexKeys, T element); int size(Object indexKeys); 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 ce4eec540e..09f53fb401 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 @@ -473,34 +473,32 @@ public UniKeysExtractor buildRightKeysExtractor() { return buildUniKeysExtractor(joiner::getRightMapping); } - public Indexer buildIndexer(boolean isLeftBridge) { - /* - * Note that if creating indexer for a right bridge node, the joiner type has to be flipped. - * ( becomes .) - */ + public Indexer buildIndexer(boolean isLeftBridge, ElementPositionTracker elementPositionTracker) { + // Note that if creating indexer for a right bridge node, the joiner type has to be flipped. + // ( becomes .) if (!hasJoiners()) { // NoneJoiner results in NoneIndexer. - return new NoneIndexer<>(); + return new NoneIndexer<>(elementPositionTracker); } else if (joiner.getJoinerCount() == 1) { // Single joiner maps directly to EqualsIndexer or ComparisonIndexer. var joinerType = joiner.getJoinerType(0); if (joinerType == JoinerType.EQUAL) { - return new EqualsIndexer<>(); + return new EqualsIndexer<>(elementPositionTracker); } else { - return new ComparisonIndexer<>(isLeftBridge ? joinerType : joinerType.flip()); + return new ComparisonIndexer<>(isLeftBridge ? joinerType : joinerType.flip(), elementPositionTracker); } } // The following code builds the children first, so it needs to iterate over the joiners in reverse order. var descendingJoinerTypeMap = joinerTypeMap.descendingMap(); - Supplier> noneIndexerSupplier = NoneIndexer::new; + Supplier> noneIndexerSupplier = () -> new NoneIndexer<>(elementPositionTracker); Supplier> downstreamIndexerSupplier = noneIndexerSupplier; var indexPropertyId = descendingJoinerTypeMap.size() - 1; for (var entry : descendingJoinerTypeMap.entrySet()) { var joinerType = entry.getValue(); if (downstreamIndexerSupplier == noneIndexerSupplier && indexPropertyId == 0) { if (joinerType == JoinerType.EQUAL) { - downstreamIndexerSupplier = EqualsIndexer::new; + downstreamIndexerSupplier = () -> new EqualsIndexer<>(elementPositionTracker); } else { var actualJoinerType = isLeftBridge ? joinerType : joinerType.flip(); - downstreamIndexerSupplier = () -> new ComparisonIndexer<>(actualJoinerType); + downstreamIndexerSupplier = () -> new ComparisonIndexer<>(actualJoinerType, elementPositionTracker); } } else { var actualDownstreamIndexerSupplier = downstreamIndexerSupplier; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexer.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexer.java index 88d060df76..277c2095ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexer.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexer.java @@ -2,41 +2,42 @@ import java.util.function.Consumer; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; - public final class NoneIndexer implements Indexer { - private final ElementAwareList tupleList = new ElementAwareList<>(); + private final IndexedSet store; + + public NoneIndexer(ElementPositionTracker elementPositionTracker) { + this.store = new IndexedSet<>(elementPositionTracker); + } @Override - public ElementAwareListEntry put(Object indexKeys, T tuple) { - return tupleList.add(tuple); + public void put(Object indexKeys, T element) { + store.add(element); } @Override - public void remove(Object indexKeys, ElementAwareListEntry entry) { - entry.remove(); + public void remove(Object indexKeys, T element) { + store.remove(element); } @Override public int size(Object indexKeys) { - return tupleList.size(); + return store.size(); } @Override public void forEach(Object indexKeys, Consumer tupleConsumer) { - tupleList.forEach(tupleConsumer); + store.forEach(tupleConsumer); } @Override public boolean isEmpty() { - return tupleList.size() == 0; + return store.isEmpty(); } @Override public String toString() { - return "size = " + tupleList.size(); + return "size = " + store.size(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/OutputStoreSizeTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/OutputStoreSizeTracker.java new file mode 100644 index 0000000000..b9e9a3e66d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/OutputStoreSizeTracker.java @@ -0,0 +1,46 @@ +package ai.timefold.solver.core.impl.bavet.common.tuple; + +/** + * Tracks the size of the output store by allowing reservations of positions. + * Once the final size is computed, no further reservations can be made + * and output tuples will be created using this size of their {@link AbstractTuple tuple store}. + */ +public final class OutputStoreSizeTracker implements TupleStorePositionTracker { + + private int effectiveOutputStoreSize; + private int finalOutputStoreSize = -1; + + public OutputStoreSizeTracker(int initialSize) { + if (initialSize < 0) { + throw new IllegalArgumentException( + "Impossible state: The initialSize (%d) must be non-negative.".formatted(initialSize)); + } + this.effectiveOutputStoreSize = initialSize; + } + + /** + * @return the next available position in the output store, reserved exclusively for use by the caller + * @throws IllegalStateException if {@link #computeOutputStoreSize()} has already been called. + */ + @Override + public int reserveNextAvailablePosition() { + if (finalOutputStoreSize >= 0) { + throw new IllegalStateException("Impossible state: The finalOutputStoreSize (%s) has already been computed." + .formatted(finalOutputStoreSize)); + } + return effectiveOutputStoreSize++; + } + + /** + * Finalizes the output store size and prevents further reservations. + * + * @return the final output store size + */ + public int computeOutputStoreSize() { + if (finalOutputStoreSize < 0) { + finalOutputStoreSize = effectiveOutputStoreSize; + } + return finalOutputStoreSize; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/TupleStorePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/TupleStorePositionTracker.java new file mode 100644 index 0000000000..87a34cf448 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/tuple/TupleStorePositionTracker.java @@ -0,0 +1,7 @@ +package ai.timefold.solver.core.impl.bavet.common.tuple; + +public interface TupleStorePositionTracker { + + int reserveNextAvailablePosition(); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedIfExistsQuadNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedIfExistsQuadNode.java index 3503e7fd0d..7f282d844d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedIfExistsQuadNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedIfExistsQuadNode.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedIfExistsQuadNode extends AbstractIndexedIfExistsNode, E> { @@ -12,23 +13,10 @@ public final class IndexedIfExistsQuadNode extends AbstractIndexe private final PentaPredicate filtering; public IndexedIfExistsQuadNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, -1, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public IndexedIfExistsQuadNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, PentaPredicate filtering) { - super(shouldExist, indexerFactory.buildQuadLeftKeysExtractor(), indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, indexerFactory.buildQuadLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedJoinQuadNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedJoinQuadNode.java index adc92c74d5..2e583a0657 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedJoinQuadNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/IndexedJoinQuadNode.java @@ -3,35 +3,30 @@ import ai.timefold.solver.core.api.function.QuadPredicate; import ai.timefold.solver.core.impl.bavet.common.AbstractIndexedJoinNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedJoinQuadNode extends AbstractIndexedJoinNode, D, QuadTuple> { private final QuadPredicate filtering; - private final int outputStoreSize; - - public IndexedJoinQuadNode(IndexerFactory indexerFactory, - int inputStoreIndexABC, int inputStoreIndexEntryABC, int inputStoreIndexOutTupleListABC, - int inputStoreIndexD, int inputStoreIndexEntryD, int inputStoreIndexOutTupleListD, - TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering, - int outputStoreSize, int outputStoreIndexOutEntryABC, int outputStoreIndexOutEntryD) { - super(indexerFactory.buildTriLeftKeysExtractor(), indexerFactory, - inputStoreIndexABC, inputStoreIndexEntryABC, inputStoreIndexOutTupleListABC, - inputStoreIndexD, inputStoreIndexEntryD, inputStoreIndexOutTupleListD, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexOutEntryABC, outputStoreIndexOutEntryD); + + public IndexedJoinQuadNode(IndexerFactory indexerFactory, TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering) { + super(indexerFactory.buildTriLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected QuadTuple createOutTuple(TriTuple leftTuple, UniTuple rightTuple) { return new QuadTuple<>(leftTuple.factA, leftTuple.factB, leftTuple.factC, rightTuple.factA, - outputStoreSize); + outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedIfExistsQuadNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedIfExistsQuadNode.java index 88de613585..4de6ab2ec3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedIfExistsQuadNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedIfExistsQuadNode.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedIfExistsQuadNode extends AbstractUnindexedIfExistsNode, E> { @@ -11,21 +12,11 @@ public final class UnindexedIfExistsQuadNode extends AbstractUnin private final PentaPredicate filtering; public UnindexedIfExistsQuadNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, - inputStoreIndexLeftCounterEntry, -1, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public UnindexedIfExistsQuadNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, int inputStoreIndexRightEntry, - int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, PentaPredicate filtering) { super(shouldExist, - inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, inputStoreIndexRightEntry, - inputStoreIndexRightTrackerList, + leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedJoinQuadNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedJoinQuadNode.java index 96111c2bc3..8aeb285ced 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedJoinQuadNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/quad/UnindexedJoinQuadNode.java @@ -2,35 +2,30 @@ import ai.timefold.solver.core.api.function.QuadPredicate; import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedJoinNode; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedJoinQuadNode extends AbstractUnindexedJoinNode, D, QuadTuple> { private final QuadPredicate filtering; - private final int outputStoreSize; - - public UnindexedJoinQuadNode( - int inputStoreIndexLeftEntry, int inputStoreIndexLeftOutTupleList, - int inputStoreIndexRightEntry, int inputStoreIndexRightOutTupleList, - TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering, - int outputStoreSize, - int outputStoreIndexLeftOutEntry, int outputStoreIndexRightOutEntry) { - super(inputStoreIndexLeftEntry, inputStoreIndexLeftOutTupleList, - inputStoreIndexRightEntry, inputStoreIndexRightOutTupleList, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexLeftOutEntry, outputStoreIndexRightOutEntry); + + public UnindexedJoinQuadNode(TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering) { + super(leftTupleStorePositionTracker, rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected QuadTuple createOutTuple(TriTuple leftTuple, UniTuple rightTuple) { return new QuadTuple<>(leftTuple.factA, leftTuple.factB, leftTuple.factC, rightTuple.factA, - outputStoreSize); + outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedIfExistsTriNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedIfExistsTriNode.java index e061769494..65e942d9a5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedIfExistsTriNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedIfExistsTriNode.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedIfExistsTriNode extends AbstractIndexedIfExistsNode, D> { @@ -12,23 +13,10 @@ public final class IndexedIfExistsTriNode extends AbstractIndexedIfE private final QuadPredicate filtering; public IndexedIfExistsTriNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, -1, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public IndexedIfExistsTriNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering) { - super(shouldExist, indexerFactory.buildTriLeftKeysExtractor(), indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, indexerFactory.buildTriLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedJoinTriNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedJoinTriNode.java index 3751f3bd4f..d14b25f8a0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedJoinTriNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/IndexedJoinTriNode.java @@ -4,33 +4,29 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractIndexedJoinNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedJoinTriNode extends AbstractIndexedJoinNode, C, TriTuple> { private final TriPredicate filtering; - private final int outputStoreSize; - - public IndexedJoinTriNode(IndexerFactory indexerFactory, - int inputStoreIndexAB, int inputStoreIndexEntryAB, int inputStoreIndexOutTupleListAB, - int inputStoreIndexC, int inputStoreIndexEntryC, int inputStoreIndexOutTupleListC, - TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering, - int outputStoreSize, int outputStoreIndexOutEntryAB, int outputStoreIndexOutEntryC) { - super(indexerFactory.buildBiLeftKeysExtractor(), indexerFactory, - inputStoreIndexAB, inputStoreIndexEntryAB, inputStoreIndexOutTupleListAB, - inputStoreIndexC, inputStoreIndexEntryC, inputStoreIndexOutTupleListC, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexOutEntryAB, outputStoreIndexOutEntryC); + + public IndexedJoinTriNode(IndexerFactory indexerFactory, TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering) { + super(indexerFactory.buildBiLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected TriTuple createOutTuple(BiTuple leftTuple, UniTuple rightTuple) { - return new TriTuple<>(leftTuple.factA, leftTuple.factB, rightTuple.factA, outputStoreSize); + return new TriTuple<>(leftTuple.factA, leftTuple.factB, rightTuple.factA, + outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedIfExistsTriNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedIfExistsTriNode.java index b83d7623f1..99d2607339 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedIfExistsTriNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedIfExistsTriNode.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedIfExistsTriNode extends AbstractUnindexedIfExistsNode, D> { @@ -11,22 +12,11 @@ public final class UnindexedIfExistsTriNode extends AbstractUnindexe private final QuadPredicate filtering; public UnindexedIfExistsTriNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, - inputStoreIndexLeftCounterEntry, -1, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public UnindexedIfExistsTriNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, int inputStoreIndexRightEntry, - int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, QuadPredicate filtering) { - super(shouldExist, - inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, inputStoreIndexRightEntry, - inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedJoinTriNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedJoinTriNode.java index 0755e70dc5..dfcbc68b95 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedJoinTriNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/tri/UnindexedJoinTriNode.java @@ -3,33 +3,29 @@ import ai.timefold.solver.core.api.function.TriPredicate; import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedJoinNode; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedJoinTriNode extends AbstractUnindexedJoinNode, C, TriTuple> { private final TriPredicate filtering; - private final int outputStoreSize; - - public UnindexedJoinTriNode( - int inputStoreIndexLeftEntry, int inputStoreIndexLeftOutTupleList, - int inputStoreIndexRightEntry, int inputStoreIndexRightOutTupleList, - TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering, - int outputStoreSize, - int outputStoreIndexLeftOutEntry, int outputStoreIndexRightOutEntry) { - super(inputStoreIndexLeftEntry, inputStoreIndexLeftOutTupleList, - inputStoreIndexRightEntry, inputStoreIndexRightOutTupleList, - nextNodesTupleLifecycle, filtering != null, - outputStoreIndexLeftOutEntry, outputStoreIndexRightOutEntry); + + public UnindexedJoinTriNode(TupleStorePositionTracker leftTupleStorePositionTracker, + TupleStorePositionTracker rightTupleStorePositionTracker, OutputStoreSizeTracker outputStoreSizeTracker, + TupleLifecycle> nextNodesTupleLifecycle, TriPredicate filtering) { + super(leftTupleStorePositionTracker, rightTupleStorePositionTracker, outputStoreSizeTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; - this.outputStoreSize = outputStoreSize; } @Override protected TriTuple createOutTuple(BiTuple leftTuple, UniTuple rightTuple) { - return new TriTuple<>(leftTuple.factA, leftTuple.factB, rightTuple.factA, outputStoreSize); + return new TriTuple<>(leftTuple.factA, leftTuple.factB, rightTuple.factA, + outputStoreSizeTracker.computeOutputStoreSize()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/IndexedIfExistsUniNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/IndexedIfExistsUniNode.java index 0f7860615f..2b589deff3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/IndexedIfExistsUniNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/IndexedIfExistsUniNode.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractIndexedIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class IndexedIfExistsUniNode extends AbstractIndexedIfExistsNode, B> { @@ -12,23 +13,10 @@ public final class IndexedIfExistsUniNode extends AbstractIndexedIfExistsN private final BiPredicate filtering; public IndexedIfExistsUniNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, -1, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public IndexedIfExistsUniNode(boolean shouldExist, IndexerFactory indexerFactory, - int inputStoreIndexLeftKeys, int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, - int inputStoreIndexRightKeys, int inputStoreIndexRightEntry, int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering) { - super(shouldExist, indexerFactory.buildUniLeftKeysExtractor(), indexerFactory, - inputStoreIndexLeftKeys, inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, - inputStoreIndexRightKeys, inputStoreIndexRightEntry, inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, indexerFactory.buildUniLeftKeysExtractor(), indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, nextNodesTupleLifecycle, filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/UnindexedIfExistsUniNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/UnindexedIfExistsUniNode.java index 3d8709b282..04721aba36 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/UnindexedIfExistsUniNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/uni/UnindexedIfExistsUniNode.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractUnindexedIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; public final class UnindexedIfExistsUniNode extends AbstractUnindexedIfExistsNode, B> { @@ -11,22 +12,11 @@ public final class UnindexedIfExistsUniNode extends AbstractUnindexedIfExi private final BiPredicate filtering; public UnindexedIfExistsUniNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexRightEntry, - TupleLifecycle> nextNodesTupleLifecycle) { - this(shouldExist, - inputStoreIndexLeftCounterEntry, -1, inputStoreIndexRightEntry, -1, - nextNodesTupleLifecycle, null); - } - - public UnindexedIfExistsUniNode(boolean shouldExist, - int inputStoreIndexLeftCounterEntry, int inputStoreIndexLeftTrackerList, int inputStoreIndexRightEntry, - int inputStoreIndexRightTrackerList, + TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle> nextNodesTupleLifecycle, BiPredicate filtering) { - super(shouldExist, - inputStoreIndexLeftCounterEntry, inputStoreIndexLeftTrackerList, inputStoreIndexRightEntry, - inputStoreIndexRightTrackerList, - nextNodesTupleLifecycle, filtering != null); + super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, + filtering != null); this.filtering = filtering; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/JoinBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/JoinBiEnumeratingStream.java index e70b1afdc2..4a527ef433 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/JoinBiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/JoinBiEnumeratingStream.java @@ -7,7 +7,9 @@ import ai.timefold.solver.core.impl.bavet.bi.UnindexedJoinBiNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; @@ -45,26 +47,18 @@ public void collectActiveEnumeratingStreams(Set buildHelper) { var solutionView = buildHelper.getSessionContext().solutionView(); var filteringDataJoiner = this.filtering == null ? null : this.filtering.toBiPredicate(solutionView); - var outputStoreSize = buildHelper.extractTupleStoreSize(this); TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); var indexerFactory = new IndexerFactory<>(joiner.toBiJoiner()); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + OutputStoreSizeTracker outputStoreSizeTracker = new OutputStoreSizeTracker(buildHelper.extractTupleStoreSize(this)); var node = indexerFactory.hasJoiners() - ? new IndexedJoinBiNode<>(indexerFactory, - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filteringDataJoiner, - outputStoreSize + 2, outputStoreSize, outputStoreSize + 1) - : new UnindexedJoinBiNode<>( - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filteringDataJoiner, - outputStoreSize + 2, outputStoreSize, outputStoreSize + 1); + ? new IndexedJoinBiNode<>(indexerFactory, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filteringDataJoiner) + : new UnindexedJoinBiNode<>(leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filteringDataJoiner); buildHelper.addNode(node, this, leftParent, rightParent); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/IfExistsUniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/IfExistsUniEnumeratingStream.java index 0664f4523c..b0ab4f1b07 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/IfExistsUniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/IfExistsUniEnumeratingStream.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.impl.bavet.common.AbstractIfExistsNode; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.bavet.uni.IndexedIfExistsUniNode; import ai.timefold.solver.core.impl.bavet.uni.UnindexedIfExistsUniNode; @@ -63,35 +64,18 @@ private AbstractIfExistsNode, B> getNode(IndexerFactory indexerFa DataNodeBuildHelper buildHelper, TupleLifecycle> downstream) { var sessionContext = buildHelper.getSessionContext(); var isFiltering = filtering != null; + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()); if (indexerFactory.hasJoiners()) { - if (isFiltering) { - return new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream, filtering.toBiPredicate(sessionContext.solutionView())); - } else { - return new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream); - } - } else if (isFiltering) { - return new UnindexedIfExistsUniNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream, filtering.toBiPredicate(sessionContext.solutionView())); + return new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, downstream, + isFiltering ? filtering.toBiPredicate(sessionContext.solutionView()) : null); } else { - return new UnindexedIfExistsUniNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), downstream); + return new UnindexedIfExistsUniNode<>(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + downstream, + isFiltering ? filtering.toBiPredicate(sessionContext.solutionView()) : null); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetIfExistsBiConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetIfExistsBiConstraintStream.java index 1291db1aa7..7e0e3bcbd2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetIfExistsBiConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetIfExistsBiConstraintStream.java @@ -11,6 +11,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.tri.joiner.DefaultTriJoiner; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintFactory; import ai.timefold.solver.core.impl.score.stream.bavet.common.BavetIfExistsConstraintStream; @@ -66,30 +67,15 @@ public BavetAbstractConstraintStream getTupleSource() { public > void buildNode(ConstraintNodeBuildHelper buildHelper) { TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()); var node = indexerFactory.hasJoiners() - ? (filtering == null ? new IndexedIfExistsBiNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - downstream) - : new IndexedIfExistsBiNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - downstream, filtering)) - : (filtering == null ? new UnindexedIfExistsBiNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), downstream) - : new UnindexedIfExistsBiNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentAB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeC.getTupleSource()), - downstream, filtering)); + ? new IndexedIfExistsBiNode<>(shouldExist, indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, downstream, filtering) + : new UnindexedIfExistsBiNode<>(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + downstream, filtering); buildHelper.addNode(node, this, this, parentBridgeC); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetJoinBiConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetJoinBiConstraintStream.java index 8df0b6a407..c04dfd736e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetJoinBiConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetJoinBiConstraintStream.java @@ -11,7 +11,9 @@ import ai.timefold.solver.core.impl.bavet.common.BavetAbstractConstraintStream; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.score.stream.bavet.BavetConstraintFactory; import ai.timefold.solver.core.impl.score.stream.bavet.common.BavetJoinConstraintStream; import ai.timefold.solver.core.impl.score.stream.bavet.common.ConstraintNodeBuildHelper; @@ -54,26 +56,18 @@ public void collectActiveConstraintStreams(Set> void buildNode(ConstraintNodeBuildHelper buildHelper) { - int outputStoreSize = buildHelper.extractTupleStoreSize(this); TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + OutputStoreSizeTracker outputStoreSizeTracker = new OutputStoreSizeTracker(buildHelper.extractTupleStoreSize(this)); var node = indexerFactory.hasJoiners() - ? new IndexedJoinBiNode<>(indexerFactory, - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1) - : new UnindexedJoinBiNode<>( - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1); + ? new IndexedJoinBiNode<>(indexerFactory, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering) + : new UnindexedJoinBiNode<>(leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering); buildHelper.addNode(node, this, leftParent, rightParent); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetIfExistsQuadConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetIfExistsQuadConstraintStream.java index 0225a7f1d4..e06ce7500d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetIfExistsQuadConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetIfExistsQuadConstraintStream.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.penta.joiner.DefaultPentaJoiner; import ai.timefold.solver.core.impl.bavet.quad.IndexedIfExistsQuadNode; import ai.timefold.solver.core.impl.bavet.quad.UnindexedIfExistsQuadNode; @@ -66,30 +67,15 @@ public BavetAbstractConstraintStream getTupleSource() { public > void buildNode(ConstraintNodeBuildHelper buildHelper) { TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()); var node = indexerFactory.hasJoiners() - ? (filtering == null ? new IndexedIfExistsQuadNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - downstream) - : new IndexedIfExistsQuadNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - downstream, filtering)) - : (filtering == null ? new UnindexedIfExistsQuadNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), downstream) - : new UnindexedIfExistsQuadNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABCD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeE.getTupleSource()), - downstream, filtering)); + ? new IndexedIfExistsQuadNode<>(shouldExist, indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, downstream, filtering) + : new UnindexedIfExistsQuadNode<>(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + downstream, filtering); buildHelper.addNode(node, this, this, parentBridgeE); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetJoinQuadConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetJoinQuadConstraintStream.java index 69e39b4e54..cf0f9d873c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetJoinQuadConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetJoinQuadConstraintStream.java @@ -7,8 +7,10 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.bavet.common.BavetAbstractConstraintStream; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.QuadTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.quad.IndexedJoinQuadNode; import ai.timefold.solver.core.impl.bavet.quad.UnindexedJoinQuadNode; import ai.timefold.solver.core.impl.bavet.quad.joiner.DefaultQuadJoiner; @@ -57,26 +59,18 @@ public void collectActiveConstraintStreams(Set> void buildNode(ConstraintNodeBuildHelper buildHelper) { - int outputStoreSize = buildHelper.extractTupleStoreSize(this); TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + OutputStoreSizeTracker outputStoreSizeTracker = new OutputStoreSizeTracker(buildHelper.extractTupleStoreSize(this)); var node = indexerFactory.hasJoiners() - ? new IndexedJoinQuadNode<>(indexerFactory, - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1) - : new UnindexedJoinQuadNode<>( - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1); + ? new IndexedJoinQuadNode<>(indexerFactory, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering) + : new UnindexedJoinQuadNode<>(leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering); buildHelper.addNode(node, this, leftParent, rightParent); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetIfExistsTriConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetIfExistsTriConstraintStream.java index 83ff4355f4..4fb8128b4f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetIfExistsTriConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetIfExistsTriConstraintStream.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.quad.joiner.DefaultQuadJoiner; import ai.timefold.solver.core.impl.bavet.tri.IndexedIfExistsTriNode; import ai.timefold.solver.core.impl.bavet.tri.UnindexedIfExistsTriNode; @@ -66,30 +67,15 @@ public BavetAbstractConstraintStream getTupleSource() { public > void buildNode(ConstraintNodeBuildHelper buildHelper) { TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()); var node = indexerFactory.hasJoiners() - ? (filtering == null ? new IndexedIfExistsTriNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - downstream) - : new IndexedIfExistsTriNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - downstream, filtering)) - : (filtering == null ? new UnindexedIfExistsTriNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), downstream) - : new UnindexedIfExistsTriNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentABC.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeD.getTupleSource()), - downstream, filtering)); + ? new IndexedIfExistsTriNode<>(shouldExist, indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, downstream, filtering) + : new UnindexedIfExistsTriNode<>(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + downstream, filtering); buildHelper.addNode(node, this, this, parentBridgeD); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetJoinTriConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetJoinTriConstraintStream.java index 8303c34a00..090467c6f8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetJoinTriConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetJoinTriConstraintStream.java @@ -7,8 +7,10 @@ import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.bavet.common.BavetAbstractConstraintStream; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; +import ai.timefold.solver.core.impl.bavet.common.tuple.OutputStoreSizeTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.TriTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.tri.IndexedJoinTriNode; import ai.timefold.solver.core.impl.bavet.tri.UnindexedJoinTriNode; import ai.timefold.solver.core.impl.bavet.tri.joiner.DefaultTriJoiner; @@ -57,26 +59,18 @@ public void collectActiveConstraintStreams(Set> void buildNode(ConstraintNodeBuildHelper buildHelper) { - int outputStoreSize = buildHelper.extractTupleStoreSize(this); TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + OutputStoreSizeTracker outputStoreSizeTracker = new OutputStoreSizeTracker(buildHelper.extractTupleStoreSize(this)); var node = indexerFactory.hasJoiners() - ? new IndexedJoinTriNode<>(indexerFactory, - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1) - : new UnindexedJoinTriNode<>( - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()), - downstream, filtering, outputStoreSize + 2, - outputStoreSize, outputStoreSize + 1); + ? new IndexedJoinTriNode<>(indexerFactory, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering) + : new UnindexedJoinTriNode<>(leftTupleStorePositionTracker, rightTupleStorePositionTracker, + outputStoreSizeTracker, downstream, filtering); buildHelper.addNode(node, this, leftParent, rightParent); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/uni/BavetIfExistsUniConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/uni/BavetIfExistsUniConstraintStream.java index f74a234858..91fb548b36 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/uni/BavetIfExistsUniConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/uni/BavetIfExistsUniConstraintStream.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.impl.bavet.common.BavetAbstractConstraintStream; import ai.timefold.solver.core.impl.bavet.common.index.IndexerFactory; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.bavet.uni.IndexedIfExistsUniNode; import ai.timefold.solver.core.impl.bavet.uni.UnindexedIfExistsUniNode; @@ -65,30 +66,15 @@ public BavetAbstractConstraintStream getTupleSource() { public > void buildNode(ConstraintNodeBuildHelper buildHelper) { TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); IndexerFactory indexerFactory = new IndexerFactory<>(joiner); + TupleStorePositionTracker leftTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()); + TupleStorePositionTracker rightTupleStorePositionTracker = + () -> buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()); var node = indexerFactory.hasJoiners() - ? (filtering == null ? new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream) - : new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream, filtering)) - : (filtering == null ? new UnindexedIfExistsUniNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), downstream) - : new UnindexedIfExistsUniNode<>(shouldExist, - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentA.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - buildHelper.reserveTupleStoreIndex(parentBridgeB.getTupleSource()), - downstream, filtering)); + ? new IndexedIfExistsUniNode<>(shouldExist, indexerFactory, leftTupleStorePositionTracker, + rightTupleStorePositionTracker, downstream, filtering) + : new UnindexedIfExistsUniNode<>(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, + downstream, filtering); buildHelper.addNode(node, this, this, parentBridgeB); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsAndComparisonIndexerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsAndComparisonIndexerTest.java index 63330f8193..789e6eb605 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsAndComparisonIndexerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsAndComparisonIndexerTest.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.api.score.stream.Joiners; import ai.timefold.solver.core.impl.bavet.bi.joiner.DefaultBiJoiner; +import ai.timefold.solver.core.impl.bavet.common.TuplePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import org.junit.jupiter.api.Test; @@ -14,16 +15,17 @@ class EqualsAndComparisonIndexerTest extends AbstractIndexerTest { private final DefaultBiJoiner joiner = (DefaultBiJoiner) Joiners.equal(Person::gender) .and(Joiners.lessThanOrEqual(Person::age)); + private final ElementPositionTracker> positionFunction = new TuplePositionTracker<>(0); @Test void iEmpty() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); assertThat(getTuples(indexer, "F", 40)).isEmpty(); } @Test void put() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); assertThat(indexer.size(IndexKeys.ofMany("F", 40))).isEqualTo(0); indexer.put(IndexKeys.ofMany("F", 40), annTuple); @@ -32,18 +34,18 @@ void put() { @Test void removeTwice() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); - var annEntry = indexer.put(IndexKeys.ofMany("F", 40), annTuple); + indexer.put(IndexKeys.ofMany("F", 40), annTuple); - indexer.remove(IndexKeys.ofMany("F", 40), annEntry); - assertThatThrownBy(() -> indexer.remove(IndexKeys.ofMany("F", 40), annEntry)) + indexer.remove(IndexKeys.ofMany("F", 40), annTuple); + assertThatThrownBy(() -> indexer.remove(IndexKeys.ofMany("F", 40), annTuple)) .isInstanceOf(IllegalStateException.class); } @Test void visit() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); indexer.put(IndexKeys.ofMany("F", 40), annTuple); @@ -61,7 +63,7 @@ void visit() { } private static UniTuple newTuple(String factA) { - return new UniTuple<>(factA, 0); + return new UniTuple<>(factA, 1); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexerTest.java index 54f9bc0dd0..ef40f86d11 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/EqualsIndexerTest.java @@ -5,6 +5,7 @@ import ai.timefold.solver.core.api.score.stream.Joiners; import ai.timefold.solver.core.impl.bavet.bi.joiner.DefaultBiJoiner; +import ai.timefold.solver.core.impl.bavet.common.TuplePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.util.Pair; @@ -15,16 +16,17 @@ class EqualsIndexerTest extends AbstractIndexerTest { private final DefaultBiJoiner joiner = (DefaultBiJoiner) Joiners.equal(Person::gender) .and(Joiners.equal(Person::age)); + private final ElementPositionTracker> positionFunction = new TuplePositionTracker<>(0); @Test void isEmpty() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); assertThat(getTuples(indexer, "F", 40)).isEmpty(); } @Test void put() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); assertThat(indexer.size(IndexKeys.ofMany("F", 40))).isEqualTo(0); indexer.put(IndexKeys.ofMany("F", 40), annTuple); @@ -33,18 +35,18 @@ void put() { @Test void removeTwice() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); - var annEntry = indexer.put(IndexKeys.ofMany("F", 40), annTuple); + indexer.put(IndexKeys.ofMany("F", 40), annTuple); - indexer.remove(IndexKeys.ofMany("F", 40), annEntry); - assertThatThrownBy(() -> indexer.remove(IndexKeys.ofMany("F", 40), annEntry)) + indexer.remove(IndexKeys.ofMany("F", 40), annTuple); + assertThatThrownBy(() -> indexer.remove(IndexKeys.ofMany("F", 40), annTuple)) .isInstanceOf(IllegalStateException.class); } @Test void visit() { - var indexer = new IndexerFactory<>(joiner).buildIndexer(true); + var indexer = new IndexerFactory<>(joiner).buildIndexer(true, positionFunction); var annTuple = newTuple("Ann-F-40"); indexer.put(IndexKeys.of(new Pair<>("F", 40)), annTuple); @@ -61,7 +63,7 @@ void visit() { } private static UniTuple newTuple(String factA) { - return new UniTuple<>(factA, 0); + return new UniTuple<>(factA, 1); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java new file mode 100644 index 0000000000..8c75058fbc --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -0,0 +1,245 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class IndexedSetTest { + + private final ElementPositionTracker stringTracker = new SimpleTracker<>(); + + @Test + void addMultipleElements() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactly("A", "B", "C"); + } + + @Test + void addDuplicateElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + assertThatThrownBy(() -> set.add("A")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was already added"); + } + + @Test + void removeElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.remove("A"); + + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + } + + @Test + void removeNonExistentElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + assertThatThrownBy(() -> set.remove("B")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } + + @Test + void removeFromEmptySet() { + var set = new IndexedSet<>(stringTracker); + + assertThatThrownBy(() -> set.remove("A")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } + + @Test + void removeMiddleElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("A", "C"); + } + + @Test + void removeLastElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("C"); + + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("A", "B"); + } + + @Test + void removeFirstElement() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("A"); + + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("B", "C"); + } + + @Test + void multipleRemovalsAndAdditions() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + set.add("D"); + set.remove("A"); + set.add("E"); + + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactly("C", "D", "E"); + } + + @Test + void forEach() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + var result = new ArrayList(); + set.forEach(result::add); + + assertThat(result).containsExactly("A", "B", "C"); + } + + @Test + void forEachOnEmptySet() { + var set = new IndexedSet<>(stringTracker); + + var result = new ArrayList(); + set.forEach(result::add); + + assertThat(result).isEmpty(); + } + + @Test + void forEachWithRemoval() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + + var result = new ArrayList(); + set.forEach(element -> { + result.add(element); + if (element.equals("B")) { + set.remove("B"); + } + }); + + assertThat(result).containsExactly("A", "B", "C", "D"); + assertThat(set.asList()).containsExactly("A", "C", "D"); + } + + @Test + void isEmpty() { + var set = new IndexedSet<>(stringTracker); + + assertThat(set.isEmpty()).isTrue(); + + set.add("A"); + assertThat(set.isEmpty()).isFalse(); + + set.remove("A"); + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void asListOnEmptySet() { + var set = new IndexedSet<>(stringTracker); + + assertThat(set.asList()).isEmpty(); + } + + @Test + void toStringOnEmptySet() { + var set = new IndexedSet<>(stringTracker); + + assertThat(set.toString()).isEqualTo("[]"); + } + + @Test + void toStringWithElements() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + + assertThat(set.toString()).isEqualTo("[A, B]"); + } + + @Test + void largeSetWithManyRemovals() { + var intTracker = new SimpleTracker(); + var set = new IndexedSet<>(intTracker); + + for (int i = 0; i < 100; i++) { + set.add(i); + } + + for (int i = 0; i < 50; i++) { + set.remove(i * 2); + } + + assertThat(set.size()).isEqualTo(50); + for (int i = 0; i < 50; i++) { + assertThat(set.asList()).contains(i * 2 + 1); + } + } + + private static final class SimpleTracker implements ElementPositionTracker { + + private final Map positions = new HashMap<>(); + + @Override + public int setPosition(T element, int position) { + return positions.put(element, position) == null ? -1 : position; + } + + @Override + public int getPosition(T element) { + return positions.getOrDefault(element, -1); + } + + @Override + public int clearPosition(T element) { + var position = positions.remove(element); + return position == null ? -1 : position; + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexerTest.java index 2f1ea45a70..8d0aaf3863 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/NoneIndexerTest.java @@ -4,15 +4,18 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import ai.timefold.solver.core.impl.bavet.common.TuplePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import org.junit.jupiter.api.Test; class NoneIndexerTest extends AbstractIndexerTest { + private final ElementPositionTracker> elementPositionTracker = new TuplePositionTracker<>(0); + @Test void isEmpty() { - var indexer = new NoneIndexer<>(); + var indexer = new NoneIndexer<>(elementPositionTracker); assertSoftly(softly -> { softly.assertThat(getTuples(indexer)).isEmpty(); softly.assertThat(indexer.isEmpty()).isTrue(); @@ -21,7 +24,7 @@ void isEmpty() { @Test void put() { - var indexer = new NoneIndexer<>(); + var indexer = new NoneIndexer<>(elementPositionTracker); var annTuple = newTuple("Ann-F-40"); assertThat(indexer.size(IndexKeys.none())).isEqualTo(0); indexer.put(IndexKeys.none(), annTuple); @@ -34,26 +37,26 @@ void put() { @Test void removeTwice() { - var indexer = new NoneIndexer<>(); + var indexer = new NoneIndexer<>(elementPositionTracker); var annTuple = newTuple("Ann-F-40"); - var annEntry = indexer.put(IndexKeys.none(), annTuple); + indexer.put(IndexKeys.none(), annTuple); assertSoftly(softly -> { softly.assertThat(indexer.isEmpty()).isFalse(); softly.assertThat(getTuples(indexer)).containsExactly(annTuple); }); - indexer.remove(IndexKeys.none(), annEntry); + indexer.remove(IndexKeys.none(), annTuple); assertSoftly(softly -> { softly.assertThat(indexer.isEmpty()).isTrue(); softly.assertThat(getTuples(indexer)).isEmpty(); }); - assertThatThrownBy(() -> indexer.remove(IndexKeys.none(), annEntry)) + assertThatThrownBy(() -> indexer.remove(IndexKeys.none(), annTuple)) .isInstanceOf(IllegalStateException.class); } @Test void visit() { - var indexer = new NoneIndexer<>(); + var indexer = new NoneIndexer<>(elementPositionTracker); var annTuple = newTuple("Ann-F-40"); indexer.put(IndexKeys.none(), annTuple); @@ -64,7 +67,7 @@ void visit() { } private static UniTuple newTuple(String factA) { - return new UniTuple<>(factA, 0); + return new UniTuple<>(factA, 1); } } From 28281fff17d8841012126d9751770d4b73e1b125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 8 Nov 2025 13:27:33 +0100 Subject: [PATCH 02/21] Optimizations around the counters --- .../common/AbstractIndexedIfExistsNode.java | 3 +- .../common/AbstractUnindexedIfExistsNode.java | 2 +- .../core/impl/bavet/common/ExistsCounter.java | 4 ++ .../bavet/common/ExistsCounterHandle.java | 3 ++ .../ExistsCounterHandlePositionTracker.java | 49 +++++-------------- .../common/ExistsCounterPositionTracker.java | 30 ++++++------ .../bavet/common/TuplePositionTracker.java | 13 ++--- .../common/index/ElementPositionTracker.java | 14 ++---- .../impl/bavet/common/index/IndexedSet.java | 26 +++++----- .../bavet/common/index/IndexedSetTest.java | 19 +------ 10 files changed, 58 insertions(+), 105 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index bd174958b4..8bcc9170d9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -42,8 +42,7 @@ protected AbstractIndexedIfExistsNode(boolean shouldExist, this.inputStoreIndexLeftKeys = leftTupleStorePositionTracker.reserveNextAvailablePosition(); this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); this.inputStoreIndexRightKeys = rightTupleStorePositionTracker.reserveNextAvailablePosition(); - this.indexerLeft = indexerFactory.buildIndexer(true, - new ExistsCounterPositionTracker<>(leftTupleStorePositionTracker.reserveNextAvailablePosition())); + this.indexerLeft = indexerFactory.buildIndexer(true, ExistsCounterPositionTracker.instance()); this.indexerRight = indexerFactory.buildIndexer(false, new TuplePositionTracker<>(rightTupleStorePositionTracker.reserveNextAvailablePosition())); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index 2ade3386dc..52626016fa 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -32,7 +32,7 @@ protected AbstractUnindexedIfExistsNode(boolean shouldExist, TupleStorePositionT this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); this.inputStoreIndexRightTuple = rightTupleStorePositionTracker.reserveNextAvailablePosition(); this.leftCounterSet = new IndexedSet<>( - new ExistsCounterPositionTracker<>(leftTupleStorePositionTracker.reserveNextAvailablePosition())); + new ExistsCounterPositionTracker<>()); this.rightTupleSet = new IndexedSet<>(new TuplePositionTracker<>(inputStoreIndexRightTuple)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java index a47bdd2910..623f12c159 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java @@ -3,12 +3,16 @@ import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; +import org.jspecify.annotations.NullMarked; + +@NullMarked public final class ExistsCounter extends AbstractPropagationMetadataCarrier { final Tuple_ leftTuple; TupleState state = TupleState.DEAD; // It's the node's job to mark a new instance as CREATING. int countRight = 0; + int indexedSetPositon = -1; ExistsCounter(Tuple_ leftTuple) { this.leftTuple = leftTuple; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java index f6e40c734c..ac10f4f385 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -3,6 +3,8 @@ import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; +import org.jspecify.annotations.NullMarked; + /** * Used for filtering in {@link AbstractIfExistsNode}. * There is no place where both left and right sets for each counter would be kept together, @@ -11,6 +13,7 @@ * * @param */ +@NullMarked final class ExistsCounterHandle { final ExistsCounter counter; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java index d204fed047..b19efed2a5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandlePositionTracker.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.bavet.common; +import java.util.function.ToIntFunction; + import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; @@ -7,31 +9,21 @@ @SuppressWarnings({ "rawtypes", "unchecked" }) @NullMarked -record ExistsCounterHandlePositionTracker(PositionGetter positionGetter, - PositionClearer positionClearer, +record ExistsCounterHandlePositionTracker( + ToIntFunction> positionGetter, PositionSetter positionSetter) implements ElementPositionTracker> { private static final ExistsCounterHandlePositionTracker LEFT = new ExistsCounterHandlePositionTracker( - tracker -> tracker.leftPosition, - tracker -> { - var result = tracker.leftPosition; - tracker.leftPosition = -1; - return result; - }, + (ToIntFunction) tracker -> tracker.leftPosition, (tracker, position) -> { var oldValue = tracker.leftPosition; tracker.leftPosition = position; return oldValue; }); private static final ExistsCounterHandlePositionTracker RIGHT = new ExistsCounterHandlePositionTracker( - tracker -> tracker.rightPosition, - tracker -> { - var result = tracker.rightPosition; - tracker.rightPosition = -1; - return result; - }, + (ToIntFunction) tracker -> tracker.rightPosition, (tracker, position) -> { var oldValue = tracker.rightPosition; tracker.rightPosition = position; @@ -47,34 +39,15 @@ public static ExistsCounterHandlePositionTracker< } @Override - public int getPosition(ExistsCounterHandle element) { - return positionGetter.apply(element); - } - - @Override - public int setPosition(ExistsCounterHandle element, int position) { - return positionSetter.apply(element, position); + public void setPosition(ExistsCounterHandle element, int position) { + positionSetter.apply(element, position); } @Override public int clearPosition(ExistsCounterHandle element) { - return positionClearer.apply(element); - } - - @FunctionalInterface - @NullMarked - interface PositionGetter { - - int apply(ExistsCounterHandle tracker); - - } - - @FunctionalInterface - @NullMarked - interface PositionClearer { - - int apply(ExistsCounterHandle tracker); - + var oldPosition = positionGetter.applyAsInt(element); + positionSetter.apply(element, -1); + return oldPosition; } @FunctionalInterface diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java index 0d52a39c73..d6c3645300 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterPositionTracker.java @@ -3,32 +3,30 @@ import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; -record ExistsCounterPositionTracker(int inputStorePosition) +import org.jspecify.annotations.NullMarked; + +@SuppressWarnings({ "unchecked", "rawtypes" }) +@NullMarked +record ExistsCounterPositionTracker() implements ElementPositionTracker> { - @Override - public int getPosition(ExistsCounter element) { - var tuple = element.getTuple(); - var value = tuple.getStore(inputStorePosition); - return value == null ? -1 : (int) value; + private static final ExistsCounterPositionTracker INSTANCE = new ExistsCounterPositionTracker(); + + public static ExistsCounterPositionTracker instance() { + return INSTANCE; } @Override - public int setPosition(ExistsCounter element, int position) { - var tuple = element.getTuple(); - var oldValue = getPosition(element); - tuple.setStore(inputStorePosition, position); - return oldValue; + public void setPosition(ExistsCounter element, int position) { + element.indexedSetPositon = position; } @Override public int clearPosition(ExistsCounter element) { - try { - return element.getTuple().removeStore(inputStorePosition); - } catch (NullPointerException e) { - return -1; - } + var oldPosition = element.indexedSetPositon; + element.indexedSetPositon = -1; + return oldPosition; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java index a6e801db41..96bc069bfd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java @@ -3,21 +3,16 @@ import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; +import org.jspecify.annotations.NullMarked; + +@NullMarked public record TuplePositionTracker(int inputStorePosition) implements ElementPositionTracker { @Override - public int getPosition(Tuple_ element) { - var value = element.getStore(inputStorePosition); - return value == null ? -1 : (int) value; - } - - @Override - public int setPosition(Tuple_ element, int position) { - var oldValue = getPosition(element); + public void setPosition(Tuple_ element, int position) { element.setStore(inputStorePosition, position); - return oldValue; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java index f0c50f848b..c3460ef140 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/ElementPositionTracker.java @@ -1,29 +1,23 @@ package ai.timefold.solver.core.impl.bavet.common.index; +import org.jspecify.annotations.NullMarked; + /** * Allows to read and modify the position of an element in an {@link IndexedSet}. * Typically points to a field in the element itself. * * @param */ +@NullMarked public interface ElementPositionTracker { - /** - * Gets the position of the given element. - * - * @param element never null - * @return >= 0 if the element is tracked, or -1 if it is not tracked - */ - int getPosition(T element); - /** * Sets the position of the given element. * * @param element never null * @param position >= 0 - * @return the previous position of the element, or -1 if it was not tracked before */ - int setPosition(T element, int position); + void setPosition(T element, int position); /** * Clears the position of the given element. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index e2bab41547..31a9d3a38c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -12,8 +12,9 @@ import org.jspecify.annotations.Nullable; /** - * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element without knowing its position - * and without an expensive lookup. + * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element + * without knowing its position and without an expensive lookup. + * It also allows for direct random access like a list. *

* It uses an {@link ElementPositionTracker} to track the insertion position of each element. * When an element is removed, the insertion position of later elements is not changed. @@ -29,8 +30,10 @@ * Random access is not required for Constraint Streams, but Neighborhoods make heavy use of it; * if we used the {@link ElementAwareList} implementation instead, * we would have to copy the elements to an array every time we need to access them randomly during move generation. - * - * + *

+ * For performance reasons, this class does not check if an element was already added; + * duplicates must be avoided by the caller and will cause undefined behavior. + * * @param */ @NullMarked @@ -52,20 +55,19 @@ private List getElementList() { } /** - * Appends the specified element to the end of this collection, if not already present. - * Will use identity comparison to check for presence; - * two different instances which {@link Object#equals(Object) equal} are considered different elements. + * Appends the specified element to the end of this collection. + * If the element is already present, + * undefined, unexpected, and incorrect behavior should be expected. + *

+ * Presence of the element can be checked using the associated {@link ElementPositionTracker}. + * For performance reasons, this method avoids that check. * * @param element element to be appended to this collection - * @throws IllegalStateException if the element was already present in this collection */ public void add(T element) { var actualElementList = getElementList(); actualElementList.add(element); - if (elementPositionTracker.setPosition(element, actualElementList.size() - 1) >= 0) { - throw new IllegalStateException("Impossible state: the element (%s) was already added to the IndexedSet." - .formatted(element)); - } + elementPositionTracker.setPosition(element, actualElementList.size() - 1); } /** diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 8c75058fbc..e893eabe96 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -25,16 +25,6 @@ void addMultipleElements() { assertThat(set.asList()).containsExactly("A", "B", "C"); } - @Test - void addDuplicateElement() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - assertThatThrownBy(() -> set.add("A")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was already added"); - } - @Test void removeElement() { var set = new IndexedSet<>(stringTracker); @@ -226,13 +216,8 @@ private static final class SimpleTracker implements ElementPositionTracker private final Map positions = new HashMap<>(); @Override - public int setPosition(T element, int position) { - return positions.put(element, position) == null ? -1 : position; - } - - @Override - public int getPosition(T element) { - return positions.getOrDefault(element, -1); + public void setPosition(T element, int position) { + positions.put(element, position); } @Override From b7caf52c4f5818bfb4ab53114393f4e27270453d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 8 Nov 2025 17:32:13 +0100 Subject: [PATCH 03/21] Experiment with a different approach --- .../impl/bavet/common/index/IndexedSet.java | 121 ++++++++++-------- .../bavet/common/index/IndexedSetTest.java | 39 ++---- 2 files changed, 77 insertions(+), 83 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 31a9d3a38c..4c6e59f3d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -1,16 +1,16 @@ package ai.timefold.solver.core.impl.bavet.common.index; +import ai.timefold.solver.core.impl.util.ElementAwareList; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; +import java.util.BitSet; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Consumer; -import ai.timefold.solver.core.impl.util.ElementAwareList; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - /** * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element * without knowing its position and without an expensive lookup. @@ -33,6 +33,9 @@ *

* For performance reasons, this class does not check if an element was already added; * duplicates must be avoided by the caller and will cause undefined behavior. + *

+ * This class is not thread-safe. + * It is in fact very thread-unsafe. * * @param */ @@ -41,7 +44,8 @@ public final class IndexedSet { private final ElementPositionTracker elementPositionTracker; private @Nullable ArrayList elementList; // Lazily initialized, so that empty indexes use no memory. - private int removalCount = 0; + private final BitSet gaps = new BitSet(0); + private int gapCount = 0; public IndexedSet(ElementPositionTracker elementPositionTracker) { this.elementPositionTracker = Objects.requireNonNull(elementPositionTracker); @@ -66,8 +70,16 @@ private List getElementList() { */ public void add(T element) { var actualElementList = getElementList(); - actualElementList.add(element); - elementPositionTracker.setPosition(element, actualElementList.size() - 1); + if (gapCount > 0) { + var gapIndex = gaps.nextSetBit(0); + actualElementList.set(gapIndex, element); + elementPositionTracker.setPosition(element, gapIndex); + gaps.clear(gapIndex); + gapCount--; + } else { + actualElementList.add(element); + elementPositionTracker.setPosition(element, actualElementList.size() - 1); + } } /** @@ -94,46 +106,19 @@ private boolean innerRemove(T element) { return false; } var actualElementList = getElementList(); - var upperBound = Math.min(insertionPosition, actualElementList.size() - 1); - var lowerBound = Math.max(0, insertionPosition - removalCount); - var actualPosition = findElement(actualElementList, element, lowerBound, upperBound); - if (actualPosition < 0) { - return false; - } - actualElementList.remove(actualPosition); - if (isEmpty()) { - removalCount = 0; - } else if (actualElementList.size() > actualPosition) { - // We only mark removals that actually affect later elements. - // Removing the last element does not affect any other element. - removalCount++; + if (insertionPosition == actualElementList.size() - 1) { + // The element was the last one added; we can simply remove it. + actualElementList.remove(insertionPosition); + } else { + actualElementList.set(insertionPosition, null); + gaps.set(insertionPosition); + gapCount++; } return true; } - /** - * Search for the element in the given range. - * - * @param actualElementList the list to search in - * @param element the element to search for - * @param startIndex start of the range we are currently considering (inclusive) - * @param endIndex end of the range we are currently considering (inclusive) - * @return the index of the element if found, -1 otherwise - */ - private static int findElement(List actualElementList, T element, int startIndex, int endIndex) { - for (var i = endIndex; i >= startIndex; i--) { - // Iterating backwards as the element is more likely to be closer to the end of the range, - // which is where it was originally inserted. - var maybeElement = actualElementList.get(i); - if (maybeElement == element) { - return i; - } - } - return -1; - } - public int size() { - return elementList == null ? 0 : elementList.size(); + return elementList == null ? 0 : elementList.size() - gapCount; } /** @@ -146,19 +131,16 @@ public void forEach(Consumer tupleConsumer) { if (elementList == null) { return; } - var i = 0; - while (i < elementList.size()) { - var oldRemovalCount = removalCount; // The consumer may remove some elements, shifting others forward. - tupleConsumer.accept(elementList.get(i)); - var elementDrift = removalCount - oldRemovalCount; - // Move to the next element, adjusting for any shifts due to removals. - // If no elements were removed by the consumer, we simply move to the next index. - i -= elementDrift - 1; + for (var i = 0; i < elementList.size(); i++) { + var element = elementList.get(i); + if (element != null) { + tupleConsumer.accept(element); + } } } public boolean isEmpty() { - return elementList == null || elementList.isEmpty(); + return size() == 0; } /** @@ -168,11 +150,40 @@ public boolean isEmpty() { * @return a standard list view of this element-aware list */ public List asList() { - return elementList == null ? Collections.emptyList() : elementList; + if (elementList == null) { + return Collections.emptyList(); + } + var actualElementList = getElementList(); + defrag(actualElementList); + return actualElementList; + } + + private void defrag(List actualElementList) { + if (gapCount == 0) { + return; + } + var gap = gaps.nextSetBit(0); + while (gap >= 0) { + var lastNonGapIndex = findNonGapFromEnd(actualElementList); + if (lastNonGapIndex < 0 || gap >= lastNonGapIndex) { + break; + } + var lastElement = actualElementList.remove(lastNonGapIndex); + actualElementList.set(gap, lastElement); + elementPositionTracker.setPosition(lastElement, gap); + gap = gaps.nextSetBit(gap + 1); + } + gaps.clear(); + gapCount = 0; } - public String toString() { - return elementList == null ? "[]" : elementList.toString(); + private int findNonGapFromEnd(List actualElementList) { + var end = actualElementList.size() - 1; + var lastNonGap = gaps.previousClearBit(end); + for (var i = end; i > lastNonGap; i--) { + actualElementList.remove(i); + } + return lastNonGap; } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index e893eabe96..32d019a845 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,13 +1,13 @@ package ai.timefold.solver.core.impl.bavet.common.index; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class IndexedSetTest { @@ -22,7 +22,7 @@ void addMultipleElements() { set.add("C"); assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactly("A", "B", "C"); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "B", "C"); } @Test @@ -65,7 +65,7 @@ void removeMiddleElement() { set.remove("B"); assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("A", "C"); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "C"); } @Test @@ -78,7 +78,7 @@ void removeLastElement() { set.remove("C"); assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("A", "B"); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "B"); } @Test @@ -91,7 +91,7 @@ void removeFirstElement() { set.remove("A"); assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("B", "C"); + assertThat(set.asList()).containsExactlyInAnyOrder("B", "C"); } @Test @@ -107,7 +107,7 @@ void multipleRemovalsAndAdditions() { set.add("E"); assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactly("C", "D", "E"); + assertThat(set.asList()).containsExactlyInAnyOrder("C", "D", "E"); } @Test @@ -121,7 +121,7 @@ void forEach() { var result = new ArrayList(); set.forEach(result::add); - assertThat(result).containsExactly("A", "B", "C"); + assertThat(result).containsExactlyInAnyOrder("A", "B", "C"); } @Test @@ -151,8 +151,8 @@ void forEachWithRemoval() { } }); - assertThat(result).containsExactly("A", "B", "C", "D"); - assertThat(set.asList()).containsExactly("A", "C", "D"); + assertThat(result).containsExactlyInAnyOrder("A", "B", "C", "D"); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "D"); } @Test @@ -175,23 +175,6 @@ void asListOnEmptySet() { assertThat(set.asList()).isEmpty(); } - @Test - void toStringOnEmptySet() { - var set = new IndexedSet<>(stringTracker); - - assertThat(set.toString()).isEqualTo("[]"); - } - - @Test - void toStringWithElements() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - - assertThat(set.toString()).isEqualTo("[A, B]"); - } - @Test void largeSetWithManyRemovals() { var intTracker = new SimpleTracker(); From 8ccec1ad7e316cc4120796feb5d27dbe3180dec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Sat, 8 Nov 2025 17:32:13 +0100 Subject: [PATCH 04/21] Experiment with a different approach --- .../impl/bavet/common/AbstractJoinNode.java | 9 +- .../impl/bavet/common/index/IndexedSet.java | 86 +++++-- .../bavet/common/index/IndexedSetTest.java | 231 ++++++++++++++++++ 3 files changed, 295 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java index cfc660e8cb..27f08d26e5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java @@ -169,14 +169,7 @@ private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightT private static @Nullable Tuple_ findOutTuple(IndexedSet outTupleSet, IndexedSet referenceOutTupleSet, int outputStoreIndexOutSet) { // Hack: the outTuple has no left/right input tuple reference, use the left/right outSet reference instead. - var list = outTupleSet.asList(); - for (var i = 0; i < list.size(); i++) { // Avoid allocating iterators. - var outTuple = list.get(i); - if (referenceOutTupleSet == outTuple.getStore(outputStoreIndexOutSet)) { - return outTuple; - } - } - return null; + return outTupleSet.findFirst(outTuple -> referenceOutTupleSet == outTuple.getStore(outputStoreIndexOutSet)); } protected final void retractOutTuple(OutTuple_ outTuple) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 4c6e59f3d6..f3784f3465 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -1,15 +1,17 @@ package ai.timefold.solver.core.impl.bavet.common.index; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - import java.util.ArrayList; import java.util.BitSet; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Consumer; +import java.util.function.Predicate; + +import ai.timefold.solver.core.impl.util.ElementAwareList; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element @@ -72,16 +74,24 @@ public void add(T element) { var actualElementList = getElementList(); if (gapCount > 0) { var gapIndex = gaps.nextSetBit(0); - actualElementList.set(gapIndex, element); - elementPositionTracker.setPosition(element, gapIndex); - gaps.clear(gapIndex); - gapCount--; + putElementIntoGap(actualElementList, element, gapIndex); } else { actualElementList.add(element); elementPositionTracker.setPosition(element, actualElementList.size() - 1); } } + private void putElementIntoGap(List elementList, T element, int gap) { + setElementList(elementList, element, gap); + gaps.clear(gap); + gapCount--; + } + + private void setElementList(List elementList, T element, int position) { + elementList.set(position, element); + elementPositionTracker.setPosition(element, position); + } + /** * Removes the first occurrence of the specified element from this collection, if it is present. * Will use identity comparison to check for presence; @@ -109,6 +119,7 @@ private boolean innerRemove(T element) { if (insertionPosition == actualElementList.size() - 1) { // The element was the last one added; we can simply remove it. actualElementList.remove(insertionPosition); + removeTailGap(actualElementList); } else { actualElementList.set(insertionPosition, null); gaps.set(insertionPosition); @@ -125,18 +136,37 @@ public int size() { * Performs the given action for each element of the collection * until all elements have been processed. * - * @param tupleConsumer the action to be performed for each element + * @param elementConsumer the action to be performed for each element */ - public void forEach(Consumer tupleConsumer) { - if (elementList == null) { + public void forEach(Consumer elementConsumer) { + if (isEmpty()) { return; } + findFirst(element -> { + elementConsumer.accept(element); + return false; // Iterate until the end. + }); + } + + public @Nullable T findFirst(Predicate elementPredicate) { + if (isEmpty()) { + return null; + } for (var i = 0; i < elementList.size(); i++) { var element = elementList.get(i); - if (element != null) { - tupleConsumer.accept(element); + if (element == null) { + var nonGap = removeTailGap(elementList); + if (i >= nonGap) { + return null; + } + element = elementList.remove(nonGap); + putElementIntoGap(elementList, element, i); + } + if (elementPredicate.test(element)) { + return element; } } + return null; } public boolean isEmpty() { @@ -153,9 +183,8 @@ public List asList() { if (elementList == null) { return Collections.emptyList(); } - var actualElementList = getElementList(); - defrag(actualElementList); - return actualElementList; + defrag(elementList); + return elementList; } private void defrag(List actualElementList) { @@ -164,26 +193,37 @@ private void defrag(List actualElementList) { } var gap = gaps.nextSetBit(0); while (gap >= 0) { - var lastNonGapIndex = findNonGapFromEnd(actualElementList); + var lastNonGapIndex = removeTailGap(actualElementList); if (lastNonGapIndex < 0 || gap >= lastNonGapIndex) { break; } var lastElement = actualElementList.remove(lastNonGapIndex); - actualElementList.set(gap, lastElement); - elementPositionTracker.setPosition(lastElement, gap); + setElementList(actualElementList, lastElement, gap); gap = gaps.nextSetBit(gap + 1); } + resetGaps(); + } + + private void resetGaps() { gaps.clear(); gapCount = 0; } - private int findNonGapFromEnd(List actualElementList) { + private int removeTailGap(List actualElementList) { var end = actualElementList.size() - 1; var lastNonGap = gaps.previousClearBit(end); - for (var i = end; i > lastNonGap; i--) { - actualElementList.remove(i); + if (lastNonGap < 0) { + actualElementList.clear(); + resetGaps(); + return -1; + } else { + for (var i = end; i > lastNonGap; i--) { + actualElementList.remove(i); + } + gaps.clear(lastNonGap, end + 1); + gapCount = gaps.cardinality(); + return lastNonGap; } - return lastNonGap; } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 32d019a845..a1ec214038 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.bavet.common.index; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; import java.util.ArrayList; @@ -194,6 +195,236 @@ void largeSetWithManyRemovals() { } } + @Test + void addToGapAfterRemoval() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + set.add("D"); + + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "D"); + } + + @Test + void multipleGapsFilledInOrder() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.remove("B"); + set.remove("D"); + set.add("E"); + set.add("F"); + + assertThat(set.size()).isEqualTo(4); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "E", "F"); + } + + @Test + void findFirstWithPredicate() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + var result = set.findFirst(element -> element.equals("B")); + + assertThat(result).isEqualTo("B"); + } + + @Test + void findFirstOnEmptySet() { + var set = new IndexedSet<>(stringTracker); + + var result = set.findFirst(element -> true); + + assertThat(result).isNull(); + } + + @Test + void findFirstNoMatch() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + var result = set.findFirst(element -> element.equals("Z")); + + assertThat(result).isNull(); + } + + @Test + void findFirstWithGaps() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.remove("B"); + + var result = set.findFirst(element -> element.equals("C")); + + assertThat(result).isEqualTo("C"); + assertThat(set.size()).isEqualTo(3); + } + + @Test + void defragmentationDuringAsList() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.add("E"); + set.remove("B"); + set.remove("D"); + + var list = set.asList(); + + assertThat(list).containsExactlyInAnyOrder("A", "C", "E"); + assertThat(set.size()).isEqualTo(3); + } + + @Test + void removeElementWithNegativeInsertionPosition() { + var tracker = new SimpleTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + + tracker.positions.put("B", -1); + + assertThatThrownBy(() -> set.remove("B")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } + + @Test + void sizeWithMultipleGaps() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.add("E"); + set.remove("B"); + set.remove("D"); + + assertThat(set.size()).isEqualTo(3); + } + + @Test + void addAfterMultipleRemovals() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.remove("A"); + set.remove("C"); + set.add("E"); + set.add("F"); + + assertThat(set.size()).isEqualTo(4); + assertThat(set.asList()).containsExactlyInAnyOrder("B", "D", "E", "F"); + } + + @Test + void consecutiveAdditionsAfterClearingGaps() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.remove("A"); + set.add("C"); + set.add("D"); + + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactlyInAnyOrder("B", "C", "D"); + } + + @Test + void removeAllElementsOneByOne() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + set.remove("A"); + set.remove("B"); + set.remove("C"); + + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + assertThat(set.asList()).isEmpty(); + } + + @Test + void forEachWithMultipleGaps() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.add("E"); + set.remove("B"); + set.remove("D"); + + var result = new ArrayList(); + set.forEach(result::add); + + assertThat(result).containsExactlyInAnyOrder("A", "C", "E"); + } + + @Test + void removeDoesNotLeaveTrailingGaps() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); // Leave a gap. + set.remove("C"); // Remove the final element, which requires the preceding gap to be removed as well. + + assertThat(set.size()).isEqualTo(1); + assertThat(set.asList()).containsExactlyInAnyOrder("A"); + } + + @Test + void findFirstDefragsInternalList() { + var set = new IndexedSet<>(stringTracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + set.remove("A"); + set.remove("C"); + + var result = new ArrayList(); + set.findFirst(element -> { + result.add(element); + return false; + }); + + assertThat(result).containsExactlyInAnyOrder("B", "D"); + } + + @NullMarked private static final class SimpleTracker implements ElementPositionTracker { private final Map positions = new HashMap<>(); From f69937d7a7894c4ab9f088c98255c728f6b925b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 08:27:40 +0100 Subject: [PATCH 05/21] Remove the use of BitSet --- .../impl/bavet/common/index/IndexedSet.java | 113 +++++++----------- .../bavet/common/index/IndexedSetTest.java | 8 +- 2 files changed, 50 insertions(+), 71 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index f3784f3465..d1960822ab 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.bavet.common.index; import java.util.ArrayList; -import java.util.BitSet; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -19,15 +18,12 @@ * It also allows for direct random access like a list. *

* It uses an {@link ElementPositionTracker} to track the insertion position of each element. - * When an element is removed, the insertion position of later elements is not changed. - * Instead, when the next element is removed, the search starts from its last known insertion position, - * iterating backwards until the element is found. - * We also keep a counter of deleted elements to avoid excessive iteration; - * we can guarantee that the current position of an element will not be further away - * than the number of earlier deletions. + * When an element is removed, it is replaced by null at its insertion position; + * therefore the insertion position of later elements is not changed. + * The list is compacted back during iteration or when {@link #asList()} is called. *

* Together with the fact that removals are relatively rare, - * this keeps the average removal cost low while giving us all benefits of {@link ArrayList}, + * this keeps the overhead low while giving us all benefits of {@link ArrayList}, * such as memory efficiency, random access, and fast iteration. * Random access is not required for Constraint Streams, but Neighborhoods make heavy use of it; * if we used the {@link ElementAwareList} implementation instead, @@ -45,15 +41,14 @@ public final class IndexedSet { private final ElementPositionTracker elementPositionTracker; - private @Nullable ArrayList elementList; // Lazily initialized, so that empty indexes use no memory. - private final BitSet gaps = new BitSet(0); + private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. private int gapCount = 0; public IndexedSet(ElementPositionTracker elementPositionTracker) { this.elementPositionTracker = Objects.requireNonNull(elementPositionTracker); } - private List getElementList() { + private List<@Nullable T> getElementList() { if (elementList == null) { elementList = new ArrayList<>(); } @@ -63,7 +58,7 @@ private List getElementList() { /** * Appends the specified element to the end of this collection. * If the element is already present, - * undefined, unexpected, and incorrect behavior should be expected. + * an undefined, unexpected, and incorrect behavior should be expected. *

* Presence of the element can be checked using the associated {@link ElementPositionTracker}. * For performance reasons, this method avoids that check. @@ -72,24 +67,8 @@ private List getElementList() { */ public void add(T element) { var actualElementList = getElementList(); - if (gapCount > 0) { - var gapIndex = gaps.nextSetBit(0); - putElementIntoGap(actualElementList, element, gapIndex); - } else { - actualElementList.add(element); - elementPositionTracker.setPosition(element, actualElementList.size() - 1); - } - } - - private void putElementIntoGap(List elementList, T element, int gap) { - setElementList(elementList, element, gap); - gaps.clear(gap); - gapCount--; - } - - private void setElementList(List elementList, T element, int position) { - elementList.set(position, element); - elementPositionTracker.setPosition(element, position); + actualElementList.add(element); + elementPositionTracker.setPosition(element, actualElementList.size() - 1); } /** @@ -121,8 +100,8 @@ private boolean innerRemove(T element) { actualElementList.remove(insertionPosition); removeTailGap(actualElementList); } else { + // We replace the element with null, creating a gap. actualElementList.set(insertionPosition, null); - gaps.set(insertionPosition); gapCount++; } return true; @@ -149,18 +128,16 @@ public void forEach(Consumer elementConsumer) { } public @Nullable T findFirst(Predicate elementPredicate) { - if (isEmpty()) { - return null; - } - for (var i = 0; i < elementList.size(); i++) { - var element = elementList.get(i); + var actualElementList = getElementList(); + for (var i = 0; i < actualElementList.size(); i++) { + var element = actualElementList.get(i); if (element == null) { - var nonGap = removeTailGap(elementList); - if (i >= nonGap) { + var lastNonGapIndex = removeTailGap(actualElementList); + if (lastNonGapIndex < 0 || i >= lastNonGapIndex) { return null; } - element = elementList.remove(nonGap); - putElementIntoGap(elementList, element, i); + element = actualElementList.remove(lastNonGapIndex); + putElementIntoGap(actualElementList, element, i); } if (elementPredicate.test(element)) { return element; @@ -169,6 +146,12 @@ public void forEach(Consumer elementConsumer) { return null; } + private void putElementIntoGap(List<@Nullable T> elementList, T element, int gap) { + elementList.set(gap, element); + elementPositionTracker.setPosition(element, gap); + gapCount--; + } + public boolean isEmpty() { return size() == 0; } @@ -187,43 +170,39 @@ public List asList() { return elementList; } - private void defrag(List actualElementList) { + private void defrag(List<@Nullable T> actualElementList) { if (gapCount == 0) { return; } - var gap = gaps.nextSetBit(0); - while (gap >= 0) { - var lastNonGapIndex = removeTailGap(actualElementList); - if (lastNonGapIndex < 0 || gap >= lastNonGapIndex) { - break; + var elementsAtTheBack = 0; + for (var i = removeTailGap(actualElementList); i >= 0 && gapCount > 0; i--) { + if (actualElementList.get(i) == null) { + if (elementsAtTheBack > 0) { + var element = actualElementList.remove(actualElementList.size() - 1); + putElementIntoGap(actualElementList, element, i); + } else { + actualElementList.remove(i); + gapCount--; + } + } else { + elementsAtTheBack++; } - var lastElement = actualElementList.remove(lastNonGapIndex); - setElementList(actualElementList, lastElement, gap); - gap = gaps.nextSetBit(gap + 1); } - resetGaps(); - } - - private void resetGaps() { - gaps.clear(); - gapCount = 0; } - private int removeTailGap(List actualElementList) { - var end = actualElementList.size() - 1; - var lastNonGap = gaps.previousClearBit(end); - if (lastNonGap < 0) { + private int removeTailGap(List<@Nullable T> actualElementList) { + if (gapCount == actualElementList.size()) { actualElementList.clear(); - resetGaps(); + gapCount = 0; return -1; - } else { - for (var i = end; i > lastNonGap; i--) { - actualElementList.remove(i); - } - gaps.clear(lastNonGap, end + 1); - gapCount = gaps.cardinality(); - return lastNonGap; } + var end = actualElementList.size() - 1; + while (end >= 0 && actualElementList.get(end) == null) { + actualElementList.remove(end); + gapCount--; + end--; + } + return end; } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index a1ec214038..f845d38164 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,14 +1,14 @@ package ai.timefold.solver.core.impl.bavet.common.index; -import org.jspecify.annotations.NullMarked; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; class IndexedSetTest { From 275cb838a7eefb1cf598e69834207e2eb7835ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 10:22:20 +0100 Subject: [PATCH 06/21] Only compact when necessary --- .../bavet/common/TuplePositionTracker.java | 17 +- .../impl/bavet/common/index/IndexedSet.java | 72 ++-- .../bavet/common/index/IndexedSetTest.java | 373 ++++++------------ .../move/ChangeMoveDefinitionTest.java | 92 ++--- .../move/ListChangeMoveDefinitionTest.java | 126 +++--- .../move/ListSwapMoveDefinitionTest.java | 8 +- 6 files changed, 286 insertions(+), 402 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java index 96bc069bfd..355768a5c4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/TuplePositionTracker.java @@ -2,6 +2,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.ElementPositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; +import ai.timefold.solver.core.impl.util.MutableInt; import org.jspecify.annotations.NullMarked; @@ -11,17 +12,19 @@ public record TuplePositionTracker(int inputStoreP ElementPositionTracker { @Override - public void setPosition(Tuple_ element, int position) { - element.setStore(inputStorePosition, position); + public void setPosition(Tuple_ element, int position) { // Avoids autoboxing on updates. + MutableInt oldPosition = element.getStore(inputStorePosition); + if (oldPosition == null) { + element.setStore(inputStorePosition, new MutableInt(position)); + } else { + oldPosition.setValue(position); + } } @Override public int clearPosition(Tuple_ element) { - try { - return element.removeStore(inputStorePosition); - } catch (NullPointerException e) { - return -1; - } + MutableInt oldPosition = element.removeStore(inputStorePosition); + return oldPosition == null ? -1 : oldPosition.intValue(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index d1960822ab..660d33f582 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -16,11 +16,14 @@ * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element * without knowing its position and without an expensive lookup. * It also allows for direct random access like a list. + * The original insertion order of elements may not be preserved during iteration, + * but it is deterministic and predictable. *

* It uses an {@link ElementPositionTracker} to track the insertion position of each element. * When an element is removed, it is replaced by null at its insertion position; * therefore the insertion position of later elements is not changed. - * The list is compacted back during iteration or when {@link #asList()} is called. + * The set is compacted back during iteration or when {@link #asList()} is called, + * by replacing these gaps with elements from the back of the set. *

* Together with the fact that removals are relatively rare, * this keeps the overhead low while giving us all benefits of {@link ArrayList}, @@ -40,6 +43,9 @@ @NullMarked public final class IndexedSet { + private static final int MINIMUM_GAP_COUNT_FOR_COMPACTION = 10; + private static final double GAP_RATIO_FOR_COMPACTION = 0.1; + private final ElementPositionTracker elementPositionTracker; private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. private int gapCount = 0; @@ -98,7 +104,6 @@ private boolean innerRemove(T element) { if (insertionPosition == actualElementList.size() - 1) { // The element was the last one added; we can simply remove it. actualElementList.remove(insertionPosition); - removeTailGap(actualElementList); } else { // We replace the element with null, creating a gap. actualElementList.set(insertionPosition, null); @@ -128,16 +133,32 @@ public void forEach(Consumer elementConsumer) { } public @Nullable T findFirst(Predicate elementPredicate) { + if (isEmpty()) { + return null; + } + + var shouldCompact = shouldCompact(size()); var actualElementList = getElementList(); - for (var i = 0; i < actualElementList.size(); i++) { + var elementsAtTheBack = 0; + for (var i = actualElementList.size() - 1; i >= 0; i--) { + // We remove gaps back to front so that we keep elements as close to their original position as possible. var element = actualElementList.get(i); if (element == null) { - var lastNonGapIndex = removeTailGap(actualElementList); - if (lastNonGapIndex < 0 || i >= lastNonGapIndex) { - return null; + if (shouldCompact) { + if (elementsAtTheBack > 0) { + var elementToMove = actualElementList.remove(actualElementList.size() - 1); + putElementIntoGap(actualElementList, elementToMove, i); + } else { + actualElementList.remove(i); + gapCount--; + } + if (gapCount == 0) { + shouldCompact = false; + } } - element = actualElementList.remove(lastNonGapIndex); - putElementIntoGap(actualElementList, element, i); + continue; + } else { + elementsAtTheBack++; } if (elementPredicate.test(element)) { return element; @@ -146,6 +167,14 @@ public void forEach(Consumer elementConsumer) { return null; } + private boolean shouldCompact(int elementCount) { + if (elementCount < MINIMUM_GAP_COUNT_FOR_COMPACTION) { + return false; + } + var gapPercentage = gapCount / (double) elementCount; + return gapPercentage > GAP_RATIO_FOR_COMPACTION; + } + private void putElementIntoGap(List<@Nullable T> elementList, T element, int gap) { elementList.set(gap, element); elementPositionTracker.setPosition(element, gap); @@ -163,19 +192,17 @@ public boolean isEmpty() { * @return a standard list view of this element-aware list */ public List asList() { - if (elementList == null) { + if (isEmpty()) { return Collections.emptyList(); } - defrag(elementList); + forceCompaction(elementList); return elementList; } - private void defrag(List<@Nullable T> actualElementList) { - if (gapCount == 0) { - return; - } + private void forceCompaction(List<@Nullable T> actualElementList) { + // We remove gaps back to front so that we keep elements as close to their original position as possible. var elementsAtTheBack = 0; - for (var i = removeTailGap(actualElementList); i >= 0 && gapCount > 0; i--) { + for (var i = actualElementList.size() - 1; gapCount > 0; i--) { if (actualElementList.get(i) == null) { if (elementsAtTheBack > 0) { var element = actualElementList.remove(actualElementList.size() - 1); @@ -190,19 +217,4 @@ private void defrag(List<@Nullable T> actualElementList) { } } - private int removeTailGap(List<@Nullable T> actualElementList) { - if (gapCount == actualElementList.size()) { - actualElementList.clear(); - gapCount = 0; - return -1; - } - var end = actualElementList.size() - 1; - while (end >= 0 && actualElementList.get(end) == null) { - actualElementList.remove(end); - gapCount--; - end--; - } - return end; - } - } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index f845d38164..3b3a635e63 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,63 +1,72 @@ package ai.timefold.solver.core.impl.bavet.common.index; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.assertj.core.api.SoftAssertions; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; -class IndexedSetTest { +@NullMarked +class IndexedSetAdditionalTest { private final ElementPositionTracker stringTracker = new SimpleTracker<>(); @Test - void addMultipleElements() { + void addToEmptySet() { var set = new IndexedSet<>(stringTracker); set.add("A"); - set.add("B"); - set.add("C"); - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "B", "C"); + assertThat(set.size()).isEqualTo(1); + assertThat(set.isEmpty()).isFalse(); + assertThat(set.asList()).containsExactlyInAnyOrder("A"); } @Test - void removeElement() { - var set = new IndexedSet<>(stringTracker); + void shouldNotCompactWithFewElements() { + var tracker = new CountingTracker(); + var set = new IndexedSet<>(tracker); - set.add("A"); - set.remove("A"); + // Add 5 elements, remove 1 (20% gaps, but below minimum) + for (int i = 0; i < 5; i++) { + set.add(String.valueOf(i)); + } + set.remove("2"); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); + tracker.resetSetPositionCount(); + set.findFirst(e -> false); + + // No compaction should happen since we're below minimum gap count + assertThat(tracker.setPositionCount).isZero(); } @Test - void removeNonExistentElement() { - var set = new IndexedSet<>(stringTracker); + void shouldCompactWhenGapRatioExceeded() { + var tracker = new CountingTracker(); + var set = new IndexedSet<>(tracker); - set.add("A"); - assertThatThrownBy(() -> set.remove("B")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } + // Add 20 elements, remove 3 (15% gaps, above 10% threshold) + for (int i = 0; i < 20; i++) { + set.add(String.valueOf(i)); + } + set.remove("5"); + set.remove("10"); + set.remove("15"); - @Test - void removeFromEmptySet() { - var set = new IndexedSet<>(stringTracker); + tracker.resetSetPositionCount(); + set.findFirst(e -> false); - assertThatThrownBy(() -> set.remove("A")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); + // Compaction should happen + assertThat(tracker.setPositionCount).isPositive(); } @Test - void removeMiddleElement() { + void asListCompactsSet() { var set = new IndexedSet<>(stringTracker); set.add("A"); @@ -65,220 +74,99 @@ void removeMiddleElement() { set.add("C"); set.remove("B"); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "C"); - } - - @Test - void removeLastElement() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("C"); + var list = set.asList(); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "B"); + assertThat(list).containsExactlyInAnyOrder("A", "C"); } @Test - void removeFirstElement() { + void asListOnSetWithOnlyGaps() { var set = new IndexedSet<>(stringTracker); set.add("A"); set.add("B"); - set.add("C"); set.remove("A"); - - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactlyInAnyOrder("B", "C"); - } - - @Test - void multipleRemovalsAndAdditions() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); set.remove("B"); - set.add("D"); - set.remove("A"); - set.add("E"); - - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactlyInAnyOrder("C", "D", "E"); - } - @Test - void forEach() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); - - var result = new ArrayList(); - set.forEach(result::add); - - assertThat(result).containsExactlyInAnyOrder("A", "B", "C"); - } - - @Test - void forEachOnEmptySet() { - var set = new IndexedSet<>(stringTracker); - - var result = new ArrayList(); - set.forEach(result::add); + var list = set.asList(); - assertThat(result).isEmpty(); + assertThat(list).isEmpty(); } @Test - void forEachWithRemoval() { + void findFirstStopsAtFirstMatch() { var set = new IndexedSet<>(stringTracker); + var counter = new AtomicInteger(0); set.add("A"); set.add("B"); set.add("C"); - set.add("D"); - var result = new ArrayList(); - set.forEach(element -> { - result.add(element); - if (element.equals("B")) { - set.remove("B"); - } + var result = set.findFirst(element -> { + counter.incrementAndGet(); + return element.equals("B"); }); - assertThat(result).containsExactlyInAnyOrder("A", "B", "C", "D"); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "D"); - } - - @Test - void isEmpty() { - var set = new IndexedSet<>(stringTracker); - - assertThat(set.isEmpty()).isTrue(); - - set.add("A"); - assertThat(set.isEmpty()).isFalse(); - - set.remove("A"); - assertThat(set.isEmpty()).isTrue(); - } - - @Test - void asListOnEmptySet() { - var set = new IndexedSet<>(stringTracker); - - assertThat(set.asList()).isEmpty(); - } - - @Test - void largeSetWithManyRemovals() { - var intTracker = new SimpleTracker(); - var set = new IndexedSet<>(intTracker); - - for (int i = 0; i < 100; i++) { - set.add(i); - } - - for (int i = 0; i < 50; i++) { - set.remove(i * 2); - } - - assertThat(set.size()).isEqualTo(50); - for (int i = 0; i < 50; i++) { - assertThat(set.asList()).contains(i * 2 + 1); - } - } - - @Test - void addToGapAfterRemoval() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); - set.add("D"); - - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "D"); + assertThat(result).isEqualTo("B"); + assertThat(counter.get()).isLessThanOrEqualTo(3); } @Test - void multipleGapsFilledInOrder() { + void findFirstWithAllGaps() { var set = new IndexedSet<>(stringTracker); set.add("A"); set.add("B"); set.add("C"); - set.add("D"); + set.remove("A"); set.remove("B"); - set.remove("D"); - set.add("E"); - set.add("F"); - - assertThat(set.size()).isEqualTo(4); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "C", "E", "F"); - } - - @Test - void findFirstWithPredicate() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); - - var result = set.findFirst(element -> element.equals("B")); - - assertThat(result).isEqualTo("B"); - } - - @Test - void findFirstOnEmptySet() { - var set = new IndexedSet<>(stringTracker); + set.remove("C"); - var result = set.findFirst(element -> true); + var result = set.findFirst(e -> true); assertThat(result).isNull(); } @Test - void findFirstNoMatch() { + void forEachOnSetAfterCompaction() { var set = new IndexedSet<>(stringTracker); set.add("A"); set.add("B"); set.add("C"); + set.remove("B"); + set.asList(); // Force compaction - var result = set.findFirst(element -> element.equals("Z")); + var result = new ArrayList(); + set.forEach(result::add); - assertThat(result).isNull(); + assertThat(result).containsExactlyInAnyOrder("A", "C"); } @Test - void findFirstWithGaps() { + void mixedOperationsWithCompaction() { var set = new IndexedSet<>(stringTracker); - set.add("A"); - set.add("B"); - set.add("C"); - set.add("D"); - set.remove("B"); + for (int i = 0; i < 50; i++) { + set.add("E" + i); + } + for (int i = 0; i < 25; i += 2) { + set.remove("E" + i); + } - var result = set.findFirst(element -> element.equals("C")); + var list = set.asList(); // Force compaction - assertThat(result).isEqualTo("C"); - assertThat(set.size()).isEqualTo(3); + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(list) + .hasSize(37); + for (int i = 1; i < 50; i += 2) { + softly.assertThat(list).contains("E" + i); + } + }); } @Test - void defragmentationDuringAsList() { + void removeFromMiddlePreservesOrder() { var set = new IndexedSet<>(stringTracker); set.add("A"); @@ -292,25 +180,23 @@ void defragmentationDuringAsList() { var list = set.asList(); assertThat(list).containsExactlyInAnyOrder("A", "C", "E"); - assertThat(set.size()).isEqualTo(3); } @Test - void removeElementWithNegativeInsertionPosition() { - var tracker = new SimpleTracker(); - var set = new IndexedSet<>(tracker); + void gapAtEndIsRemoved() { + var set = new IndexedSet<>(stringTracker); set.add("A"); + set.add("B"); + set.add("C"); + set.remove("C"); - tracker.positions.put("B", -1); - - assertThatThrownBy(() -> set.remove("B")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "B"); } @Test - void sizeWithMultipleGaps() { + void multipleGapsAtEndAreRemoved() { var set = new IndexedSet<>(stringTracker); set.add("A"); @@ -318,54 +204,53 @@ void sizeWithMultipleGaps() { set.add("C"); set.add("D"); set.add("E"); - set.remove("B"); set.remove("D"); + set.remove("E"); assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "B", "C"); } @Test - void addAfterMultipleRemovals() { + void addAfterRemovingLastElement() { var set = new IndexedSet<>(stringTracker); set.add("A"); set.add("B"); + set.remove("B"); set.add("C"); - set.add("D"); - set.remove("A"); - set.remove("C"); - set.add("E"); - set.add("F"); - assertThat(set.size()).isEqualTo(4); - assertThat(set.asList()).containsExactlyInAnyOrder("B", "D", "E", "F"); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactlyInAnyOrder("A", "C"); } @Test - void consecutiveAdditionsAfterClearingGaps() { + void sizeIsConsistentAfterOperations() { var set = new IndexedSet<>(stringTracker); + assertThat(set.size()).isZero(); + set.add("A"); + assertThat(set.size()).isEqualTo(1); + set.add("B"); + assertThat(set.size()).isEqualTo(2); + set.remove("A"); - set.add("C"); - set.add("D"); + assertThat(set.size()).isEqualTo(1); - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactlyInAnyOrder("B", "C", "D"); + set.add("C"); + assertThat(set.size()).isEqualTo(2); } @Test - void removeAllElementsOneByOne() { + void emptyAfterRemovingAllElements() { var set = new IndexedSet<>(stringTracker); set.add("A"); set.add("B"); - set.add("C"); - set.remove("A"); set.remove("B"); - set.remove("C"); assertThat(set.isEmpty()).isTrue(); assertThat(set.size()).isZero(); @@ -373,65 +258,45 @@ void removeAllElementsOneByOne() { } @Test - void forEachWithMultipleGaps() { + void forEachEmptyAfterRemovals() { var set = new IndexedSet<>(stringTracker); set.add("A"); - set.add("B"); - set.add("C"); - set.add("D"); - set.add("E"); - set.remove("B"); - set.remove("D"); + set.remove("A"); var result = new ArrayList(); set.forEach(result::add); - assertThat(result).containsExactlyInAnyOrder("A", "C", "E"); - } - - @Test - void removeDoesNotLeaveTrailingGaps() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); // Leave a gap. - set.remove("C"); // Remove the final element, which requires the preceding gap to be removed as well. - - assertThat(set.size()).isEqualTo(1); - assertThat(set.asList()).containsExactlyInAnyOrder("A"); + assertThat(result).isEmpty(); } - @Test - void findFirstDefragsInternalList() { - var set = new IndexedSet<>(stringTracker); + @NullMarked + private static final class SimpleTracker implements ElementPositionTracker { - set.add("A"); - set.add("B"); - set.add("C"); - set.add("D"); - set.remove("A"); - set.remove("C"); + private final Map positions = new HashMap<>(); - var result = new ArrayList(); - set.findFirst(element -> { - result.add(element); - return false; - }); + @Override + public void setPosition(T element, int position) { + positions.put(element, position); + } - assertThat(result).containsExactlyInAnyOrder("B", "D"); + @Override + public int clearPosition(T element) { + var position = positions.remove(element); + return position == null ? -1 : position; + } } @NullMarked - private static final class SimpleTracker implements ElementPositionTracker { + private static final class CountingTracker implements ElementPositionTracker { private final Map positions = new HashMap<>(); + private int setPositionCount = 0; @Override public void setPosition(T element, int position) { positions.put(element, position); + setPositionCount++; } @Override @@ -439,6 +304,10 @@ public int clearPosition(T element) { var position = positions.remove(element); return position == null ? -1 : position; } + + void resetSetPositionCount() { + setPositionCount = 0; + } } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java index cc47071583..ed12680bdf 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java @@ -59,35 +59,35 @@ void fromSolution() { .toList(); assertThat(moveList).hasSize(4); - var firstMove = moveList.get(0); + var move1 = moveList.get(0); assertSoftly(softly -> { - softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(firstEntity); - softly.assertThat(firstMove.extractPlanningValues()) + softly.assertThat(move1.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(move1.extractPlanningValues()) .containsExactly(firstValue); }); - var secondMove = moveList.get(1); + var move2 = moveList.get(1); assertSoftly(softly -> { - softly.assertThat(secondMove.extractPlanningEntities()) - .containsExactly(firstEntity); - softly.assertThat(secondMove.extractPlanningValues()) + softly.assertThat(move2.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(move2.extractPlanningValues()) .containsExactly(secondValue); }); - var thirdMove = moveList.get(2); + var move3 = moveList.get(2); assertSoftly(softly -> { - softly.assertThat(thirdMove.extractPlanningEntities()) - .containsExactly(secondEntity); - softly.assertThat(thirdMove.extractPlanningValues()) + softly.assertThat(move3.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(move3.extractPlanningValues()) .containsExactly(firstValue); }); - var fourthMove = moveList.get(3); + var move4 = moveList.get(3); assertSoftly(softly -> { - softly.assertThat(fourthMove.extractPlanningEntities()) - .containsExactly(secondEntity); - softly.assertThat(fourthMove.extractPlanningValues()) + softly.assertThat(move4.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(move4.extractPlanningValues()) .containsExactly(secondValue); }); } @@ -119,35 +119,35 @@ void fromSolutionIncompleteValueRange() { .toList(); assertThat(moveList).hasSize(4); - var firstMove = moveList.get(0); + var move1 = moveList.get(0); assertSoftly(softly -> { - softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(firstEntity); - softly.assertThat(firstMove.extractPlanningValues()) + softly.assertThat(move1.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(move1.extractPlanningValues()) .containsExactly(firstValue); }); - var secondMove = moveList.get(1); + var move2 = moveList.get(1); assertSoftly(softly -> { - softly.assertThat(secondMove.extractPlanningEntities()) - .containsExactly(firstEntity); - softly.assertThat(secondMove.extractPlanningValues()) + softly.assertThat(move2.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(move2.extractPlanningValues()) .containsExactly(secondValue); }); - var thirdMove = moveList.get(2); + var move3 = moveList.get(2); assertSoftly(softly -> { - softly.assertThat(thirdMove.extractPlanningEntities()) - .containsExactly(secondEntity); - softly.assertThat(thirdMove.extractPlanningValues()) + softly.assertThat(move3.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(move3.extractPlanningValues()) .containsExactly(firstValue); }); - var fourthMove = moveList.get(3); + var move4 = moveList.get(3); assertSoftly(softly -> { - softly.assertThat(fourthMove.extractPlanningEntities()) - .containsExactly(secondEntity); - softly.assertThat(fourthMove.extractPlanningValues()) + softly.assertThat(move4.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(move4.extractPlanningValues()) .containsExactly(secondValue); }); } @@ -213,30 +213,30 @@ void fromEntityAllowsUnassigned() { .toList(); assertThat(moveList).hasSize(3); - var firstMove = moveList.get(0); + var move1 = moveList.get(0); assertSoftly(softly -> { - softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(firstEntity); - softly.assertThat(firstMove.extractPlanningValues()) + softly.assertThat(move1.extractPlanningEntities()) + .containsExactly(secondEntity); + softly.assertThat(move1.extractPlanningValues()) .hasSize(1) .containsNull(); }); - var secondMove = moveList.get(1); + var move2 = moveList.get(1); assertSoftly(softly -> { - softly.assertThat(secondMove.extractPlanningEntities()) + softly.assertThat(move2.extractPlanningEntities()) .containsExactly(secondEntity); - softly.assertThat(secondMove.extractPlanningValues()) - .hasSize(1) - .containsNull(); + softly.assertThat(move2.extractPlanningValues()) + .containsExactly(firstValue); }); - var thirdMove = moveList.get(2); + var move3 = moveList.get(2); assertSoftly(softly -> { - softly.assertThat(thirdMove.extractPlanningEntities()) - .containsExactly(secondEntity); - softly.assertThat(thirdMove.extractPlanningValues()) - .containsExactly(firstValue); + softly.assertThat(move3.extractPlanningEntities()) + .containsExactly(firstEntity); + softly.assertThat(move3.extractPlanningValues()) + .hasSize(1) + .containsNull(); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java index 2fb657a726..31544de7f9 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java @@ -61,20 +61,20 @@ void fromSolution() { var move1 = getListAssignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) .containsExactly(unassignedValue); }); var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move2.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e1); softly.assertThat(move2.extractPlanningValues()) .containsExactly(unassignedValue); }); @@ -132,46 +132,46 @@ void fromEntity() { // v3 is unassigned; it can be assigned to e2, but not to e1. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListChangeMove(moveList, 0); + var move1 = getListAssignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move1.getSourceIndex()).isEqualTo(0); - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v3); }); var move3 = getListAssignMove(moveList, 2); assertSoftly(softly -> { - softly.assertThat(move3.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move3.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move3.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move3.getDestinationIndex()).isEqualTo(0); softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e1); softly.assertThat(move3.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v2); }); - var move4 = getListAssignMove(moveList, 3); + var move4 = getListChangeMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move4.getSourceIndex()).isEqualTo(0); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); } @@ -203,26 +203,24 @@ void fromEntityAllowsUnassigned() { // v3 is unassigned; it can be assigned to e2, but not to e1. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListUnassignMove(moveList, 0); + var move1 = getListAssignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move1.getSourceIndex()).isEqualTo(0); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); softly.assertThat(move1.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); - var move2 = getListChangeMove(moveList, 1); + var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move2.getSourceIndex()).isEqualTo(0); - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); var move3 = getListAssignMove(moveList, 2); @@ -235,24 +233,26 @@ void fromEntityAllowsUnassigned() { .containsExactly(v2); }); - var move4 = getListAssignMove(moveList, 3); + var move4 = getListChangeMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move4.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move4.getSourceIndex()).isEqualTo(0); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); - var move5 = getListAssignMove(moveList, 4); + var move5 = getListUnassignMove(moveList, 4); assertSoftly(softly -> { - softly.assertThat(move5.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move5.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move5.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move5.getSourceIndex()).isEqualTo(0); softly.assertThat(move5.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move5.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); } @@ -282,56 +282,56 @@ void fromSolutionAllowsUnassigned() { // v2 is unassigned; it can be assigned to e1 or e2. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListUnassignMove(moveList, 0); + var move1 = getListAssignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move1.getSourceIndex()).isEqualTo(0); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); softly.assertThat(move1.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v2); }); - var move2 = getListChangeMove(moveList, 1); + var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move2.getSourceIndex()).isEqualTo(0); softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e1); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v2); }); var move3 = getListAssignMove(moveList, 2); assertSoftly(softly -> { - softly.assertThat(move3.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move3.getDestinationEntity()).isEqualTo(e2); softly.assertThat(move3.getDestinationIndex()).isEqualTo(0); softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2); softly.assertThat(move3.extractPlanningValues()) .containsExactly(v2); }); - var move4 = getListAssignMove(moveList, 3); + var move4 = getListChangeMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move4.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move4.getSourceIndex()).isEqualTo(0); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v1); }); - var move5 = getListAssignMove(moveList, 4); + var move5 = getListUnassignMove(moveList, 4); assertSoftly(softly -> { - softly.assertThat(move5.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move5.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move5.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move5.getSourceIndex()).isEqualTo(0); softly.assertThat(move5.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move5.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v1); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java index 6d4b1fb617..bd3425dbc6 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java @@ -71,17 +71,17 @@ void fromSolution() { var move2 = (ListSwapMove) moveList.get(1); assertSoftly(softly -> { softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue1); + .containsExactly(assignedValue3, assignedValue2); }); var move3 = (ListSwapMove) moveList.get(2); assertSoftly(softly -> { softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move3.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue2); + .containsExactly(assignedValue3, assignedValue1); }); } From cbb657eb4c5af2bdd1b5c7b56ca61929f137370f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 14:09:57 +0100 Subject: [PATCH 07/21] Fix compaction bugs --- .../impl/bavet/common/index/IndexedSet.java | 115 ++++-- .../bavet/common/index/IndexedSetTest.java | 383 ++++++++++-------- 2 files changed, 281 insertions(+), 217 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 660d33f582..a09783c76c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -43,7 +43,7 @@ @NullMarked public final class IndexedSet { - private static final int MINIMUM_GAP_COUNT_FOR_COMPACTION = 10; + private static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 10; private static final double GAP_RATIO_FOR_COMPACTION = 0.1; private final ElementPositionTracker elementPositionTracker; @@ -73,8 +73,16 @@ public IndexedSet(ElementPositionTracker elementPositionTracker) { */ public void add(T element) { var actualElementList = getElementList(); + var lastElementPosition = actualElementList.size() - 1; + if (gapCount > 0) { + if (actualElementList.get(lastElementPosition) == null) { // We can reuse the gap at the back. + putElementIntoGap(actualElementList, element, lastElementPosition); + return; + } + } + // Put the element at the end of the list. actualElementList.add(element); - elementPositionTracker.setPosition(element, actualElementList.size() - 1); + elementPositionTracker.setPosition(element, lastElementPosition + 1); } /** @@ -126,61 +134,78 @@ public void forEach(Consumer elementConsumer) { if (isEmpty()) { return; } - findFirst(element -> { + forEach(element -> { elementConsumer.accept(element); return false; // Iterate until the end. }); } - public @Nullable T findFirst(Predicate elementPredicate) { + private @Nullable T forEach(Predicate elementPredicate) { if (isEmpty()) { return null; } - var shouldCompact = shouldCompact(size()); - var actualElementList = getElementList(); - var elementsAtTheBack = 0; - for (var i = actualElementList.size() - 1; i >= 0; i--) { - // We remove gaps back to front so that we keep elements as close to their original position as possible. - var element = actualElementList.get(i); + var elementCount = elementList.size(); + var shouldCompact = shouldCompact(elementCount); + + // We remove gaps back to front so that we keep elements as close to their original position as possible. + for (var i = elementCount - 1; i >= 0; i--) { + if (elementCount == gapCount) { + // If all elements were removed, clear the list to free memory and terminate iteration. + clearList(); + return null; + } + + var element = elementList.get(i); if (element == null) { if (shouldCompact) { - if (elementsAtTheBack > 0) { - var elementToMove = actualElementList.remove(actualElementList.size() - 1); - putElementIntoGap(actualElementList, elementToMove, i); - } else { - actualElementList.remove(i); + if (i < elementCount - 1) { + var elementToMove = elementList.remove(elementCount - 1); + putElementIntoGap(elementList, elementToMove, i); + } else { // The gap is at the back already. + elementList.remove(i); gapCount--; } + elementCount--; if (gapCount == 0) { shouldCompact = false; } } - continue; } else { - elementsAtTheBack++; - } - if (elementPredicate.test(element)) { - return element; + if (elementPredicate.test(element)) { + return element; + } + elementCount = elementList.size(); // Update in case that the predicate removed some elements. } } return null; } + private void putElementIntoGap(List<@Nullable T> elementList, T element, int gap) { + elementList.set(gap, element); + elementPositionTracker.setPosition(element, gap); + gapCount--; + } + + public @Nullable T findFirst(Predicate elementPredicate) { + return forEach(elementPredicate); + } + + private void clearList() { + if (elementList != null) { + elementList.clear(); + gapCount = 0; + } + } + private boolean shouldCompact(int elementCount) { - if (elementCount < MINIMUM_GAP_COUNT_FOR_COMPACTION) { + if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { return false; } var gapPercentage = gapCount / (double) elementCount; return gapPercentage > GAP_RATIO_FOR_COMPACTION; } - private void putElementIntoGap(List<@Nullable T> elementList, T element, int gap) { - elementList.set(gap, element); - elementPositionTracker.setPosition(element, gap); - gapCount--; - } - public boolean isEmpty() { return size() == 0; } @@ -192,27 +217,35 @@ public boolean isEmpty() { * @return a standard list view of this element-aware list */ public List asList() { - if (isEmpty()) { + if (elementList == null) { return Collections.emptyList(); } - forceCompaction(elementList); - return elementList; + forceCompaction(); + return elementList.isEmpty() ? Collections.emptyList() : elementList; } - private void forceCompaction(List<@Nullable T> actualElementList) { + private void forceCompaction() { // We remove gaps back to front so that we keep elements as close to their original position as possible. - var elementsAtTheBack = 0; - for (var i = actualElementList.size() - 1; gapCount > 0; i--) { - if (actualElementList.get(i) == null) { - if (elementsAtTheBack > 0) { - var element = actualElementList.remove(actualElementList.size() - 1); - putElementIntoGap(actualElementList, element, i); - } else { - actualElementList.remove(i); + var elementCount = elementList.size(); + for (var i = elementCount - 1; i >= 0; i--) { + if (elementCount == gapCount) { + // If all elements were removed, clear the list to free memory and terminate iteration. + clearList(); + return; + } + + if (elementList.get(i) == null) { + if (i < elementCount - 1) { + var element = elementList.remove(elementCount - 1); + putElementIntoGap(elementList, element, i); + } else { // The gap is at the back already. + elementList.remove(i); gapCount--; } - } else { - elementsAtTheBack++; + elementCount--; + if (gapCount == 0) { + return; + } } } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 3b3a635e63..4d8ee23bc6 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,126 +1,174 @@ package ai.timefold.solver.core.impl.bavet.common.index; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -import org.assertj.core.api.SoftAssertions; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; -@NullMarked -class IndexedSetAdditionalTest { +class IndexedSetTest { - private final ElementPositionTracker stringTracker = new SimpleTracker<>(); + private IndexedSet createIndexedSet() { + return new IndexedSet<>(new CompactingIndexPositionTracker<>()); + } @Test - void addToEmptySet() { - var set = new IndexedSet<>(stringTracker); + void addMultipleElements() { + var set = createIndexedSet(); + set.add("A"); + set.add("B"); + set.add("C"); + + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactly("A", "B", "C"); + } + @Test + void removeLastElement() { + var set = createIndexedSet(); set.add("A"); + set.add("B"); + set.remove("B"); assertThat(set.size()).isEqualTo(1); - assertThat(set.isEmpty()).isFalse(); - assertThat(set.asList()).containsExactlyInAnyOrder("A"); + assertThat(set.asList()).containsExactly("A"); } @Test - void shouldNotCompactWithFewElements() { - var tracker = new CountingTracker(); - var set = new IndexedSet<>(tracker); + void removeFirstElement() { + var set = createIndexedSet(); + set.add("A"); + set.add("B"); + set.remove("A"); - // Add 5 elements, remove 1 (20% gaps, but below minimum) - for (int i = 0; i < 5; i++) { - set.add(String.valueOf(i)); - } - set.remove("2"); + assertThat(set.size()).isEqualTo(1); + assertThat(set.asList()).containsExactly("B"); + } - tracker.resetSetPositionCount(); - set.findFirst(e -> false); + @Test + void removeMiddleElement() { + var set = createIndexedSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); - // No compaction should happen since we're below minimum gap count - assertThat(tracker.setPositionCount).isZero(); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("A", "C"); } @Test - void shouldCompactWhenGapRatioExceeded() { - var tracker = new CountingTracker(); - var set = new IndexedSet<>(tracker); + void removeNonExistentElementFails() { + var set = createIndexedSet(); + set.add("A"); - // Add 20 elements, remove 3 (15% gaps, above 10% threshold) - for (int i = 0; i < 20; i++) { - set.add(String.valueOf(i)); - } - set.remove("5"); - set.remove("10"); - set.remove("15"); + assertThatThrownBy(() -> set.remove("B")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } - tracker.resetSetPositionCount(); - set.findFirst(e -> false); + @Test + void removeFromEmptySetFails() { + var set = createIndexedSet(); - // Compaction should happen - assertThat(tracker.setPositionCount).isPositive(); + assertThatThrownBy(() -> set.remove("A")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); } @Test - void asListCompactsSet() { - var set = new IndexedSet<>(stringTracker); - + void removeAllElements() { + var set = createIndexedSet(); set.add("A"); set.add("B"); set.add("C"); - set.remove("B"); - var list = set.asList(); + set.remove("A"); + set.remove("B"); + set.remove("C"); - assertThat(list).containsExactlyInAnyOrder("A", "C"); + assertThat(set.size()).isZero(); + assertThat(set.isEmpty()).isTrue(); + assertThat(set.asList()).isEmpty(); } @Test - void asListOnSetWithOnlyGaps() { - var set = new IndexedSet<>(stringTracker); + void emptySetOperations() { + var set = createIndexedSet(); + assertThat(set.size()).isZero(); + assertThat(set.isEmpty()).isTrue(); + assertThat(set.asList()).isEmpty(); + assertThat(set.findFirst(e -> true)).isNull(); + + var elements = new ArrayList<>(); + set.forEach(elements::add); + assertThat(elements).isEmpty(); + } + + @Test + void forEachIteratesAllElements() { + var set = createIndexedSet(); set.add("A"); set.add("B"); - set.remove("A"); - set.remove("B"); + set.add("C"); - var list = set.asList(); + var elements = new ArrayList(); + set.forEach(elements::add); - assertThat(list).isEmpty(); + assertThat(elements).containsExactlyInAnyOrder("A", "B", "C"); } @Test - void findFirstStopsAtFirstMatch() { - var set = new IndexedSet<>(stringTracker); - var counter = new AtomicInteger(0); + void forEachWithGaps() { + var set = createIndexedSet(); + for (int i = 0; i < 20; i++) { + set.add("Element-" + i); + } + // Remove some elements to create gaps + set.remove("Element-5"); + set.remove("Element-10"); + set.remove("Element-15"); + + var elements = new ArrayList(); + set.forEach(elements::add); + assertThat(elements) + .hasSize(17) + .doesNotContain("Element-5", "Element-10", "Element-15"); + } + + @Test + void findFirstReturnsFirstMatch() { + var set = createIndexedSet(); set.add("A"); set.add("B"); set.add("C"); - var result = set.findFirst(element -> { - counter.incrementAndGet(); - return element.equals("B"); - }); + var result = set.findFirst(e -> e.equals("B")); assertThat(result).isEqualTo("B"); - assertThat(counter.get()).isLessThanOrEqualTo(3); } @Test - void findFirstWithAllGaps() { - var set = new IndexedSet<>(stringTracker); - + void findFirstReturnsNullWhenNoMatch() { + var set = createIndexedSet(); set.add("A"); set.add("B"); - set.add("C"); - set.remove("A"); - set.remove("B"); - set.remove("C"); + + var result = set.findFirst(e -> e.equals("C")); + + assertThat(result).isNull(); + } + + @Test + void findFirstOnEmptySet() { + var set = createIndexedSet(); var result = set.findFirst(e -> true); @@ -128,105 +176,134 @@ void findFirstWithAllGaps() { } @Test - void forEachOnSetAfterCompaction() { - var set = new IndexedSet<>(stringTracker); + void compactionTriggeredDuringForEach() { + var set = createIndexedSet(); + // Add enough elements to trigger compaction + for (int i = 0; i < 20; i++) { + set.add("Element-" + i); + } - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); - set.asList(); // Force compaction + // Remove elements to create gaps (more than 10% to trigger compaction) + set.remove("Element-1"); + set.remove("Element-3"); + set.remove("Element-5"); - var result = new ArrayList(); - set.forEach(result::add); + var elements = new ArrayList(); + set.forEach(elements::add); - assertThat(result).containsExactlyInAnyOrder("A", "C"); + assertThat(elements).hasSize(17); + assertThat(set.size()).isEqualTo(17); } @Test - void mixedOperationsWithCompaction() { - var set = new IndexedSet<>(stringTracker); - - for (int i = 0; i < 50; i++) { - set.add("E" + i); - } - for (int i = 0; i < 25; i += 2) { - set.remove("E" + i); + void compactionTriggeredDuringAsList() { + var set = createIndexedSet(); + for (int i = 0; i < 20; i++) { + set.add("Element-" + i); } - var list = set.asList(); // Force compaction + set.remove("Element-1"); + set.remove("Element-3"); + set.remove("Element-5"); + + var list = set.asList(); + + assertThat(list) + .hasSize(17) + .doesNotContainNull() + .doesNotContain("Element-1", "Element-3", "Element-5"); + } - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(list) - .hasSize(37); - for (int i = 1; i < 50; i += 2) { - softly.assertThat(list).contains("E" + i); + @Test + void multipleExternalRemovalsDuringForEach() { + var set = createIndexedSet(); + for (int i = 0; i < 30; i++) { + set.add("Element-" + i); + } + + var counter = new AtomicInteger(0); + set.forEach(element -> { + var count = counter.incrementAndGet(); + if (count == 5) { + set.remove("Element-15"); + } else if (count == 10) { + set.remove("Element-20"); + } else if (count == 15) { + set.remove("Element-25"); } }); + + assertThat(set.asList()) + .doesNotContain("Element-25", "Element-20", "Element-15"); } @Test - void removeFromMiddlePreservesOrder() { - var set = new IndexedSet<>(stringTracker); - + void removeAllElementsDuringForEach() { + var set = createIndexedSet(); set.add("A"); set.add("B"); set.add("C"); - set.add("D"); - set.add("E"); - set.remove("B"); - set.remove("D"); - var list = set.asList(); + var elements = new ArrayList(); + set.forEach(element -> { + elements.add(element); + set.remove(element); + }); - assertThat(list).containsExactlyInAnyOrder("A", "C", "E"); + assertThat(elements).hasSize(3); + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); } @Test - void gapAtEndIsRemoved() { - var set = new IndexedSet<>(stringTracker); + void findFirstWithExternalRemoval() { + var set = createIndexedSet(); + for (int i = 0; i < 20; i++) { + set.add("Element-" + i); + } - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("C"); + var found = set.findFirst(element -> { + if (element.equals("Element-5")) { + set.remove("Element-15"); + return true; + } + return false; + }); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "B"); + assertThat(found).isEqualTo("Element-5"); + assertThat(set.size()).isEqualTo(19); } @Test - void multipleGapsAtEndAreRemoved() { - var set = new IndexedSet<>(stringTracker); + void gapAtTheBackIsRemovedDirectly() { + var set = createIndexedSet(); + for (int i = 0; i < 20; i++) { + set.add("Element-" + i); + } - set.add("A"); - set.add("B"); - set.add("C"); - set.add("D"); - set.add("E"); - set.remove("D"); - set.remove("E"); + // Create a gap at position 10 + set.remove("Element-10"); - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "B", "C"); + // During forEach, compaction will move Element-19 to position 10 + // Then create a gap at position 19 (the back) + set.remove("Element-19"); + + var list = set.asList(); + assertThat(list) + .hasSize(18) + .doesNotContainNull(); } @Test - void addAfterRemovingLastElement() { - var set = new IndexedSet<>(stringTracker); + void asListReturnsEmptyListForEmptySet() { + var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.remove("B"); - set.add("C"); - - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactlyInAnyOrder("A", "C"); + assertThat(set.asList()).isEmpty(); } @Test - void sizeIsConsistentAfterOperations() { - var set = new IndexedSet<>(stringTracker); + void sizeIsCorrectAfterMultipleOperations() { + var set = createIndexedSet(); assertThat(set.size()).isZero(); @@ -241,73 +318,27 @@ void sizeIsConsistentAfterOperations() { set.add("C"); assertThat(set.size()).isEqualTo(2); - } - @Test - void emptyAfterRemovingAllElements() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.add("B"); - set.remove("A"); set.remove("B"); - - assertThat(set.isEmpty()).isTrue(); + set.remove("C"); assertThat(set.size()).isZero(); - assertThat(set.asList()).isEmpty(); - } - - @Test - void forEachEmptyAfterRemovals() { - var set = new IndexedSet<>(stringTracker); - - set.add("A"); - set.remove("A"); - - var result = new ArrayList(); - set.forEach(result::add); - - assertThat(result).isEmpty(); - } - - @NullMarked - private static final class SimpleTracker implements ElementPositionTracker { - - private final Map positions = new HashMap<>(); - - @Override - public void setPosition(T element, int position) { - positions.put(element, position); - } - - @Override - public int clearPosition(T element) { - var position = positions.remove(element); - return position == null ? -1 : position; - } } @NullMarked - private static final class CountingTracker implements ElementPositionTracker { + private static final class CompactingIndexPositionTracker implements ElementPositionTracker { - private final Map positions = new HashMap<>(); - private int setPositionCount = 0; + private final Map positionMap = new HashMap<>(); @Override public void setPosition(T element, int position) { - positions.put(element, position); - setPositionCount++; + positionMap.put(element, position); } @Override public int clearPosition(T element) { - var position = positions.remove(element); - return position == null ? -1 : position; + Integer result = positionMap.remove(element); + return result != null ? result : -1; } - void resetSetPositionCount() { - setPositionCount = 0; - } } - } From b053aaa3e70e99ab2bbf1a19aaf31b341b4fe9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 15:12:27 +0100 Subject: [PATCH 08/21] Less reliance on list size --- .../impl/bavet/common/index/IndexedSet.java | 124 +++++++++--------- 1 file changed, 64 insertions(+), 60 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index a09783c76c..559717ede1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -16,8 +16,8 @@ * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element * without knowing its position and without an expensive lookup. * It also allows for direct random access like a list. - * The original insertion order of elements may not be preserved during iteration, - * but it is deterministic and predictable. + * The order of iteration is not guaranteed to be the insertion order, + * but it is stable and predictable. *

* It uses an {@link ElementPositionTracker} to track the insertion position of each element. * When an element is removed, it is replaced by null at its insertion position; @@ -44,10 +44,11 @@ public final class IndexedSet { private static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 10; - private static final double GAP_RATIO_FOR_COMPACTION = 0.1; + private static final double GAP_RATIO_FOR_COMPACTION = 0.2; private final ElementPositionTracker elementPositionTracker; private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. + private int lastElementPosition = -1; private int gapCount = 0; public IndexedSet(ElementPositionTracker elementPositionTracker) { @@ -73,16 +74,8 @@ public IndexedSet(ElementPositionTracker elementPositionTracker) { */ public void add(T element) { var actualElementList = getElementList(); - var lastElementPosition = actualElementList.size() - 1; - if (gapCount > 0) { - if (actualElementList.get(lastElementPosition) == null) { // We can reuse the gap at the back. - putElementIntoGap(actualElementList, element, lastElementPosition); - return; - } - } - // Put the element at the end of the list. actualElementList.add(element); - elementPositionTracker.setPosition(element, lastElementPosition + 1); + elementPositionTracker.setPosition(element, ++lastElementPosition); } /** @@ -109,9 +102,10 @@ private boolean innerRemove(T element) { return false; } var actualElementList = getElementList(); - if (insertionPosition == actualElementList.size() - 1) { + if (insertionPosition == lastElementPosition) { // The element was the last one added; we can simply remove it. actualElementList.remove(insertionPosition); + lastElementPosition--; } else { // We replace the element with null, creating a gap. actualElementList.set(insertionPosition, null); @@ -120,15 +114,24 @@ private boolean innerRemove(T element) { return true; } + public boolean isEmpty() { + return size() == 0; + } + public int size() { - return elementList == null ? 0 : elementList.size() - gapCount; + return elementList == null ? 0 : lastElementPosition - gapCount + 1; } /** * Performs the given action for each element of the collection * until all elements have been processed. + * The order of iteration is not guaranteed to be the insertion order, + * but it is stable and predictable. * - * @param elementConsumer the action to be performed for each element + * @param elementConsumer the action to be performed for each element; + * may include removing elements from the collection, + * but additions or swaps are not allowed; + * undefined behavior will occur if that is attempted. */ public void forEach(Consumer elementConsumer) { if (isEmpty()) { @@ -145,12 +148,11 @@ public void forEach(Consumer elementConsumer) { return null; } - var elementCount = elementList.size(); - var shouldCompact = shouldCompact(elementCount); + var shouldCompact = shouldCompact(lastElementPosition + 1); // We remove gaps back to front so that we keep elements as close to their original position as possible. - for (var i = elementCount - 1; i >= 0; i--) { - if (elementCount == gapCount) { + for (var i = lastElementPosition; i >= 0; i--) { + if (lastElementPosition + 1 == gapCount) { // If all elements were removed, clear the list to free memory and terminate iteration. clearList(); return null; @@ -158,56 +160,66 @@ public void forEach(Consumer elementConsumer) { var element = elementList.get(i); if (element == null) { - if (shouldCompact) { - if (i < elementCount - 1) { - var elementToMove = elementList.remove(elementCount - 1); - putElementIntoGap(elementList, elementToMove, i); - } else { // The gap is at the back already. - elementList.remove(i); - gapCount--; - } - elementCount--; - if (gapCount == 0) { - shouldCompact = false; - } + if (!shouldCompact) { + continue; } + shouldCompact = !fillGap(i); } else { if (elementPredicate.test(element)) { return element; } - elementCount = elementList.size(); // Update in case that the predicate removed some elements. } } return null; } - private void putElementIntoGap(List<@Nullable T> elementList, T element, int gap) { - elementList.set(gap, element); - elementPositionTracker.setPosition(element, gap); - gapCount--; - } - - public @Nullable T findFirst(Predicate elementPredicate) { - return forEach(elementPredicate); + private boolean shouldCompact(int elementCount) { + if (gapCount == 0) { + return false; + } + if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { + return false; + } + var gapPercentage = gapCount / (double) elementCount; + return gapPercentage > GAP_RATIO_FOR_COMPACTION; } private void clearList() { if (elementList != null) { elementList.clear(); gapCount = 0; + lastElementPosition = -1; } } - private boolean shouldCompact(int elementCount) { - if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { - return false; + /** + * Fills the gap at position i by moving the last element into it. + * + * @param i the position of the gap to fill + * @return true if there are no more gaps after filling this one + */ + private boolean fillGap(int i) { + if (i < lastElementPosition) { // Fill the gap if there are elements after it. + var elementToMove = elementList.remove(lastElementPosition); + elementList.set(i, elementToMove); + elementPositionTracker.setPosition(elementToMove, i); + } else { // The gap is at the back already. + elementList.remove(i); } - var gapPercentage = gapCount / (double) elementCount; - return gapPercentage > GAP_RATIO_FOR_COMPACTION; + lastElementPosition--; + gapCount--; + return gapCount == 0; } - public boolean isEmpty() { - return size() == 0; + /** + * As defined by {@link #forEach(Consumer)}, + * but stops when the predicate returns true for an element. + * + * @param elementPredicate the predicate to be tested for each element + * @return the first element for which the predicate returned true, or null if none + */ + public @Nullable T findFirst(Predicate elementPredicate) { + return forEach(elementPredicate); } /** @@ -226,24 +238,16 @@ public List asList() { private void forceCompaction() { // We remove gaps back to front so that we keep elements as close to their original position as possible. - var elementCount = elementList.size(); - for (var i = elementCount - 1; i >= 0; i--) { - if (elementCount == gapCount) { + for (var i = lastElementPosition; i >= 0; i--) { + if (lastElementPosition + 1 == gapCount) { // If all elements were removed, clear the list to free memory and terminate iteration. clearList(); return; } - if (elementList.get(i) == null) { - if (i < elementCount - 1) { - var element = elementList.remove(elementCount - 1); - putElementIntoGap(elementList, element, i); - } else { // The gap is at the back already. - elementList.remove(i); - gapCount--; - } - elementCount--; - if (gapCount == 0) { + var element = elementList.get(i); + if (element == null) { + if (fillGap(i)) { // If there are no more gaps, we can stop. return; } } From fa44ae7af93e415e643ffc5fc9ce56d340f615ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 21:12:38 +0100 Subject: [PATCH 09/21] Naming --- .../bavet/common/AbstractIfExistsNode.java | 34 +++++++++---------- .../common/AbstractIndexedIfExistsNode.java | 33 ++++++++---------- .../common/AbstractUnindexedIfExistsNode.java | 33 ++++++++---------- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index b73360c97a..73ab6cac60 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -20,8 +20,8 @@ public abstract class AbstractIfExistsNode> { protected final boolean shouldExist; - protected final int inputStoreIndexLeftTrackerSet; // -1 if !isFiltering - protected final int inputStoreIndexRightTrackerSet; // -1 if !isFiltering + protected final int inputStoreIndexLeftHandleSet; // -1 if !isFiltering + protected final int inputStoreIndexRightHandleSet; // -1 if !isFiltering protected final boolean isFiltering; private final DynamicPropagationQueue> propagationQueue; @@ -29,8 +29,8 @@ protected AbstractIfExistsNode(boolean shouldExist, TupleStorePositionTracker le TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { this.shouldExist = shouldExist; - this.inputStoreIndexLeftTrackerSet = isFiltering ? leftTupleStorePositionTracker.reserveNextAvailablePosition() : -1; - this.inputStoreIndexRightTrackerSet = isFiltering ? rightTupleStorePositionTracker.reserveNextAvailablePosition() : -1; + this.inputStoreIndexLeftHandleSet = isFiltering ? leftTupleStorePositionTracker.reserveNextAvailablePosition() : -1; + this.inputStoreIndexRightHandleSet = isFiltering ? rightTupleStorePositionTracker.reserveNextAvailablePosition() : -1; this.isFiltering = isFiltering; this.propagationQueue = new DynamicPropagationQueue<>(nextNodesTupleLifecycle); } @@ -114,26 +114,26 @@ protected void decrementCounterRight(ExistsCounter counter) { } // Else do not even propagate an update } - IndexedSet> updateRightTrackerSet(UniTuple rightTuple) { - IndexedSet> rightTrackerSet = rightTuple.getStore(inputStoreIndexRightTrackerSet); - rightTrackerSet.forEach(tuple -> { - decrementCounterRight(tuple.counter); - tuple.remove(); + IndexedSet> updateRightHandleSet(UniTuple rightTuple) { + IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); + rightHandleSet.forEach(handle -> { + decrementCounterRight(handle.counter); + handle.remove(); }); - return rightTrackerSet; + return rightHandleSet; } void updateCounterFromLeft(LeftTuple_ leftTuple, UniTuple rightTuple, ExistsCounter counter, - IndexedSet> leftTrackerSet) { + IndexedSet> leftHandleSet) { if (testFiltering(leftTuple, rightTuple)) { counter.countRight++; - IndexedSet> rightTrackerSet = rightTuple.getStore(inputStoreIndexRightTrackerSet); - new ExistsCounterHandle<>(counter, leftTrackerSet, rightTrackerSet); + IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); + new ExistsCounterHandle<>(counter, leftHandleSet, rightHandleSet); } } void updateCounterFromRight(UniTuple rightTuple, ExistsCounter counter, - IndexedSet> rightTrackerSet) { + IndexedSet> rightHandleSet) { var leftTuple = counter.leftTuple; if (!leftTuple.state.isActive()) { // Assume the following scenario: @@ -154,9 +154,9 @@ void updateCounterFromRight(UniTuple rightTuple, ExistsCounter> leftTrackerSet = - counter.leftTuple.getStore(inputStoreIndexLeftTrackerSet); - new ExistsCounterHandle<>(counter, leftTrackerSet, rightTrackerSet); + IndexedSet> leftHandleSet = + counter.leftTuple.getStore(inputStoreIndexLeftHandleSet); + new ExistsCounterHandle<>(counter, leftHandleSet, rightHandleSet); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index 8bcc9170d9..4c151846d4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -68,10 +68,10 @@ private void updateCounterRight(LeftTuple_ leftTuple, Object indexKeys, ExistsCo if (!isFiltering) { counter.countRight = indexerRight.size(indexKeys); } else { - var leftTrackerSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); + var leftHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); indexerRight.forEach(indexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerSet)); - leftTuple.setStore(inputStoreIndexLeftTrackerSet, leftTrackerSet); + rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftHandleSet)); + leftTuple.setStore(inputStoreIndexLeftHandleSet, leftHandleSet); } } @@ -93,12 +93,11 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - IndexedSet> leftTrackerSet = - leftTuple.getStore(inputStoreIndexLeftTrackerSet); - leftTrackerSet.forEach(ExistsCounterHandle::remove); + IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); + leftHandleSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; indexerRight.forEach(oldIndexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftTrackerSet)); + rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftHandleSet)); updateCounterLeft(counter); } } else { @@ -126,9 +125,8 @@ public final void retractLeft(LeftTuple_ leftTuple) { private void updateIndexerLeft(Object indexKeys, ExistsCounter counter, LeftTuple_ leftTuple) { indexerLeft.remove(indexKeys, counter); if (isFiltering) { - IndexedSet> leftTrackerSet = - leftTuple.getStore(inputStoreIndexLeftTrackerSet); - leftTrackerSet.forEach(ExistsCounterHandle::remove); + IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); + leftHandleSet.forEach(ExistsCounterHandle::remove); } } @@ -150,10 +148,9 @@ private void updateCounterLeft(UniTuple rightTuple, Object indexKeys) { if (!isFiltering) { indexerLeft.forEach(indexKeys, this::incrementCounterRight); } else { - var rightTrackerSet = - new IndexedSet>(ExistsCounterHandlePositionTracker.right()); - indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(rightTuple, counter, rightTrackerSet)); - rightTuple.setStore(inputStoreIndexRightTrackerSet, rightTrackerSet); + var rightHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); + indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(rightTuple, counter, rightHandleSet)); + rightTuple.setStore(inputStoreIndexRightHandleSet, rightHandleSet); } } @@ -169,16 +166,16 @@ public final void updateRight(UniTuple rightTuple) { if (oldIndexKeys.equals(newIndexKeys)) { // No need for re-indexing because the index keys didn't change if (isFiltering) { - var rightTrackerSet = updateRightTrackerSet(rightTuple); + var rightHandleSet = updateRightHandleSet(rightTuple); indexerLeft.forEach(oldIndexKeys, - counter -> updateCounterFromRight(rightTuple, counter, rightTrackerSet)); + counter -> updateCounterFromRight(rightTuple, counter, rightHandleSet)); } } else { indexerRight.remove(oldIndexKeys, rightTuple); if (!isFiltering) { indexerLeft.forEach(oldIndexKeys, this::decrementCounterRight); } else { - updateRightTrackerSet(rightTuple); + updateRightHandleSet(rightTuple); } rightTuple.setStore(inputStoreIndexRightKeys, newIndexKeys); indexerRight.put(newIndexKeys, rightTuple); @@ -197,7 +194,7 @@ public final void retractRight(UniTuple rightTuple) { if (!isFiltering) { indexerLeft.forEach(indexKeys, this::decrementCounterRight); } else { - updateRightTrackerSet(rightTuple); + updateRightHandleSet(rightTuple); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index 52626016fa..d200881414 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -31,8 +31,7 @@ protected AbstractUnindexedIfExistsNode(boolean shouldExist, TupleStorePositionT super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); this.inputStoreIndexRightTuple = rightTupleStorePositionTracker.reserveNextAvailablePosition(); - this.leftCounterSet = new IndexedSet<>( - new ExistsCounterPositionTracker<>()); + this.leftCounterSet = new IndexedSet<>(ExistsCounterPositionTracker.instance()); this.rightTupleSet = new IndexedSet<>(new TuplePositionTracker<>(inputStoreIndexRightTuple)); } @@ -50,10 +49,9 @@ public final void insertLeft(LeftTuple_ leftTuple) { if (!isFiltering) { counter.countRight = rightTupleSet.size(); } else { - var leftTrackerSet = - new IndexedSet>(ExistsCounterHandlePositionTracker.left()); - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerSet)); - leftTuple.setStore(inputStoreIndexLeftTrackerSet, leftTrackerSet); + var leftHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftHandleSet)); + leftTuple.setStore(inputStoreIndexLeftHandleSet, leftHandleSet); } initCounterLeft(counter); } @@ -71,11 +69,10 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - IndexedSet> leftTrackerSet = - leftTuple.getStore(inputStoreIndexLeftTrackerSet); - leftTrackerSet.forEach(ExistsCounterHandle::remove); + IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); + leftHandleSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftTrackerSet)); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftHandleSet)); updateCounterLeft(counter); } } @@ -89,8 +86,8 @@ public final void retractLeft(LeftTuple_ leftTuple) { } leftCounterSet.remove(counter); if (isFiltering) { - IndexedSet> leftTrackerSet = leftTuple.getStore(inputStoreIndexLeftTrackerSet); - leftTrackerSet.forEach(ExistsCounterHandle::remove); + IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); + leftHandleSet.forEach(ExistsCounterHandle::remove); } killCounterLeft(counter); } @@ -106,9 +103,9 @@ public final void insertRight(UniTuple rightTuple) { if (!isFiltering) { leftCounterSet.forEach(this::incrementCounterRight); } else { - var rightTrackerSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); - leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightTrackerSet)); - rightTuple.setStore(inputStoreIndexRightTrackerSet, rightTrackerSet); + var rightHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); + leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightHandleSet)); + rightTuple.setStore(inputStoreIndexRightHandleSet, rightHandleSet); } } @@ -120,8 +117,8 @@ public final void updateRight(UniTuple rightTuple) { return; } if (isFiltering) { - var rightTrackerSet = updateRightTrackerSet(rightTuple); - leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightTrackerSet)); + var rightHandleSet = updateRightHandleSet(rightTuple); + leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightHandleSet)); } } @@ -135,7 +132,7 @@ public final void retractRight(UniTuple rightTuple) { if (!isFiltering) { leftCounterSet.forEach(this::decrementCounterRight); } else { - updateRightTrackerSet(rightTuple); + updateRightHandleSet(rightTuple); } } From 8c3f60ff12f330e701f0e21126869a6fb6bbcfb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 18 Nov 2025 22:06:47 +0100 Subject: [PATCH 10/21] Optimize ifExists? --- .../bavet/common/AbstractIfExistsNode.java | 16 +++++--------- .../common/AbstractIndexedIfExistsNode.java | 22 +++++++------------ .../common/AbstractUnindexedIfExistsNode.java | 14 +++++------- .../core/impl/bavet/common/ExistsCounter.java | 2 ++ .../bavet/common/ExistsCounterHandle.java | 9 +++----- 5 files changed, 23 insertions(+), 40 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index 73ab6cac60..e9b47874b4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -20,16 +20,13 @@ public abstract class AbstractIfExistsNode> { protected final boolean shouldExist; - protected final int inputStoreIndexLeftHandleSet; // -1 if !isFiltering protected final int inputStoreIndexRightHandleSet; // -1 if !isFiltering protected final boolean isFiltering; private final DynamicPropagationQueue> propagationQueue; - protected AbstractIfExistsNode(boolean shouldExist, TupleStorePositionTracker leftTupleStorePositionTracker, - TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, - boolean isFiltering) { + protected AbstractIfExistsNode(boolean shouldExist, TupleStorePositionTracker rightTupleStorePositionTracker, + TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { this.shouldExist = shouldExist; - this.inputStoreIndexLeftHandleSet = isFiltering ? leftTupleStorePositionTracker.reserveNextAvailablePosition() : -1; this.inputStoreIndexRightHandleSet = isFiltering ? rightTupleStorePositionTracker.reserveNextAvailablePosition() : -1; this.isFiltering = isFiltering; this.propagationQueue = new DynamicPropagationQueue<>(nextNodesTupleLifecycle); @@ -123,12 +120,11 @@ IndexedSet> updateRightHandleSet(UniTuple rightTuple, ExistsCounter counter, - IndexedSet> leftHandleSet) { + void updateCounterFromLeft(LeftTuple_ leftTuple, UniTuple rightTuple, ExistsCounter counter) { if (testFiltering(leftTuple, rightTuple)) { counter.countRight++; IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); - new ExistsCounterHandle<>(counter, leftHandleSet, rightHandleSet); + new ExistsCounterHandle<>(counter, rightHandleSet); } } @@ -154,9 +150,7 @@ void updateCounterFromRight(UniTuple rightTuple, ExistsCounter> leftHandleSet = - counter.leftTuple.getStore(inputStoreIndexLeftHandleSet); - new ExistsCounterHandle<>(counter, leftHandleSet, rightHandleSet); + new ExistsCounterHandle<>(counter, rightHandleSet); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index 4c151846d4..f7f767989f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -36,7 +36,7 @@ protected AbstractIndexedIfExistsNode(boolean shouldExist, TupleStorePositionTracker leftTupleStorePositionTracker, TupleStorePositionTracker rightTupleStorePositionTracker, TupleLifecycle nextNodesTupleLifecycle, boolean isFiltering) { - super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); + super(shouldExist, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); this.keysExtractorLeft = keysExtractorLeft; this.keysExtractorRight = indexerFactory.buildRightKeysExtractor(); this.inputStoreIndexLeftKeys = leftTupleStorePositionTracker.reserveNextAvailablePosition(); @@ -68,10 +68,7 @@ private void updateCounterRight(LeftTuple_ leftTuple, Object indexKeys, ExistsCo if (!isFiltering) { counter.countRight = indexerRight.size(indexKeys); } else { - var leftHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); - indexerRight.forEach(indexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftHandleSet)); - leftTuple.setStore(inputStoreIndexLeftHandleSet, leftHandleSet); + indexerRight.forEach(indexKeys, rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter)); } } @@ -93,15 +90,13 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); - leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.leftHandleSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; - indexerRight.forEach(oldIndexKeys, - rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter, leftHandleSet)); + indexerRight.forEach(oldIndexKeys, rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter)); updateCounterLeft(counter); } } else { - updateIndexerLeft(oldIndexKeys, counter, leftTuple); + updateIndexerLeft(oldIndexKeys, counter); counter.countRight = 0; leftTuple.setStore(inputStoreIndexLeftKeys, newIndexKeys); indexerLeft.put(newIndexKeys, counter); @@ -118,15 +113,14 @@ public final void retractLeft(LeftTuple_ leftTuple) { return; } ExistsCounter counter = leftTuple.getStore(inputStoreIndexLeftCounter); - updateIndexerLeft(indexKeys, counter, leftTuple); + updateIndexerLeft(indexKeys, counter); killCounterLeft(counter); } - private void updateIndexerLeft(Object indexKeys, ExistsCounter counter, LeftTuple_ leftTuple) { + private void updateIndexerLeft(Object indexKeys, ExistsCounter counter) { indexerLeft.remove(indexKeys, counter); if (isFiltering) { - IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); - leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.leftHandleSet.forEach(ExistsCounterHandle::remove); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index d200881414..f519518869 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -28,7 +28,7 @@ public abstract class AbstractUnindexedIfExistsNode nextNodesTupleLifecycle, boolean isFiltering) { - super(shouldExist, leftTupleStorePositionTracker, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); + super(shouldExist, rightTupleStorePositionTracker, nextNodesTupleLifecycle, isFiltering); this.inputStoreIndexLeftCounter = leftTupleStorePositionTracker.reserveNextAvailablePosition(); this.inputStoreIndexRightTuple = rightTupleStorePositionTracker.reserveNextAvailablePosition(); this.leftCounterSet = new IndexedSet<>(ExistsCounterPositionTracker.instance()); @@ -49,9 +49,7 @@ public final void insertLeft(LeftTuple_ leftTuple) { if (!isFiltering) { counter.countRight = rightTupleSet.size(); } else { - var leftHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.left()); - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftHandleSet)); - leftTuple.setStore(inputStoreIndexLeftHandleSet, leftHandleSet); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter)); } initCounterLeft(counter); } @@ -69,10 +67,9 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); - leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.leftHandleSet.forEach(ExistsCounterHandle::remove); counter.countRight = 0; - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter, leftHandleSet)); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter)); updateCounterLeft(counter); } } @@ -86,8 +83,7 @@ public final void retractLeft(LeftTuple_ leftTuple) { } leftCounterSet.remove(counter); if (isFiltering) { - IndexedSet> leftHandleSet = leftTuple.getStore(inputStoreIndexLeftHandleSet); - leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.leftHandleSet.forEach(ExistsCounterHandle::remove); } killCounterLeft(counter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java index 623f12c159..5c0abaeaa5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.bavet.common; +import ai.timefold.solver.core.impl.bavet.common.index.IndexedSet; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; @@ -10,6 +11,7 @@ public final class ExistsCounter extends AbstractPropagationMetadataCarrier { final Tuple_ leftTuple; + final IndexedSet> leftHandleSet = new IndexedSet<>(ExistsCounterHandlePositionTracker.left()); TupleState state = TupleState.DEAD; // It's the node's job to mark a new instance as CREATING. int countRight = 0; int indexedSetPositon = -1; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java index ac10f4f385..7c5a66d582 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -17,22 +17,19 @@ final class ExistsCounterHandle { final ExistsCounter counter; - private final IndexedSet> leftSet; private final IndexedSet> rightSet; int leftPosition = -1; int rightPosition = -1; - ExistsCounterHandle(ExistsCounter counter, IndexedSet> leftSet, - IndexedSet> rightSet) { + ExistsCounterHandle(ExistsCounter counter, IndexedSet> rightSet) { this.counter = counter; - this.leftSet = leftSet; - leftSet.add(this); + counter.leftHandleSet.add(this); this.rightSet = rightSet; rightSet.add(this); } public void remove() { - leftSet.remove(this); + counter.leftHandleSet.remove(this); rightSet.remove(this); } From 5eace12f3e1dd522a1914690c7974313e52b12f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 08:18:53 +0100 Subject: [PATCH 11/21] Optimize iteration --- .../impl/bavet/common/index/IndexedSet.java | 46 ++- .../bavet/common/index/IndexedSetTest.java | 341 ++++++++++++++++-- 2 files changed, 334 insertions(+), 53 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 559717ede1..52d7e0bf68 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -24,6 +24,9 @@ * therefore the insertion position of later elements is not changed. * The set is compacted back during iteration or when {@link #asList()} is called, * by replacing these gaps with elements from the back of the set. + * This operation also doesn't change the insertion position of any elements + * except for those that are moved from the back to fill a gap. + * Therefore the insertion positions, on average, remain stable over time. *

* Together with the fact that removals are relatively rare, * this keeps the overhead low while giving us all benefits of {@link ArrayList}, @@ -43,8 +46,9 @@ @NullMarked public final class IndexedSet { - private static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 10; - private static final double GAP_RATIO_FOR_COMPACTION = 0.2; + // Compaction during forEach() only makes performance sense for larger sets with a significant amount of gaps. + private static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 100; + private static final double GAP_RATIO_FOR_COMPACTION = 0.1; private final ElementPositionTracker elementPositionTracker; private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. @@ -149,8 +153,31 @@ public void forEach(Consumer elementConsumer) { } var shouldCompact = shouldCompact(lastElementPosition + 1); + return shouldCompact ? forEachCompacting(elementPredicate) : forEachNonCompacting(elementPredicate); + } + + private boolean shouldCompact(int elementCount) { + if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { + return false; + } + var gapPercentage = gapCount / (double) elementCount; + return gapPercentage > GAP_RATIO_FOR_COMPACTION; + } + private @Nullable T forEachNonCompacting(Predicate elementPredicate) { + // We iterate back to front for consistency with the compacting version. + for (var i = lastElementPosition; i >= 0; i--) { + var element = elementList.get(i); + if (element != null && elementPredicate.test(element)) { + return element; + } + } + return null; + } + + private @Nullable T forEachCompacting(Predicate elementPredicate) { // We remove gaps back to front so that we keep elements as close to their original position as possible. + var shouldCompact = true; for (var i = lastElementPosition; i >= 0; i--) { if (lastElementPosition + 1 == gapCount) { // If all elements were removed, clear the list to free memory and terminate iteration. @@ -173,17 +200,6 @@ public void forEach(Consumer elementConsumer) { return null; } - private boolean shouldCompact(int elementCount) { - if (gapCount == 0) { - return false; - } - if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { - return false; - } - var gapPercentage = gapCount / (double) elementCount; - return gapPercentage > GAP_RATIO_FOR_COMPACTION; - } - private void clearList() { if (elementList != null) { elementList.clear(); @@ -232,7 +248,9 @@ public List asList() { if (elementList == null) { return Collections.emptyList(); } - forceCompaction(); + if (gapCount > 0) { // The list must not return any nulls. + forceCompaction(); + } return elementList.isEmpty() ? Collections.emptyList() : elementList; } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 4d8ee23bc6..7186e29958 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -175,45 +175,6 @@ void findFirstOnEmptySet() { assertThat(result).isNull(); } - @Test - void compactionTriggeredDuringForEach() { - var set = createIndexedSet(); - // Add enough elements to trigger compaction - for (int i = 0; i < 20; i++) { - set.add("Element-" + i); - } - - // Remove elements to create gaps (more than 10% to trigger compaction) - set.remove("Element-1"); - set.remove("Element-3"); - set.remove("Element-5"); - - var elements = new ArrayList(); - set.forEach(elements::add); - - assertThat(elements).hasSize(17); - assertThat(set.size()).isEqualTo(17); - } - - @Test - void compactionTriggeredDuringAsList() { - var set = createIndexedSet(); - for (int i = 0; i < 20; i++) { - set.add("Element-" + i); - } - - set.remove("Element-1"); - set.remove("Element-3"); - set.remove("Element-5"); - - var list = set.asList(); - - assertThat(list) - .hasSize(17) - .doesNotContainNull() - .doesNotContain("Element-1", "Element-3", "Element-5"); - } - @Test void multipleExternalRemovalsDuringForEach() { var set = createIndexedSet(); @@ -324,6 +285,308 @@ void sizeIsCorrectAfterMultipleOperations() { assertThat(set.size()).isZero(); } + @Test + void compactionTriggersAtCorrectThreshold() { + var set = createIndexedSet(); + // Add 100 elements (minimum for compaction) + for (int i = 0; i < 100; i++) { + set.add("Element-" + i); + } + + // Remove 10 elements to create exactly 10% gaps (threshold) + for (int i = 0; i < 10; i++) { + set.remove("Element-" + i); + } + + // One more removal should trigger compaction during forEach + set.remove("Element-10"); + + var elements = new ArrayList(); + set.forEach(elements::add); + + assertThat(elements).hasSize(89); + assertThat(set.asList()).hasSize(89).doesNotContainNull(); + } + + @Test + void compactionDoesNotTriggerBelowMinimumElements() { + var set = createIndexedSet(); + // Add 99 elements (below minimum for compaction) + for (int i = 0; i < 99; i++) { + set.add("Element-" + i); + } + + // Remove 50% of elements (well above threshold) + for (int i = 0; i < 50; i++) { + set.remove("Element-" + i); + } + + var elements = new ArrayList(); + set.forEach(elements::add); + + assertThat(elements).hasSize(49); + // Even though gaps exist, compaction shouldn't have happened during forEach + // It should happen during asList() instead + assertThat(set.asList()).hasSize(49).doesNotContainNull(); + } + + @Test + void compactionDoesNotTriggerBelowGapThreshold() { + var set = createIndexedSet(); + // Add 100 elements + for (int i = 0; i < 100; i++) { + set.add("Element-" + i); + } + + // Remove 9 elements (9% gaps, below 10% threshold) + for (int i = 0; i < 9; i++) { + set.remove("Element-" + i); + } + + var elements = new ArrayList(); + set.forEach(elements::add); + + assertThat(elements).hasSize(91); + } + + @Test + void externalRemovalDuringCompactionAtStart() { + var set = createIndexedSet(); + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction (20 gaps = 13.3%) + for (int i = 0; i < 20; i++) { + set.remove("Element-" + i); + } + + var processedElements = new ArrayList(); + var counter = new AtomicInteger(0); + + set.forEach(element -> { + processedElements.add(element); + if (counter.incrementAndGet() == 1) { + // Remove element during compaction (compaction happens back-to-front) + set.remove("Element-100"); + } + }); + + assertThat(processedElements).hasSize(129); // 150 - 20 - 1 + assertThat(set.asList()).doesNotContain("Element-100").doesNotContainNull(); + } + + @Test + void externalRemovalDuringCompactionAtEnd() { + var set = createIndexedSet(); + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction + for (int i = 0; i < 20; i++) { + set.remove("Element-" + i); + } + + var processedElements = new ArrayList(); + var counter = new AtomicInteger(0); + + set.forEach(element -> { + if (counter.incrementAndGet() == 100) { + // Remove element near the end during compaction + set.remove("Element-149"); + } else { + processedElements.add(element); + } + }); + + assertThat(processedElements).hasSize(129); + assertThat(set.asList()) + .doesNotContain("Element-149") + .doesNotContainNull(); + } + + @Test + void multipleExternalRemovalsDuringCompaction() { + var set = createIndexedSet(); + for (int i = 0; i < 201; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction (25 gaps = 12.5%) + var startFrom = 25; + for (int i = 0; i < startFrom; i++) { + set.remove("Element-" + i); + } + + var counter = new AtomicInteger(0); + var removedExternally = new ArrayList(); + + set.forEach(element -> { + var count = counter.incrementAndGet(); + if (count % 20 == 0) { + var toRemove = "Element-" + ((count / 10) + startFrom); + set.remove(toRemove); + removedExternally.add(toRemove); + } + }); + + assertThat(set.asList()) + .doesNotContainAnyElementsOf(removedExternally) + .doesNotContainNull(); + } + + @Test + void externalRemovalOfElementBeingCompacted() { + var set = createIndexedSet(); + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Create gaps in the middle to trigger compaction + for (int i = 50; i < 70; i++) { + set.remove("Element-" + i); + } + + var processedElements = new ArrayList(); + + set.forEach(element -> { + processedElements.add(element); + // Try to remove an element that might be moved during compaction + if (element.equals("Element-70")) { + set.remove("Element-149"); // Last element, likely to be moved + } + }); + + assertThat(set.asList()).doesNotContain("Element-149").doesNotContainNull(); + } + + @Test + void removeAllElementsDuringCompaction() { + var set = createIndexedSet(); + for (int i = 0; i < 120; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction (15 gaps = 12.5%) + for (int i = 0; i < 15; i++) { + set.remove("Element-" + i); + } + + set.forEach(element -> { + set.remove(element); + }); + + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + assertThat(set.asList()).isEmpty(); + } + + @Test + void compactionClearsListWhenAllElementsRemoved() { + var set = createIndexedSet(); + for (int i = 0; i < 100; i++) { + set.add("Element-" + i); + } + + // Remove all elements + for (int i = 0; i < 100; i++) { + set.remove("Element-" + i); + } + + // Trigger compaction through forEach + set.forEach(element -> { + }); + + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + assertThat(set.asList()).isEmpty(); + } + + @Test + void asListForcesCompaction() { + var set = createIndexedSet(); + for (int i = 0; i < 50; i++) { + set.add("Element-" + i); + } + + // Create gaps (but below threshold for forEach compaction) + for (int i = 0; i < 10; i++) { + set.remove("Element-" + i); + } + + // asList should force compaction regardless of threshold + var list = set.asList(); + + assertThat(list) + .hasSize(40) + .doesNotContainNull(); + } + + @Test + void findFirstTriggersCompactionAndReturnsCorrectElement() { + var set = createIndexedSet(); + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction + for (int i = 0; i < 20; i++) { + set.remove("Element-" + i); + } + + var found = set.findFirst(element -> element.equals("Element-100")); + + assertThat(found).isEqualTo("Element-100"); + assertThat(set.asList()).hasSize(130).doesNotContainNull(); + } + + @Test + void findFirstWithExternalRemovalDuringCompaction() { + var set = createIndexedSet(); + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Create gaps to trigger compaction + for (int i = 0; i < 20; i++) { + set.remove("Element-" + i); + } + + var counter = new AtomicInteger(0); + var found = set.findFirst(element -> { + if (counter.incrementAndGet() == 10) { + set.remove("Element-100"); + } + return element.equals("Element-50"); + }); + + assertThat(found).isEqualTo("Element-50"); + assertThat(set.asList()).doesNotContain("Element-100").hasSize(129); + } + + @Test + void compactionPreservesElementsCloseToOriginalPosition() { + var tracker = new CompactingIndexPositionTracker(); + var set = new IndexedSet<>(tracker); + + for (int i = 0; i < 150; i++) { + set.add("Element-" + i); + } + + // Remove first 20 elements to create gaps at the beginning + for (int i = 0; i < 20; i++) { + set.remove("Element-" + i); + } + + // Force compaction + set.asList(); + + // Elements from the back should have moved forward + // The last element (Element-149) should now be at position 0 + assertThat(tracker.clearPosition("Element-149")).isLessThan(20); + } + @NullMarked private static final class CompactingIndexPositionTracker implements ElementPositionTracker { From 51dce0adb91517789491d7d45e475e60299dffae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 08:37:24 +0100 Subject: [PATCH 12/21] Further ifExists optimization --- .../bavet/common/AbstractIfExistsNode.java | 2 +- .../common/AbstractIndexedIfExistsNode.java | 5 +-- .../common/AbstractUnindexedIfExistsNode.java | 5 +-- .../core/impl/bavet/common/ExistsCounter.java | 9 +++++ .../bavet/common/ExistsCounterHandle.java | 10 ++--- .../impl/bavet/common/index/IndexedSet.java | 39 ++++++++++--------- 6 files changed, 40 insertions(+), 30 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index e9b47874b4..2779873b7d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -114,8 +114,8 @@ protected void decrementCounterRight(ExistsCounter counter) { IndexedSet> updateRightHandleSet(UniTuple rightTuple) { IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); rightHandleSet.forEach(handle -> { - decrementCounterRight(handle.counter); handle.remove(); + decrementCounterRight(handle.counter); }); return rightHandleSet; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index f7f767989f..04e157e7aa 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -90,8 +90,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - counter.leftHandleSet.forEach(ExistsCounterHandle::remove); - counter.countRight = 0; + counter.clearIncludingCount(); indexerRight.forEach(oldIndexKeys, rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter)); updateCounterLeft(counter); } @@ -120,7 +119,7 @@ public final void retractLeft(LeftTuple_ leftTuple) { private void updateIndexerLeft(Object indexKeys, ExistsCounter counter) { indexerLeft.remove(indexKeys, counter); if (isFiltering) { - counter.leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.clearWithoutCount(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index f519518869..479f5c157a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -67,8 +67,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { updateUnchangedCounterLeft(counter); } else { // Call filtering for the leftTuple and rightTuple combinations again - counter.leftHandleSet.forEach(ExistsCounterHandle::remove); - counter.countRight = 0; + counter.clearIncludingCount(); rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter)); updateCounterLeft(counter); } @@ -83,7 +82,7 @@ public final void retractLeft(LeftTuple_ leftTuple) { } leftCounterSet.remove(counter); if (isFiltering) { - counter.leftHandleSet.forEach(ExistsCounterHandle::remove); + counter.clearWithoutCount(); } killCounterLeft(counter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java index 5c0abaeaa5..1cb680cf59 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java @@ -20,6 +20,15 @@ public final class ExistsCounter this.leftTuple = leftTuple; } + public void clearWithoutCount() { + leftHandleSet.forEach(ExistsCounterHandle::remove); + } + + public void clearIncludingCount() { + clearWithoutCount(); + countRight = 0; + } + @Override public Tuple_ getTuple() { return leftTuple; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java index 7c5a66d582..54bcbfff5e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -17,20 +17,20 @@ final class ExistsCounterHandle { final ExistsCounter counter; - private final IndexedSet> rightSet; + final IndexedSet> rightHandleSet; int leftPosition = -1; int rightPosition = -1; - ExistsCounterHandle(ExistsCounter counter, IndexedSet> rightSet) { + ExistsCounterHandle(ExistsCounter counter, IndexedSet> rightHandleSet) { this.counter = counter; counter.leftHandleSet.add(this); - this.rightSet = rightSet; - rightSet.add(this); + this.rightHandleSet = rightHandleSet; + rightHandleSet.add(this); } public void remove() { counter.leftHandleSet.remove(this); - rightSet.remove(this); + rightHandleSet.remove(this); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 52d7e0bf68..863b37d773 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -115,6 +115,7 @@ private boolean innerRemove(T element) { actualElementList.set(insertionPosition, null); gapCount++; } + clearIfPossible(); return true; } @@ -148,15 +149,11 @@ public void forEach(Consumer elementConsumer) { } private @Nullable T forEach(Predicate elementPredicate) { - if (isEmpty()) { - return null; - } - - var shouldCompact = shouldCompact(lastElementPosition + 1); - return shouldCompact ? forEachCompacting(elementPredicate) : forEachNonCompacting(elementPredicate); + return shouldCompact() ? forEachCompacting(elementPredicate) : forEachNonCompacting(elementPredicate); } - private boolean shouldCompact(int elementCount) { + private boolean shouldCompact() { + int elementCount = lastElementPosition + 1; if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { return false; } @@ -166,7 +163,9 @@ private boolean shouldCompact(int elementCount) { private @Nullable T forEachNonCompacting(Predicate elementPredicate) { // We iterate back to front for consistency with the compacting version. - for (var i = lastElementPosition; i >= 0; i--) { + // The predicate may remove elements during iteration, + // therefore we check every time that the list still has elements. + for (var i = lastElementPosition; i >= 0 && lastElementPosition >= 0; i--) { var element = elementList.get(i); if (element != null && elementPredicate.test(element)) { return element; @@ -176,12 +175,12 @@ private boolean shouldCompact(int elementCount) { } private @Nullable T forEachCompacting(Predicate elementPredicate) { - // We remove gaps back to front so that we keep elements as close to their original position as possible. var shouldCompact = true; - for (var i = lastElementPosition; i >= 0; i--) { - if (lastElementPosition + 1 == gapCount) { - // If all elements were removed, clear the list to free memory and terminate iteration. - clearList(); + // We remove gaps back to front so that we keep elements as close to their original position as possible. + // The predicate may remove elements during iteration, + // therefore we check every time that the list still has elements. + for (var i = lastElementPosition; i >= 0 && lastElementPosition >= 0; i--) { + if (clearIfPossible()) { return null; } @@ -200,12 +199,15 @@ private boolean shouldCompact(int elementCount) { return null; } - private void clearList() { - if (elementList != null) { + private boolean clearIfPossible() { + if (gapCount > 0 && lastElementPosition + 1 == gapCount) { + // All positions are gaps. Clear the list entirely. elementList.clear(); gapCount = 0; lastElementPosition = -1; + return true; } + return false; } /** @@ -235,6 +237,9 @@ private boolean fillGap(int i) { * @return the first element for which the predicate returned true, or null if none */ public @Nullable T findFirst(Predicate elementPredicate) { + if (isEmpty()) { + return null; + } return forEach(elementPredicate); } @@ -257,9 +262,7 @@ public List asList() { private void forceCompaction() { // We remove gaps back to front so that we keep elements as close to their original position as possible. for (var i = lastElementPosition; i >= 0; i--) { - if (lastElementPosition + 1 == gapCount) { - // If all elements were removed, clear the list to free memory and terminate iteration. - clearList(); + if (clearIfPossible()) { return; } From 5020767e238991e56b004efe99f1e6e93c271e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 11:47:35 +0100 Subject: [PATCH 13/21] More optimization --- .../impl/bavet/common/index/IndexedSet.java | 17 +- .../bavet/common/index/IndexedSetTest.java | 197 ++++++------------ 2 files changed, 79 insertions(+), 135 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 863b37d773..bb1832c703 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -47,8 +47,8 @@ public final class IndexedSet { // Compaction during forEach() only makes performance sense for larger sets with a significant amount of gaps. - private static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 100; - private static final double GAP_RATIO_FOR_COMPACTION = 0.1; + static final int MINIMUM_ELEMENT_COUNT_FOR_COMPACTION = 20; + static final double GAP_RATIO_FOR_COMPACTION = 0.1; private final ElementPositionTracker elementPositionTracker; private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. @@ -162,10 +162,14 @@ private boolean shouldCompact() { } private @Nullable T forEachNonCompacting(Predicate elementPredicate) { + return forEachNonCompacting(elementPredicate, lastElementPosition); + } + + private @Nullable T forEachNonCompacting(Predicate elementPredicate, int startingIndex) { // We iterate back to front for consistency with the compacting version. // The predicate may remove elements during iteration, // therefore we check every time that the list still has elements. - for (var i = lastElementPosition; i >= 0 && lastElementPosition >= 0; i--) { + for (var i = startingIndex; i >= 0 && lastElementPosition >= 0; i--) { var element = elementList.get(i); if (element != null && elementPredicate.test(element)) { return element; @@ -175,7 +179,6 @@ private boolean shouldCompact() { } private @Nullable T forEachCompacting(Predicate elementPredicate) { - var shouldCompact = true; // We remove gaps back to front so that we keep elements as close to their original position as possible. // The predicate may remove elements during iteration, // therefore we check every time that the list still has elements. @@ -186,10 +189,10 @@ private boolean shouldCompact() { var element = elementList.get(i); if (element == null) { - if (!shouldCompact) { - continue; + var hasRemainingGaps = !fillGap(i); + if (!hasRemainingGaps) { + return forEachNonCompacting(elementPredicate, i - 1); } - shouldCompact = !fillGap(i); } else { if (elementPredicate.test(element)) { return element; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 7186e29958..4bee44340d 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -11,6 +11,7 @@ import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; +@NullMarked class IndexedSetTest { private IndexedSet createIndexedSet() { @@ -127,7 +128,7 @@ void forEachIteratesAllElements() { @Test void forEachWithGaps() { var set = createIndexedSet(); - for (int i = 0; i < 20; i++) { + for (var i = 0; i < 20; i++) { set.add("Element-" + i); } // Remove some elements to create gaps @@ -178,7 +179,7 @@ void findFirstOnEmptySet() { @Test void multipleExternalRemovalsDuringForEach() { var set = createIndexedSet(); - for (int i = 0; i < 30; i++) { + for (var i = 0; i < 30; i++) { set.add("Element-" + i); } @@ -219,7 +220,7 @@ void removeAllElementsDuringForEach() { @Test void findFirstWithExternalRemoval() { var set = createIndexedSet(); - for (int i = 0; i < 20; i++) { + for (var i = 0; i < 20; i++) { set.add("Element-" + i); } @@ -238,7 +239,7 @@ void findFirstWithExternalRemoval() { @Test void gapAtTheBackIsRemovedDirectly() { var set = createIndexedSet(); - for (int i = 0; i < 20; i++) { + for (var i = 0; i < 20; i++) { set.add("Element-" + i); } @@ -289,12 +290,14 @@ void sizeIsCorrectAfterMultipleOperations() { void compactionTriggersAtCorrectThreshold() { var set = createIndexedSet(); // Add 100 elements (minimum for compaction) - for (int i = 0; i < 100; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Remove 10 elements to create exactly 10% gaps (threshold) - for (int i = 0; i < 10; i++) { + var removedElementCount = elementCount / 10; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } @@ -304,118 +307,115 @@ void compactionTriggersAtCorrectThreshold() { var elements = new ArrayList(); set.forEach(elements::add); - assertThat(elements).hasSize(89); - assertThat(set.asList()).hasSize(89).doesNotContainNull(); + assertThat(elements).hasSize(elementCount - removedElementCount - 1); + assertThat(set.asList()) + .hasSize(elementCount - removedElementCount - 1) + .doesNotContainNull(); } @Test void compactionDoesNotTriggerBelowMinimumElements() { var set = createIndexedSet(); + // Add 99 elements (below minimum for compaction) - for (int i = 0; i < 99; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Remove 50% of elements (well above threshold) - for (int i = 0; i < 50; i++) { + var removedElementCount = elementCount / 2; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } var elements = new ArrayList(); set.forEach(elements::add); - assertThat(elements).hasSize(49); + assertThat(elements).hasSize(elementCount - removedElementCount); // Even though gaps exist, compaction shouldn't have happened during forEach // It should happen during asList() instead - assertThat(set.asList()).hasSize(49).doesNotContainNull(); - } - - @Test - void compactionDoesNotTriggerBelowGapThreshold() { - var set = createIndexedSet(); - // Add 100 elements - for (int i = 0; i < 100; i++) { - set.add("Element-" + i); - } - - // Remove 9 elements (9% gaps, below 10% threshold) - for (int i = 0; i < 9; i++) { - set.remove("Element-" + i); - } - - var elements = new ArrayList(); - set.forEach(elements::add); - - assertThat(elements).hasSize(91); + assertThat(set.asList()) + .hasSize(elementCount - removedElementCount) + .doesNotContainNull(); } @Test void externalRemovalDuringCompactionAtStart() { var set = createIndexedSet(); - for (int i = 0; i < 150; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 50; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Create gaps to trigger compaction (20 gaps = 13.3%) - for (int i = 0; i < 20; i++) { + var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 5; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } var processedElements = new ArrayList(); var counter = new AtomicInteger(0); + var removedElement = "Element-" + (removedElementCount + 1); set.forEach(element -> { processedElements.add(element); if (counter.incrementAndGet() == 1) { // Remove element during compaction (compaction happens back-to-front) - set.remove("Element-100"); + set.remove(removedElement); } }); - assertThat(processedElements).hasSize(129); // 150 - 20 - 1 - assertThat(set.asList()).doesNotContain("Element-100").doesNotContainNull(); + assertThat(processedElements).hasSize(elementCount - removedElementCount - 1); // 150 - 20 - 1 + assertThat(set.asList()) + .doesNotContain(removedElement) + .doesNotContainNull(); } @Test void externalRemovalDuringCompactionAtEnd() { var set = createIndexedSet(); - for (int i = 0; i < 150; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 50; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Create gaps to trigger compaction - for (int i = 0; i < 20; i++) { + var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 5; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } var processedElements = new ArrayList(); var counter = new AtomicInteger(0); + var elementToRemove = "Element-" + (elementCount - 1); set.forEach(element -> { - if (counter.incrementAndGet() == 100) { + if (counter.incrementAndGet() == IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { // Remove element near the end during compaction - set.remove("Element-149"); + set.remove(elementToRemove); } else { processedElements.add(element); } }); - assertThat(processedElements).hasSize(129); + assertThat(processedElements).hasSize(elementCount - removedElementCount - 1); assertThat(set.asList()) - .doesNotContain("Element-149") + .doesNotContain(elementToRemove) .doesNotContainNull(); } @Test void multipleExternalRemovalsDuringCompaction() { var set = createIndexedSet(); - for (int i = 0; i < 201; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION * 2 + 1; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Create gaps to trigger compaction (25 gaps = 12.5%) - var startFrom = 25; - for (int i = 0; i < startFrom; i++) { + var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 4; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } @@ -425,7 +425,7 @@ void multipleExternalRemovalsDuringCompaction() { set.forEach(element -> { var count = counter.incrementAndGet(); if (count % 20 == 0) { - var toRemove = "Element-" + ((count / 10) + startFrom); + var toRemove = "Element-" + ((count / 10) + removedElementCount); set.remove(toRemove); removedExternally.add(toRemove); } @@ -439,43 +439,47 @@ void multipleExternalRemovalsDuringCompaction() { @Test void externalRemovalOfElementBeingCompacted() { var set = createIndexedSet(); - for (int i = 0; i < 150; i++) { + + var extra = 50; + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + extra; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Create gaps in the middle to trigger compaction - for (int i = 50; i < 70; i++) { + var removedElementCount = 20; + for (var i = extra; i < extra + removedElementCount; i++) { set.remove("Element-" + i); } - var processedElements = new ArrayList(); - + var lastElement = "Element-" + (elementCount - 1); set.forEach(element -> { - processedElements.add(element); // Try to remove an element that might be moved during compaction - if (element.equals("Element-70")) { - set.remove("Element-149"); // Last element, likely to be moved + if (element.equals("Element-" + (extra + removedElementCount + 1))) { + set.remove(lastElement); // Last element, likely to be moved } }); - assertThat(set.asList()).doesNotContain("Element-149").doesNotContainNull(); + assertThat(set.asList()) + .doesNotContain(lastElement) + .doesNotContainNull(); } @Test void removeAllElementsDuringCompaction() { var set = createIndexedSet(); - for (int i = 0; i < 120; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 20; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Create gaps to trigger compaction (15 gaps = 12.5%) - for (int i = 0; i < 15; i++) { + var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 10 + 5; + for (var i = 0; i < removedElementCount; i++) { set.remove("Element-" + i); } - set.forEach(element -> { - set.remove(element); - }); + set.forEach(set::remove); assertThat(set.isEmpty()).isTrue(); assertThat(set.size()).isZero(); @@ -485,12 +489,13 @@ void removeAllElementsDuringCompaction() { @Test void compactionClearsListWhenAllElementsRemoved() { var set = createIndexedSet(); - for (int i = 0; i < 100; i++) { + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + for (var i = 0; i < elementCount; i++) { set.add("Element-" + i); } // Remove all elements - for (int i = 0; i < 100; i++) { + for (var i = 0; i < elementCount; i++) { set.remove("Element-" + i); } @@ -506,12 +511,12 @@ void compactionClearsListWhenAllElementsRemoved() { @Test void asListForcesCompaction() { var set = createIndexedSet(); - for (int i = 0; i < 50; i++) { + for (var i = 0; i < 50; i++) { set.add("Element-" + i); } // Create gaps (but below threshold for forEach compaction) - for (int i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { set.remove("Element-" + i); } @@ -523,70 +528,6 @@ void asListForcesCompaction() { .doesNotContainNull(); } - @Test - void findFirstTriggersCompactionAndReturnsCorrectElement() { - var set = createIndexedSet(); - for (int i = 0; i < 150; i++) { - set.add("Element-" + i); - } - - // Create gaps to trigger compaction - for (int i = 0; i < 20; i++) { - set.remove("Element-" + i); - } - - var found = set.findFirst(element -> element.equals("Element-100")); - - assertThat(found).isEqualTo("Element-100"); - assertThat(set.asList()).hasSize(130).doesNotContainNull(); - } - - @Test - void findFirstWithExternalRemovalDuringCompaction() { - var set = createIndexedSet(); - for (int i = 0; i < 150; i++) { - set.add("Element-" + i); - } - - // Create gaps to trigger compaction - for (int i = 0; i < 20; i++) { - set.remove("Element-" + i); - } - - var counter = new AtomicInteger(0); - var found = set.findFirst(element -> { - if (counter.incrementAndGet() == 10) { - set.remove("Element-100"); - } - return element.equals("Element-50"); - }); - - assertThat(found).isEqualTo("Element-50"); - assertThat(set.asList()).doesNotContain("Element-100").hasSize(129); - } - - @Test - void compactionPreservesElementsCloseToOriginalPosition() { - var tracker = new CompactingIndexPositionTracker(); - var set = new IndexedSet<>(tracker); - - for (int i = 0; i < 150; i++) { - set.add("Element-" + i); - } - - // Remove first 20 elements to create gaps at the beginning - for (int i = 0; i < 20; i++) { - set.remove("Element-" + i); - } - - // Force compaction - set.asList(); - - // Elements from the back should have moved forward - // The last element (Element-149) should now be at position 0 - assertThat(tracker.clearPosition("Element-149")).isLessThan(20); - } - @NullMarked private static final class CompactingIndexPositionTracker implements ElementPositionTracker { @@ -599,7 +540,7 @@ public void setPosition(T element, int position) { @Override public int clearPosition(T element) { - Integer result = positionMap.remove(element); + var result = positionMap.remove(element); return result != null ? result : -1; } From e093e19c716afd6fad998a79a38a8293ac165e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 16:29:05 +0100 Subject: [PATCH 14/21] Iterate forward --- .../impl/bavet/common/index/IndexedSet.java | 104 +-- .../bavet/common/index/IndexedSetTest.java | 786 ++++++++---------- .../move/ChangeMoveDefinitionTest.java | 26 +- .../move/ListChangeMoveDefinitionTest.java | 126 +-- .../move/ListSwapMoveDefinitionTest.java | 9 +- 5 files changed, 471 insertions(+), 580 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index bb1832c703..8c766cd020 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -16,30 +16,29 @@ * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element * without knowing its position and without an expensive lookup. * It also allows for direct random access like a list. - * The order of iteration is not guaranteed to be the insertion order, - * but it is stable and predictable. + * The order of iteration isn't guaranteed to be the insertion order, + * and it changes over time as elements are added and removed. *

* It uses an {@link ElementPositionTracker} to track the insertion position of each element. - * When an element is removed, it is replaced by null at its insertion position; - * therefore the insertion position of later elements is not changed. + * When an element is removed, it's replaced by null at its insertion position, creating a gap; + * therefore, the insertion position of later elements isn't changed. * The set is compacted back during iteration or when {@link #asList()} is called, * by replacing these gaps with elements from the back of the set. * This operation also doesn't change the insertion position of any elements * except for those that are moved from the back to fill a gap. - * Therefore the insertion positions, on average, remain stable over time. *

* Together with the fact that removals are relatively rare, * this keeps the overhead low while giving us all benefits of {@link ArrayList}, * such as memory efficiency, random access, and fast iteration. - * Random access is not required for Constraint Streams, but Neighborhoods make heavy use of it; + * Random access isn't required for Constraint Streams, but Neighborhoods make heavy use of it; * if we used the {@link ElementAwareList} implementation instead, - * we would have to copy the elements to an array every time we need to access them randomly during move generation. + * we'd have to copy the elements to an array every time we need to access them randomly during move generation. *

- * For performance reasons, this class does not check if an element was already added; + * For performance reasons, this class doesn't check if an element was already added; * duplicates must be avoided by the caller and will cause undefined behavior. *

- * This class is not thread-safe. - * It is in fact very thread-unsafe. + * This class isn't thread-safe. + * It's in fact very thread-unsafe. * * @param */ @@ -130,8 +129,7 @@ public int size() { /** * Performs the given action for each element of the collection * until all elements have been processed. - * The order of iteration is not guaranteed to be the insertion order, - * but it is stable and predictable. + * The order of iteration may change as elements are added and removed. * * @param elementConsumer the action to be performed for each element; * may include removing elements from the collection, @@ -162,14 +160,11 @@ private boolean shouldCompact() { } private @Nullable T forEachNonCompacting(Predicate elementPredicate) { - return forEachNonCompacting(elementPredicate, lastElementPosition); + return forEachNonCompacting(elementPredicate, 0); } private @Nullable T forEachNonCompacting(Predicate elementPredicate, int startingIndex) { - // We iterate back to front for consistency with the compacting version. - // The predicate may remove elements during iteration, - // therefore we check every time that the list still has elements. - for (var i = startingIndex; i >= 0 && lastElementPosition >= 0; i--) { + for (var i = startingIndex; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element != null && elementPredicate.test(element)) { return element; @@ -179,25 +174,25 @@ private boolean shouldCompact() { } private @Nullable T forEachCompacting(Predicate elementPredicate) { - // We remove gaps back to front so that we keep elements as close to their original position as possible. - // The predicate may remove elements during iteration, - // therefore we check every time that the list still has elements. - for (var i = lastElementPosition; i >= 0 && lastElementPosition >= 0; i--) { - if (clearIfPossible()) { - return null; - } - + if (clearIfPossible()) { + return null; + } + for (var i = 0; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element == null) { - var hasRemainingGaps = !fillGap(i); - if (!hasRemainingGaps) { - return forEachNonCompacting(elementPredicate, i - 1); - } - } else { - if (elementPredicate.test(element)) { - return element; + element = fillGap(i); + if (element == null) { // There was nothing to fill the gap with; we are done. + return null; } } + if (elementPredicate.test(element)) { + return element; + } + if (gapCount == 0) { + // No more gaps to fill; we can continue without compacting. + // This is an optimized loop which no longer checks for gaps. + return forEachNonCompacting(elementPredicate, i + 1); + } } return null; } @@ -216,20 +211,26 @@ private boolean clearIfPossible() { /** * Fills the gap at position i by moving the last element into it. * - * @param i the position of the gap to fill - * @return true if there are no more gaps after filling this one + * @param gapPosition the position of the gap to fill + * @return the element that now occupies position i, or null if no element further in the list can fill the gap */ - private boolean fillGap(int i) { - if (i < lastElementPosition) { // Fill the gap if there are elements after it. - var elementToMove = elementList.remove(lastElementPosition); - elementList.set(i, elementToMove); - elementPositionTracker.setPosition(elementToMove, i); - } else { // The gap is at the back already. - elementList.remove(i); + private @Nullable T fillGap(int gapPosition) { + T lastRemovedElement = null; + while (lastElementPosition >= gapPosition) { + lastRemovedElement = elementList.remove(lastElementPosition); + lastElementPosition--; + gapCount--; // Even if this is not a gap, we still mark a gap as removed... + if (lastRemovedElement != null) { + break; + } + } + if (lastRemovedElement == null) { + return null; } - lastElementPosition--; - gapCount--; - return gapCount == 0; + // ... because this actually fills the main gap we were asked to fill. + elementList.set(gapPosition, lastRemovedElement); + elementPositionTracker.setPosition(lastRemovedElement, gapPosition); + return lastRemovedElement; } /** @@ -248,7 +249,7 @@ private boolean fillGap(int i) { /** * Returns a standard {@link List} view of this collection. - * Users must not modify the returned list, as that would also change the underlying data structure. + * Users mustn't modify the returned list, as that'd also change the underlying data structure. * * @return a standard list view of this element-aware list */ @@ -263,15 +264,14 @@ public List asList() { } private void forceCompaction() { - // We remove gaps back to front so that we keep elements as close to their original position as possible. - for (var i = lastElementPosition; i >= 0; i--) { - if (clearIfPossible()) { - return; - } - + if (clearIfPossible()) { + return; + } + for (var i = 0; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element == null) { - if (fillGap(i)) { // If there are no more gaps, we can stop. + element = fillGap(i); + if (element == null || gapCount == 0) { // If there are no more gaps, we can stop. return; } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index 4bee44340d..afcedb0355 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -9,523 +9,413 @@ import java.util.concurrent.atomic.AtomicInteger; import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @NullMarked class IndexedSetTest { - private IndexedSet createIndexedSet() { + private static IndexedSet createSet() { return new IndexedSet<>(new CompactingIndexPositionTracker<>()); } - @Test - void addMultipleElements() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); + @Nested + class BasicOperations { - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactly("A", "B", "C"); - } - - @Test - void removeLastElement() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.remove("B"); - - assertThat(set.size()).isEqualTo(1); - assertThat(set.asList()).containsExactly("A"); - } - - @Test - void removeFirstElement() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.remove("A"); - - assertThat(set.size()).isEqualTo(1); - assertThat(set.asList()).containsExactly("B"); - } - - @Test - void removeMiddleElement() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); - - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("A", "C"); - } - - @Test - void removeNonExistentElementFails() { - var set = createIndexedSet(); - set.add("A"); - - assertThatThrownBy(() -> set.remove("B")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } - - @Test - void removeFromEmptySetFails() { - var set = createIndexedSet(); - - assertThatThrownBy(() -> set.remove("A")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } - - @Test - void removeAllElements() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); - - set.remove("A"); - set.remove("B"); - set.remove("C"); - - assertThat(set.size()).isZero(); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.asList()).isEmpty(); - } - - @Test - void emptySetOperations() { - var set = createIndexedSet(); - - assertThat(set.size()).isZero(); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.asList()).isEmpty(); - assertThat(set.findFirst(e -> true)).isNull(); - - var elements = new ArrayList<>(); - set.forEach(elements::add); - assertThat(elements).isEmpty(); - } - - @Test - void forEachIteratesAllElements() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); - - var elements = new ArrayList(); - set.forEach(elements::add); - - assertThat(elements).containsExactlyInAnyOrder("A", "B", "C"); - } - - @Test - void forEachWithGaps() { - var set = createIndexedSet(); - for (var i = 0; i < 20; i++) { - set.add("Element-" + i); + @Test + void emptySetHasSizeZero() { + var set = createSet(); + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + assertThat(set.asList()).isEmpty(); } - // Remove some elements to create gaps - set.remove("Element-5"); - set.remove("Element-10"); - set.remove("Element-15"); - - var elements = new ArrayList(); - set.forEach(elements::add); - - assertThat(elements) - .hasSize(17) - .doesNotContain("Element-5", "Element-10", "Element-15"); - } - - @Test - void findFirstReturnsFirstMatch() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); - - var result = set.findFirst(e -> e.equals("B")); - - assertThat(result).isEqualTo("B"); - } - - @Test - void findFirstReturnsNullWhenNoMatch() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - - var result = set.findFirst(e -> e.equals("C")); - - assertThat(result).isNull(); - } - - @Test - void findFirstOnEmptySet() { - var set = createIndexedSet(); - - var result = set.findFirst(e -> true); - assertThat(result).isNull(); - } - - @Test - void multipleExternalRemovalsDuringForEach() { - var set = createIndexedSet(); - for (var i = 0; i < 30; i++) { - set.add("Element-" + i); - } - - var counter = new AtomicInteger(0); - set.forEach(element -> { - var count = counter.incrementAndGet(); - if (count == 5) { - set.remove("Element-15"); - } else if (count == 10) { - set.remove("Element-20"); - } else if (count == 15) { - set.remove("Element-25"); - } - }); - - assertThat(set.asList()) - .doesNotContain("Element-25", "Element-20", "Element-15"); - } - - @Test - void removeAllElementsDuringForEach() { - var set = createIndexedSet(); - set.add("A"); - set.add("B"); - set.add("C"); - - var elements = new ArrayList(); - set.forEach(element -> { - elements.add(element); - set.remove(element); - }); - - assertThat(elements).hasSize(3); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); - } - - @Test - void findFirstWithExternalRemoval() { - var set = createIndexedSet(); - for (var i = 0; i < 20; i++) { - set.add("Element-" + i); + @Test + void addSingleElement() { + var set = createSet(); + set.add("A"); + assertThat(set.isEmpty()).isFalse(); + assertThat(set.size()).isEqualTo(1); + assertThat(set.asList()).containsExactly("A"); } - var found = set.findFirst(element -> { - if (element.equals("Element-5")) { - set.remove("Element-15"); - return true; - } - return false; - }); + @Test + void addMultipleElements() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + assertThat(set.size()).isEqualTo(3); + assertThat(set.asList()).containsExactly("A", "B", "C"); + } - assertThat(found).isEqualTo("Element-5"); - assertThat(set.size()).isEqualTo(19); - } + @Test + void removeSingleElement() { + var set = createSet(); + set.add("A"); + set.remove("A"); + assertThat(set.isEmpty()).isTrue(); + assertThat(set.size()).isZero(); + assertThat(set.asList()).isEmpty(); + } - @Test - void gapAtTheBackIsRemovedDirectly() { - var set = createIndexedSet(); - for (var i = 0; i < 20; i++) { - set.add("Element-" + i); + @Test + void removeLastElement() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("C"); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("A", "B"); } - // Create a gap at position 10 - set.remove("Element-10"); + @Test + void removeMiddleElement() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("A", "C"); + } - // During forEach, compaction will move Element-19 to position 10 - // Then create a gap at position 19 (the back) - set.remove("Element-19"); + @Test + void removeFirstElement() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("A"); + assertThat(set.size()).isEqualTo(2); + assertThat(set.asList()).containsExactly("C", "B"); + } - var list = set.asList(); - assertThat(list) - .hasSize(18) - .doesNotContainNull(); - } + @Test + void removeNonExistentElementThrows() { + var set = createSet(); + set.add("A"); + assertThatThrownBy(() -> set.remove("B")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } - @Test - void asListReturnsEmptyListForEmptySet() { - var set = createIndexedSet(); + @Test + void removeFromEmptySetThrows() { + var set = createSet(); + assertThatThrownBy(() -> set.remove("A")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } - assertThat(set.asList()).isEmpty(); + @Test + void removeAlreadyRemovedElementThrows() { + var set = createSet(); + set.add("A"); + set.remove("A"); + assertThatThrownBy(() -> set.remove("A")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found"); + } } - @Test - void sizeIsCorrectAfterMultipleOperations() { - var set = createIndexedSet(); - - assertThat(set.size()).isZero(); - - set.add("A"); - assertThat(set.size()).isEqualTo(1); - - set.add("B"); - assertThat(set.size()).isEqualTo(2); + @Nested + class ForEachTests { - set.remove("A"); - assertThat(set.size()).isEqualTo(1); - - set.add("C"); - assertThat(set.size()).isEqualTo(2); - - set.remove("B"); - set.remove("C"); - assertThat(set.size()).isZero(); - } - - @Test - void compactionTriggersAtCorrectThreshold() { - var set = createIndexedSet(); - // Add 100 elements (minimum for compaction) - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + @Test + void forEachOnEmptySet() { + var set = createSet(); + var visited = new ArrayList(); + set.forEach(visited::add); + assertThat(visited).isEmpty(); } - // Remove 10 elements to create exactly 10% gaps (threshold) - var removedElementCount = elementCount / 10; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void forEachVisitsAllElements() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + var visited = new ArrayList(); + set.forEach(visited::add); + assertThat(visited).containsExactlyInAnyOrder("A", "B", "C"); } - // One more removal should trigger compaction during forEach - set.remove("Element-10"); - - var elements = new ArrayList(); - set.forEach(elements::add); + @Test + void forEachSkipsGaps() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + var visited = new ArrayList(); + set.forEach(visited::add); + assertThat(visited).containsExactlyInAnyOrder("A", "C"); + } - assertThat(elements).hasSize(elementCount - removedElementCount - 1); - assertThat(set.asList()) - .hasSize(elementCount - removedElementCount - 1) - .doesNotContainNull(); + @Test + void forEachWithRemovalDuringIteration() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + set.add("D"); + var visited = new ArrayList(); + set.forEach(element -> { + visited.add(element); + if (element.equals("B")) { + set.remove("C"); + } + }); + assertThat(visited).containsExactlyInAnyOrder("A", "B", "D"); + assertThat(set.size()).isEqualTo(3); + } } - @Test - void compactionDoesNotTriggerBelowMinimumElements() { - var set = createIndexedSet(); + @Nested + class FindFirstTests { - // Add 99 elements (below minimum for compaction) - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + @Test + void findFirstOnEmptySet() { + var set = createSet(); + assertThat(set.findFirst(s -> true)).isNull(); } - // Remove 50% of elements (well above threshold) - var removedElementCount = elementCount / 2; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void findFirstReturnsMatchingElement() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + assertThat(set.findFirst(s -> s.equals("B"))).isEqualTo("B"); } - var elements = new ArrayList(); - set.forEach(elements::add); - - assertThat(elements).hasSize(elementCount - removedElementCount); - // Even though gaps exist, compaction shouldn't have happened during forEach - // It should happen during asList() instead - assertThat(set.asList()) - .hasSize(elementCount - removedElementCount) - .doesNotContainNull(); - } - - @Test - void externalRemovalDuringCompactionAtStart() { - var set = createIndexedSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 50; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + @Test + void findFirstReturnsNullWhenNoMatch() { + var set = createSet(); + set.add("A"); + set.add("B"); + assertThat(set.findFirst(s -> s.equals("C"))).isNull(); } - // Create gaps to trigger compaction (20 gaps = 13.3%) - var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 5; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void findFirstStopsAtFirstMatch() { + var set = createSet(); + set.add("A"); + set.add("B"); + set.add("C"); + var callCount = new AtomicInteger(0); + set.findFirst(s -> { + callCount.incrementAndGet(); + return s.equals("B"); + }); + assertThat(callCount.get()).isLessThanOrEqualTo(2); } + } - var processedElements = new ArrayList(); - var counter = new AtomicInteger(0); + @Nested + class CompactionTests { - var removedElement = "Element-" + (removedElementCount + 1); - set.forEach(element -> { - processedElements.add(element); - if (counter.incrementAndGet() == 1) { - // Remove element during compaction (compaction happens back-to-front) - set.remove(removedElement); + @Test + void noCompactionForSmallSets() { + var set = createSet(); + // Add fewer elements than MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + for (var i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; i++) { + set.add("Element" + i); } - }); - - assertThat(processedElements).hasSize(elementCount - removedElementCount - 1); // 150 - 20 - 1 - assertThat(set.asList()) - .doesNotContain(removedElement) - .doesNotContainNull(); - } - - @Test - void externalRemovalDuringCompactionAtEnd() { - var set = createIndexedSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 50; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + // Remove half of them to create gaps + for (var i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 2; i++) { + set.remove("Element" + i); + } + var visited = new ArrayList(); + set.forEach(visited::add); + // Verify elements are still visited correctly + assertThat(visited).hasSize(IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 2 - 1); } - // Create gaps to trigger compaction - var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 5; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void compactionTriggeredByGapRatio() { + var set = createSet(); + var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + // Add elements + for (var i = 0; i < elementCount; i++) { + set.add("Element" + i); + } + // Remove enough elements to exceed GAP_RATIO_FOR_COMPACTION (10%) + var gapsNeeded = (int) (elementCount * IndexedSet.GAP_RATIO_FOR_COMPACTION) + 1; + for (var i = 0; i < gapsNeeded; i++) { + set.remove("Element" + i); + } + var visited = new ArrayList(); + set.forEach(visited::add); + assertThat(visited).hasSize(elementCount - gapsNeeded); + // After compaction, asList should not trigger further compaction + assertThat(set.asList()).hasSize(elementCount - gapsNeeded); } - var processedElements = new ArrayList(); - var counter = new AtomicInteger(0); - - var elementToRemove = "Element-" + (elementCount - 1); - set.forEach(element -> { - if (counter.incrementAndGet() == IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { - // Remove element near the end during compaction - set.remove(elementToRemove); - } else { - processedElements.add(element); + @Test + void externalRemovalDuringCompaction() { + var set = createSet(); + var elementCount = 30; + for (var i = 0; i < elementCount; i++) { + set.add("Element" + i); } - }); - - assertThat(processedElements).hasSize(elementCount - removedElementCount - 1); - assertThat(set.asList()) - .doesNotContain(elementToRemove) - .doesNotContainNull(); - } - - @Test - void multipleExternalRemovalsDuringCompaction() { - var set = createIndexedSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION * 2 + 1; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + // Remove elements to trigger compaction + for (var i = 0; i < 5; i++) { + set.remove("Element" + i); + } + var visited = new ArrayList(); + set.forEach(element -> { + visited.add(element); + // Remove an element during iteration + if (element.equals("Element10")) { + set.remove("Element20"); + } + }); + assertThat(set.size()).isEqualTo(24); + assertThat(visited).doesNotContain("Element20"); } - // Create gaps to trigger compaction (25 gaps = 12.5%) - var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 4; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void multipleExternalRemovalsDuringCompaction() { + var set = createSet(); + for (var i = 0; i < 40; i++) { + set.add("Element" + i); + } + // Create gaps to trigger compaction + for (var i = 0; i < 6; i++) { + set.remove("Element" + i); + } + var visited = new ArrayList(); + var removed = new ArrayList(); + set.forEach(element -> { + visited.add(element); + if (element.equals("Element10")) { + set.remove("Element20"); + removed.add("Element20"); + } + if (element.equals("Element15")) { + set.remove("Element25"); + removed.add("Element25"); + } + }); + assertThat(set.size()).isEqualTo(32); + assertThat(visited).doesNotContainAnyElementsOf(removed); } - var counter = new AtomicInteger(0); - var removedExternally = new ArrayList(); - - set.forEach(element -> { - var count = counter.incrementAndGet(); - if (count % 20 == 0) { - var toRemove = "Element-" + ((count / 10) + removedElementCount); - set.remove(toRemove); - removedExternally.add(toRemove); + @Test + void removalOfLastElementDuringCompaction() { + var set = createSet(); + for (var i = 0; i < 30; i++) { + set.add("Element" + i); } - }); - - assertThat(set.asList()) - .doesNotContainAnyElementsOf(removedExternally) - .doesNotContainNull(); - } - - @Test - void externalRemovalOfElementBeingCompacted() { - var set = createIndexedSet(); - - var extra = 50; - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + extra; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + for (var i = 0; i < 4; i++) { + set.remove("Element" + i); + } + set.forEach(element -> { + if (element.equals("Element10")) { + set.remove("Element29"); // Last element + } + }); + assertThat(set.size()).isEqualTo(25); } - // Create gaps in the middle to trigger compaction - var removedElementCount = 20; - for (var i = extra; i < extra + removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void clearAllGapsDuringCompaction() { + var set = createSet(); + for (var i = 0; i < 25; i++) { + set.add("Element" + i); + } + // Remove all elements to create only gaps + for (var i = 0; i < 25; i++) { + set.remove("Element" + i); + } + assertThat(set.isEmpty()).isTrue(); + assertThat(set.asList()).isEmpty(); } - var lastElement = "Element-" + (elementCount - 1); - set.forEach(element -> { - // Try to remove an element that might be moved during compaction - if (element.equals("Element-" + (extra + removedElementCount + 1))) { - set.remove(lastElement); // Last element, likely to be moved + @Test + void asListTriggersCompaction() { + var set = createSet(); + for (var i = 0; i < 30; i++) { + set.add("Element" + i); } - }); - - assertThat(set.asList()) - .doesNotContain(lastElement) - .doesNotContainNull(); - } - - @Test - void removeAllElementsDuringCompaction() { - var set = createIndexedSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + 20; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + set.remove("Element5"); + set.remove("Element10"); + set.remove("Element15"); + var list = set.asList(); + assertThat(list).hasSize(27); + assertThat(list).doesNotContainNull(); } - // Create gaps to trigger compaction (15 gaps = 12.5%) - var removedElementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 10 + 5; - for (var i = 0; i < removedElementCount; i++) { - set.remove("Element-" + i); + @Test + void forceCompactionWithMultipleGaps() { + var set = createSet(); + for (var i = 0; i < 50; i++) { + set.add("Element" + i); + } + // Create multiple gaps + for (var i = 0; i < 10; i += 2) { + set.remove("Element" + i); + } + var list = set.asList(); + assertThat(list).hasSize(45); + assertThat(list).doesNotContainNull(); } - - set.forEach(set::remove); - - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); - assertThat(set.asList()).isEmpty(); } - @Test - void compactionClearsListWhenAllElementsRemoved() { - var set = createIndexedSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; - for (var i = 0; i < elementCount; i++) { - set.add("Element-" + i); + @Nested + class EdgeCases { + + @Test + void removeAndReAddCycle() { + var set = createSet(); + set.add("A"); + set.remove("A"); + set.add("A"); + assertThat(set.size()).isEqualTo(1); + assertThat(set.asList()).containsExactly("A"); } - // Remove all elements - for (var i = 0; i < elementCount; i++) { - set.remove("Element-" + i); + @Test + void largeNumberOfElements() { + var set = createSet(); + var count = 1000; + for (var i = 0; i < count; i++) { + set.add("Element" + i); + } + assertThat(set.size()).isEqualTo(count); + assertThat(set.asList()).hasSize(count); } - // Trigger compaction through forEach - set.forEach(element -> { - }); - - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); - assertThat(set.asList()).isEmpty(); - } - - @Test - void asListForcesCompaction() { - var set = createIndexedSet(); - for (var i = 0; i < 50; i++) { - set.add("Element-" + i); + @Test + void largeNumberOfGaps() { + var set = createSet(); + for (var i = 0; i < 100; i++) { + set.add("Element" + i); + } + for (var i = 0; i < 50; i++) { + set.remove("Element" + (i * 2)); + } + assertThat(set.size()).isEqualTo(50); + var list = set.asList(); + assertThat(list).hasSize(50); + assertThat(list).doesNotContainNull(); } - // Create gaps (but below threshold for forEach compaction) - for (var i = 0; i < 10; i++) { - set.remove("Element-" + i); + @Test + void alternatingAddAndRemove() { + var set = createSet(); + for (var i = 0; i < 100; i++) { + set.add("Element" + i); + if (i > 0 && i % 2 == 0) { + set.remove("Element" + (i - 1)); + } + } + assertThat(set.size()).isGreaterThan(0); + assertThat(set.asList()).doesNotContainNull(); } - - // asList should force compaction regardless of threshold - var list = set.asList(); - - assertThat(list) - .hasSize(40) - .doesNotContainNull(); } @NullMarked diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java index ed12680bdf..264abd99a5 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinitionTest.java @@ -62,7 +62,7 @@ void fromSolution() { var move1 = moveList.get(0); assertSoftly(softly -> { softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(move1.extractPlanningValues()) .containsExactly(firstValue); }); @@ -70,7 +70,7 @@ void fromSolution() { var move2 = moveList.get(1); assertSoftly(softly -> { softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(move2.extractPlanningValues()) .containsExactly(secondValue); }); @@ -78,7 +78,7 @@ void fromSolution() { var move3 = moveList.get(2); assertSoftly(softly -> { softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(move3.extractPlanningValues()) .containsExactly(firstValue); }); @@ -86,7 +86,7 @@ void fromSolution() { var move4 = moveList.get(3); assertSoftly(softly -> { softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(move4.extractPlanningValues()) .containsExactly(secondValue); }); @@ -122,7 +122,7 @@ void fromSolutionIncompleteValueRange() { var move1 = moveList.get(0); assertSoftly(softly -> { softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(move1.extractPlanningValues()) .containsExactly(firstValue); }); @@ -130,7 +130,7 @@ void fromSolutionIncompleteValueRange() { var move2 = moveList.get(1); assertSoftly(softly -> { softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(move2.extractPlanningValues()) .containsExactly(secondValue); }); @@ -138,7 +138,7 @@ void fromSolutionIncompleteValueRange() { var move3 = moveList.get(2); assertSoftly(softly -> { softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(move3.extractPlanningValues()) .containsExactly(firstValue); }); @@ -146,7 +146,7 @@ void fromSolutionIncompleteValueRange() { var move4 = moveList.get(3); assertSoftly(softly -> { softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(move4.extractPlanningValues()) .containsExactly(secondValue); }); @@ -216,7 +216,7 @@ void fromEntityAllowsUnassigned() { var move1 = moveList.get(0); assertSoftly(softly -> { softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(move1.extractPlanningValues()) .hasSize(1) .containsNull(); @@ -227,16 +227,16 @@ void fromEntityAllowsUnassigned() { softly.assertThat(move2.extractPlanningEntities()) .containsExactly(secondEntity); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(firstValue); + .hasSize(1) + .containsNull(); }); var move3 = moveList.get(2); assertSoftly(softly -> { softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(move3.extractPlanningValues()) - .hasSize(1) - .containsNull(); + .containsExactly(firstValue); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java index 31544de7f9..2fb657a726 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinitionTest.java @@ -61,20 +61,20 @@ void fromSolution() { var move1 = getListAssignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(0); softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e1); softly.assertThat(move1.extractPlanningValues()) .containsExactly(unassignedValue); }); var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move2.getDestinationIndex()).isEqualTo(1); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2); softly.assertThat(move2.extractPlanningValues()) .containsExactly(unassignedValue); }); @@ -132,46 +132,46 @@ void fromEntity() { // v3 is unassigned; it can be assigned to e2, but not to e1. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListAssignMove(moveList, 0); + var move1 = getListChangeMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move1.getSourceIndex()).isEqualTo(0); + softly.assertThat(move1.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move1.getDestinationIndex()).isEqualTo(0); softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); var move2 = getListAssignMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e1); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v2); }); var move3 = getListAssignMove(moveList, 2); assertSoftly(softly -> { - softly.assertThat(move3.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move3.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move3.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move3.getDestinationIndex()).isEqualTo(1); softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2); softly.assertThat(move3.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v3); }); - var move4 = getListChangeMove(moveList, 3); + var move4 = getListAssignMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move4.getSourceIndex()).isEqualTo(0); - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); } @@ -203,24 +203,26 @@ void fromEntityAllowsUnassigned() { // v3 is unassigned; it can be assigned to e2, but not to e1. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListAssignMove(moveList, 0); + var move1 = getListUnassignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move1.getSourceIndex()).isEqualTo(0); softly.assertThat(move1.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); - var move2 = getListAssignMove(moveList, 1); + var move2 = getListChangeMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move2.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move2.getSourceIndex()).isEqualTo(0); + softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v3); + .containsExactly(v1); }); var move3 = getListAssignMove(moveList, 2); @@ -233,26 +235,24 @@ void fromEntityAllowsUnassigned() { .containsExactly(v2); }); - var move4 = getListChangeMove(moveList, 3); + var move4 = getListAssignMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move4.getSourceIndex()).isEqualTo(0); - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move4.getDestinationIndex()).isEqualTo(1); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); - var move5 = getListUnassignMove(moveList, 4); + var move5 = getListAssignMove(moveList, 4); assertSoftly(softly -> { - softly.assertThat(move5.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move5.getSourceIndex()).isEqualTo(0); + softly.assertThat(move5.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move5.getDestinationIndex()).isEqualTo(0); softly.assertThat(move5.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move5.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v3); }); } @@ -282,56 +282,56 @@ void fromSolutionAllowsUnassigned() { // v2 is unassigned; it can be assigned to e1 or e2. // e2 has one value already, and therefore two possible assignments, 0 and 1. - var move1 = getListAssignMove(moveList, 0); + var move1 = getListUnassignMove(moveList, 0); assertSoftly(softly -> { - softly.assertThat(move1.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move1.getDestinationIndex()).isEqualTo(1); + softly.assertThat(move1.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move1.getSourceIndex()).isEqualTo(0); softly.assertThat(move1.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v1); }); - var move2 = getListAssignMove(moveList, 1); + var move2 = getListChangeMove(moveList, 1); assertSoftly(softly -> { + softly.assertThat(move2.getSourceEntity()).isEqualTo(e2); + softly.assertThat(move2.getSourceIndex()).isEqualTo(0); softly.assertThat(move2.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move2.getDestinationIndex()).isEqualTo(0); softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e1); + .containsExactly(e2, e1); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(v2); + .containsExactly(v1); }); var move3 = getListAssignMove(moveList, 2); assertSoftly(softly -> { - softly.assertThat(move3.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move3.getDestinationEntity()).isEqualTo(e1); softly.assertThat(move3.getDestinationIndex()).isEqualTo(0); softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e1); softly.assertThat(move3.extractPlanningValues()) .containsExactly(v2); }); - var move4 = getListChangeMove(moveList, 3); + var move4 = getListAssignMove(moveList, 3); assertSoftly(softly -> { - softly.assertThat(move4.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move4.getSourceIndex()).isEqualTo(0); - softly.assertThat(move4.getDestinationEntity()).isEqualTo(e1); - softly.assertThat(move4.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move4.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move4.getDestinationIndex()).isEqualTo(1); softly.assertThat(move4.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move4.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v2); }); - var move5 = getListUnassignMove(moveList, 4); + var move5 = getListAssignMove(moveList, 4); assertSoftly(softly -> { - softly.assertThat(move5.getSourceEntity()).isEqualTo(e2); - softly.assertThat(move5.getSourceIndex()).isEqualTo(0); + softly.assertThat(move5.getDestinationEntity()).isEqualTo(e2); + softly.assertThat(move5.getDestinationIndex()).isEqualTo(0); softly.assertThat(move5.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move5.extractPlanningValues()) - .containsExactly(v1); + .containsExactly(v2); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java index bd3425dbc6..224c4991a8 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinitionTest.java @@ -71,18 +71,19 @@ void fromSolution() { var move2 = (ListSwapMove) moveList.get(1); assertSoftly(softly -> { softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2); + .containsExactly(e2, e1); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue2); + .containsExactly(assignedValue3, assignedValue1); }); var move3 = (ListSwapMove) moveList.get(2); assertSoftly(softly -> { softly.assertThat(move3.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e2); softly.assertThat(move3.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue1); + .containsExactly(assignedValue3, assignedValue2); }); + } @Test From 55d08760daac819a76c3a551c98e3acb69fd3cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 18:33:49 +0100 Subject: [PATCH 15/21] Optimize joins --- .../impl/bavet/common/AbstractJoinNode.java | 108 ++++++++++-------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java index 27f08d26e5..0f6fcc728c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java @@ -10,8 +10,6 @@ import ai.timefold.solver.core.impl.bavet.common.tuple.TupleStorePositionTracker; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; -import org.jspecify.annotations.Nullable; - /** * This class has two direct children: {@link AbstractIndexedJoinNode} and {@link AbstractUnindexedJoinNode}. * The logic in either is identical, except that the latter removes all indexing work. @@ -92,13 +90,50 @@ protected final void innerUpdateLeft(LeftTuple_ leftTuple, Consumer updateOutTupleLeft(outTuple, leftTuple)); } else { - rightTupleConsumer.accept(rightTuple -> { - IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); - processOutTupleUpdate(leftTuple, rightTuple, outTupleSetRight, outTupleSetLeft, outputStoreIndexRightOutSet); - }); + if (!leftTuple.state.isActive()) { + // Assume the following scenario: + // - The join is of two entities of the same type, both filtering out unassigned. + // - One entity became unassigned, so the outTuple is getting retracted. + // - The other entity is still assigned and is being updated. + // + // This means the filter would be called with (unassignedEntity, assignedEntity), + // which breaks the expectation that the filter is only called on two assigned entities + // and requires adding null checks to the filter for something that should intuitively be impossible. + // We avoid this situation as it is clear that the outTuple must be retracted anyway, + // and therefore any further updates to it are pointless. + // + // It is possible that the same problem would exist coming from the other side as well, + // and therefore the right tuple would have to be checked for active state as well. + // However, no such issue could have been reproduced; when in doubt, leave it out. + return; + } + rightTupleConsumer.accept(rightTuple -> findAndProcessOutTuple(leftTuple, rightTuple, outTupleSetLeft, + rightTuple.getStore(inputStoreIndexRightOutTupleSet), outputStoreIndexRightOutSet)); + } + } + + private void findAndProcessOutTuple(LeftTuple_ leftTuple, UniTuple rightTuple, IndexedSet sourceSet, + IndexedSet referenceSet, int outputStoreIndex) { + var outTuple = findOutTuple(sourceSet, referenceSet, outputStoreIndex); + if (testFiltering(leftTuple, rightTuple)) { + if (outTuple == null) { + insertOutTuple(leftTuple, rightTuple); + } else { + updateOutTupleLeft(outTuple, leftTuple); + } + } else { + if (outTuple != null) { + retractOutTuple(outTuple); + } } } + private static OutTuple_ findOutTuple(IndexedSet sourceSet, + IndexedSet referenceSet, int outputStoreIndex) { + // Hack: outTuple has no left/right input tuple reference, use the left/right outSet reference instead. + return sourceSet.findFirst(tuple -> referenceSet == tuple.getStore(outputStoreIndex)); + } + private void updateOutTupleLeft(OutTuple_ outTuple, LeftTuple_ leftTuple) { setOutTupleLeftFacts(outTuple, leftTuple); doUpdateOutTuple(outTuple); @@ -126,52 +161,29 @@ protected final void innerUpdateRight(UniTuple rightTuple, Consumer { - IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); - processOutTupleUpdate(leftTuple, rightTuple, outTupleSetLeft, outTupleSetRight, outputStoreIndexLeftOutSet); + if (!leftTuple.state.isActive()) { + // Assume the following scenario: + // - The join is of two entities of the same type, both filtering out unassigned. + // - One entity became unassigned, so the outTuple is getting retracted. + // - The other entity is still assigned and is being updated. + // + // This means the filter would be called with (unassignedEntity, assignedEntity), + // which breaks the expectation that the filter is only called on two assigned entities + // and requires adding null checks to the filter for something that should intuitively be impossible. + // We avoid this situation as it is clear that the outTuple must be retracted anyway, + // and therefore any further updates to it are pointless. + // + // It is possible that the same problem would exist coming from the other side as well, + // and therefore the right tuple would have to be checked for active state as well. + // However, no such issue could have been reproduced; when in doubt, leave it out. + return; + } + findAndProcessOutTuple(leftTuple, rightTuple, outTupleSetRight, + leftTuple.getStore(inputStoreIndexLeftOutTupleSet), outputStoreIndexLeftOutSet); }); } } - private void processOutTupleUpdate(LeftTuple_ leftTuple, UniTuple rightTuple, - IndexedSet referenceOutTupleSet, IndexedSet outTupleSet, - int outputStoreIndexOutSet) { - if (!leftTuple.state.isActive()) { - // Assume the following scenario: - // - The join is of two entities of the same type, both filtering out unassigned. - // - One entity became unassigned, so the outTuple is getting retracted. - // - The other entity is still assigned and is being updated. - // - // This means the filter would be called with (unassignedEntity, assignedEntity), - // which breaks the expectation that the filter is only called on two assigned entities - // and requires adding null checks to the filter for something that should intuitively be impossible. - // We avoid this situation as it is clear that the outTuple must be retracted anyway, - // and therefore any further updates to it are pointless. - // - // It is possible that the same problem would exist coming from the other side as well, - // and therefore the right tuple would have to be checked for active state as well. - // However, no such issue could have been reproduced; when in doubt, leave it out. - return; - } - var outTuple = findOutTuple(outTupleSet, referenceOutTupleSet, outputStoreIndexOutSet); - if (testFiltering(leftTuple, rightTuple)) { - if (outTuple == null) { - insertOutTuple(leftTuple, rightTuple); - } else { - updateOutTupleLeft(outTuple, leftTuple); - } - } else { - if (outTuple != null) { - retractOutTuple(outTuple); - } - } - } - - private static @Nullable Tuple_ findOutTuple(IndexedSet outTupleSet, - IndexedSet referenceOutTupleSet, int outputStoreIndexOutSet) { - // Hack: the outTuple has no left/right input tuple reference, use the left/right outSet reference instead. - return outTupleSet.findFirst(outTuple -> referenceOutTupleSet == outTuple.getStore(outputStoreIndexOutSet)); - } - protected final void retractOutTuple(OutTuple_ outTuple) { IndexedSet outSetLeft = outTuple.removeStore(outputStoreIndexLeftOutSet); outSetLeft.remove(outTuple); From 01e1d13f013e6a3341d71699fb387147c6cfa4df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 20:07:06 +0100 Subject: [PATCH 16/21] Small improvements to IndexedSet --- .../impl/bavet/common/index/IndexedSet.java | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 8c766cd020..bcbfe6f55b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -50,7 +50,7 @@ public final class IndexedSet { static final double GAP_RATIO_FOR_COMPACTION = 0.1; private final ElementPositionTracker elementPositionTracker; - private @Nullable ArrayList<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. + private @Nullable List<@Nullable T> elementList; // Lazily initialized, so that empty indexes use no memory. private int lastElementPosition = -1; private int gapCount = 0; @@ -97,33 +97,39 @@ public void remove(T element) { } private boolean innerRemove(T element) { - if (isEmpty()) { - return false; - } var insertionPosition = elementPositionTracker.clearPosition(element); if (insertionPosition < 0) { return false; } - var actualElementList = getElementList(); if (insertionPosition == lastElementPosition) { // The element was the last one added; we can simply remove it. - actualElementList.remove(insertionPosition); + elementList.remove(insertionPosition); lastElementPosition--; } else { // We replace the element with null, creating a gap. - actualElementList.set(insertionPosition, null); + elementList.set(insertionPosition, null); gapCount++; } clearIfPossible(); return true; } + private boolean clearIfPossible() { + if (gapCount > 0 && lastElementPosition + 1 == gapCount) { // All positions are gaps. Clear the list entirely. + elementList.clear(); + gapCount = 0; + lastElementPosition = -1; + return true; + } + return false; + } + public boolean isEmpty() { return size() == 0; } public int size() { - return elementList == null ? 0 : lastElementPosition - gapCount + 1; + return lastElementPosition - gapCount + 1; } /** @@ -197,17 +203,6 @@ private boolean shouldCompact() { return null; } - private boolean clearIfPossible() { - if (gapCount > 0 && lastElementPosition + 1 == gapCount) { - // All positions are gaps. Clear the list entirely. - elementList.clear(); - gapCount = 0; - lastElementPosition = -1; - return true; - } - return false; - } - /** * Fills the gap at position i by moving the last element into it. * @@ -215,24 +210,28 @@ private boolean clearIfPossible() { * @return the element that now occupies position i, or null if no element further in the list can fill the gap */ private @Nullable T fillGap(int gapPosition) { - T lastRemovedElement = null; - while (lastElementPosition >= gapPosition) { - lastRemovedElement = elementList.remove(lastElementPosition); - lastElementPosition--; - gapCount--; // Even if this is not a gap, we still mark a gap as removed... - if (lastRemovedElement != null) { - break; - } - } + T lastRemovedElement = removeLastNonGap(gapPosition); if (lastRemovedElement == null) { return null; } - // ... because this actually fills the main gap we were asked to fill. elementList.set(gapPosition, lastRemovedElement); elementPositionTracker.setPosition(lastRemovedElement, gapPosition); + gapCount--; return lastRemovedElement; } + private @Nullable T removeLastNonGap(int gapPosition) { + while (lastElementPosition >= gapPosition) { + var lastRemovedElement = elementList.remove(lastElementPosition); + lastElementPosition--; + if (lastRemovedElement != null) { + return lastRemovedElement; + } + gapCount--; + } + return null; + } + /** * As defined by {@link #forEach(Consumer)}, * but stops when the predicate returns true for an element. @@ -270,8 +269,8 @@ private void forceCompaction() { for (var i = 0; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element == null) { - element = fillGap(i); - if (element == null || gapCount == 0) { // If there are no more gaps, we can stop. + fillGap(i); + if (gapCount == 0) { // If there are no more gaps, we can stop. return; } } From 699cfb526359490081c638b9dc1544948421ea74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 19 Nov 2025 20:29:14 +0100 Subject: [PATCH 17/21] Small improvements to indexing --- .../core/impl/bavet/common/AbstractIfExistsNode.java | 11 +++++------ .../bavet/common/AbstractIndexedIfExistsNode.java | 8 ++++---- .../bavet/common/AbstractUnindexedIfExistsNode.java | 8 ++++---- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index 2779873b7d..fe4a588906 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -120,15 +120,14 @@ IndexedSet> updateRightHandleSet(UniTuple rightTuple, ExistsCounter counter) { - if (testFiltering(leftTuple, rightTuple)) { + void updateCounterFromLeft(ExistsCounter counter, UniTuple rightTuple) { + if (testFiltering(counter.leftTuple, rightTuple)) { counter.countRight++; - IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); - new ExistsCounterHandle<>(counter, rightHandleSet); + new ExistsCounterHandle<>(counter, rightTuple.getStore(inputStoreIndexRightHandleSet)); } } - void updateCounterFromRight(UniTuple rightTuple, ExistsCounter counter, + void updateCounterFromRight(ExistsCounter counter, UniTuple rightTuple, IndexedSet> rightHandleSet) { var leftTuple = counter.leftTuple; if (!leftTuple.state.isActive()) { @@ -148,7 +147,7 @@ void updateCounterFromRight(UniTuple rightTuple, ExistsCounter(counter, rightHandleSet); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java index 04e157e7aa..4baaf2ff40 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java @@ -68,7 +68,7 @@ private void updateCounterRight(LeftTuple_ leftTuple, Object indexKeys, ExistsCo if (!isFiltering) { counter.countRight = indexerRight.size(indexKeys); } else { - indexerRight.forEach(indexKeys, rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter)); + indexerRight.forEach(indexKeys, rightTuple -> updateCounterFromLeft(counter, rightTuple)); } } @@ -91,7 +91,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { } else { // Call filtering for the leftTuple and rightTuple combinations again counter.clearIncludingCount(); - indexerRight.forEach(oldIndexKeys, rightTuple -> updateCounterFromLeft(leftTuple, rightTuple, counter)); + indexerRight.forEach(oldIndexKeys, rightTuple -> updateCounterFromLeft(counter, rightTuple)); updateCounterLeft(counter); } } else { @@ -142,7 +142,7 @@ private void updateCounterLeft(UniTuple rightTuple, Object indexKeys) { indexerLeft.forEach(indexKeys, this::incrementCounterRight); } else { var rightHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); - indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(rightTuple, counter, rightHandleSet)); + indexerLeft.forEach(indexKeys, counter -> updateCounterFromRight(counter, rightTuple, rightHandleSet)); rightTuple.setStore(inputStoreIndexRightHandleSet, rightHandleSet); } } @@ -161,7 +161,7 @@ public final void updateRight(UniTuple rightTuple) { if (isFiltering) { var rightHandleSet = updateRightHandleSet(rightTuple); indexerLeft.forEach(oldIndexKeys, - counter -> updateCounterFromRight(rightTuple, counter, rightHandleSet)); + counter -> updateCounterFromRight(counter, rightTuple, rightHandleSet)); } } else { indexerRight.remove(oldIndexKeys, rightTuple); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java index 479f5c157a..a640c481ce 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedIfExistsNode.java @@ -49,7 +49,7 @@ public final void insertLeft(LeftTuple_ leftTuple) { if (!isFiltering) { counter.countRight = rightTupleSet.size(); } else { - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter)); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(counter, tuple)); } initCounterLeft(counter); } @@ -68,7 +68,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { } else { // Call filtering for the leftTuple and rightTuple combinations again counter.clearIncludingCount(); - rightTupleSet.forEach(tuple -> updateCounterFromLeft(leftTuple, tuple, counter)); + rightTupleSet.forEach(tuple -> updateCounterFromLeft(counter, tuple)); updateCounterLeft(counter); } } @@ -99,7 +99,7 @@ public final void insertRight(UniTuple rightTuple) { leftCounterSet.forEach(this::incrementCounterRight); } else { var rightHandleSet = new IndexedSet>(ExistsCounterHandlePositionTracker.right()); - leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightHandleSet)); + leftCounterSet.forEach(counter -> updateCounterFromRight(counter, rightTuple, rightHandleSet)); rightTuple.setStore(inputStoreIndexRightHandleSet, rightHandleSet); } } @@ -113,7 +113,7 @@ public final void updateRight(UniTuple rightTuple) { } if (isFiltering) { var rightHandleSet = updateRightHandleSet(rightTuple); - leftCounterSet.forEach(tuple -> updateCounterFromRight(rightTuple, tuple, rightHandleSet)); + leftCounterSet.forEach(counter -> updateCounterFromRight(counter, rightTuple, rightHandleSet)); } } From 058233b4159d68b8709252ac1d1185c9df69aecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 20 Nov 2025 09:34:49 +0100 Subject: [PATCH 18/21] Separate path for forEach --- .../impl/bavet/common/index/IndexedSet.java | 115 +++++++++++------- 1 file changed, 69 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index bcbfe6f55b..a38b2bcb61 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -58,13 +58,6 @@ public IndexedSet(ElementPositionTracker elementPositionTracker) { this.elementPositionTracker = Objects.requireNonNull(elementPositionTracker); } - private List<@Nullable T> getElementList() { - if (elementList == null) { - elementList = new ArrayList<>(); - } - return elementList; - } - /** * Appends the specified element to the end of this collection. * If the element is already present, @@ -76,30 +69,26 @@ public IndexedSet(ElementPositionTracker elementPositionTracker) { * @param element element to be appended to this collection */ public void add(T element) { - var actualElementList = getElementList(); - actualElementList.add(element); + if (elementList == null) { + elementList = new ArrayList<>(); + } + elementList.add(element); elementPositionTracker.setPosition(element, ++lastElementPosition); } /** - * Removes the first occurrence of the specified element from this collection, if it is present. + * Removes the first occurrence of the specified element from this collection, if present. * Will use identity comparison to check for presence; * two different instances which {@link Object#equals(Object) equal} are considered different elements. * * @param element element to be removed from this collection - * @throws IllegalStateException if the element was not found in this collection + * @throws IllegalStateException if the element wasn't found in this collection */ public void remove(T element) { - if (!innerRemove(element)) { - throw new IllegalStateException("Impossible state: the element (%s) was not found in the IndexedSet." - .formatted(element)); - } - } - - private boolean innerRemove(T element) { var insertionPosition = elementPositionTracker.clearPosition(element); if (insertionPosition < 0) { - return false; + throw new IllegalStateException("Impossible state: the element (%s) was not found in the IndexedSet." + .formatted(element)); } if (insertionPosition == lastElementPosition) { // The element was the last one added; we can simply remove it. @@ -111,7 +100,6 @@ private boolean innerRemove(T element) { gapCount++; } clearIfPossible(); - return true; } private boolean clearIfPossible() { @@ -139,25 +127,25 @@ public int size() { * * @param elementConsumer the action to be performed for each element; * may include removing elements from the collection, - * but additions or swaps are not allowed; + * but additions or swaps aren't allowed; * undefined behavior will occur if that is attempted. */ public void forEach(Consumer elementConsumer) { if (isEmpty()) { return; } - forEach(element -> { - elementConsumer.accept(element); - return false; // Iterate until the end. - }); - } - - private @Nullable T forEach(Predicate elementPredicate) { - return shouldCompact() ? forEachCompacting(elementPredicate) : forEachNonCompacting(elementPredicate); + if (shouldCompact()) { + forEachCompacting(elementConsumer); + } else { + forEachNonCompacting(elementConsumer); + } } private boolean shouldCompact() { - int elementCount = lastElementPosition + 1; + if (gapCount == 0) { + return false; + } + var elementCount = lastElementPosition + 1; if (elementCount < MINIMUM_ELEMENT_COUNT_FOR_COMPACTION) { return false; } @@ -165,42 +153,39 @@ private boolean shouldCompact() { return gapPercentage > GAP_RATIO_FOR_COMPACTION; } - private @Nullable T forEachNonCompacting(Predicate elementPredicate) { - return forEachNonCompacting(elementPredicate, 0); + private void forEachNonCompacting(Consumer elementConsumer) { + forEachNonCompacting(elementConsumer, 0); } - private @Nullable T forEachNonCompacting(Predicate elementPredicate, int startingIndex) { + private void forEachNonCompacting(Consumer elementConsumer, int startingIndex) { for (var i = startingIndex; i <= lastElementPosition; i++) { var element = elementList.get(i); - if (element != null && elementPredicate.test(element)) { - return element; + if (element != null) { + elementConsumer.accept(element); } } - return null; } - private @Nullable T forEachCompacting(Predicate elementPredicate) { + private void forEachCompacting(Consumer elementConsumer) { if (clearIfPossible()) { - return null; + return; } for (var i = 0; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element == null) { element = fillGap(i); if (element == null) { // There was nothing to fill the gap with; we are done. - return null; + return; } } - if (elementPredicate.test(element)) { - return element; - } + elementConsumer.accept(element); if (gapCount == 0) { // No more gaps to fill; we can continue without compacting. // This is an optimized loop which no longer checks for gaps. - return forEachNonCompacting(elementPredicate, i + 1); + forEachNonCompacting(elementConsumer, i + 1); + return; } } - return null; } /** @@ -210,7 +195,7 @@ private boolean shouldCompact() { * @return the element that now occupies position i, or null if no element further in the list can fill the gap */ private @Nullable T fillGap(int gapPosition) { - T lastRemovedElement = removeLastNonGap(gapPosition); + var lastRemovedElement = removeLastNonGap(gapPosition); if (lastRemovedElement == null) { return null; } @@ -243,7 +228,45 @@ private boolean shouldCompact() { if (isEmpty()) { return null; } - return forEach(elementPredicate); + return shouldCompact() ? findFirstCompacting(elementPredicate) : findFirstNonCompacting(elementPredicate); + } + + private @Nullable T findFirstNonCompacting(Predicate elementPredicate) { + return findFirstNonCompacting(elementPredicate, 0); + } + + private @Nullable T findFirstNonCompacting(Predicate elementPredicate, int startingIndex) { + for (var i = startingIndex; i <= lastElementPosition; i++) { + var element = elementList.get(i); + if (element != null && elementPredicate.test(element)) { + return element; + } + } + return null; + } + + private @Nullable T findFirstCompacting(Predicate elementPredicate) { + if (clearIfPossible()) { + return null; + } + for (var i = 0; i <= lastElementPosition; i++) { + var element = elementList.get(i); + if (element == null) { + element = fillGap(i); + if (element == null) { // There was nothing to fill the gap with; we are done. + return null; + } + } + if (elementPredicate.test(element)) { + return element; + } + if (gapCount == 0) { + // No more gaps to fill; we can continue without compacting. + // This is an optimized loop which no longer checks for gaps. + return findFirstNonCompacting(elementPredicate, i + 1); + } + } + return null; } /** From 58b61ad62022ee9f2445b292705cda481a61a71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 20 Nov 2025 12:51:47 +0100 Subject: [PATCH 19/21] Experiment: bulk clear for ifExists --- .../bavet/common/AbstractIfExistsNode.java | 4 +- .../bavet/common/AbstractIndexedJoinNode.java | 2 +- .../core/impl/bavet/common/ExistsCounter.java | 2 +- .../bavet/common/ExistsCounterHandle.java | 7 +++- .../impl/bavet/common/index/IndexedSet.java | 38 +++++++++++++++++-- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java index fe4a588906..346dc8038a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java @@ -113,8 +113,8 @@ protected void decrementCounterRight(ExistsCounter counter) { IndexedSet> updateRightHandleSet(UniTuple rightTuple) { IndexedSet> rightHandleSet = rightTuple.getStore(inputStoreIndexRightHandleSet); - rightHandleSet.forEach(handle -> { - handle.remove(); + rightHandleSet.clear(handle -> { + handle.removeByRight(); decrementCounterRight(handle.counter); }); return rightHandleSet; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java index 398abae78c..684697554c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java @@ -82,8 +82,8 @@ public final void updateLeft(LeftTuple_ leftTuple) { // Prefer an update over retract-insert if possible innerUpdateLeft(leftTuple, consumer -> indexerRight.forEach(oldIndexKeys, consumer)); } else { - IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); indexerLeft.remove(oldIndexKeys, leftTuple); + IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); outTupleSetLeft.forEach(this::retractOutTuple); // outTupleSetLeft is now empty, no need for leftTuple.setStore(...); indexAndPropagateLeft(leftTuple, newIndexKeys); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java index 1cb680cf59..cd2ae4390e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounter.java @@ -21,7 +21,7 @@ public final class ExistsCounter } public void clearWithoutCount() { - leftHandleSet.forEach(ExistsCounterHandle::remove); + leftHandleSet.clear(ExistsCounterHandle::removeByLeft); } public void clearIncludingCount() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java index 54bcbfff5e..4341a19b7c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -28,9 +28,12 @@ final class ExistsCounterHandle { rightHandleSet.add(this); } - public void remove() { - counter.leftHandleSet.remove(this); + public void removeByLeft() { rightHandleSet.remove(this); } + public void removeByRight() { + counter.leftHandleSet.remove(this); + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index a38b2bcb61..4a1327af73 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -104,14 +104,20 @@ public void remove(T element) { private boolean clearIfPossible() { if (gapCount > 0 && lastElementPosition + 1 == gapCount) { // All positions are gaps. Clear the list entirely. - elementList.clear(); - gapCount = 0; - lastElementPosition = -1; + forceClear(); return true; } return false; } + private void forceClear() { + if (elementList != null) { + elementList.clear(); + } + gapCount = 0; + lastElementPosition = -1; + } + public boolean isEmpty() { return size() == 0; } @@ -300,4 +306,30 @@ private void forceCompaction() { } } + public void clear(Consumer elementConsumer) { + var nonGapCount = size(); + if (nonGapCount == 0) { + forceClear(); + return; + } + var oldLastElementPosition = lastElementPosition; + for (var i = 0; i <= oldLastElementPosition; i++) { + var element = elementList.get(i); + if (element == null) { + continue; + } + elementConsumer.accept(element); + if (lastElementPosition != oldLastElementPosition) { + throw new IllegalStateException("Impossible state: the IndexedSet was modified while being cleared."); + } + elementPositionTracker.clearPosition(element); + // We can stop early once all non-gap elements have been processed. + nonGapCount--; + if (nonGapCount == 0) { + break; + } + } + forceClear(); + } + } From 6a8d0eeaedbb3f0ca14273275175aea7ace91ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 20 Nov 2025 13:20:43 +0100 Subject: [PATCH 20/21] Do the same for joins --- .../bavet/common/AbstractIndexedJoinNode.java | 8 +- .../impl/bavet/common/AbstractJoinNode.java | 34 +++++++-- .../common/AbstractUnindexedJoinNode.java | 4 +- .../impl/bavet/common/index/IndexedSet.java | 76 ++++++++++--------- 4 files changed, 75 insertions(+), 47 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java index 684697554c..a5a1e31845 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedJoinNode.java @@ -84,7 +84,7 @@ public final void updateLeft(LeftTuple_ leftTuple) { } else { indexerLeft.remove(oldIndexKeys, leftTuple); IndexedSet outTupleSetLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleSet); - outTupleSetLeft.forEach(this::retractOutTuple); + outTupleSetLeft.clear(this::retractOutTupleByLeft); // outTupleSetLeft is now empty, no need for leftTuple.setStore(...); indexAndPropagateLeft(leftTuple, newIndexKeys); } @@ -105,7 +105,7 @@ public final void retractLeft(LeftTuple_ leftTuple) { } IndexedSet outTupleSetLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleSet); indexerLeft.remove(indexKeys, leftTuple); - outTupleSetLeft.forEach(this::retractOutTuple); + outTupleSetLeft.clear(this::retractOutTupleByLeft); } @Override @@ -137,7 +137,7 @@ public final void updateRight(UniTuple rightTuple) { } else { IndexedSet outTupleSetRight = rightTuple.getStore(inputStoreIndexRightOutTupleSet); indexerRight.remove(oldIndexKeys, rightTuple); - outTupleSetRight.forEach(this::retractOutTuple); + outTupleSetRight.clear(this::retractOutTupleByRight); // outTupleSetRight is now empty, no need for rightTuple.setStore(...); indexAndPropagateRight(rightTuple, newIndexKeys); } @@ -158,7 +158,7 @@ public final void retractRight(UniTuple rightTuple) { } IndexedSet outTupleSetRight = rightTuple.removeStore(inputStoreIndexRightOutTupleSet); indexerRight.remove(indexKeys, rightTuple); - outTupleSetRight.forEach(this::retractOutTuple); + outTupleSetRight.clear(this::retractOutTupleByRight); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java index 0f6fcc728c..e4ab8822a0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractJoinNode.java @@ -128,6 +128,22 @@ private void findAndProcessOutTuple(LeftTuple_ leftTuple, UniTuple right } } + private void retractOutTuple(OutTuple_ outTuple) { + removeFromLeftOutSet(outTuple); + removeFromRightOutSet(outTuple); + propagateRetract(outTuple); + } + + private void removeFromLeftOutSet(OutTuple_ outTuple) { + IndexedSet outSetLeft = outTuple.removeStore(outputStoreIndexLeftOutSet); + outSetLeft.remove(outTuple); + } + + private void removeFromRightOutSet(OutTuple_ outTuple) { + IndexedSet outSetRight = outTuple.removeStore(outputStoreIndexRightOutSet); + outSetRight.remove(outTuple); + } + private static OutTuple_ findOutTuple(IndexedSet sourceSet, IndexedSet referenceSet, int outputStoreIndex) { // Hack: outTuple has no left/right input tuple reference, use the left/right outSet reference instead. @@ -184,11 +200,13 @@ protected final void innerUpdateRight(UniTuple rightTuple, Consumer outSetLeft = outTuple.removeStore(outputStoreIndexLeftOutSet); - outSetLeft.remove(outTuple); - IndexedSet outSetRight = outTuple.removeStore(outputStoreIndexRightOutSet); - outSetRight.remove(outTuple); + protected final void retractOutTupleByLeft(OutTuple_ outTuple) { + outTuple.removeStore(outputStoreIndexLeftOutSet); // The tuple will be removed from the set by the caller. + removeFromRightOutSet(outTuple); + propagateRetract(outTuple); + } + + private void propagateRetract(OutTuple_ outTuple) { var state = outTuple.state; if (!state.isActive()) { // Impossible because they shouldn't linger in the indexes. throw new IllegalStateException("Impossible state: The tuple (%s) in node (%s) is in an unexpected state (%s)." @@ -197,6 +215,12 @@ protected final void retractOutTuple(OutTuple_ outTuple) { propagationQueue.retract(outTuple, state == TupleState.CREATING ? TupleState.ABORTING : TupleState.DYING); } + protected final void retractOutTupleByRight(OutTuple_ outTuple) { + removeFromLeftOutSet(outTuple); + outTuple.removeStore(outputStoreIndexRightOutSet); // The tuple will be removed from the set by the caller. + propagateRetract(outTuple); + } + @Override public Propagator getPropagator() { return propagationQueue; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java index 22cba406ac..30863698fc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractUnindexedJoinNode.java @@ -71,7 +71,7 @@ public final void retractLeft(LeftTuple_ leftTuple) { } IndexedSet outTupleSetLeft = leftTuple.removeStore(inputStoreIndexLeftOutTupleSet); leftTupleSet.remove(leftTuple); - outTupleSetLeft.forEach(this::retractOutTuple); + outTupleSetLeft.clear(this::retractOutTupleByLeft); } @Override @@ -105,7 +105,7 @@ public final void retractRight(UniTuple rightTuple) { } IndexedSet outTupleSetRight = rightTuple.removeStore(inputStoreIndexRightOutTupleSet); rightTupleSet.remove(rightTuple); - outTupleSetRight.forEach(this::retractOutTuple); + outTupleSetRight.clear(this::retractOutTupleByRight); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 4a1327af73..177a082c3f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -1,5 +1,9 @@ package ai.timefold.solver.core.impl.bavet.common.index; +import ai.timefold.solver.core.impl.util.ElementAwareList; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -7,11 +11,6 @@ import java.util.function.Consumer; import java.util.function.Predicate; -import ai.timefold.solver.core.impl.util.ElementAwareList; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - /** * {@link ArrayList}-backed set which allows to {@link #remove(Object)} an element * without knowing its position and without an expensive lookup. @@ -132,9 +131,7 @@ public int size() { * The order of iteration may change as elements are added and removed. * * @param elementConsumer the action to be performed for each element; - * may include removing elements from the collection, - * but additions or swaps aren't allowed; - * undefined behavior will occur if that is attempted. + * mustn't modify the collection */ public void forEach(Consumer elementConsumer) { if (isEmpty()) { @@ -227,7 +224,7 @@ private void forEachCompacting(Consumer elementConsumer) { * As defined by {@link #forEach(Consumer)}, * but stops when the predicate returns true for an element. * - * @param elementPredicate the predicate to be tested for each element + * @param elementPredicate the predicate to be tested for each element; mustn't modify the collection * @return the first element for which the predicate returned true, or null if none */ public @Nullable T findFirst(Predicate elementPredicate) { @@ -275,6 +272,39 @@ private void forEachCompacting(Consumer elementConsumer) { return null; } + /** + * Empties the collection. + * For each element being removed, the given consumer is called. + * This is a more efficient implementation than removing elements individually. + * + * @param elementConsumer the consumer to be called for each element being removed; mustn't modify the collection + */ + public void clear(Consumer elementConsumer) { + var nonGapCount = size(); + if (nonGapCount == 0) { + forceClear(); + return; + } + var oldLastElementPosition = lastElementPosition; + for (var i = 0; i <= oldLastElementPosition; i++) { + var element = elementList.get(i); + if (element == null) { + continue; + } + elementConsumer.accept(element); + if (lastElementPosition != oldLastElementPosition) { + throw new IllegalStateException("Impossible state: the IndexedSet was modified while being cleared."); + } + elementPositionTracker.clearPosition(element); + // We can stop early once all non-gap elements have been processed. + nonGapCount--; + if (nonGapCount == 0) { + break; + } + } + forceClear(); + } + /** * Returns a standard {@link List} view of this collection. * Users mustn't modify the returned list, as that'd also change the underlying data structure. @@ -285,7 +315,7 @@ public List asList() { if (elementList == null) { return Collections.emptyList(); } - if (gapCount > 0) { // The list must not return any nulls. + if (gapCount > 0) { // The list mustn't return any nulls. forceCompaction(); } return elementList.isEmpty() ? Collections.emptyList() : elementList; @@ -306,30 +336,4 @@ private void forceCompaction() { } } - public void clear(Consumer elementConsumer) { - var nonGapCount = size(); - if (nonGapCount == 0) { - forceClear(); - return; - } - var oldLastElementPosition = lastElementPosition; - for (var i = 0; i <= oldLastElementPosition; i++) { - var element = elementList.get(i); - if (element == null) { - continue; - } - elementConsumer.accept(element); - if (lastElementPosition != oldLastElementPosition) { - throw new IllegalStateException("Impossible state: the IndexedSet was modified while being cleared."); - } - elementPositionTracker.clearPosition(element); - // We can stop early once all non-gap elements have been processed. - nonGapCount--; - if (nonGapCount == 0) { - break; - } - } - forceClear(); - } - } From b64aa2ffe7117afb1b52c395a240438da8aaf09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Thu, 20 Nov 2025 14:15:16 +0100 Subject: [PATCH 21/21] Coverage --- .../bavet/common/ExistsCounterHandle.java | 4 +- .../impl/bavet/common/index/IndexedSet.java | 15 +- .../bavet/common/index/IndexedSetTest.java | 692 +++++++++--------- 3 files changed, 333 insertions(+), 378 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java index 4341a19b7c..b1219c32da 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/ExistsCounterHandle.java @@ -29,11 +29,11 @@ final class ExistsCounterHandle { } public void removeByLeft() { - rightHandleSet.remove(this); + rightHandleSet.remove(this); // The counter will be removed from the left handle set by the caller. } public void removeByRight() { - counter.leftHandleSet.remove(this); + counter.leftHandleSet.remove(this); // The counter will be removed from the right handle set by the caller. } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java index 177a082c3f..ddcf96f379 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSet.java @@ -91,8 +91,7 @@ public void remove(T element) { } if (insertionPosition == lastElementPosition) { // The element was the last one added; we can simply remove it. - elementList.remove(insertionPosition); - lastElementPosition--; + elementList.remove(lastElementPosition--); } else { // We replace the element with null, creating a gap. elementList.set(insertionPosition, null); @@ -210,8 +209,7 @@ private void forEachCompacting(Consumer elementConsumer) { private @Nullable T removeLastNonGap(int gapPosition) { while (lastElementPosition >= gapPosition) { - var lastRemovedElement = elementList.remove(lastElementPosition); - lastElementPosition--; + var lastRemovedElement = elementList.remove(lastElementPosition--); if (lastRemovedElement != null) { return lastRemovedElement; } @@ -285,20 +283,15 @@ public void clear(Consumer elementConsumer) { forceClear(); return; } - var oldLastElementPosition = lastElementPosition; - for (var i = 0; i <= oldLastElementPosition; i++) { + for (var i = 0; i <= lastElementPosition; i++) { var element = elementList.get(i); if (element == null) { continue; } elementConsumer.accept(element); - if (lastElementPosition != oldLastElementPosition) { - throw new IllegalStateException("Impossible state: the IndexedSet was modified while being cleared."); - } elementPositionTracker.clearPosition(element); // We can stop early once all non-gap elements have been processed. - nonGapCount--; - if (nonGapCount == 0) { + if (--nonGapCount == 0) { break; } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java index afcedb0355..4ad69b5568 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/bavet/common/index/IndexedSetTest.java @@ -1,425 +1,383 @@ package ai.timefold.solver.core.impl.bavet.common.index; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import org.jspecify.annotations.NullMarked; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @NullMarked class IndexedSetTest { - private static IndexedSet createSet() { - return new IndexedSet<>(new CompactingIndexPositionTracker<>()); + @Test + void addSingleElement() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + + assertThat(set.size()).isEqualTo(1); + assertThat(set.isEmpty()).isFalse(); + assertThat(tracker.hasPosition("A")).isTrue(); } - @Nested - class BasicOperations { + @Test + void addMultipleElements() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void emptySetHasSizeZero() { - var set = createSet(); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); - assertThat(set.asList()).isEmpty(); - } + set.add("A"); + set.add("B"); + set.add("C"); - @Test - void addSingleElement() { - var set = createSet(); - set.add("A"); - assertThat(set.isEmpty()).isFalse(); - assertThat(set.size()).isEqualTo(1); - assertThat(set.asList()).containsExactly("A"); - } + assertThat(set.size()).isEqualTo(3); + assertThat(set.isEmpty()).isFalse(); + assertThat(tracker.hasPosition("A")).isTrue(); + assertThat(tracker.hasPosition("B")).isTrue(); + assertThat(tracker.hasPosition("C")).isTrue(); + } - @Test - void addMultipleElements() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - assertThat(set.size()).isEqualTo(3); - assertThat(set.asList()).containsExactly("A", "B", "C"); - } + @Test + void removeLastElement() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void removeSingleElement() { - var set = createSet(); - set.add("A"); - set.remove("A"); - assertThat(set.isEmpty()).isTrue(); - assertThat(set.size()).isZero(); - assertThat(set.asList()).isEmpty(); - } + set.add("A"); + set.add("B"); + set.remove("B"); - @Test - void removeLastElement() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("C"); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("A", "B"); - } + assertThat(set.size()).isEqualTo(1); + assertThat(tracker.hasPosition("A")).isTrue(); + assertThat(tracker.hasPosition("B")).isFalse(); + } - @Test - void removeMiddleElement() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("A", "C"); - } + @Test + void removeMiddleElement() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void removeFirstElement() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("A"); - assertThat(set.size()).isEqualTo(2); - assertThat(set.asList()).containsExactly("C", "B"); - } + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); - @Test - void removeNonExistentElementThrows() { - var set = createSet(); - set.add("A"); - assertThatThrownBy(() -> set.remove("B")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } + assertThat(set.size()).isEqualTo(2); + assertThat(tracker.hasPosition("A")).isTrue(); + assertThat(tracker.hasPosition("B")).isFalse(); + assertThat(tracker.hasPosition("C")).isTrue(); + } - @Test - void removeFromEmptySetThrows() { - var set = createSet(); - assertThatThrownBy(() -> set.remove("A")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } + @Test + void removeNonExistentElement() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void removeAlreadyRemovedElementThrows() { - var set = createSet(); - set.add("A"); - set.remove("A"); - assertThatThrownBy(() -> set.remove("A")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("was not found"); - } + set.add("A"); + + assertThatThrownBy(() -> set.remove("B")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("was not found in the IndexedSet"); } - @Nested - class ForEachTests { + @Test + void removeAllElements() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void forEachOnEmptySet() { - var set = createSet(); - var visited = new ArrayList(); - set.forEach(visited::add); - assertThat(visited).isEmpty(); - } + set.add("A"); + set.add("B"); + set.remove("A"); + set.remove("B"); - @Test - void forEachVisitsAllElements() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - var visited = new ArrayList(); - set.forEach(visited::add); - assertThat(visited).containsExactlyInAnyOrder("A", "B", "C"); - } + assertThat(set.size()).isEqualTo(0); + assertThat(set.isEmpty()).isTrue(); + } - @Test - void forEachSkipsGaps() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.remove("B"); - var visited = new ArrayList(); - set.forEach(visited::add); - assertThat(visited).containsExactlyInAnyOrder("A", "C"); - } + @Test + void removeAllElementsCreatingGaps() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); - @Test - void forEachWithRemovalDuringIteration() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - set.add("D"); - var visited = new ArrayList(); - set.forEach(element -> { - visited.add(element); - if (element.equals("B")) { - set.remove("C"); - } - }); - assertThat(visited).containsExactlyInAnyOrder("A", "B", "D"); - assertThat(set.size()).isEqualTo(3); - } + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + set.remove("A"); + set.remove("C"); + + assertThat(set.size()).isEqualTo(0); + assertThat(set.isEmpty()).isTrue(); } - @Nested - class FindFirstTests { + @Test + void forEachEmptySet() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); - @Test - void findFirstOnEmptySet() { - var set = createSet(); - assertThat(set.findFirst(s -> true)).isNull(); - } + set.forEach(result::add); - @Test - void findFirstReturnsMatchingElement() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - assertThat(set.findFirst(s -> s.equals("B"))).isEqualTo("B"); - } + assertThat(result).isEmpty(); + } - @Test - void findFirstReturnsNullWhenNoMatch() { - var set = createSet(); - set.add("A"); - set.add("B"); - assertThat(set.findFirst(s -> s.equals("C"))).isNull(); - } + @Test + void forEachWithoutGaps() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); - @Test - void findFirstStopsAtFirstMatch() { - var set = createSet(); - set.add("A"); - set.add("B"); - set.add("C"); - var callCount = new AtomicInteger(0); - set.findFirst(s -> { - callCount.incrementAndGet(); - return s.equals("B"); - }); - assertThat(callCount.get()).isLessThanOrEqualTo(2); - } + set.add("A"); + set.add("B"); + set.add("C"); + set.forEach(result::add); + + assertThat(result).containsExactlyInAnyOrder("A", "B", "C"); } - @Nested - class CompactionTests { - - @Test - void noCompactionForSmallSets() { - var set = createSet(); - // Add fewer elements than MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - for (var i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; i++) { - set.add("Element" + i); - } - // Remove half of them to create gaps - for (var i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 2; i++) { - set.remove("Element" + i); - } - var visited = new ArrayList(); - set.forEach(visited::add); - // Verify elements are still visited correctly - assertThat(visited).hasSize(IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION / 2 - 1); - } + @Test + void forEachWithGapsNoCompaction() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); - @Test - void compactionTriggeredByGapRatio() { - var set = createSet(); - var elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; - // Add elements - for (var i = 0; i < elementCount; i++) { - set.add("Element" + i); - } - // Remove enough elements to exceed GAP_RATIO_FOR_COMPACTION (10%) - var gapsNeeded = (int) (elementCount * IndexedSet.GAP_RATIO_FOR_COMPACTION) + 1; - for (var i = 0; i < gapsNeeded; i++) { - set.remove("Element" + i); - } - var visited = new ArrayList(); - set.forEach(visited::add); - assertThat(visited).hasSize(elementCount - gapsNeeded); - // After compaction, asList should not trigger further compaction - assertThat(set.asList()).hasSize(elementCount - gapsNeeded); + // Add fewer elements than MINIMUM_ELEMENT_COUNT_FOR_COMPACTION + for (int i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; i++) { + set.add("Element" + i); } + set.remove("Element5"); + set.forEach(result::add); - @Test - void externalRemovalDuringCompaction() { - var set = createSet(); - var elementCount = 30; - for (var i = 0; i < elementCount; i++) { - set.add("Element" + i); - } - // Remove elements to trigger compaction - for (var i = 0; i < 5; i++) { - set.remove("Element" + i); - } - var visited = new ArrayList(); - set.forEach(element -> { - visited.add(element); - // Remove an element during iteration - if (element.equals("Element10")) { - set.remove("Element20"); - } - }); - assertThat(set.size()).isEqualTo(24); - assertThat(visited).doesNotContain("Element20"); - } + assertThat(result).hasSize(IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 2); + } - @Test - void multipleExternalRemovalsDuringCompaction() { - var set = createSet(); - for (var i = 0; i < 40; i++) { - set.add("Element" + i); - } - // Create gaps to trigger compaction - for (var i = 0; i < 6; i++) { - set.remove("Element" + i); - } - var visited = new ArrayList(); - var removed = new ArrayList(); - set.forEach(element -> { - visited.add(element); - if (element.equals("Element10")) { - set.remove("Element20"); - removed.add("Element20"); - } - if (element.equals("Element15")) { - set.remove("Element25"); - removed.add("Element25"); - } - }); - assertThat(set.size()).isEqualTo(32); - assertThat(visited).doesNotContainAnyElementsOf(removed); - } + @Test + void forEachWithGapsTriggersCompaction() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); - @Test - void removalOfLastElementDuringCompaction() { - var set = createSet(); - for (var i = 0; i < 30; i++) { - set.add("Element" + i); - } - for (var i = 0; i < 4; i++) { - set.remove("Element" + i); - } - set.forEach(element -> { - if (element.equals("Element10")) { - set.remove("Element29"); // Last element - } - }); - assertThat(set.size()).isEqualTo(25); + // Add enough elements to trigger compaction + int elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + for (int i = 0; i < elementCount; i++) { + set.add("Element" + i); } - @Test - void clearAllGapsDuringCompaction() { - var set = createSet(); - for (var i = 0; i < 25; i++) { - set.add("Element" + i); - } - // Remove all elements to create only gaps - for (var i = 0; i < 25; i++) { - set.remove("Element" + i); - } - assertThat(set.isEmpty()).isTrue(); - assertThat(set.asList()).isEmpty(); + // Remove enough elements to exceed GAP_RATIO_FOR_COMPACTION + int gapsNeeded = (int) Math.ceil(elementCount * IndexedSet.GAP_RATIO_FOR_COMPACTION) + 1; + for (int i = 0; i < gapsNeeded; i++) { + set.remove("Element" + i); } - @Test - void asListTriggersCompaction() { - var set = createSet(); - for (var i = 0; i < 30; i++) { - set.add("Element" + i); - } - set.remove("Element5"); - set.remove("Element10"); - set.remove("Element15"); - var list = set.asList(); - assertThat(list).hasSize(27); - assertThat(list).doesNotContainNull(); - } + set.forEach(result::add); + + assertThat(result).hasSize(elementCount - gapsNeeded); + } + + @Test + void findFirstEmptySet() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + var result = set.findFirst(s -> s.equals("A")); + + assertThat(result).isNull(); + } + + @Test + void findFirstWithoutGaps() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + set.add("B"); + set.add("C"); - @Test - void forceCompactionWithMultipleGaps() { - var set = createSet(); - for (var i = 0; i < 50; i++) { - set.add("Element" + i); - } - // Create multiple gaps - for (var i = 0; i < 10; i += 2) { - set.remove("Element" + i); - } - var list = set.asList(); - assertThat(list).hasSize(45); - assertThat(list).doesNotContainNull(); + var result = set.findFirst(s -> s.equals("B")); + + assertThat(result).isEqualTo("B"); + } + + @Test + void findFirstNotFound() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + set.add("B"); + + var result = set.findFirst(s -> s.equals("C")); + + assertThat(result).isNull(); + } + + @Test + void findFirstWithGapsNoCompaction() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + for (int i = 0; i < IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION - 1; i++) { + set.add("Element" + i); } + set.remove("Element5"); + + var result = set.findFirst(s -> s.equals("Element10")); + + assertThat(result).isEqualTo("Element10"); } - @Nested - class EdgeCases { - - @Test - void removeAndReAddCycle() { - var set = createSet(); - set.add("A"); - set.remove("A"); - set.add("A"); - assertThat(set.size()).isEqualTo(1); - assertThat(set.asList()).containsExactly("A"); + @Test + void findFirstWithGapsTriggersCompaction() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + int elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + for (int i = 0; i < elementCount; i++) { + set.add("Element" + i); } - @Test - void largeNumberOfElements() { - var set = createSet(); - var count = 1000; - for (var i = 0; i < count; i++) { - set.add("Element" + i); - } - assertThat(set.size()).isEqualTo(count); - assertThat(set.asList()).hasSize(count); + int gapsNeeded = (int) Math.ceil(elementCount * IndexedSet.GAP_RATIO_FOR_COMPACTION) + 1; + for (int i = 0; i < gapsNeeded; i++) { + set.remove("Element" + i); } - @Test - void largeNumberOfGaps() { - var set = createSet(); - for (var i = 0; i < 100; i++) { - set.add("Element" + i); - } - for (var i = 0; i < 50; i++) { - set.remove("Element" + (i * 2)); - } - assertThat(set.size()).isEqualTo(50); - var list = set.asList(); - assertThat(list).hasSize(50); - assertThat(list).doesNotContainNull(); + var result = set.findFirst(s -> s.startsWith("Element")); + + assertThat(result).isNotNull(); + } + + @Test + void clearEmptySet() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); + + set.clear(result::add); + + assertThat(result).isEmpty(); + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void clearWithElements() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); + + set.add("A"); + set.add("B"); + set.add("C"); + set.clear(result::add); + + assertThat(result).containsExactlyInAnyOrder("A", "B", "C"); + assertThat(set.isEmpty()).isTrue(); + assertThat(tracker.hasPosition("A")).isFalse(); + assertThat(tracker.hasPosition("B")).isFalse(); + assertThat(tracker.hasPosition("C")).isFalse(); + } + + @Test + void clearWithGaps() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + var result = new ArrayList(); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + set.clear(result::add); + + assertThat(result).containsExactlyInAnyOrder("A", "C"); + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void asListEmptySet() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + var list = set.asList(); + + assertThat(list).isEmpty(); + } + + @Test + void asListWithoutGaps() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + set.add("B"); + set.add("C"); + + var list = set.asList(); + + assertThat(list).containsExactly("A", "B", "C"); + } + + @Test + void asListWithGapsForcesCompaction() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + set.add("B"); + set.add("C"); + set.remove("B"); + + var list = set.asList(); + + assertThat(list) + .hasSize(2) + .doesNotContainNull() + .containsExactlyInAnyOrder("A", "C"); + } + + @Test + void asListAfterClearReturnsEmptyList() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + set.add("A"); + set.add("B"); + set.remove("A"); + set.remove("B"); + + var list = set.asList(); + + assertThat(list).isEmpty(); + } + + @Test + void compactionFillsGapsCorrectly() { + var tracker = new TestElementPositionTracker(); + var set = new IndexedSet<>(tracker); + + // Create scenario where compaction will move elements + int elementCount = IndexedSet.MINIMUM_ELEMENT_COUNT_FOR_COMPACTION; + for (int i = 0; i < elementCount; i++) { + set.add("Element" + i); } - @Test - void alternatingAddAndRemove() { - var set = createSet(); - for (var i = 0; i < 100; i++) { - set.add("Element" + i); - if (i > 0 && i % 2 == 0) { - set.remove("Element" + (i - 1)); - } - } - assertThat(set.size()).isGreaterThan(0); - assertThat(set.asList()).doesNotContainNull(); + // Remove elements from the beginning to create gaps + int gapsNeeded = (int) Math.ceil(elementCount * IndexedSet.GAP_RATIO_FOR_COMPACTION) + 1; + for (int i = 0; i < gapsNeeded; i++) { + set.remove("Element" + i); } + + // Force compaction via asList + var list = set.asList(); + + assertThat(list) + .hasSize(elementCount - gapsNeeded) + .doesNotContainNull(); } - @NullMarked - private static final class CompactingIndexPositionTracker implements ElementPositionTracker { + private static final class TestElementPositionTracker implements ElementPositionTracker { private final Map positionMap = new HashMap<>(); @@ -430,9 +388,13 @@ public void setPosition(T element, int position) { @Override public int clearPosition(T element) { - var result = positionMap.remove(element); - return result != null ? result : -1; + var position = positionMap.remove(element); + return position != null ? position : -1; } + public boolean hasPosition(T element) { + return positionMap.containsKey(element); + } } + }