diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/joiner/BiJoinerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/joiner/BiJoinerComber.java index d28956ced2..807552af35 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/joiner/BiJoinerComber.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/bi/joiner/BiJoinerComber.java @@ -14,7 +14,7 @@ */ public final class BiJoinerComber { - public static BiJoinerComber comb(BiJoiner[] joiners) { + public static BiJoinerComber comb(BiJoiner... joiners) { List> defaultJoinerList = new ArrayList<>(joiners.length); List> filteringList = new ArrayList<>(joiners.length); 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..b345e73d78 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 @@ -11,6 +11,9 @@ import ai.timefold.solver.core.impl.bavet.common.joiner.JoinerType; import ai.timefold.solver.core.impl.util.ElementAwareListEntry; +import org.jspecify.annotations.NullMarked; + +@NullMarked final class ComparisonIndexer> implements Indexer { 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..bd0d8ef538 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 @@ -8,6 +8,9 @@ import ai.timefold.solver.core.impl.util.ElementAwareListEntry; +import org.jspecify.annotations.NullMarked; + +@NullMarked final class EqualsIndexer implements Indexer { private final KeyRetriever keyRetriever; 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..fb33380fcb 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 @@ -5,6 +5,8 @@ import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState; import ai.timefold.solver.core.impl.util.ElementAwareListEntry; +import org.jspecify.annotations.NullMarked; + /** * An indexer for entity or fact {@code X}, * maps a property or a combination of properties of {@code X}, denoted by {@code indexKeys}, @@ -20,6 +22,7 @@ * For example for {@code from(A).join(B)}, the tuple is {@code UniTuple} xor {@code UniTuple}. * For example for {@code Bi.join(C)}, the tuple is {@code BiTuple} xor {@code UniTuple}. */ +@NullMarked public sealed interface Indexer permits ComparisonIndexer, EqualsIndexer, NoneIndexer { ElementAwareListEntry put(Object indexKeys, T tuple); 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..f9c11ea88b 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 @@ -5,6 +5,9 @@ import ai.timefold.solver.core.impl.util.ElementAwareList; import ai.timefold.solver.core.impl.util.ElementAwareListEntry; +import org.jspecify.annotations.NullMarked; + +@NullMarked public final class NoneIndexer implements Indexer { private final ElementAwareList tupleList = new ElementAwareList<>(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/joiner/JoinerType.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/joiner/JoinerType.java index d5bcdb30d3..b6a7ea0a09 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/joiner/JoinerType.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/joiner/JoinerType.java @@ -21,18 +21,14 @@ public enum JoinerType { } public JoinerType flip() { - switch (this) { - case LESS_THAN: - return GREATER_THAN; - case LESS_THAN_OR_EQUAL: - return GREATER_THAN_OR_EQUAL; - case GREATER_THAN: - return LESS_THAN; - case GREATER_THAN_OR_EQUAL: - return LESS_THAN_OR_EQUAL; - default: - throw new IllegalStateException("The joinerType (" + this + ") cannot be flipped."); - } + return switch (this) { + case LESS_THAN -> GREATER_THAN; + case LESS_THAN_OR_EQUAL -> GREATER_THAN_OR_EQUAL; + case GREATER_THAN -> LESS_THAN; + case GREATER_THAN_OR_EQUAL -> LESS_THAN_OR_EQUAL; + default -> throw new IllegalStateException("The joinerType (%s) cannot be flipped." + .formatted(this)); + }; } public boolean matches(Object left, Object right) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java index 76b85c54f8..4d0e7f5f49 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java @@ -15,7 +15,7 @@ import ai.timefold.solver.core.impl.domain.variable.ListVariableStateDemand; import ai.timefold.solver.core.impl.domain.variable.inverserelation.InverseRelationShadowVariableDescriptor; import ai.timefold.solver.core.impl.move.director.MoveDirector; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; public final class ListVariableDescriptor extends GenuineVariableDescriptor { @@ -24,7 +24,7 @@ public final class ListVariableDescriptor extends GenuineVariableDesc var list = getValue(entity); return list.contains(element); }; - private final BiEnumeratingFilter entityContainsPinnedValuePredicate = + private final BiEnumeratingPredicate entityContainsPinnedValuePredicate = (solutionView, value, entity) -> { var moveDirector = (MoveDirector) solutionView; return moveDirector.isPinned(this, value); @@ -47,8 +47,8 @@ public BiPredicate getInListPredicate() { } @SuppressWarnings("unchecked") - public BiEnumeratingFilter getEntityContainsPinnedValuePredicate() { - return (BiEnumeratingFilter) entityContainsPinnedValuePredicate; + public BiEnumeratingPredicate getEntityContainsPinnedValuePredicate() { + return (BiEnumeratingPredicate) entityContainsPinnedValuePredicate; } public boolean allowsUnassignedValues() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/MoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/MoveStreamFactory.java index 54d9f7defd..067c1275bc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/MoveStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/MoveStreamFactory.java @@ -5,14 +5,11 @@ import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex; import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.BiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.UniEnumeratingFilter; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.BiSamplingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.UniSamplingStream; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; -import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement; @@ -61,18 +58,6 @@ public interface MoveStreamFactory { */ UniEnumeratingStream forEachUnfiltered(Class sourceClass, boolean includeNull); - /** - * Enumerate possible values for any given entity, - * where entities are obtained using {@link #forEach(Class, boolean)}, - * with the class matching the entity type of the variable. - * If the variable allows unassigned values, the resulting stream will include a null value. - * - * @param variableMetaModel the meta model of the variable to enumerate - * @return enumerating stream with all possible values of a given variable - */ - BiEnumeratingStream - forEachEntityValuePair(GenuineVariableMetaModel variableMetaModel); - /** * Enumerate all possible positions of a list variable to which a value can be assigned. * This will eliminate all positions on {@link PlanningPin pinned entities}, @@ -89,6 +74,4 @@ public interface MoveStreamFactory { UniSamplingStream pick(UniEnumeratingStream enumeratingStream); - BiSamplingStream pick(BiEnumeratingStream enumeratingStream); - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinition.java index 457405272e..e2e61f04fc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ChangeMoveDefinition.java @@ -5,6 +5,8 @@ import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveDefinition; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStreamFactory; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingJoiners; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -21,13 +23,12 @@ public ChangeMoveDefinition(PlanningVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var enumeratingStream = - moveStreamFactory.forEachEntityValuePair(variableMetaModel) - .filter((solutionView, entity, value) -> { - Value_ currentValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(entity)); - return !Objects.equals(currentValue, value); - }); - return moveStreamFactory.pick(enumeratingStream) + var nodeSharingSupportFunctions = + ((DefaultMoveStreamFactory) moveStreamFactory).getNodeSharingSupportFunctions(variableMetaModel); + return moveStreamFactory.pick(moveStreamFactory.forEach(variableMetaModel.entity().type(), false)) + .pick(moveStreamFactory.forEach(variableMetaModel.type(), variableMetaModel.allowsUnassigned()), + EnumeratingJoiners.filtering(nodeSharingSupportFunctions.differentValueFilter()), + EnumeratingJoiners.filtering(nodeSharingSupportFunctions.valueInRangeFilter())) .asMove((solution, entity, value) -> Moves.change(Objects.requireNonNull(entity), value, variableMetaModel)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinition.java index 16f171251d..363a8ba26d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListChangeMoveDefinition.java @@ -46,14 +46,10 @@ public ListChangeMoveDefinition(PlanningListVariableMetaModel build(MoveStreamFactory moveStreamFactory) { var entityValuePairs = moveStreamFactory.forEachAssignablePosition(variableMetaModel); - // The stream of these positions is joined with the stream of all existing values, - // filtering out those which would not result in a valid move. - var enumeratingStream = moveStreamFactory.forEach(variableMetaModel.type(), false) - .join(entityValuePairs, EnumeratingJoiners.filtering(this::isValidChange)); - // When picking from this stream, we decide what kind of move we need to create, - // based on whether the value is assigned or unassigned. - return moveStreamFactory.pick(enumeratingStream) - .asMove((solutionView, value, targetPosition) -> { + var availableValues = moveStreamFactory.forEach(variableMetaModel.type(), false); + return moveStreamFactory.pick(entityValuePairs) + .pick(availableValues, EnumeratingJoiners.filtering(this::isValidChange)) + .asMove((solutionView, targetPosition, value) -> { var currentPosition = solutionView.getPositionOf(variableMetaModel, Objects.requireNonNull(value)); if (targetPosition instanceof UnassignedElement) { var currentElementPosition = currentPosition.ensureAssigned(); @@ -68,7 +64,7 @@ public MoveStream build(MoveStreamFactory moveStreamFactor }); } - private boolean isValidChange(SolutionView solutionView, Value_ value, ElementPosition targetPosition) { + private boolean isValidChange(SolutionView solutionView, ElementPosition targetPosition, Value_ value) { var currentPosition = solutionView.getPositionOf(variableMetaModel, value); if (currentPosition.equals(targetPosition)) { // No change needed. return false; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinition.java index 6c68011299..80acff3f1b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/ListSwapMoveDefinition.java @@ -1,7 +1,12 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.move; import java.util.Objects; +import java.util.function.Function; +import ai.timefold.solver.core.api.domain.lookup.PlanningId; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningSolutionMetaModel; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveDefinition; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStreamFactory; @@ -17,46 +22,60 @@ public class ListSwapMoveDefinition implements MoveDefinition { private final PlanningListVariableMetaModel variableMetaModel; + private final Function planningIdGetter; public ListSwapMoveDefinition(PlanningListVariableMetaModel variableMetaModel) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.planningIdGetter = getPlanningIdGetter(variableMetaModel.entity().type()); + } + + private Function getPlanningIdGetter(Class sourceClass) { + SolutionDescriptor solutionDescriptor = + ((DefaultPlanningSolutionMetaModel) variableMetaModel.entity().solution()).solutionDescriptor(); + MemberAccessor planningIdMemberAccessor = solutionDescriptor.getPlanningIdAccessor(sourceClass); + if (planningIdMemberAccessor == null) { + throw new IllegalArgumentException( + "The fromClass (%s) has no member with a @%s annotation, so the pairs cannot be made unique ([A,B] vs [B,A])." + .formatted(sourceClass, PlanningId.class.getSimpleName())); + } + return planningIdMemberAccessor.getGetterFunction(); } @Override public MoveStream build(MoveStreamFactory moveStreamFactory) { var assignedValueStream = moveStreamFactory.forEach(variableMetaModel.type(), false) - .filter((solutionView, - value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList); - var validAssignedValuePairStream = assignedValueStream.join(assignedValueStream, - EnumeratingJoiners.filtering((SolutionView solutionView, Value_ leftValue, - Value_ rightValue) -> !Objects.equals(leftValue, rightValue))); - // Ensure unique pairs; without demanding PlanningId, this becomes tricky. - // Convert values to their locations in list. - var validAssignedValueUniquePairStream = - validAssignedValuePairStream - .map((solutionView, leftValue, rightValue) -> new UniquePair<>(leftValue, rightValue)) - .distinct() - .map((solutionView, pair) -> FullElementPosition.of(variableMetaModel, solutionView, pair.first()), - (solutionView, pair) -> FullElementPosition.of(variableMetaModel, solutionView, pair.second())); - // Eliminate pairs that cannot be swapped due to value range restrictions. - var result = validAssignedValueUniquePairStream - .filter((solutionView, leftPosition, rightPosition) -> solutionView.isValueInRange(variableMetaModel, - rightPosition.entity(), leftPosition.value()) - && solutionView.isValueInRange(variableMetaModel, leftPosition.entity(), rightPosition.value())); - // Finally pick the moves. - return moveStreamFactory.pick(result) + .filter((solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList) + .map((solutionView, value) -> new FullElementPosition<>(value, + solutionView.getPositionOf(variableMetaModel, value).ensureAssigned(), planningIdGetter)); + // TODO this requires everything that is ever swapped to implement @PlanningID; likely not acceptable + return moveStreamFactory.pick(assignedValueStream) + .pick(assignedValueStream, + EnumeratingJoiners.lessThan(a -> a), + EnumeratingJoiners.filtering(this::isValidSwap)) .asMove((solutionView, leftPosition, rightPosition) -> Moves.swap(leftPosition.elementPosition, rightPosition.elementPosition, variableMetaModel)); } + private boolean isValidSwap(SolutionView solutionView, + FullElementPosition leftPosition, + FullElementPosition rightPosition) { + if (Objects.equals(leftPosition, rightPosition)) { + return false; + } + return solutionView.isValueInRange(variableMetaModel, rightPosition.entity(), leftPosition.value()) + && solutionView.isValueInRange(variableMetaModel, leftPosition.entity(), rightPosition.value()); + } + @NullMarked - private record FullElementPosition(Value_ value, PositionInList elementPosition) { + private record FullElementPosition(Value_ value, PositionInList elementPosition, + Function planningIdGetter) implements Comparable> { public static FullElementPosition of( PlanningListVariableMetaModel variableMetaModel, - SolutionView solutionView, Value_ value) { + SolutionView solutionView, Value_ value, + Function planningIdGetter) { var assignedElement = solutionView.getPositionOf(variableMetaModel, value).ensureAssigned(); - return new FullElementPosition<>(value, assignedElement); + return new FullElementPosition<>(value, assignedElement, planningIdGetter); } public Entity_ entity() { @@ -67,6 +86,15 @@ public int index() { return elementPosition.index(); } + @Override + public int compareTo(FullElementPosition o) { + var entityComparison = planningIdGetter.apply(this.entity()).compareTo(planningIdGetter.apply(o.entity())); + if (entityComparison != 0) { + return entityComparison; + } + return Integer.compare(this.index(), o.index()); + } + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinition.java index 82d04747a5..fb2ca99208 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinition.java @@ -1,15 +1,20 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.move; -import static ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingJoiners.filtering; - import java.util.List; import java.util.Objects; +import java.util.function.Function; import java.util.stream.Stream; +import ai.timefold.solver.core.api.domain.lookup.PlanningId; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningSolutionMetaModel; import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveDefinition; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStreamFactory; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingJoiners; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.DefaultBiEnumeratingJoiner; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; @@ -60,39 +65,51 @@ public SwapMoveDefinition(List build(MoveStreamFactory moveStreamFactory) { var entityType = entityMetaModel.type(); - var enumeratingStream = moveStreamFactory.forEach(entityType, false) - .join(entityType, - filtering((SolutionView solutionView, Entity_ leftEntity, Entity_ rightEntity) -> { - if (leftEntity == rightEntity) { - return false; - } - var change = false; - for (var variableMetaModel : variableMetaModelList) { - var defaultVariableMetaModel = - (DefaultPlanningVariableMetaModel) variableMetaModel; - var variableDescriptor = defaultVariableMetaModel.variableDescriptor(); - var oldLeftValue = variableDescriptor.getValue(leftEntity); - var oldRightValue = variableDescriptor.getValue(rightEntity); - if (Objects.equals(oldLeftValue, oldRightValue)) { - continue; - } - if (solutionView.isValueInRange(variableMetaModel, leftEntity, oldRightValue) - && solutionView.isValueInRange(variableMetaModel, rightEntity, oldLeftValue)) { - change = true; - } else { - // One of the swaps falls out of range, skip this pair altogether. - return false; - } - } - return change; - })) - // Ensure unique pairs; without demanding PlanningId, this becomes tricky. - .map((solutionView, leftEntity, rightEntity) -> new UniquePair<>(leftEntity, rightEntity)) - .distinct() - .map((solutionView, pair) -> pair.first(), (solutionView, pair) -> pair.second()); - return moveStreamFactory.pick(enumeratingStream) + var entityStream = moveStreamFactory.forEach(entityType, false); + // TODO this requires everything that is ever swapped to implement @PlanningID; likely not acceptable + return moveStreamFactory.pick(entityStream) + .pick(entityStream, + buildLessThanId(entityType), + EnumeratingJoiners.filtering(this::isValidSwap)) .asMove((solutionView, leftEntity, rightEntity) -> Moves.swap(leftEntity, rightEntity, variableMetaModelList.toArray(new PlanningVariableMetaModel[0]))); } + private DefaultBiEnumeratingJoiner buildLessThanId(Class sourceClass) { + SolutionDescriptor solutionDescriptor = + ((DefaultPlanningSolutionMetaModel) entityMetaModel.solution()).solutionDescriptor(); + MemberAccessor planningIdMemberAccessor = solutionDescriptor.getPlanningIdAccessor(sourceClass); + if (planningIdMemberAccessor == null) { + throw new IllegalArgumentException( + "The fromClass (%s) has no member with a @%s annotation, so the pairs cannot be made unique ([A,B] vs [B,A])." + .formatted(sourceClass, PlanningId.class.getSimpleName())); + } + Function planningIdGetter = planningIdMemberAccessor.getGetterFunction(); + return (DefaultBiEnumeratingJoiner) EnumeratingJoiners.lessThan(planningIdGetter); + } + + private boolean isValidSwap(SolutionView solutionView, Entity_ leftEntity, Entity_ rightEntity) { + if (leftEntity == rightEntity) { + return false; + } + var change = false; + for (var variableMetaModel : variableMetaModelList) { + var defaultVariableMetaModel = (DefaultPlanningVariableMetaModel) variableMetaModel; + var variableDescriptor = defaultVariableMetaModel.variableDescriptor(); + var oldLeftValue = variableDescriptor.getValue(leftEntity); + var oldRightValue = variableDescriptor.getValue(rightEntity); + if (Objects.equals(oldLeftValue, oldRightValue)) { + continue; + } + if (solutionView.isValueInRange(variableMetaModel, leftEntity, oldRightValue) + && solutionView.isValueInRange(variableMetaModel, rightEntity, oldLeftValue)) { + change = true; + } else { + // One of the swaps falls out of range, skip this pair altogether. + return false; + } + } + return change; + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/UniquePair.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/UniquePair.java deleted file mode 100644 index ef19a1cb83..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/UniquePair.java +++ /dev/null @@ -1,25 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.maybeapi.move; - -import java.util.Objects; - -/** - * A pair of two entities where (a, b) is considered equal to (b, a). - */ -record UniquePair(Entity_ first, Entity_ second) { - - @Override - public boolean equals(Object o) { - return o instanceof UniquePair other && - ((first == other.first && second == other.second) || (first == other.second && second == other.first)); - } - - @Override - public int hashCode() { - var firstHash = Objects.hashCode(first); - var secondHash = Objects.hashCode(second); - // We always include both hashes, so that the order of first and second does not matter. - // We compute in long to minimize intermediate overflows. - var longHash = (31L * firstHash * secondHash) + firstHash + secondHash; - return Long.hashCode(longHash); - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/BiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/BiEnumeratingStream.java index 795210e356..79155ac4d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/BiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/BiEnumeratingStream.java @@ -1,7 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingMapper; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.UniEnumeratingMapper; import ai.timefold.solver.core.preview.api.move.SolutionView; @@ -11,10 +11,10 @@ public interface BiEnumeratingStream extends EnumeratingStream { /** - * Exhaustively test each fact against the {@link BiEnumeratingFilter} - * and match if {@link BiEnumeratingFilter#test(SolutionView, Object, Object)} returns true. + * Exhaustively test each fact against the {@link BiEnumeratingPredicate} + * and match if {@link BiEnumeratingPredicate#test(SolutionView, Object, Object)} returns true. */ - BiEnumeratingStream filter(BiEnumeratingFilter filter); + BiEnumeratingStream filter(BiEnumeratingPredicate filter); // ************************************************************************ // Operations with duplicate tuple possibility diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/EnumeratingJoiners.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/EnumeratingJoiners.java index 30f4323891..881e3370f1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/EnumeratingJoiners.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/EnumeratingJoiners.java @@ -5,8 +5,8 @@ import ai.timefold.solver.core.api.score.stream.bi.BiConstraintStream; import ai.timefold.solver.core.impl.bavet.common.joiner.JoinerType; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; +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.impl.neighborhood.stream.enumerating.joiner.FilteringBiEnumeratingJoiner; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; @@ -194,7 +194,7 @@ public static > BiEnumeratingJoine * @param type of the first fact in the tuple * @param type of the second fact in the tuple */ - public static BiEnumeratingJoiner filtering(BiEnumeratingFilter filter) { + public static BiEnumeratingJoiner filtering(BiEnumeratingPredicate filter) { return new FilteringBiEnumeratingJoiner<>(filter); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/UniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/UniEnumeratingStream.java index 3d24af4365..1cf29a0d48 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/UniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/UniEnumeratingStream.java @@ -1,7 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.UniEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.UniEnumeratingMapper; import ai.timefold.solver.core.preview.api.move.SolutionView; @@ -68,7 +68,7 @@ default BiEnumeratingStream join(UniEnumeratingStream - * Important: Joining is faster and more scalable than a {@link BiEnumeratingStream#filter(BiEnumeratingFilter) filter}, + * Important: Joining is faster and more scalable than a {@link BiEnumeratingStream#filter(BiEnumeratingPredicate) filter}, * because it applies hashing and/or indexing on the properties, * so it doesn't create nor checks every combination of A and B. * @@ -129,7 +129,7 @@ default BiEnumeratingStream join(Class otherClass, BiEnu * The stream will include all facts or entities of the given class, * regardless of their pinning status. *

- * Important: Joining is faster and more scalable than a {@link BiEnumeratingStream#filter(BiEnumeratingFilter) filter}, + * Important: Joining is faster and more scalable than a {@link BiEnumeratingStream#filter(BiEnumeratingPredicate) filter}, * because it applies hashing and/or indexing on the properties, * so it doesn't create nor checks every combination of A and B. * diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingJoiner.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingJoiner.java index e4f5375a57..ffffc47db3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingJoiner.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingJoiner.java @@ -1,12 +1,13 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function; import ai.timefold.solver.core.api.score.stream.Joiners; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingJoiners; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; import org.jspecify.annotations.NullMarked; /** - * Created with {@link Joiners}. + * Created with {@link EnumeratingJoiners}. * Used by {@link UniEnumeratingStream#join(Class, BiEnumeratingJoiner[])}, ... * * @see Joiners diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingFilter.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingPredicate.java similarity index 80% rename from core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingFilter.java rename to core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingPredicate.java index 2f2ee0fb3a..9a4d671f0d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingFilter.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/enumerating/function/BiEnumeratingPredicate.java @@ -18,15 +18,16 @@ * @param the type of the second parameter */ @NullMarked -public interface BiEnumeratingFilter extends TriPredicate, A, B> { +public interface BiEnumeratingPredicate extends TriPredicate, A, B> { @Override boolean test(SolutionView solutionView, @Nullable A a, @Nullable B b); @Override - default BiEnumeratingFilter + default BiEnumeratingPredicate and(TriPredicate, ? super A, ? super B> other) { - return (BiEnumeratingFilter) TriPredicate.super.and(other); + return (solutionView, a, b) -> test(solutionView, a, b) + && other.test(solutionView, a, b); } default BiPredicate toBiPredicate(SolutionView solutionView) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/sampling/UniSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/sampling/UniSamplingStream.java index 5eef8c1c0e..7b5fb4021d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/sampling/UniSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/stream/sampling/UniSamplingStream.java @@ -1,19 +1,37 @@ package ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling; -import java.util.function.BiPredicate; - import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; import org.jspecify.annotations.NullMarked; @NullMarked public interface UniSamplingStream extends SamplingStream { + @SuppressWarnings("unchecked") default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream) { - return pick(uniEnumeratingStream, (a, b) -> true); + return pick(uniEnumeratingStream, new BiEnumeratingJoiner[0]); + } + + @SuppressWarnings("unchecked") + default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + BiEnumeratingJoiner joiner) { + return pick(uniEnumeratingStream, new BiEnumeratingJoiner[] { joiner }); + } + + @SuppressWarnings("unchecked") + default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + BiEnumeratingJoiner joiner1, BiEnumeratingJoiner joiner2) { + return pick(uniEnumeratingStream, new BiEnumeratingJoiner[] { joiner1, joiner2 }); + } + + @SuppressWarnings("unchecked") + default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + BiEnumeratingJoiner joiner1, BiEnumeratingJoiner joiner2, BiEnumeratingJoiner joiner3) { + return pick(uniEnumeratingStream, new BiEnumeratingJoiner[] { joiner1, joiner2, joiner3 }); } BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, - BiPredicate filter); + BiEnumeratingJoiner... joiners); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStream.java new file mode 100644 index 0000000000..df355c3a1c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStream.java @@ -0,0 +1,82 @@ +package ai.timefold.solver.core.impl.neighborhood.move; + +import java.util.Iterator; +import java.util.Objects; +import java.util.Random; + +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.joiner.BiEnumeratingJoinerComber; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset; +import ai.timefold.solver.core.preview.api.move.Move; + +import org.jspecify.annotations.NullMarked; + +/** + * Accepts two {@link UniDataset datasets} (coming from two enumerating streams), + * and provides {@link Move} iterators, based on {@link BiEnumeratingJoinerComber joiners and filtering}. + * The datasets are called "left" and "right"; left provides instances of type A and right of type B. + * The merged iterators provide {@link Move moves} constructed by a {@link BiMoveConstructor move constructor}, + * which accepts instances of type A and B. + * + *

+ * There are two types of iterators: + * + *

    + *
  • {@link BiOriginalMoveIterator Original order iterators}, + * which iterate through all possible combinations of A and B in the original order.
  • + *
  • {@link BiRandomMoveIterator Random order iterators}, + * which pick A and B randomly.
  • + *
+ * + * Please refer to the respective iterator classes for documentation on their strategies. + * + * @param + * @param
+ * @param + */ +@NullMarked +public final class BiMoveStream implements InnerMoveStream { + + private final UniDataset leftDataset; + private final UniDataset rightDataset; + private final BiEnumeratingJoinerComber joinerComber; + private final BiMoveConstructor moveConstructor; + + public BiMoveStream(UniDataset leftDataset, UniDataset rightDataset, + BiEnumeratingJoinerComber comber, BiMoveConstructor moveConstructor) { + this.leftDataset = Objects.requireNonNull(leftDataset); + this.rightDataset = Objects.requireNonNull(rightDataset); + this.joinerComber = Objects.requireNonNull(comber); + this.moveConstructor = Objects.requireNonNull(moveConstructor); + } + + @Override + public MoveIterable getMoveIterable(NeighborhoodSession neighborhoodSession) { + var context = new BiMoveStreamContext<>((DefaultNeighborhoodSession) neighborhoodSession, leftDataset, + rightDataset, joinerComber, moveConstructor); + return new BiMoveIterable<>(context); + } + + private record BiMoveIterable(BiMoveStreamContext context) + implements + MoveIterable { + + private BiMoveIterable(BiMoveStreamContext context) { + this.context = Objects.requireNonNull(context); + } + + @Override + public Iterator> iterator() { + return new BiOriginalMoveIterator<>(context); + } + + @Override + public Iterator> iterator(Random random) { + return new BiRandomMoveIterator<>(context, random); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStreamContext.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStreamContext.java new file mode 100644 index 0000000000..f246af6271 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiMoveStreamContext.java @@ -0,0 +1,41 @@ +package ai.timefold.solver.core.impl.neighborhood.move; + +import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.BiMoveConstructor; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.BiEnumeratingJoinerComber; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.DefaultBiEnumeratingJoiner; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDatasetInstance; +import ai.timefold.solver.core.preview.api.move.Move; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public record BiMoveStreamContext(DefaultNeighborhoodSession neighborhoodSession, + UniDataset leftDataset, UniDataset rightDataset, + BiEnumeratingJoinerComber joinerComber, + BiMoveConstructor moveConstructor) { + + public UniDatasetInstance getLeftDatasetInstance() { + return neighborhoodSession.getDatasetInstance(leftDataset); + } + + public UniDatasetInstance getRightDatasetInstance() { + return neighborhoodSession.getDatasetInstance(rightDataset); + } + + public DefaultBiEnumeratingJoiner getJoiner() { + return joinerComber.mergedJoiner(); + } + + public @Nullable BiEnumeratingPredicate getFilter() { + return joinerComber.mergedFiltering(); + } + + public Move buildMove(@Nullable A left, @Nullable B right) { + return moveConstructor.apply(neighborhoodSession.getSolutionView(), left, right); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiOriginalMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiOriginalMoveIterator.java new file mode 100644 index 0000000000..5916eb95a4 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/move/BiOriginalMoveIterator.java @@ -0,0 +1,91 @@ +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.stream.enumerating.uni.UniDatasetInstance; +import ai.timefold.solver.core.preview.api.move.Move; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Original iterators build moves based on all applicable pairs of A and B instances, + * in the order in which they appear. + * This is a simple behavior that just enumerates all possible combinations. + * First an instance of A is fixed, and then all instances of B are iterated. + * Then the next instance of A is fixed, and all instances of B are iterated again. + * This continues until all instances of A have been fixed. + */ +@NullMarked +final class BiOriginalMoveIterator implements Iterator> { + + private static final UniTuple EMPTY_TUPLE = new UniTuple<>(null, 0); + + private final BiMoveStreamContext context; + private final UniDatasetInstance leftDatasetInstance; + private final UniDatasetInstance rightDatasetInstance; + + // Fields required for iteration. + private @Nullable Move nextMove; + private @Nullable Iterator> leftTupleIterator; + private @Nullable Iterator> rightTupleIterator; + private UniTuple leftTuple = EMPTY_TUPLE; + + public BiOriginalMoveIterator(BiMoveStreamContext 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: + *

    + *
  1. The left and right datasets need to support efficient random access.
  2. + *
  3. The left and right datasets are possibly large, + * which makes their copying and mutation prohibitively expensive.
  4. + *
  5. Keeping all possible pairs in memory is prohibitively expensive, + * for the same reason. + * (Cartesian product of A x B.)
  6. + *
  7. 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.
  8. + *
+ * + *

+ * 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 joiner; + private final @Nullable BiEnumeratingPredicate filter; + private final UniTuple leftTuple; + private final Iterator> rightTupleIterator; + + // Required for iteration. + private boolean hasNext = false; + private UniTuple next = EMPTY_TUPLE; + + public JoiningIterator(DefaultBiEnumeratingJoiner joiner, @Nullable BiEnumeratingPredicate filter, + SolutionView solutionView, UniTuple leftTuple, Iterator> rightTupleIterator) { + this.solutionView = Objects.requireNonNull(solutionView); + this.rightTupleIterator = Objects.requireNonNull(rightTupleIterator); + this.joiner = Objects.requireNonNull(joiner); + this.filter = filter; + this.leftTuple = leftTuple; + } + + @Override + public boolean hasNext() { + if (hasNext) { + return true; + } + + var leftFact = leftTuple.factA; + while (rightTupleIterator.hasNext()) { + var rightTuple = rightTupleIterator.next(); + var rightFact = rightTuple.factA; + if (BiRandomMoveIterator.failsJoiner(joiner, leftFact, rightFact)) { + continue; + } + // Only test the filter after the joiners all match; + // this fits user expectations as the filtering joiner is always declared last. + if (filter == null || filter.test(solutionView, leftFact, rightFact)) { + hasNext = true; + next = rightTuple; + return true; + } + } + hasNext = false; + next = EMPTY_TUPLE; + return false; + } + + @Override + public UniTuple next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var result = Objects.requireNonNull(next); + hasNext = false; + next = EMPTY_TUPLE; + return result; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactory.java index 8ec198d9a8..3e26ca020e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactory.java @@ -2,23 +2,20 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.BiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.EnumeratingJoiners; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingMapper; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.UniEnumeratingFilter; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.BiSamplingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.UniSamplingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.DatasetSessionFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.AbstractBiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; -import ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultBiFromBiSamplingStream; import ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultUniSamplingStream; import ai.timefold.solver.core.impl.score.director.SessionContext; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; @@ -89,25 +86,11 @@ public UniEnumeratingStream forEachUnfiltered(Class sourceC return enumeratingStreamFactory.forEachNonDiscriminating(sourceClass, includeNull); } - @Override - public BiEnumeratingStream forEachEntityValuePair( - GenuineVariableMetaModel variableMetaModel) { - var includeNull = - variableMetaModel instanceof PlanningVariableMetaModel planningVariableMetaModel - ? planningVariableMetaModel.allowsUnassigned() - : variableMetaModel instanceof PlanningListVariableMetaModel planningListVariableMetaModel - && planningListVariableMetaModel.allowsUnassignedValues(); - var stream = enumeratingStreamFactory.forEachExcludingPinned(variableMetaModel.type(), includeNull); - var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); - return forEach(variableMetaModel.entity().type(), false) - .join(stream, EnumeratingJoiners.filtering(nodeSharingSupportFunctions.valueInRangeFilter)); - } - @SuppressWarnings("unchecked") - private NodeSharingSupportFunctions - getNodeSharingSupportFunctions(GenuineVariableMetaModel variableMetaModel) { + public NodeSharingSupportFunctions + getNodeSharingSupportFunctions(PlanningVariableMetaModel variableMetaModel) { return (NodeSharingSupportFunctions) nodeSharingSupportFunctionMap - .computeIfAbsent(variableMetaModel, NodeSharingSupportFunctions::new); + .computeIfAbsent(variableMetaModel, ignored -> new NodeSharingSupportFunctions<>(variableMetaModel)); } @Override @@ -133,7 +116,7 @@ public BiEnumeratingStream forEach } @SuppressWarnings("unchecked") - private ListVariableNodeSharingSupportFunctions + public ListVariableNodeSharingSupportFunctions getNodeSharingSupportFunctions(PlanningListVariableMetaModel variableMetaModel) { return (ListVariableNodeSharingSupportFunctions) listVariableNodeSharingSupportFunctionsMap .computeIfAbsent(variableMetaModel, ListVariableNodeSharingSupportFunctions::new); @@ -144,31 +127,27 @@ public UniSamplingStream pick(UniEnumeratingStream(((AbstractUniEnumeratingStream) enumeratingStream).createDataset()); } - @Override - public BiSamplingStream pick(BiEnumeratingStream enumeratingStream) { - return new DefaultBiFromBiSamplingStream<>( - ((AbstractBiEnumeratingStream) enumeratingStream).createDataset()); - } - public SolutionDescriptor getSolutionDescriptor() { return enumeratingStreamFactory.getSolutionDescriptor(); } - private record NodeSharingSupportFunctions( - GenuineVariableMetaModel variableMetaModel, - BiEnumeratingFilter valueInRangeFilter) { + public record NodeSharingSupportFunctions( + PlanningVariableMetaModel variableMetaModel, + BiEnumeratingPredicate differentValueFilter, + BiEnumeratingPredicate valueInRangeFilter) { - public NodeSharingSupportFunctions(GenuineVariableMetaModel variableMetaModel) { + public NodeSharingSupportFunctions(PlanningVariableMetaModel variableMetaModel) { this(variableMetaModel, + (solutionView, entity, value) -> !Objects.equals(solutionView.getValue(variableMetaModel, entity), value), (solutionView, entity, value) -> solutionView.isValueInRange(variableMetaModel, entity, value)); } } - private record ListVariableNodeSharingSupportFunctions( + public record ListVariableNodeSharingSupportFunctions( PlanningListVariableMetaModel variableMetaModel, UniEnumeratingFilter unpinnedValueFilter, - BiEnumeratingFilter valueInRangeFilter, + BiEnumeratingPredicate valueInRangeFilter, BiEnumeratingMapper toElementPositionMapper) { public ListVariableNodeSharingSupportFunctions( diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultNeighborhoodSession.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultNeighborhoodSession.java index 682d14d786..84e709d16f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultNeighborhoodSession.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultNeighborhoodSession.java @@ -4,8 +4,6 @@ import ai.timefold.solver.core.impl.neighborhood.maybeapi.NeighborhoodSession; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.DatasetSession; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.BiDataset; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.BiDatasetInstance; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDatasetInstance; import ai.timefold.solver.core.preview.api.move.SolutionView; @@ -28,10 +26,6 @@ public UniDatasetInstance getDatasetInstance(UniDataset) datasetSession.getInstance(dataset); } - public BiDatasetInstance getDatasetInstance(BiDataset dataset) { - return (BiDatasetInstance) datasetSession.getInstance(dataset); - } - public void insert(Object fact) { datasetSession.insert(fact); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/AbstractBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/AbstractBiEnumeratingStream.java index a6f1d61afc..14f0c870f2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/AbstractBiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/AbstractBiEnumeratingStream.java @@ -7,8 +7,8 @@ import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.BiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingMapper; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.AftBridgeBiEnumeratingStream; @@ -32,7 +32,7 @@ protected AbstractBiEnumeratingStream(EnumeratingStreamFactory enumer } @Override - public final BiEnumeratingStream filter(BiEnumeratingFilter filter) { + public final BiEnumeratingStream filter(BiEnumeratingPredicate filter) { return shareAndAddChild(new FilterBiEnumeratingStream<>(enumeratingStreamFactory, this, filter)); } @@ -74,9 +74,4 @@ public AbstractBiEnumeratingStream distinct() { return groupBy(ConstantLambdaUtils.biPickFirst(), ConstantLambdaUtils.biPickSecond()); } - public BiDataset createDataset() { - var stream = shareAndAddChild(new TerminalBiEnumeratingStream<>(enumeratingStreamFactory, this)); - return stream.getDataset(); - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDataset.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDataset.java deleted file mode 100644 index e146037c9c..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDataset.java +++ /dev/null @@ -1,22 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; - -import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDataset; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public final class BiDataset extends AbstractDataset> { - - public BiDataset(EnumeratingStreamFactory enumeratingStreamFactory, - AbstractBiEnumeratingStream parent) { - super(enumeratingStreamFactory, parent); - } - - @Override - public BiDatasetInstance instantiate(int storeIndex) { - return new BiDatasetInstance<>(this, storeIndex); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDatasetInstance.java deleted file mode 100644 index 2cc7f1a135..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiDatasetInstance.java +++ /dev/null @@ -1,161 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Random; - -import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDataset; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDatasetInstance; -import ai.timefold.solver.core.impl.util.CollectionUtils; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -public final class BiDatasetInstance - extends AbstractDatasetInstance> { - - private final Map>> tupleListMap = new LinkedHashMap<>(); - - public BiDatasetInstance(AbstractDataset> parent, int inputStoreIndex) { - super(parent, inputStoreIndex); - } - - @Override - public void insert(BiTuple tuple) { - var tupleList = tupleListMap.computeIfAbsent(tuple.factA, key -> new ElementAwareList<>()); - var entry = tupleList.add(tuple); - tuple.setStore(inputStoreIndex, entry); - } - - @Override - public void update(BiTuple tuple) { - var actualTupleList = tuple.getStore(inputStoreIndex); - if (actualTupleList == null) { // The tuple was not inserted yet. - insert(tuple); - return; - } - var expectedTupleList = tupleListMap.get(tuple.factA); - if (actualTupleList == expectedTupleList) { - return; // Changing the tuple did not change the key. - } - retract(tuple); - insert(tuple); - } - - @Override - public void retract(BiTuple tuple) { - ElementAwareListEntry> entry = tuple.removeStore(inputStoreIndex); - // No fail fast if null because we don't track which tuples made it through the filter predicate(s) - if (entry != null) { - var tupleList = entry.getList(); - entry.remove(); - if (tupleList.size() == 0) { - tupleListMap.remove(tuple.factA); - } - } - } - - public Iterator> iterator() { - return new OriginalTupleMapIterator<>(tupleListMap); - } - - public Iterator> iterator(Random workingRandom) { - return new RandomTupleMapIterator<>(tupleListMap, workingRandom); - } - - @NullMarked - private static final class OriginalTupleMapIterator implements Iterator> { - - private final Iterator>> listIterator; - private @Nullable Iterator> currentIterator = null; - - public OriginalTupleMapIterator(Map>> tupleListMap) { - this.listIterator = tupleListMap.values().iterator(); - } - - @Override - public boolean hasNext() { - while ((currentIterator == null || !currentIterator.hasNext()) && listIterator.hasNext()) { - currentIterator = listIterator.next().iterator(); - } - return currentIterator != null && currentIterator.hasNext(); - } - - @Override - public BiTuple next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - return currentIterator.next(); - } - } - - @NullMarked - private static final class RandomTupleMapIterator implements Iterator> { - - private final Random workingRandom; - private final Map>> allTuplesMap; - private final List keyList; - private final Map>> unvisitedTuplesMap; - private @Nullable BiTuple selection; - - public RandomTupleMapIterator(Map>> allTuplesMap, Random workingRandom) { - this.workingRandom = workingRandom; - this.allTuplesMap = allTuplesMap; - this.keyList = new ArrayList<>(allTuplesMap.keySet()); - this.unvisitedTuplesMap = CollectionUtils.newHashMap(allTuplesMap.size()); - } - - @Override - public boolean hasNext() { - if (selection != null) { - // If we already have a selection, return true. - return true; - } else if (keyList.isEmpty()) { - // All keys were removed. This means all tuples from all lists were removed. - return false; - } - // At least one key is available, meaning at least one list contains at least one unvisited tuple. - var randomKeyIndex = workingRandom.nextInt(keyList.size()); - var randomKey = keyList.get(randomKeyIndex); - var randomAccessList = unvisitedTuplesMap.get(randomKey); - if (randomAccessList == null) { - // The key exists, but the random access list is empty. - // This means that the list needs to be filled from the original tuple list, - // as all its items are now available for random access. - // This is done on-demand to avoid unnecessary computation and memory use. - var tupleList = allTuplesMap.get(randomKey); - randomAccessList = new ArrayList<>(tupleList.size()); - tupleList.forEach(randomAccessList::add); - unvisitedTuplesMap.put(randomKey, randomAccessList); - } - selection = randomAccessList.remove(workingRandom.nextInt(randomAccessList.size())); - if (randomAccessList.isEmpty()) { - // The random access list is now empty, so we remove the key from the unvisited map. - // This will make the key unavailable for future iterations. - unvisitedTuplesMap.remove(randomKey); - keyList.remove(randomKeyIndex); - } - return true; - } - - @Override - public BiTuple next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - var result = selection; - selection = null; - return result; - } - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/FilterBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/FilterBiEnumeratingStream.java index d19fa6da82..03400fc79c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/FilterBiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/FilterBiEnumeratingStream.java @@ -4,7 +4,7 @@ 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.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; @@ -14,11 +14,11 @@ final class FilterBiEnumeratingStream extends AbstractBiEnumeratingStream { - private final BiEnumeratingFilter filter; + private final BiEnumeratingPredicate filter; public FilterBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, AbstractBiEnumeratingStream parent, - BiEnumeratingFilter filter) { + BiEnumeratingPredicate filter) { super(enumeratingStreamFactory, parent); this.filter = Objects.requireNonNull(filter, "The filter cannot be null."); } 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..c22213dc68 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 @@ -8,7 +8,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.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; @@ -22,11 +22,11 @@ public final class JoinBiEnumeratingStream extends AbstractBiEn private final ForeBridgeUniEnumeratingStream leftParent; private final ForeBridgeUniEnumeratingStream rightParent; private final DefaultBiEnumeratingJoiner joiner; - private final BiEnumeratingFilter filtering; + private final BiEnumeratingPredicate filtering; public JoinBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, ForeBridgeUniEnumeratingStream leftParent, ForeBridgeUniEnumeratingStream rightParent, - DefaultBiEnumeratingJoiner joiner, BiEnumeratingFilter filtering) { + DefaultBiEnumeratingJoiner joiner, BiEnumeratingPredicate filtering) { super(enumeratingStreamFactory); this.leftParent = leftParent; this.rightParent = rightParent; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/TerminalBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/TerminalBiEnumeratingStream.java deleted file mode 100644 index 4b47059d32..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/TerminalBiEnumeratingStream.java +++ /dev/null @@ -1,40 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; - -import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.TerminalEnumeratingStream; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -final class TerminalBiEnumeratingStream - extends AbstractBiEnumeratingStream - implements TerminalEnumeratingStream, BiDataset> { - - private final BiDataset dataset; - - public TerminalBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, - AbstractBiEnumeratingStream parent) { - super(enumeratingStreamFactory, parent); - this.dataset = new BiDataset<>(enumeratingStreamFactory, this); - } - - @Override - public void buildNode(DataNodeBuildHelper buildHelper) { - assertEmptyChildStreamList(); - var inputStoreIndex = buildHelper.reserveTupleStoreIndex(parent.getTupleSource()); - buildHelper.putInsertUpdateRetract(this, dataset.instantiate(inputStoreIndex)); - } - - @Override - public BiDataset getDataset() { - return dataset; - } - - @Override - public String toString() { - return "Terminal node"; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDataset.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDataset.java index 5b1b2eaf7f..c9d3a2470a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDataset.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDataset.java @@ -24,7 +24,7 @@ public void collectActiveEnumeratingStreams(Set instantiate(int storeIndex); + public abstract AbstractDatasetInstance instantiate(int entryStoreIndex); @Override public boolean equals(Object entity) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDatasetInstance.java index 577b5b2087..bc050cdd04 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractDatasetInstance.java @@ -1,8 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; -import java.util.Iterator; import java.util.Objects; -import java.util.Random; +import java.util.function.Predicate; import ai.timefold.solver.core.impl.bavet.common.tuple.AbstractTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; @@ -11,22 +10,24 @@ @NullMarked public abstract class AbstractDatasetInstance - implements TupleLifecycle { + implements TupleLifecycle, Iterable { private final AbstractDataset parent; - protected final int inputStoreIndex; + protected final int entryStoreIndex; - protected AbstractDatasetInstance(AbstractDataset parent, int inputStoreIndex) { + protected AbstractDatasetInstance(AbstractDataset parent, int rightMostPositionStoreIndex) { this.parent = Objects.requireNonNull(parent); - this.inputStoreIndex = inputStoreIndex; + this.entryStoreIndex = rightMostPositionStoreIndex; } public AbstractDataset getParent() { return parent; } - public abstract Iterator iterator(); + public abstract DefaultUniqueRandomSequence buildRandomSequence(); - public abstract Iterator iterator(Random workingRandom); + public abstract FilteredUniqueRandomSequence buildRandomSequence(Predicate predicate); + + public abstract int size(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequence.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequence.java new file mode 100644 index 0000000000..d1080e7d59 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequence.java @@ -0,0 +1,117 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import java.util.BitSet; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Random; + +import org.jspecify.annotations.NullMarked; + +/** + * Keeps an exact track of which items were already removed from the sequence, + * so that no item is ever returned twice. + * It accepts a list of unique items on input, and does not copy or modify it. + * + * @param + */ +@NullMarked +public final class DefaultUniqueRandomSequence implements UniqueRandomSequence { + + private static final DefaultUniqueRandomSequence EMPTY = new DefaultUniqueRandomSequence<>(Collections.emptyList()); + + @SuppressWarnings("unchecked") + public static DefaultUniqueRandomSequence empty() { + return (DefaultUniqueRandomSequence) EMPTY; + } + + private final List originalList; + private final int length; + private final BitSet removed; + + private int removedCount; + private int leftmostIndex; + private int rightmostIndex; + + public DefaultUniqueRandomSequence(List listOfUniqueItems) { + this.originalList = Collections.unmodifiableList(listOfUniqueItems); + this.length = listOfUniqueItems.size(); + this.removed = new BitSet(length); + this.removedCount = 0; + this.leftmostIndex = 0; + this.rightmostIndex = length - 1; + } + + @Override + public SequenceElement pick(Random workingRandom) { + var randomIndex = pickIndex(workingRandom); + return new SequenceElement<>(originalList.get(randomIndex), randomIndex); + } + + private int pickIndex(Random workingRandom) { + if (isEmpty()) { + throw new NoSuchElementException(); + } + + // Pick a random index from the underlying list. + // If the index has already been removed, find the next closest active one. + // If no such index is found, pick the previous closest active one. + // This algorithm ensures that we do not pick the same index twice. + var randomIndex = workingRandom.nextInt(leftmostIndex, rightmostIndex + 1); + return pickIndex(workingRandom, randomIndex); + } + + int pickIndex(Random workingRandom, int index) { + if (removed.get(index)) { + // use the closest index to avoid skewing the probability + index = determineActiveIndex(workingRandom, index); + if (index < 0 || index >= length) { + throw new NoSuchElementException(); + } + } + return index; + } + + private int determineActiveIndex(Random workingRandom, int randomIndex) { + var nextClearIndex = removed.nextClearBit(randomIndex); + var previousClearIndex = removed.previousClearBit(randomIndex); + + var nextIndexDistance = nextClearIndex >= length ? Integer.MAX_VALUE : nextClearIndex - randomIndex; + var previousIndexDistance = previousClearIndex == -1 ? Integer.MAX_VALUE : randomIndex - previousClearIndex; + + // if the distance is equal, randomly choose between them, + // otherwise return the one that is closer to the random index + if (nextIndexDistance == previousIndexDistance) { + return workingRandom.nextBoolean() ? nextClearIndex : previousClearIndex; + } + return nextIndexDistance < previousIndexDistance ? nextClearIndex : previousClearIndex; + } + + @Override + public T remove(Random workingRandom) { + return remove(pickIndex(workingRandom)); + } + + @Override + public T remove(int index) { + if (removed.get(index)) { + throw new IllegalArgumentException("The index (%s) has already been removed.".formatted(index)); + } + removed.set(index); + removedCount++; + + // update the leftmost and rightmost zero index to keep probability distribution even + if (index == leftmostIndex) { + leftmostIndex = removed.nextClearBit(leftmostIndex); + } + if (index == rightmostIndex) { + rightmostIndex = removed.previousClearBit(rightmostIndex); + } + return originalList.get(index); + } + + public boolean isEmpty() { + return removedCount >= length; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequence.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequence.java new file mode 100644 index 0000000000..80b72d9a98 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequence.java @@ -0,0 +1,74 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Random; +import java.util.function.Predicate; + +import org.jspecify.annotations.NullMarked; + +/** + * Unlike {@link DefaultUniqueRandomSequence}, this class only returns elements that match the given filter. + * Because we can't predict how many elements will be filtered out, + * and because we don't want to pre-filter the entire list, + * this class may need to try multiple times to find a matching element. + * It also can't provide an emptiness check + * and has to rely on {@link NoSuchElementException} to be thrown when everything's been finally filtered out. + * Other than that, it relies on {@link DefaultUniqueRandomSequence} to keep track of removed elements. + * + * @param + */ +@NullMarked +public final class FilteredUniqueRandomSequence implements UniqueRandomSequence { + + private final List originalList; + private final Predicate filter; + private final DefaultUniqueRandomSequence delegate; + + public FilteredUniqueRandomSequence(List listOfUniqueItems, Predicate filter) { + this.originalList = Collections.unmodifiableList(listOfUniqueItems); + this.filter = Objects.requireNonNull(filter); + this.delegate = new DefaultUniqueRandomSequence<>(originalList); + } + + @Override + public SequenceElement pick(Random workingRandom) { + var index = pickIndex(workingRandom); + return new SequenceElement<>(originalList.get(index), index); + } + + public int pickIndex(Random workingRandom) { + if (delegate.isEmpty()) { + throw new NoSuchElementException("No more elements to pick from."); + } + var nonRemovedElement = delegate.pick(workingRandom); + var originalRandomIndex = nonRemovedElement.index(); + + var actualValueIndex = originalRandomIndex; + var value = nonRemovedElement.value(); + while (!filter.test(value)) { + delegate.remove(actualValueIndex); + // We try the same random index again; the underlying sequence will find the next best non-removed element. + actualValueIndex = delegate.pickIndex(workingRandom, originalRandomIndex); + value = originalList.get(actualValueIndex); + } + return actualValueIndex; + } + + @Override + public T remove(Random workingRandom) { + return remove(pickIndex(workingRandom)); + } + + @Override + public T remove(int index) { + var element = delegate.remove(index); + if (!filter.test(originalList.get(index))) { + throw new NoSuchElementException(); + } + return element; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/UniqueRandomSequence.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/UniqueRandomSequence.java new file mode 100644 index 0000000000..8f546ede4c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/UniqueRandomSequence.java @@ -0,0 +1,54 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import java.util.NoSuchElementException; +import java.util.Random; + +import org.jspecify.annotations.NullMarked; + +/** + * Exists to support random unique selection. + * It accepts a list of unique items on input, and does not copy or modify it. + * Instead, it keeps metadata on which indexes of the list were removed already, never to return them again. + * Does not allow null values. + * + * @param + */ +@NullMarked +public sealed interface UniqueRandomSequence + permits DefaultUniqueRandomSequence, FilteredUniqueRandomSequence { + + /** + * Picks a random element from the list which has not already been removed. + * Once an element of the list is removed either via {@link #remove(Random)} or {@link #remove(int)}, + * it will never be returned again by this method. + * + * @param workingRandom the random number generator to use + * @return a random element from the list which has not already been removed + * @throws NoSuchElementException if there are no more elements to pick from + */ + SequenceElement pick(Random workingRandom); + + /** + * Removes a random element in the underlying list which has not already been removed. + * Once this method returns, no subsequent {@link #pick(Random)} will return this element ever again. + * + * @param workingRandom the random number generator to use + * @return The element which exists in the original list at the removed index. + * @throws NoSuchElementException if there are no more elements to pick from + */ + T remove(Random workingRandom); + + /** + * Removes the element at the given index in the underlying list. + * Once this method returns, no subsequent {@link #pick(Random)} will return this element ever again. + * + * @param index the index of the element to remove + * @return The element which exists in the original list at the given index. + * @throws NoSuchElementException if the index has already been removed + */ + T remove(int index); + + record SequenceElement(T value, int index) { + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/AbstractDataJoiner.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/AbstractDataJoiner.java deleted file mode 100644 index d516311876..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/AbstractDataJoiner.java +++ /dev/null @@ -1,37 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner; - -import java.util.Objects; -import java.util.function.Function; - -import ai.timefold.solver.core.impl.bavet.common.joiner.JoinerType; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public abstract class AbstractDataJoiner { - - protected final Function[] rightMappings; - protected final JoinerType[] joinerTypes; - - protected AbstractDataJoiner(Function rightMapping, JoinerType joinerType) { - this(new Function[] { rightMapping }, new JoinerType[] { joinerType }); - } - - protected AbstractDataJoiner(Function[] rightMappings, JoinerType[] joinerTypes) { - this.rightMappings = (Function[]) Objects.requireNonNull(rightMappings); - this.joinerTypes = Objects.requireNonNull(joinerTypes); - } - - public final Function getRightMapping(int index) { - return rightMappings[index]; - } - - public final int getJoinerCount() { - return joinerTypes.length; - } - - public final JoinerType getJoinerType(int index) { - return joinerTypes[index]; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiDataJoinerComber.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiEnumeratingJoinerComber.java similarity index 75% rename from core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiDataJoinerComber.java rename to core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiEnumeratingJoinerComber.java index d52f7c0262..78f159de22 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiDataJoinerComber.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/BiEnumeratingJoinerComber.java @@ -3,8 +3,8 @@ import java.util.ArrayList; import java.util.List; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.preview.api.move.SolutionView; import org.jspecify.annotations.NullMarked; @@ -17,12 +17,12 @@ * @param */ @NullMarked -public record BiDataJoinerComber(DefaultBiEnumeratingJoiner mergedJoiner, - @Nullable BiEnumeratingFilter mergedFiltering) { +public record BiEnumeratingJoinerComber(DefaultBiEnumeratingJoiner mergedJoiner, + @Nullable BiEnumeratingPredicate mergedFiltering) { - public static BiDataJoinerComber comb(BiEnumeratingJoiner[] joiners) { + public static BiEnumeratingJoinerComber comb(BiEnumeratingJoiner[] joiners) { List> defaultJoinerList = new ArrayList<>(joiners.length); - List> filteringList = new ArrayList<>(joiners.length); + List> filteringList = new ArrayList<>(joiners.length); int indexOfFirstFilter = -1; // Make sure all indexing joiners, if any, come before filtering joiners. This is necessary for performance. @@ -46,12 +46,12 @@ Maybe reorder the joiners such that filtering() joiners are later in the paramet } } DefaultBiEnumeratingJoiner mergedJoiner = DefaultBiEnumeratingJoiner.merge(defaultJoinerList); - BiEnumeratingFilter mergedFiltering = mergeFiltering(filteringList); - return new BiDataJoinerComber<>(mergedJoiner, mergedFiltering); + BiEnumeratingPredicate mergedFiltering = mergeFiltering(filteringList); + return new BiEnumeratingJoinerComber<>(mergedJoiner, mergedFiltering); } - private static @Nullable BiEnumeratingFilter - mergeFiltering(List> filteringList) { + private static @Nullable BiEnumeratingPredicate + mergeFiltering(List> filteringList) { if (filteringList.isEmpty()) { return null; } @@ -61,7 +61,7 @@ Maybe reorder the joiners such that filtering() joiners are later in the paramet default -> // Avoid predicate.and() when more than 2 predicates for debugging and potentially performance (SolutionView solutionView, A a, B b) -> { - for (BiEnumeratingFilter predicate : filteringList) { + for (BiEnumeratingPredicate predicate : filteringList) { if (!predicate.test(solutionView, a, b)) { return false; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/DefaultBiEnumeratingJoiner.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/DefaultBiEnumeratingJoiner.java index 44b40debb6..0c486555f1 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/DefaultBiEnumeratingJoiner.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/DefaultBiEnumeratingJoiner.java @@ -14,7 +14,7 @@ @SuppressWarnings({ "unchecked", "rawtypes" }) @NullMarked -public final class DefaultBiEnumeratingJoiner extends AbstractDataJoiner implements BiEnumeratingJoiner { +public final class DefaultBiEnumeratingJoiner extends AbstractJoiner implements BiEnumeratingJoiner { private static final DefaultBiEnumeratingJoiner NONE = new DefaultBiEnumeratingJoiner(new Function[0], new JoinerType[0], new Function[0]); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/FilteringBiEnumeratingJoiner.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/FilteringBiEnumeratingJoiner.java index 15a34297c7..9d1bba0f23 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/FilteringBiEnumeratingJoiner.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/joiner/FilteringBiEnumeratingJoiner.java @@ -1,13 +1,13 @@ package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import org.jspecify.annotations.NullMarked; @NullMarked public record FilteringBiEnumeratingJoiner( - BiEnumeratingFilter filter) implements BiEnumeratingJoiner { + BiEnumeratingPredicate filter) implements BiEnumeratingJoiner { @Override public FilteringBiEnumeratingJoiner and(BiEnumeratingJoiner otherJoiner) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/AbstractUniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/AbstractUniEnumeratingStream.java index 22db40d4cc..a490bc099c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/AbstractUniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/AbstractUniEnumeratingStream.java @@ -19,7 +19,7 @@ import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.AftBridgeBiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.AftBridgeUniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeUniEnumeratingStream; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.BiDataJoinerComber; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.BiEnumeratingJoinerComber; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; import org.jspecify.annotations.NullMarked; @@ -49,7 +49,7 @@ public BiEnumeratingStream join(UniEnumeratingStream) otherStream; var leftBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, this); var rightBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, other); - var joinerComber = BiDataJoinerComber. comb(joiners); + var joinerComber = BiEnumeratingJoinerComber. comb(joiners); var joinStream = new JoinBiEnumeratingStream<>(enumeratingStreamFactory, leftBridge, rightBridge, joinerComber.mergedJoiner(), joinerComber.mergedFiltering()); return enumeratingStreamFactory.share(joinStream, joinStream_ -> { @@ -94,7 +94,7 @@ private UniEnumeratingStream ifExistsOrNot(boolean shouldExist UniEnumeratingStream otherStream, BiEnumeratingJoiner[] joiners) { var other = (AbstractUniEnumeratingStream) otherStream; - var joinerComber = BiDataJoinerComber. comb(joiners); + var joinerComber = BiEnumeratingJoinerComber. comb(joiners); var parentBridgeB = other.shareAndAddChild(new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, other)); return enumeratingStreamFactory 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..1ba6284c0d 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 @@ -9,7 +9,7 @@ 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; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingFilter; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingPredicate; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; @@ -29,13 +29,13 @@ final class IfExistsUniEnumeratingStream private final ForeBridgeUniEnumeratingStream parentBridgeB; private final boolean shouldExist; private final DefaultBiEnumeratingJoiner joiner; - private final @Nullable BiEnumeratingFilter filtering; + private final @Nullable BiEnumeratingPredicate filtering; public IfExistsUniEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, AbstractUniEnumeratingStream parentA, ForeBridgeUniEnumeratingStream parentBridgeB, boolean shouldExist, DefaultBiEnumeratingJoiner joiner, - @Nullable BiEnumeratingFilter filtering) { + @Nullable BiEnumeratingPredicate filtering) { super(enumeratingStreamFactory); this.parentA = parentA; this.parentBridgeB = parentBridgeB; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDataset.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDataset.java index 1094060a7e..f667a66768 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDataset.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDataset.java @@ -15,8 +15,8 @@ public UniDataset(EnumeratingStreamFactory enumeratingStreamFactory, } @Override - public UniDatasetInstance instantiate(int storeIndex) { - return new UniDatasetInstance<>(this, storeIndex); + public UniDatasetInstance instantiate(int entryStoreIndex) { + return new UniDatasetInstance<>(this, entryStoreIndex); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDatasetInstance.java index 6dabba658a..793901e453 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniDatasetInstance.java @@ -1,13 +1,14 @@ package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni; +import java.util.ArrayList; import java.util.Iterator; -import java.util.Random; +import java.util.function.Predicate; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDataset; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDatasetInstance; -import ai.timefold.solver.core.impl.util.ElementAwareList; -import ai.timefold.solver.core.impl.util.ElementAwareListEntry; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DefaultUniqueRandomSequence; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.FilteredUniqueRandomSequence; import org.jspecify.annotations.NullMarked; @@ -15,16 +16,19 @@ public final class UniDatasetInstance extends AbstractDatasetInstance> { - private final ElementAwareList> tupleList = new ElementAwareList<>(); + private final ArrayList> tupleList = new ArrayList<>(); - public UniDatasetInstance(AbstractDataset> parent, int inputStoreIndex) { - super(parent, inputStoreIndex); + public UniDatasetInstance(AbstractDataset> parent, int rightMostPositionStoreIndex) { + super(parent, rightMostPositionStoreIndex); } @Override public void insert(UniTuple tuple) { - var entry = tupleList.add(tuple); - tuple.setStore(inputStoreIndex, entry); + tupleList.add(tuple); + // Since elements are only ever added at the end, + // the index is always the right-most position that the tuple could be found at. + var rightMostIndex = tupleList.size() - 1; + tuple.setStore(entryStoreIndex, rightMostIndex); } @Override @@ -34,16 +38,39 @@ public void update(UniTuple tuple) { @Override public void retract(UniTuple tuple) { - ElementAwareListEntry> entry = tuple.removeStore(inputStoreIndex); - entry.remove(); + // The tuple knows the right-most index it could be found at. + // But retracts may have shifted other tuples to the left, + // so we need to search backwards from there. + // Thankfully retracts are relatively rare. + int rightMostIndex = Math.min(tuple.removeStore(entryStoreIndex), tupleList.size() - 1); + for (int i = rightMostIndex; i >= 0; i--) { + if (tupleList.get(i) == tuple) { + tupleList.remove(i); + return; + } + } + throw new IllegalStateException("Impossible state: tuple (%s) not found." + .formatted(tuple)); } + @Override public Iterator> iterator() { return tupleList.iterator(); } - public Iterator> iterator(Random workingRandom) { - return tupleList.randomizedIterator(workingRandom); + @Override + public DefaultUniqueRandomSequence> buildRandomSequence() { + return new DefaultUniqueRandomSequence<>(tupleList); + } + + @Override + public FilteredUniqueRandomSequence> buildRandomSequence(Predicate> predicate) { + return new FilteredUniqueRandomSequence<>(tupleList, predicate); + } + + @Override + public int size() { + return tupleList.size(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromBiSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromBiSamplingStream.java deleted file mode 100644 index a77dd116b8..0000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromBiSamplingStream.java +++ /dev/null @@ -1,27 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.sampling; - -import java.util.Objects; - -import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.BiMoveConstructor; -import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.BiSamplingStream; -import ai.timefold.solver.core.impl.neighborhood.move.FromBiUniMoveStream; -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.BiDataset; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public final class DefaultBiFromBiSamplingStream implements BiSamplingStream { - - private final BiDataset dataset; - - public DefaultBiFromBiSamplingStream(BiDataset dataset) { - this.dataset = Objects.requireNonNull(dataset); - } - - @Override - public MoveStream asMove(BiMoveConstructor moveConstructor) { - return new FromBiUniMoveStream<>(dataset, Objects.requireNonNull(moveConstructor)); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromUnisSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java similarity index 55% rename from core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromUnisSamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java index a072a8799b..f26871d24d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiFromUnisSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java @@ -1,33 +1,33 @@ package ai.timefold.solver.core.impl.neighborhood.stream.sampling; import java.util.Objects; -import java.util.function.BiPredicate; import ai.timefold.solver.core.impl.neighborhood.maybeapi.MoveStream; import ai.timefold.solver.core.impl.neighborhood.maybeapi.move.BiMoveConstructor; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.BiSamplingStream; -import ai.timefold.solver.core.impl.neighborhood.move.FromUniBiMoveStream; +import ai.timefold.solver.core.impl.neighborhood.move.BiMoveStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.BiEnumeratingJoinerComber; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset; import org.jspecify.annotations.NullMarked; @NullMarked -public final class DefaultBiFromUnisSamplingStream implements BiSamplingStream { +public final class DefaultBiSamplingStream implements BiSamplingStream { private final UniDataset leftDataset; private final UniDataset rightDataset; - private final BiPredicate filter; + private final BiEnumeratingJoinerComber comber; - public DefaultBiFromUnisSamplingStream(UniDataset leftDataset, UniDataset rightDataset, - BiPredicate filter) { + public DefaultBiSamplingStream(UniDataset leftDataset, UniDataset rightDataset, + BiEnumeratingJoinerComber comber) { this.leftDataset = Objects.requireNonNull(leftDataset); this.rightDataset = Objects.requireNonNull(rightDataset); - this.filter = Objects.requireNonNull(filter); + this.comber = Objects.requireNonNull(comber); } @Override public MoveStream asMove(BiMoveConstructor moveConstructor) { - return new FromUniBiMoveStream<>(leftDataset, rightDataset, filter, Objects.requireNonNull(moveConstructor)); + return new BiMoveStream<>(leftDataset, rightDataset, comber, Objects.requireNonNull(moveConstructor)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultUniSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultUniSamplingStream.java index 237c19b1b6..19b928cf24 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultUniSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultUniSamplingStream.java @@ -1,10 +1,11 @@ package ai.timefold.solver.core.impl.neighborhood.stream.sampling; import java.util.Objects; -import java.util.function.BiPredicate; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.UniEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.enumerating.function.BiEnumeratingJoiner; import ai.timefold.solver.core.impl.neighborhood.maybeapi.stream.sampling.BiSamplingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.joiner.BiEnumeratingJoinerComber; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniDataset; @@ -20,16 +21,16 @@ public DefaultUniSamplingStream(UniDataset dataset) { } @Override - public BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, - BiPredicate filter) { - return new DefaultBiFromUnisSamplingStream<>(dataset, - ((AbstractUniEnumeratingStream) uniEnumeratingStream).createDataset(), - filter); + public UniDataset getDataset() { + return dataset; } @Override - public UniDataset getDataset() { - return dataset; + public BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + BiEnumeratingJoiner... joiners) { + return new DefaultBiSamplingStream<>(dataset, + ((AbstractUniEnumeratingStream) uniEnumeratingStream).createDataset(), + BiEnumeratingJoinerComber.comb(joiners)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java index 0c23315575..2300322b0c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java @@ -1,12 +1,9 @@ package ai.timefold.solver.core.impl.util; -import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; -import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; -import java.util.Random; import java.util.function.Consumer; /** @@ -167,36 +164,6 @@ public void clear() { size = 0; } - /** - * Returns an iterator that will randomly iterate over the elements. - * This iterator is exhaustive; once every element has been once iterated over, - * the iterator returns false for every subsequent {@link Iterator#hasNext()}. - * The iterator does not support the {@link Iterator#remove()} operation. - * - * @param random The random instance to use for shuffling. - * @return never null - */ - public Iterator randomizedIterator(Random random) { - return switch (size) { - case 0 -> Collections.emptyIterator(); - case 1 -> Collections.singleton(first.getElement()).iterator(); - case 2 -> { - var list = random.nextBoolean() ? List.of(first.getElement(), last.getElement()) - : List.of(last.getElement(), first.getElement()); - yield list.iterator(); - } - default -> { - var copy = new ArrayList(size); - var indexList = new ArrayList(size); - forEach(e -> { // Two lists, single iteration. - copy.add(e); - indexList.add(copy.size() - 1); - }); - yield new RandomElementAwareListIterator<>(copy, indexList, random); - } - }; - } - @Override public String toString() { switch (size) { @@ -242,41 +209,4 @@ public T next() { } - /** - * The idea of this iterator is that the list will rarely ever be iterated over in its entirety. - * In fact, Neighborhoods API is likely to only use the first few elements. - * Therefore, shuffling the entire list would be a waste of time. - * Instead, we pick random index every time and keep a list of unused indexes. - * - * @param The element type. Often a tuple. - */ - private static final class RandomElementAwareListIterator implements Iterator { - - private final List elementList; - private final List unusedIndexList; - private final Random random; - - public RandomElementAwareListIterator(List copiedList, List unusedIndexList, Random random) { - this.random = random; - this.elementList = copiedList; - this.unusedIndexList = unusedIndexList; - } - - @Override - public boolean hasNext() { - return !unusedIndexList.isEmpty(); - } - - @Override - public T next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - var randomUnusedIndex = random.nextInt(unusedIndexList.size()); - var elementIndex = unusedIndexList.remove(randomUnusedIndex); - return elementList.get(elementIndex); - } - - } - } 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..9bd0938779 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 @@ -256,69 +256,43 @@ void fromSolutionAllowsUnassigned() { // Filters out moves that would change the value to the value the entity already has. // Therefore this will have 4 moves (2 entities * 2 values) as opposed to 6 (2 entities * 3 values). var moveIterable = createMoveIterable(new ChangeMoveDefinition<>(variableMetaModel), solutionDescriptor, solution); - assertThat(moveIterable).hasSize(4); - var moveList = StreamSupport.stream(moveIterable.spliterator(), false) .map(m -> (ChangeMove) m) .toList(); assertThat(moveList).hasSize(4); - // TODO There is a strange issue here that needs to be investigated, - // as it potentially breaks difficulty comparators. - - // The node network receives: - // firstEntity + null; filtered out - // secondEntity + null - // firstEntity + firstValue - // secondEntity + firstValue - // firstEntity + secondValue - // secondEntity + secondValue; filtered out - - // Therefore the iterator receives: - // secondEntity + null - // firstEntity + firstValue - // secondEntity + firstValue - // firstEntity + secondValue - - // This means that secondEntity is actually encountered first, and therefore will be iterated first. - // A strange behavior of original iteration when combined with dataset caching before picking, - // where the node network (= cache) is fully built long before the iteration starts. - // A possible fix would be to refactor the node network to first iterate right inputs - // (values first in this case) - // but wouldn't that just create a similar issue in other places? - - // Second entity is assigned to secondValue, therefore the applicable moves assign to null and firstValue. + // First entity is assigned to null, therefore the applicable moves assign to firstValue and secondValue. var firstMove = moveList.get(0); assertSoftly(softly -> { softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(firstMove.extractPlanningValues()) - .containsExactly(new TestdataValue[] { null }); + .containsExactly(firstValue); }); var secondMove = moveList.get(1); assertSoftly(softly -> { softly.assertThat(secondMove.extractPlanningEntities()) - .containsExactly(secondEntity); + .containsExactly(firstEntity); softly.assertThat(secondMove.extractPlanningValues()) - .containsExactly(firstValue); + .containsExactly(secondValue); }); - // First entity is assigned to null, therefore the applicable moves assign to firstValue and secondValue. + // Second entity is assigned to secondValue, therefore the applicable moves assign to null and firstValue. var thirdMove = moveList.get(2); assertSoftly(softly -> { softly.assertThat(thirdMove.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(thirdMove.extractPlanningValues()) - .containsExactly(firstValue); + .containsExactly(new TestdataValue[] { null }); }); var fourthMove = moveList.get(3); assertSoftly(softly -> { softly.assertThat(fourthMove.extractPlanningEntities()) - .containsExactly(firstEntity); + .containsExactly(secondEntity); softly.assertThat(fourthMove.extractPlanningValues()) - .containsExactly(secondValue); + .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 2fb657a726..0fe0020281 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 @@ -69,36 +69,36 @@ void fromSolution() { .containsExactly(unassignedValue); }); - var move2 = getListAssignMove(moveList, 1); + var move2 = getListChangeMove(moveList, 1); assertSoftly(softly -> { - softly.assertThat(move2.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move2.getDestinationIndex()).isEqualTo(1); + 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(unassignedValue); + .containsExactly(initiallyAssignedValue); }); var move3 = getListAssignMove(moveList, 2); assertSoftly(softly -> { softly.assertThat(move3.getDestinationEntity()).isEqualTo(e2); - softly.assertThat(move3.getDestinationIndex()).isEqualTo(0); + softly.assertThat(move3.getDestinationIndex()).isEqualTo(1); softly.assertThat(move3.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move3.extractPlanningValues()) .containsExactly(unassignedValue); }); - 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(initiallyAssignedValue); + .containsExactly(unassignedValue); }); } 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..3d58a5f625 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 @@ -63,17 +63,17 @@ void fromSolution() { var move1 = (ListSwapMove) moveList.get(0); assertSoftly(softly -> { softly.assertThat(move1.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e1, e2); softly.assertThat(move1.extractPlanningValues()) - .containsExactly(assignedValue2, assignedValue1); + .containsExactly(assignedValue1, assignedValue2); }); var move2 = (ListSwapMove) moveList.get(1); assertSoftly(softly -> { softly.assertThat(move2.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e1, e2); softly.assertThat(move2.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue1); + .containsExactly(assignedValue1, assignedValue3); }); var move3 = (ListSwapMove) moveList.get(2); @@ -81,7 +81,7 @@ void fromSolution() { softly.assertThat(move3.extractPlanningEntities()) .containsExactly(e2); softly.assertThat(move3.extractPlanningValues()) - .containsExactly(assignedValue3, assignedValue2); + .containsExactly(assignedValue2, assignedValue3); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinitionTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinitionTest.java index e4478d8a7b..3359f2c6fe 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinitionTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/maybeapi/move/SwapMoveDefinitionTest.java @@ -54,17 +54,17 @@ void univariate() { var firstMove = moveList.get(0); assertSoftly(softly -> { softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e1, e2); softly.assertThat(firstMove.extractPlanningValues()) - .containsExactly(v2, v1); + .containsExactly(v1, v2); }); var secondMove = moveList.get(1); assertSoftly(softly -> { softly.assertThat(secondMove.extractPlanningEntities()) - .containsExactly(e3, e2); + .containsExactly(e2, e3); softly.assertThat(secondMove.extractPlanningValues()) - .containsExactly(v1, v2); + .containsExactly(v2, v1); }); } @@ -95,17 +95,17 @@ void multivariate() { var firstMove = moveList.get(0); assertSoftly(softly -> { softly.assertThat(firstMove.extractPlanningEntities()) - .containsExactly(e2, e1); + .containsExactly(e1, e2); softly.assertThat(firstMove.extractPlanningValues()) - .containsExactly(v2, v1, otherV2, otherV1); + .containsExactly(v1, v2, otherV1, otherV2); }); var secondMove = moveList.get(1); assertSoftly(softly -> { softly.assertThat(secondMove.extractPlanningEntities()) - .containsExactly(e3, e2); + .containsExactly(e2, e3); softly.assertThat(secondMove.extractPlanningValues()) - .containsExactly(v1, v2, otherV1, otherV2); + .containsExactly(v2, v1, otherV2, otherV1); }); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequenceTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequenceTest.java new file mode 100644 index 0000000000..8c571fdd14 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/DefaultUniqueRandomSequenceTest.java @@ -0,0 +1,479 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Random; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class DefaultUniqueRandomSequenceTest { + + @Test + void emptySet() { + var emptySet = DefaultUniqueRandomSequence.empty(); + assertThat(emptySet.isEmpty()).isTrue(); + + var random = new Random(0); + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> emptySet.pick(random)); + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> emptySet.remove(random)); + } + + @Test + void emptySingleton() { + var emptySet1 = DefaultUniqueRandomSequence.empty(); + var emptySet2 = DefaultUniqueRandomSequence.empty(); + assertThat(emptySet1).isSameAs(emptySet2); + } + + @Test + void singleElementSetPickAndRemove() { + var list = List.of("A"); + var set = new DefaultUniqueRandomSequence<>(list); + + assertThat(set.isEmpty()).isFalse(); + + var random = new Random(0); + var element = set.pick(random); + assertThat(element.value()).isEqualTo("A"); + assertThat(element.index()).isZero(); + + var cleared = set.remove(random); + assertThat(cleared).isEqualTo("A"); + assertThat(set.isEmpty()).isTrue(); + + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> set.pick(random)); + } + + @Test + void singleElementSetRemoveByIndex() { + var list = List.of("A"); + var set = new DefaultUniqueRandomSequence<>(list); + + var cleared = set.remove(0); + assertThat(cleared).isEqualTo("A"); + assertThat(set.isEmpty()).isTrue(); + + var random = new Random(0); + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> set.pick(random)); + } + + @Test + void multipleElementSet() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + assertThat(set.isEmpty()).isFalse(); + + var random = new Random(0); + var element = set.pick(random); + assertThat(element.value()).isIn(list); + assertThat(element.index()).isBetween(0, 4); + } + + @Test + void pickDoesNotModifySet() { + var list = List.of("A", "B", "C"); + var set = new DefaultUniqueRandomSequence<>(list); + + var random = new Random(0); + var element1 = set.pick(random); + var element2 = set.pick(random); + var element3 = set.pick(random); + + assertThat(set.isEmpty()).isFalse(); + // Elements may be the same since pick doesn't clear + assertThat(element1.value()).isIn(list); + assertThat(element2.value()).isIn(list); + assertThat(element3.value()).isIn(list); + } + + @Test + void removeAllElements() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + var random = new Random(0); + var clearedElements = new HashSet(); + + for (int i = 0; i < 5; i++) { + assertThat(set.isEmpty()).isFalse(); + var cleared = set.remove(random); + clearedElements.add(cleared); + } + + assertThat(set.isEmpty()).isTrue(); + assertThat(clearedElements).containsExactlyInAnyOrderElementsOf(list); + + assertThatExceptionOfType(NoSuchElementException.class) + .isThrownBy(() -> set.pick(random)); + } + + @Test + void removeByIndexSequentially() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + assertThat(set.remove(0)).isEqualTo("A"); + assertThat(set.isEmpty()).isFalse(); + + assertThat(set.remove(1)).isEqualTo("B"); + assertThat(set.isEmpty()).isFalse(); + + assertThat(set.remove(2)).isEqualTo("C"); + assertThat(set.isEmpty()).isFalse(); + + assertThat(set.remove(3)).isEqualTo("D"); + assertThat(set.isEmpty()).isFalse(); + + assertThat(set.remove(4)).isEqualTo("E"); + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void removeByIndexReverseOrder() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + assertThat(set.remove(4)).isEqualTo("E"); + assertThat(set.remove(3)).isEqualTo("D"); + assertThat(set.remove(2)).isEqualTo("C"); + assertThat(set.remove(1)).isEqualTo("B"); + assertThat(set.remove(0)).isEqualTo("A"); + + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void removeByIndexRandomOrder() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + assertThat(set.remove(2)).isEqualTo("C"); + assertThat(set.remove(0)).isEqualTo("A"); + assertThat(set.remove(4)).isEqualTo("E"); + assertThat(set.remove(1)).isEqualTo("B"); + assertThat(set.remove(3)).isEqualTo("D"); + + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void pickAfterPartialRemove() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(1); // Clear "B" + set.remove(3); // Clear "D" + + var random = new Random(0); + var pickedElements = new HashSet(); + + // Pick multiple times to verify we never get cleared elements + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + pickedElements.add(element.value()); + assertThat(element.value()).isNotIn("B", "D"); + assertThat(element.value()).isIn("A", "C", "E"); + } + + // Verify we can pick all remaining elements + assertThat(pickedElements).containsAnyOf("A", "C", "E"); + } + + @Test + void removeByIndexThenPickRemaining() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(0); // Clear "A" + set.remove(4); // Clear "E" + + var random = new Random(42); + var pickedElements = new HashSet(); + + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + pickedElements.add(element.value()); + } + + assertThat(pickedElements) + .doesNotContain("A", "E") + .containsAnyOf("B", "C", "D"); + } + + @Test + void mixedPickAndRemove() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + var random = new Random(0); + + var picked1 = set.pick(random); + assertThat(picked1.value()).isIn(list); + + var cleared1 = set.remove(random); + assertThat(cleared1).isIn(list); + + var picked2 = set.pick(random); + assertThat(picked2.value()).isIn(list); + + set.remove(picked2.index()); + + assertThat(set.isEmpty()).isFalse(); + } + + @Test + void randomDistribution() { + // Test that random selection is reasonably distributed + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + var random = new Random(12345); + var counts = new int[5]; + + for (int i = 0; i < 1000; i++) { + var element = set.pick(random); + counts[element.index()]++; + } + + // Each element should be picked at least once in 1000 tries + for (int count : counts) { + assertThat(count).isGreaterThan(0); + } + } + + @Test + void randomDistributionAfterPartialRemove() { + // Test that random selection remains distributed after clearing some elements + var list = List.of("A", "B", "C", "D", "E", "F", "G", "H", "I", "J"); + var set = new DefaultUniqueRandomSequence<>(list); + + // Clear some elements + set.remove(1); // B + set.remove(5); // F + set.remove(8); // I + + var random = new Random(12345); + var pickedElements = new HashSet(); + + for (int i = 0; i < 100; i++) { + var element = set.pick(random); + pickedElements.add(element.value()); + } + + assertThat(pickedElements) + .doesNotContain("B", "F", "I") + .containsAnyOf("A", "C", "D", "E", "G", "H", "J"); + } + + @Test + void removeUntilOneRemaining() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(0); + set.remove(1); + set.remove(2); + set.remove(4); + + assertThat(set.isEmpty()).isFalse(); + + var random = new Random(0); + var element = set.pick(random); + assertThat(element.value()).isEqualTo("D"); + assertThat(element.index()).isEqualTo(3); + + set.remove(3); + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void removeLeftmost() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(0); // Clear leftmost + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isNotEqualTo("A"); + } + } + + @Test + void removeRightmost() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(4); // Clear rightmost + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isNotEqualTo("E"); + } + } + + @Test + void removeBothEnds() { + var list = List.of("A", "B", "C", "D", "E"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(0); // Clear leftmost + set.remove(4); // Clear rightmost + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isIn("B", "C", "D"); + } + } + + @Test + void removeConsecutiveFromLeft() { + var list = List.of("A", "B", "C", "D", "E", "F"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(0); + set.remove(1); + set.remove(2); + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isIn("D", "E", "F"); + } + } + + @Test + void removeConsecutiveFromRight() { + var list = List.of("A", "B", "C", "D", "E", "F"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(5); + set.remove(4); + set.remove(3); + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isIn("A", "B", "C"); + } + } + + @Test + void removeAlternatingPattern() { + var list = List.of("A", "B", "C", "D", "E", "F", "G", "H"); + var set = new DefaultUniqueRandomSequence<>(list); + + // Clear every other element + set.remove(0); + set.remove(2); + set.remove(4); + set.remove(6); + + var random = new Random(0); + for (int i = 0; i < 20; i++) { + var element = set.pick(random); + assertThat(element.value()).isIn("B", "D", "F", "H"); + assertThat(element.index()).isIn(1, 3, 5, 7); + } + } + + @Test + void randomAccessElementRecord() { + var element = new DefaultUniqueRandomSequence.SequenceElement<>("test", 5); + assertThat(element.value()).isEqualTo("test"); + assertThat(element.index()).isEqualTo(5); + } + + @Test + void multipleRandomSeeds() { + var list = List.of("A", "B", "C", "D", "E"); + + var set1 = new DefaultUniqueRandomSequence<>(list); + var random1 = new Random(123); + var element1 = set1.pick(random1); + + var set2 = new DefaultUniqueRandomSequence<>(list); + var random2 = new Random(123); + var element2 = set2.pick(random2); + + // Same seed should produce same result + assertThat(element1).isEqualTo(element2); + + var set3 = new DefaultUniqueRandomSequence<>(list); + var random3 = new Random(456); + var element3 = set3.pick(random3); + + // Different seed might produce different result (not guaranteed, but likely) + // Just verify it doesn't crash + assertThat(element3.value()).isIn(list); + } + + @Test + void largeSet() { + var list = new ArrayList(); + for (int i = 0; i < 1000; i++) { + list.add("Element" + i); + } + + var set = new DefaultUniqueRandomSequence<>(list); + var random = new Random(0); + + // Clear half of the elements + for (int i = 0; i < 500; i++) { + assertThatNoException().isThrownBy(() -> set.remove(random)); + } + + assertThat(set.isEmpty()).isFalse(); + + // Clear the rest + for (int i = 0; i < 500; i++) { + set.remove(random); + } + + assertThat(set.isEmpty()).isTrue(); + } + + @Test + void removeSameIndexMultipleTimes() { + var list = List.of("A", "B", "C"); + var set = new DefaultUniqueRandomSequence<>(list); + + set.remove(1); // Clear "B" + Assertions.assertThatThrownBy(() -> set.remove(1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void verifyUniquenessOfClearedElements() { + var list = List.of("A", "B", "C", "D", "E", "F", "G", "H", "I", "J"); + var set = new DefaultUniqueRandomSequence<>(list); + + var random = new Random(99999); + var clearedElements = new ArrayList(); + + while (!set.isEmpty()) { + clearedElements.add(set.remove(random)); + } + + // All cleared elements should be unique + assertThat(clearedElements).hasSize(10); + assertThat(new HashSet<>(clearedElements)).hasSize(10); + assertThat(clearedElements).containsExactlyInAnyOrderElementsOf(list); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequenceTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequenceTest.java new file mode 100644 index 0000000000..4a823d1607 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/FilteredUniqueRandomSequenceTest.java @@ -0,0 +1,133 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.function.Predicate; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.Parameter; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * The class under test uses randomness, and has a whole lot of possible corner cases. + * Therefore, we run the same tests with a variety of random seeds to increase coverage. + * None of these are expected to fail. + * If any fail, the random seed is printed in the test name for reproducibility. + */ +@MethodSource("randomSeeds") +@ParameterizedClass +@Execution(ExecutionMode.CONCURRENT) +class FilteredUniqueRandomSequenceTest { + + private static final List ELEMENTS = List.of("A", "B", "C", "D"); + + private static Stream randomSeeds() { + return IntStream.range(0, 10) + .mapToObj(Arguments::of); + } + + @Parameter + private int randomSeed; + + Random random; + + @BeforeEach + void beforeEach() { + random = new Random(randomSeed); + } + + @CsvSource(useHeadersInDisplayName = true, value = """ + totalElementCount, filteredElementCount + 2, 1 + 3, 1 + 3, 2 + 4, 1 + 4, 2 + 4, 3 + """) + @ParameterizedTest(name = "{arguments}") + void exhaust(int totalElementCount, int filteredElementCount) { + var list = ELEMENTS.subList(0, totalElementCount); + var filteredElements = new HashSet(); + while (filteredElements.size() < filteredElementCount) { + filteredElements.add(list.get(random.nextInt(list.size()))); + } + Predicate filter = Predicate.not(filteredElements::contains); + + var sequence = new FilteredUniqueRandomSequence<>(list, filter); + var expectedUnfilteredSize = list.size() - filteredElements.size(); + + var picked = new LinkedHashSet<>(expectedUnfilteredSize); + for (int i = 0; i < expectedUnfilteredSize; i++) { + var element = sequence.pick(random); + picked.add(element.value()); + sequence.remove(element.index()); + } + + assertSoftly(softly -> { + softly.assertThat(picked).hasSize(expectedUnfilteredSize); + softly.assertThat(picked).doesNotContainAnyElementsOf(filteredElements); + }); + } + + @CsvSource(useHeadersInDisplayName = true, value = """ + elementCount + 1 + 2 + 3 + 4 + """) + @ParameterizedTest(name = "{arguments}") + void throwsWhenExhausted(int elementCount) { + var list = ELEMENTS.subList(0, elementCount); + var filteredElements = new HashSet(); + while (filteredElements.size() < elementCount) { + filteredElements.add(list.get(random.nextInt(list.size()))); + } + Predicate filter = Predicate.not(filteredElements::contains); + + var sequence = new FilteredUniqueRandomSequence<>(list, filter); + var expectedUnfilteredSize = list.size() - filteredElements.size(); + + for (int i = 0; i < expectedUnfilteredSize; i++) { + sequence.remove(i); + } + + // Once all the unfiltered elements are removed, picking should throw. + assertThatThrownBy(() -> sequence.pick(random)) + .isInstanceOf(NoSuchElementException.class); + } + + @CsvSource(useHeadersInDisplayName = true, value = """ + elementCount + 1 + 2 + 3 + 4 + """) + @ParameterizedTest(name = "{arguments}") + void throwsWhenUnknowinglyEmpty(int elementCount) { + var list = ELEMENTS.subList(0, elementCount); + Predicate filter = Predicate.not(list::contains); + + // Everything is filtered out, but the sequence has no way of knowing that. + var sequence = new FilteredUniqueRandomSequence<>(list, filter); + assertThatThrownBy(() -> sequence.pick(random)) + .isInstanceOf(NoSuchElementException.class); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java index caca7bab01..32f92ed802 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java @@ -4,11 +4,7 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.util.NoSuchElementException; -import java.util.Random; -import ai.timefold.solver.core.testutil.TestRandom; - -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; class ElementAwareListTest { @@ -143,38 +139,6 @@ void iterator() { assertThat(iter.hasNext()).isFalse(); } - @Test - void randomizedIterator() { - // create a list and add some elements - var list = new ElementAwareList(); - assertSoftly(softly -> { - var iter = list.randomizedIterator(new Random(0)); - softly.assertThat(iter.hasNext()).isFalse(); - softly.assertThatThrownBy(iter::next).isInstanceOf(NoSuchElementException.class); - }); - - list.add("A"); - assertOrder(list, new String[] { "A" }, 0); - - // Each order of elements should be generated exactly once, to guarantee fair shuffling. - // The particular order doesn't matter, as long as each combination is listed once. - var bEntry = list.add("B"); - assertOrder(list, new String[] { "B", "A" }, 0, 0); - assertOrder(list, new String[] { "A", "B" }, 1, 0); - - list.add("C"); - assertOrder(list, new String[] { "A", "B", "C" }, 0, 0, 0); - assertOrder(list, new String[] { "A", "C", "B" }, 0, 1, 0); - assertOrder(list, new String[] { "B", "A", "C" }, 1, 0, 0); - assertOrder(list, new String[] { "B", "C", "A" }, 1, 1, 0); - assertOrder(list, new String[] { "C", "A", "B" }, 2, 0, 0); - assertOrder(list, new String[] { "C", "B", "A" }, 2, 1, 0); - - bEntry.remove(); - assertOrder(list, new String[] { "C", "A" }, 0, 0); - assertOrder(list, new String[] { "A", "C" }, 1, 0); - } - @Test void clear() { var list = new ElementAwareList(); @@ -185,11 +149,4 @@ void clear() { assertThat(list.size()).isZero(); } - private void assertOrder(ElementAwareList list, String[] elements, int... randoms) { - var iter = list.randomizedIterator(new TestRandom(randoms)); - Assertions.assertThat(iter) - .toIterable() - .containsExactly(elements); - } - }