context) {
+ this.context = Objects.requireNonNull(context);
+ this.leftDatasetInstance = context.getLeftDatasetInstance();
+ this.rightDatasetInstance = context.getRightDatasetInstance();
+ }
+
+ @Override
+ public boolean hasNext() {
+ // If we already found the next move, return true.
+ if (nextMove != null) {
+ return true;
+ }
+
+ // Initialize if needed.
+ if (leftTupleIterator == null) {
+ leftTupleIterator = leftDatasetInstance.iterator();
+ // If first iterator is empty, there's no next move.
+ if (!leftTupleIterator.hasNext()) {
+ return false;
+ }
+ }
+
+ // Try to find the next valid move.
+ var joiner = context.getJoiner();
+ var filter = context.getFilter();
+ var solutionView = context.neighborhoodSession().getSolutionView();
+ while (true) {
+ if (rightTupleIterator == null || !rightTupleIterator.hasNext()) {
+ if (leftTupleIterator.hasNext()) { // The second iterator is exhausted or the first one was not yet created.
+ leftTuple = leftTupleIterator.next();
+ rightTupleIterator =
+ new JoiningIterator<>(joiner, filter, solutionView, leftTuple, rightDatasetInstance.iterator());
+ } else { // No more elements in both iterators.
+ return false;
+ }
+ } else { // Both iterators have elements.
+ var leftFact = leftTuple.factA;
+ var rightFact = rightTupleIterator.next().factA;
+ nextMove = context.buildMove(leftFact, rightFact);
+ return true;
+ }
+ }
+ }
+
+ @Override
+ public Move next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ var result = Objects.requireNonNull(nextMove);
+ nextMove = null;
+ return result;
+ }
+
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiRandomMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiRandomMoveIterator.java
new file mode 100644
index 0000000000..eb3ee45efa
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiRandomMoveIterator.java
@@ -0,0 +1,159 @@
+package ai.timefold.solver.core.impl.neighborhood.move;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Random;
+
+import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple;
+import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DefaultUniqueRandomSequence;
+import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.UniqueRandomSequence;
+import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.DefaultBiEnumeratingJoiner;
+import ai.timefold.solver.core.impl.util.CollectionUtils;
+import ai.timefold.solver.core.preview.api.move.Move;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An iterator for the bi-move stream which returns (A,B) pairs in random order.
+ * This iterator implements sampling without replacement,
+ * meaning that once a particular (A,B) pair has been returned,
+ * it will never be returned again by this iterator.
+ * This means that this random move iterator will eventually end.
+ *
+ *
+ * This iterator's implementation is determined by the following considerations:
+ *
+ * - The left and right datasets need to support efficient random access.
+ * - The left and right datasets are possibly large,
+ * which makes their copying and mutation prohibitively expensive.
+ * - Keeping all possible pairs in memory is prohibitively expensive,
+ * for the same reason.
+ * (Cartesian product of A x B.)
+ * - The solver will never require all possible pairs of (A,B).
+ * Instead, it will terminate the iteration after selecting just a handful,
+ * as the chance of accepting a move grows more and more with each new move.
+ *
+ *
+ *
+ * From the above, the key design decisions are:
+ *
+ * - Both left and right datasets are kept in the {@link ArrayList} in which they came.
+ * This list will never be copied, nor will it be mutated.
+ * - When an item needs to be selected from either list, it is wrapped in {@link DefaultUniqueRandomSequence},
+ * which allows to pick random elements and remembers which elements were already picked,
+ * never to pick them again.
+ * - This type is only created when needed.
+ * Once A is picked, a sequence for B is created and stored for later use in case A is picked again.
+ * Once the B sequence is exhausted, it is removed and A is discarded.
+ * - Filtering of (A,B) pair only happens after both A and B have been randomly selected.
+ * This guarantees that filtering is only applied when necessary,
+ * as opposed to pre-filtering the entire dataset,
+ * which could be prohibitively expensive.
+ * - If the filter rejects the pair, (A,B) is discarded and a new B is selected.
+ * This guarantees that A keeps its selection probability of (1/A).
+ *
+ *
+ * This implementation is somewhat expensive in terms of CPU and memory,
+ * but it is likely the best we can do given the constraints.
+ */
+@NullMarked
+final class BiRandomMoveIterator implements Iterator> {
+
+ private final BiMoveStreamContext context;
+ private final Random workingRandom;
+
+ // Fields required for iteration.
+ private final DefaultUniqueRandomSequence> leftTupleSequence;
+ private final Map, UniqueRandomSequence>> rightTupleSequenceMap;
+ private @Nullable Move nextMove;
+
+ public BiRandomMoveIterator(BiMoveStreamContext context, Random workingRandom) {
+ this.context = Objects.requireNonNull(context);
+ this.workingRandom = Objects.requireNonNull(workingRandom);
+ var leftDatasetInstance = context.getLeftDatasetInstance();
+ this.leftTupleSequence = leftDatasetInstance.buildRandomSequence();
+ this.rightTupleSequenceMap = leftTupleSequence.isEmpty() ? Collections.emptyMap()
+ : CollectionUtils.newIdentityHashMap(leftDatasetInstance.size());
+ }
+
+ private UniqueRandomSequence> computeRightSequence(UniTuple leftTuple) {
+ var rightDatasetInstance = context.getRightDatasetInstance();
+ var rightTupleCount = rightDatasetInstance.size();
+ if (rightTupleCount == 0) {
+ return DefaultUniqueRandomSequence.empty();
+ }
+ var joiner = context.getJoiner();
+ var filter = context.getFilter();
+ if (joiner.getJoinerCount() == 0 && filter == null) {
+ // Shortcut: no joiners and no filter means we can take the entire right dataset as-is.
+ return rightDatasetInstance.buildRandomSequence();
+ }
+ var leftFact = leftTuple.factA;
+ var solutionView = context.neighborhoodSession().getSolutionView();
+ return rightDatasetInstance.buildRandomSequence(rightTuple -> {
+ var rightFact = rightTuple.factA;
+ if (failsJoiner(joiner, leftFact, rightFact)) {
+ return false;
+ }
+ // Only test the filter after the joiners all match;
+ // this fits user expectations as the filtering joiner is always declared last.
+ return filter == null || filter.test(solutionView, leftFact, rightFact);
+ });
+ }
+
+ static boolean failsJoiner(DefaultBiEnumeratingJoiner joiner, A leftFact, B rightFact) {
+ var joinerCount = joiner.getJoinerCount();
+ for (var joinerId = 0; joinerId < joinerCount; joinerId++) {
+ var joinerType = joiner.getJoinerType(joinerId);
+ var mappedLeft = joiner.getLeftMapping(joinerId).apply(leftFact);
+ var mappedRight = joiner.getRightMapping(joinerId).apply(rightFact);
+ if (!joinerType.matches(mappedLeft, mappedRight)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (nextMove != null) {
+ return true;
+ }
+
+ while (!leftTupleSequence.isEmpty()) {
+ var leftElement = leftTupleSequence.pick(workingRandom);
+ var leftTuple = leftElement.value();
+ var rightTupleSequence = rightTupleSequenceMap.computeIfAbsent(leftTuple, this::computeRightSequence);
+ try {
+ var bTuple = rightTupleSequence.remove(workingRandom);
+ var leftFact = leftTuple.factA;
+ var rightFact = bTuple.factA;
+ nextMove = context.buildMove(leftFact, rightFact);
+ } catch (NoSuchElementException e) {
+ // We cannot guarantee that the right sequence is empty, because we do not check filtering eagerly.
+ // Therefore we can run into a situation where there are no more elements passing the filter,
+ // even though the sequence is not technically empty.
+ // We only find this out at runtime.
+ leftTupleSequence.remove(leftElement.index());
+ rightTupleSequenceMap.remove(leftTuple);
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public Move next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ var result = Objects.requireNonNull(nextMove);
+ nextMove = null;
+ return result;
+ }
+
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromBiUniMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromBiUniMoveStream.java
deleted file mode 100644
index 08f9416b23..0000000000
--- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromBiUniMoveStream.java
+++ /dev/null
@@ -1,124 +0,0 @@
-package ai.timefold.solver.core.impl.neighborhood.move;
-
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-import java.util.Objects;
-import java.util.Random;
-import java.util.Set;
-import java.util.function.Supplier;
-
-import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple;
-import ai.timefold.solver.core.impl.neighborhood.maybeapi.NeighborhoodSession;
-import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.BiMoveConstructor;
-import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession;
-import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.BiDataset;
-import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream;
-import ai.timefold.solver.core.preview.api.move.Move;
-import ai.timefold.solver.core.preview.api.move.SolutionView;
-
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-@NullMarked
-public final class FromBiUniMoveStream implements InnerMoveStream {
-
- private final BiDataset aDataset;
- private final BiMoveConstructor moveConstructor;
-
- public FromBiUniMoveStream(BiDataset aDataset, BiMoveConstructor moveConstructor) {
- this.aDataset = Objects.requireNonNull(aDataset);
- this.moveConstructor = Objects.requireNonNull(moveConstructor);
- }
-
- @Override
- public MoveIterable getMoveIterable(NeighborhoodSession neighborhoodSession) {
- return new InnerMoveIterable((DefaultNeighborhoodSession) neighborhoodSession);
- }
-
- @Override
- public void collectActiveEnumeratingStreams(Set> enumeratingStreamSet) {
- aDataset.collectActiveEnumeratingStreams(enumeratingStreamSet);
- }
-
- @NullMarked
- private final class InnerMoveIterator implements Iterator> {
-
- private final IteratorSupplier iteratorSupplier;
- private final SolutionView solutionView;
-
- // Fields required for iteration.
- private @Nullable Move nextMove;
- private @Nullable Iterator> iterator;
-
- public InnerMoveIterator(DefaultNeighborhoodSession neighborhoodSession) {
- var aInstance = neighborhoodSession.getDatasetInstance(aDataset);
- this.iteratorSupplier = aInstance::iterator;
- this.solutionView = neighborhoodSession.getSolutionView();
- }
-
- public InnerMoveIterator(DefaultNeighborhoodSession neighborhoodSession, Random random) {
- var aInstance = neighborhoodSession.getDatasetInstance(aDataset);
- this.iteratorSupplier = () -> aInstance.iterator(random);
- this.solutionView = neighborhoodSession.getSolutionView();
- }
-
- @Override
- public boolean hasNext() {
- // If we already found the next move, return true.
- if (nextMove != null) {
- return true;
- }
-
- // Initialize iterator if needed.
- if (iterator == null) {
- iterator = iteratorSupplier.get();
- }
-
- // If iterator is empty, there's no next move.
- if (!iterator.hasNext()) {
- return false;
- }
-
- var tuple = iterator.next();
- nextMove = moveConstructor.apply(solutionView, tuple.factA, tuple.factB);
- return true;
- }
-
- @Override
- public Move next() {
- if (!hasNext()) {
- throw new NoSuchElementException();
- }
- var result = nextMove;
- nextMove = null;
- return result;
- }
-
- @FunctionalInterface
- private interface IteratorSupplier extends Supplier>> {
-
- }
- }
-
- @NullMarked
- private final class InnerMoveIterable implements MoveIterable {
-
- private final DefaultNeighborhoodSession neighborhoodSession;
-
- public InnerMoveIterable(DefaultNeighborhoodSession neighborhoodSession) {
- this.neighborhoodSession = Objects.requireNonNull(neighborhoodSession);
- }
-
- @Override
- public Iterator> iterator() {
- return new InnerMoveIterator(neighborhoodSession);
- }
-
- @Override
- public Iterator> iterator(Random random) {
- return new InnerMoveIterator(neighborhoodSession, random);
- }
-
- }
-
-}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromUniBiMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromUniBiMoveStream.java
deleted file mode 100644
index 55457ffc3f..0000000000
--- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/FromUniBiMoveStream.java
+++ /dev/null
@@ -1,159 +0,0 @@
-package ai.timefold.solver.core.impl.neighborhood.move;
-
-import java.util.Iterator;
-import java.util.NoSuchElementException;
-import java.util.Objects;
-import java.util.Random;
-import java.util.Set;
-import java.util.function.BiPredicate;
-import java.util.function.Supplier;
-
-import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple;
-import ai.timefold.solver.core.impl.neighborhood.maybeapi.NeighborhoodSession;
-import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.BiMoveConstructor;
-import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession;
-import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream;
-import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset;
-import ai.timefold.solver.core.preview.api.move.Move;
-import ai.timefold.solver.core.preview.api.move.SolutionView;
-
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-@NullMarked
-public final class FromUniBiMoveStream implements InnerMoveStream {
-
- private final UniDataset aDataset;
- private final UniDataset bDataset;
- private final BiMoveConstructor moveConstructor;
- private final BiPredicate filter;
-
- public FromUniBiMoveStream(UniDataset aDataset, UniDataset bDataset, BiPredicate filter,
- BiMoveConstructor moveConstructor) {
- this.aDataset = Objects.requireNonNull(aDataset);
- this.bDataset = Objects.requireNonNull(bDataset);
- this.filter = Objects.requireNonNull(filter);
- this.moveConstructor = Objects.requireNonNull(moveConstructor);
- }
-
- @Override
- public MoveIterable getMoveIterable(NeighborhoodSession neighborhoodSession) {
- return new BiMoveIterable((DefaultNeighborhoodSession) neighborhoodSession);
- }
-
- @Override
- public void collectActiveEnumeratingStreams(Set> enumeratingStreamSet) {
- aDataset.collectActiveEnumeratingStreams(enumeratingStreamSet);
- bDataset.collectActiveEnumeratingStreams(enumeratingStreamSet);
- }
-
- private final class BiMoveIterator implements Iterator> {
-
- private final IteratorSupplier aIteratorSupplier;
- private final IteratorSupplier bIteratorSupplier;
- private final SolutionView solutionView;
-
- // Fields required for iteration.
- private @Nullable Move nextMove;
- private @Nullable Iterator> aIterator;
- private @Nullable Iterator> bIterator;
- private @Nullable A currentA;
-
- public BiMoveIterator(DefaultNeighborhoodSession neighborhoodSession) {
- var aInstance = neighborhoodSession.getDatasetInstance(aDataset);
- this.aIteratorSupplier = aInstance::iterator;
- var bInstance = neighborhoodSession.getDatasetInstance(bDataset);
- this.bIteratorSupplier = bInstance::iterator;
- this.solutionView = neighborhoodSession.getSolutionView();
- }
-
- public BiMoveIterator(DefaultNeighborhoodSession neighborhoodSession, Random random) {
- var aInstance = neighborhoodSession.getDatasetInstance(aDataset);
- this.aIteratorSupplier = () -> aInstance.iterator(random);
- var bInstance = neighborhoodSession.getDatasetInstance(bDataset);
- this.bIteratorSupplier = () -> bInstance.iterator(random);
- this.solutionView = neighborhoodSession.getSolutionView();
- }
-
- @Override
- public boolean hasNext() {
- // If we already found the next move, return true.
- if (nextMove != null) {
- return true;
- }
-
- // Initialize iterators if needed.
- if (aIterator == null) {
- aIterator = aIteratorSupplier.get();
- // If first iterator is empty, there's no next move.
- if (!aIterator.hasNext()) {
- return false;
- }
- currentA = aIterator.next().factA;
- bIterator = bIteratorSupplier.get();
- }
-
- // Try to find the next valid move.
- while (true) {
- // If inner iterator has more elements...
- while (bIterator.hasNext()) {
- var bTuple = bIterator.next();
- var currentB = bTuple.factA;
-
- // Check if this pair passes the filter...
- if (filter.test(currentA, currentB)) {
- // ... and create the next move.
- nextMove = moveConstructor.apply(solutionView, currentA, currentB);
- return true;
- }
- }
-
- // Inner iterator exhausted, move to next outer element.
- if (aIterator.hasNext()) {
- currentA = aIterator.next().factA;
- // Reset inner iterator for new outer element.
- bIterator = bIteratorSupplier.get();
- } else {
- // Both iterators exhausted.
- return false;
- }
- }
- }
-
- @Override
- public Move next() {
- if (!hasNext()) {
- throw new NoSuchElementException();
- }
- var result = nextMove;
- nextMove = null;
- return result;
- }
-
- @FunctionalInterface
- private interface IteratorSupplier extends Supplier>> {
-
- }
- }
-
- private final class BiMoveIterable implements MoveIterable {
-
- private final DefaultNeighborhoodSession neighborhoodSession;
-
- public BiMoveIterable(DefaultNeighborhoodSession neighborhoodSession) {
- this.neighborhoodSession = Objects.requireNonNull(neighborhoodSession);
- }
-
- @Override
- public Iterator> iterator() {
- return new BiMoveIterator(neighborhoodSession);
- }
-
- @Override
- public Iterator> iterator(Random random) {
- return new BiMoveIterator(neighborhoodSession, random);
- }
-
- }
-
-}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/InnerMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/InnerMoveStream.java
index c9f17301d8..7ba24db179 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/InnerMoveStream.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/InnerMoveStream.java
@@ -1,10 +1,7 @@
package ai.timefold.solver.core.impl.neighborhood.move;
-import java.util.Set;
-
import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream;
import ai.timefold.solver.core.impl.neighborhood.maybeapi.NeighborhoodSession;
-import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream;
import org.jspecify.annotations.NullMarked;
@@ -14,6 +11,4 @@ public interface InnerMoveStream extends MoveStream {
@Override
MoveIterable getMoveIterable(NeighborhoodSession neighborhoodSession);
- void collectActiveEnumeratingStreams(Set> enumeratingStreamSet);
-
}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/JoiningIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/JoiningIterator.java
new file mode 100644
index 0000000000..ddda16f8c8
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/JoiningIterator.java
@@ -0,0 +1,75 @@
+package ai.timefold.solver.core.impl.neighborhood.move;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+
+import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple;
+import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate;
+import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.DefaultBiEnumeratingJoiner;
+import ai.timefold.solver.core.preview.api.move.SolutionView;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+@NullMarked
+final class JoiningIterator implements Iterator> {
+
+ private static final UniTuple EMPTY_TUPLE = new UniTuple<>(null, 0);
+
+ private final SolutionView solutionView;
+ private final DefaultBiEnumeratingJoiner