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 7ab97f0260..66f381364d 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 @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.stream; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners.filtering; + import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -87,24 +89,60 @@ public UniEnumeratingStream forEachUnfiltered(Class sourceC .computeIfAbsent(variableMetaModel, ignored -> new NodeSharingSupportFunctions<>(variableMetaModel)); } + @Override + public UniEnumeratingStream + forEachAssignedValueUnfiltered(PlanningListVariableMetaModel variableMetaModel) { + var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); + return forEachUnfiltered(variableMetaModel.type(), false) + .filter(nodeSharingSupportFunctions.assignedValueFilter); + } + + @Override + public UniEnumeratingStream + forEachAssignedValue(PlanningListVariableMetaModel variableMetaModel) { + var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); + return forEach(variableMetaModel.type(), false) + .filter(nodeSharingSupportFunctions.assignedValueFilter); + } + + @Override + public UniEnumeratingStream + forEachDestination(PlanningListVariableMetaModel variableMetaModel) { + var unpinnedEntities = forEach(variableMetaModel.entity().type(), false); + // Stream with unpinned values, which are assigned to any list variable; + // always includes null so that we can later create a position at the end of the list, + // i.e. with no value after it. + var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); + var unpinnedValues = forEach(variableMetaModel.type(), true) + .filter(nodeSharingSupportFunctions.assignedValueOrNullFilter); + // Joins the two previous streams to create pairs of (entity, value), + // eliminating values which do not match that entity's value range. + // It maps these pairs to expected target positions in that entity's list variable. + return unpinnedEntities.join(unpinnedValues, + filtering(nodeSharingSupportFunctions.valueInRangeFilter)) + .map(nodeSharingSupportFunctions.toPositionInListMapper) + .distinct(); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) @Override public UniEnumeratingStream - forEachAssignablePosition(PlanningListVariableMetaModel variableMetaModel) { - // Stream with unpinned entities; - // includes null if the variable allows unassigned values. - var unpinnedEntities = - forEach(variableMetaModel.entity().type(), variableMetaModel.allowsUnassignedValues()); + forEachDestinationIncludingUnassigned(PlanningListVariableMetaModel variableMetaModel) { + if (!variableMetaModel.allowsUnassignedValues()) { + return (UniEnumeratingStream) forEachDestination(variableMetaModel); + } + var unpinnedEntities = forEach(variableMetaModel.entity().type(), true); // Stream with unpinned values, which are assigned to any list variable; // always includes null so that we can later create a position at the end of the list, // i.e. with no value after it. var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); var unpinnedValues = forEach(variableMetaModel.type(), true) - .filter(nodeSharingSupportFunctions.unpinnedValueFilter); + .filter(nodeSharingSupportFunctions.assignedValueOrNullFilter); // Joins the two previous streams to create pairs of (entity, value), // eliminating values which do not match that entity's value range. // It maps these pairs to expected target positions in that entity's list variable. return unpinnedEntities.join(unpinnedValues, - NeighborhoodsJoiners.filtering(nodeSharingSupportFunctions.valueInRangeFilter)) + filtering(nodeSharingSupportFunctions.valueInRangeFilter)) .map(nodeSharingSupportFunctions.toElementPositionMapper) .distinct(); } @@ -126,6 +164,28 @@ public SolutionDescriptor getSolutionDescriptor() { return enumeratingStreamFactory.getSolutionDescriptor(); } + @Override + public UniEnumeratingStream + forEachAssignablePosition(PlanningListVariableMetaModel variableMetaModel) { + // Stream with unpinned entities; + // includes null if the variable allows unassigned values. + var unpinnedEntities = + forEach(variableMetaModel.entity().type(), variableMetaModel.allowsUnassignedValues()); + // Stream with unpinned values, which are assigned to any list variable; + // always includes null so that we can later create a position at the end of the list, + // i.e. with no value after it. + var nodeSharingSupportFunctions = getNodeSharingSupportFunctions(variableMetaModel); + var unpinnedValues = forEach(variableMetaModel.type(), true) + .filter(nodeSharingSupportFunctions.assignedValueOrNullFilter); + // Joins the two previous streams to create pairs of (entity, value), + // eliminating values which do not match that entity's value range. + // It maps these pairs to expected target positions in that entity's list variable. + return unpinnedEntities.join(unpinnedValues, + NeighborhoodsJoiners.filtering(nodeSharingSupportFunctions.valueInRangeFilter)) + .map(nodeSharingSupportFunctions.toElementPositionMapper) + .distinct(); + } + public record NodeSharingSupportFunctions( PlanningVariableMetaModel variableMetaModel, BiNeighborhoodsPredicate differentValueFilter, @@ -142,14 +202,19 @@ public NodeSharingSupportFunctions(PlanningVariableMetaModel( PlanningListVariableMetaModel variableMetaModel, UniNeighborhoodsPredicate unpinnedValueFilter, + UniNeighborhoodsPredicate assignedValueOrNullFilter, + UniNeighborhoodsPredicate assignedValueFilter, BiNeighborhoodsPredicate valueInRangeFilter, - BiNeighborhoodsMapper toElementPositionMapper) { + BiNeighborhoodsMapper toElementPositionMapper, + BiNeighborhoodsMapper toPositionInListMapper) { public ListVariableNodeSharingSupportFunctions( PlanningListVariableMetaModel variableMetaModel) { this(variableMetaModel, + (solutionView, value) -> value == null || !solutionView.isPinned(variableMetaModel, value), (solutionView, value) -> value == null || solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList, + (solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList, (solutionView, entity, value) -> { if (entity == null || value == null) { // Necessary for the null to survive until the later stage, @@ -169,6 +234,14 @@ public ListVariableNodeSharingSupportFunctions( } else { // This will trigger assignment of the value immediately before this value. return solutionView.getPositionOf(variableMetaModel, value); } + }, + (solutionView, entity, value) -> { + var valueCount = solutionView.countValues(variableMetaModel, entity); + if (value == null || valueCount == 0) { // This will trigger assignment of the value at the end of the list. + return ElementPosition.of(entity, valueCount); + } else { // This will trigger assignment of the value immediately before this value. + return solutionView.getPositionOf(variableMetaModel, value).ensureAssigned(); + } }); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractLeftDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractLeftDatasetInstance.java index 6b54965a31..ba923538e8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractLeftDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractLeftDatasetInstance.java @@ -6,6 +6,7 @@ import ai.timefold.solver.core.impl.bavet.common.index.UniqueRandomIterator; import ai.timefold.solver.core.impl.bavet.common.tuple.Tuple; import ai.timefold.solver.core.impl.util.ElementAwareArrayList; +import ai.timefold.solver.core.impl.util.ElementAwareArrayList.Entry; import org.jspecify.annotations.NullMarked; @@ -28,17 +29,34 @@ public int getRightIteratorStoreIndex() { @Override public void insert(Tuple_ tuple) { + if (tuple.getStore(entryStoreIndex) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(tuple)); + } + tuple.setStore(entryStoreIndex, tupleList.add(tuple)); } @Override public void update(Tuple_ tuple) { - // No need to do anything. + if (tuple.getStore(entryStoreIndex) == null) { + // No fail fast if null because we don't track which tuples made it through the filter predicate(s) + insert(tuple); + } else { + // No need to do anything. + } } @Override public void retract(Tuple_ tuple) { - tupleList.remove(tuple.removeStore(entryStoreIndex)); + Entry entry = tuple.removeStore(entryStoreIndex); + if (entry == null) { + // No fail fast if null because we don't track which tuples made it through the filter predicate(s) + return; + } + + tupleList.remove(entry); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractRightDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractRightDatasetInstance.java index 133bcf5201..c08cdf1795 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractRightDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/AbstractRightDatasetInstance.java @@ -30,6 +30,12 @@ protected AbstractRightDatasetInstance(AbstractDataset parent, @Override public void insert(UniTuple tuple) { + if (tuple.getStore(compositeKeyStoreIndex) != null) { + throw new IllegalStateException( + "Impossible state: the input for the tuple (%s) was already added in the tupleStore." + .formatted(tuple)); + } + var compositeKey = compositeKeyExtractor.apply(tuple); tuple.setStore(entryStoreIndex, indexer.put(compositeKey, tuple)); tuple.setStore(compositeKeyStoreIndex, compositeKey); @@ -38,6 +44,12 @@ public void insert(UniTuple tuple) { @Override public void update(UniTuple tuple) { var oldCompositeKey = tuple.getStore(compositeKeyStoreIndex); + if (oldCompositeKey == null) { + // No fail fast if null because we don't track which tuples made it through the filter predicate(s) + insert(tuple); + return; + } + var newCompositeKey = compositeKeyExtractor.apply(tuple); if (!Objects.equals(oldCompositeKey, newCompositeKey)) { indexer.remove(oldCompositeKey, tuple.getStore(entryStoreIndex)); @@ -48,7 +60,13 @@ public void update(UniTuple tuple) { @Override public void retract(UniTuple tuple) { - indexer.remove(tuple.removeStore(compositeKeyStoreIndex), tuple.removeStore(entryStoreIndex)); + var compositeKey = tuple.removeStore(compositeKeyStoreIndex); + if (compositeKey == null) { + // No fail fast if null because we don't track which tuples made it through the filter predicate(s) + return; + } + + indexer.remove(compositeKey, tuple.removeStore(entryStoreIndex)); } public Iterator> iterator(Object compositeKey) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProvider.java index 37b084e0ee..671b415ab8 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProvider.java @@ -45,7 +45,7 @@ public ListChangeMoveProvider(PlanningListVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var entityValuePairs = moveStreamFactory.forEachAssignablePosition(variableMetaModel); + var entityValuePairs = moveStreamFactory.forEachDestinationIncludingUnassigned(variableMetaModel); var availableValues = moveStreamFactory.forEach(variableMetaModel.type(), false); return moveStreamFactory.pick(entityValuePairs) .pick(availableValues, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/MoveStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/MoveStreamFactory.java index f827931678..02a80a00c6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/MoveStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/MoveStreamFactory.java @@ -7,7 +7,9 @@ import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement; +import ai.timefold.solver.core.preview.api.move.SolutionView; import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.EnumeratingStream; import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsPredicate; @@ -37,6 +39,8 @@ public interface MoveStreamFactory { * This stream returns shadow entities regardless of whether they are assigned to any genuine entity. * They can easily be {@link UniEnumeratingStream#filter(UniNeighborhoodsPredicate) filtered out}. * + * @param sourceClass the class of the instances to enumerate + * @param includeNull if true, the stream will include a single null element * @return A stream containing a tuple for each of the entities as described above. * @see PlanningPin An annotation to mark the entire entity as pinned. * @see PlanningPinToIndex An annotation to specify only a portion of {@link PlanningListVariable} is pinned. @@ -55,20 +59,81 @@ public interface MoveStreamFactory { */ UniEnumeratingStream forEachUnfiltered(Class sourceClass, boolean includeNull); + /** + * Enumerate all values assigned to any entity's {@link PlanningListVariable}. + * Unlike {@link #forEachAssignedValue(PlanningListVariableMetaModel)}, this will include pinned values. + * You can use {@link SolutionView#getPositionOf(PlanningListVariableMetaModel, Object)} + * later downstream to get the position of the value in an entity's list variable, if needed. + * + * @param variableMetaModel the meta model of the list variable to enumerate + * @return enumerating stream with all values as defined above + * @see PlanningPin An annotation to mark the entire entity as pinned. + * @see PlanningPinToIndex An annotation to specify only a portion of {@link PlanningListVariable} is pinned. + */ + UniEnumeratingStream + forEachAssignedValueUnfiltered(PlanningListVariableMetaModel variableMetaModel); + + /** + * Enumerate all values assigned to any entity's {@link PlanningListVariable}. + * This will not include any pinned positions or fully pinned entities. + * You can use {@link SolutionView#getPositionOf(PlanningListVariableMetaModel, Object)} + * later downstream to get the position of the value in an entity's list variable, if needed. + * + * @param variableMetaModel the meta model of the list variable to enumerate + * @return enumerating stream with all values as defined above + * @see PlanningPin An annotation to mark the entire entity as pinned. + * @see PlanningPinToIndex An annotation to specify only a portion of {@link PlanningListVariable} is pinned. + */ + UniEnumeratingStream + forEachAssignedValue(PlanningListVariableMetaModel variableMetaModel); + + /** + * Enumerate all possible positions of a list variable to which a value can be assigned. + * This will include one position past the current end of the list, allowing for assigning at the end of a list. + * It will not include any pinned positions or fully pinned entities, as well as {@link UnassignedElement}. + * To include {@link UnassignedElement}, + * use {@link #forEachDestinationIncludingUnassigned(PlanningListVariableMetaModel)} instead. + * + * @param variableMetaModel the meta model of the list variable to enumerate + * @return enumerating stream with positions as defined above + * @see ElementPosition Read more about element positions. + * @see PlanningPin An annotation to mark the entire entity as pinned. + * @see PlanningPinToIndex An annotation to specify only a portion of {@link PlanningListVariable} is pinned. + */ + UniEnumeratingStream + forEachDestination(PlanningListVariableMetaModel variableMetaModel); + + /** + * As defined by {@link #forEachDestination(PlanningListVariableMetaModel)}, + * but also includes a single {@link UnassignedElement} position + * if the list variable allows unassigned values. + * If the list variable does not allow unassigned values, + * then this method behaves exactly the same as {@link #forEachDestination(PlanningListVariableMetaModel)}. + */ + UniEnumeratingStream + forEachDestinationIncludingUnassigned(PlanningListVariableMetaModel variableMetaModel); + + UniSamplingStream pick(UniEnumeratingStream enumeratingStream); + /** * 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}, * as well as all {@link PlanningPinToIndex pinned indexes}. * If the list variable {@link PlanningListVariable#allowsUnassignedValues() allows unassigned values}, * the resulting stream will include a single instance of {@link UnassignedElement} instance. + *

+ * Will be removed right before this API is moved out of preview. * * @param variableMetaModel the meta model of the list variable to enumerate * @return enumerating stream with all assignable positions of a given list variable * @see ElementPosition Read more about element positions. + * @deprecated Use {@link #forEachDestinationIncludingUnassigned(PlanningListVariableMetaModel)} instead, + * or see if {@link #forEachDestination(PlanningListVariableMetaModel)} + * or {@link #forEachAssignedValue(PlanningListVariableMetaModel)} + * fits your needs better. */ + @Deprecated(forRemoval = true) UniEnumeratingStream forEachAssignablePosition(PlanningListVariableMetaModel variableMetaModel); - UniSamplingStream pick(UniEnumeratingStream enumeratingStream); - } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java deleted file mode 100644 index e97e6a39ae..0000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsBasedLocalSearchTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood; - -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; - -import java.util.HashSet; -import java.util.List; -import java.util.Random; - -import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; -import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; -import ai.timefold.solver.core.config.localsearch.decider.acceptor.LocalSearchAcceptorConfig; -import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchForagerConfig; -import ai.timefold.solver.core.config.solver.EnvironmentMode; -import ai.timefold.solver.core.config.solver.termination.TerminationConfig; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; -import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhase; -import ai.timefold.solver.core.impl.localsearch.decider.LocalSearchDecider; -import ai.timefold.solver.core.impl.localsearch.decider.acceptor.AcceptorFactory; -import ai.timefold.solver.core.impl.localsearch.decider.forager.LocalSearchForagerFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; -import ai.timefold.solver.core.impl.solver.AbstractSolver; -import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; -import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; -import ai.timefold.solver.core.impl.solver.scope.SolverScope; -import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; -import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; -import ai.timefold.solver.core.preview.api.move.builtin.ChangeMoveProvider; -import ai.timefold.solver.core.testdomain.TestdataEntity; -import ai.timefold.solver.core.testdomain.TestdataSolution; -import ai.timefold.solver.core.testdomain.TestdataValue; - -import org.jspecify.annotations.NonNull; -import org.junit.jupiter.api.Test; - -class NeighborhoodsBasedLocalSearchTest { - - @Test - void changeMoveBasedLocalSearch() { - var solutionDescriptor = TestdataSolution.buildSolutionDescriptor(); - var heuristicConfigPolicy = new HeuristicConfigPolicy.Builder() - .withSolutionDescriptor(solutionDescriptor) - .build(); - var termination = (PhaseTermination) TerminationFactory - . create(new TerminationConfig() - .withBestScoreLimit("0")) // All entities are assigned to a particular value. - .buildTermination(heuristicConfigPolicy); - var moveRepository = getMoveRepository(solutionDescriptor); - var acceptor = AcceptorFactory. create(new LocalSearchAcceptorConfig() - .withLateAcceptanceSize(400)) - .buildAcceptor(heuristicConfigPolicy); - var forager = LocalSearchForagerFactory. create(new LocalSearchForagerConfig() - .withAcceptedCountLimit(1)) - .buildForager(); - var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager); - var localSearchPhase = new DefaultLocalSearchPhase.Builder<>(0, "", termination, localSearchDecider) - .build(); - - // Generates a solution whose entities' values are all set to the second value. - // The easy calculator penalizes this. - // The goal of the solver is to get all the entities to be assigned to the first value. - var solution = TestdataSolution.generateSolution(2, 5); - var secondValue = solution.getValueList().get(1); - solution.getEntityList().forEach(e -> e.setValue(secondValue)); - - var scoreDirector = new EasyScoreDirectorFactory<>(solutionDescriptor, new TestingEasyScoreCalculator()) - .buildScoreDirector(); - scoreDirector.setWorkingSolution(solution); - var score = scoreDirector.calculateScore(); - - var bestSolutionRecaller = new BestSolutionRecaller(); - var solver = mock(AbstractSolver.class); - doReturn(List.of(localSearchPhase)).when(solver).getPhaseList(); - doReturn(bestSolutionRecaller).when(solver).getBestSolutionRecaller(); - var solverEventSupport = new SolverEventSupport(solver); - bestSolutionRecaller.setSolverEventSupport(solverEventSupport); - var solverScope = new SolverScope(); - solverScope.setSolver(solver); - solverScope.setWorkingRandom(new Random()); - solverScope.setScoreDirector(scoreDirector); - solverScope.setBestScore(score); - solverScope.setBestSolution(scoreDirector.cloneSolution(solution)); - solverScope.setProblemSizeStatistics(scoreDirector.getValueRangeManager().getProblemSizeStatistics()); - solverScope.startingNow(); - - bestSolutionRecaller.solvingStarted(solverScope); - assertThatCode(() -> localSearchPhase.solve(solverScope)) - .doesNotThrowAnyException(); - } - - private static NeighborhoodsBasedMoveRepository - getMoveRepository(SolutionDescriptor solutionDescriptor) { - var variableMetaModel = solutionDescriptor.getMetaModel() - .genuineEntity(TestdataEntity.class) - .basicVariable(); - var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT); - // Random selection otherwise LS gets stuck in an endless loop. - return new NeighborhoodsBasedMoveRepository<>(moveStreamFactory, - List.of(new ChangeMoveProvider<>(variableMetaModel)), true); - } - - /** - * Penalizes the number of values which are not the first value. - */ - private static final class TestingEasyScoreCalculator implements EasyScoreCalculator { - - @Override - public @NonNull SimpleScore calculateScore(@NonNull TestdataSolution testdataSolution) { - var valueList = testdataSolution.getValueList(); - var firstValue = valueList.get(0); - var valueSet = new HashSet(valueList.size()); - testdataSolution.getEntityList().forEach(e -> { - if (e.getValue() != firstValue) { - valueSet.add(e.getValue()); - } - }); - return SimpleScore.of(-valueSet.size()); - } - - } - -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsNotificationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsNotificationTest.java deleted file mode 100644 index 71192b3178..0000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsNotificationTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood; - -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import java.util.List; - -import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; -import ai.timefold.solver.core.api.score.stream.Constraint; -import ai.timefold.solver.core.config.solver.EnvironmentMode; -import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; -import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; -import ai.timefold.solver.core.preview.api.move.builtin.Moves; -import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListEntity; -import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListSolution; -import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListValue; - -import org.junit.jupiter.api.Test; - -class NeighborhoodsNotificationTest { - - @Test - void notifyWhenValueIndexChangesOnSimpleModel() { - var solution = new TestdataDeclarativeSimpleListSolution(); - var entity1 = new TestdataDeclarativeSimpleListEntity("e1", 0, 0); - var entity2 = new TestdataDeclarativeSimpleListEntity("e2", 0, 0); - - var value1 = new TestdataDeclarativeSimpleListValue("v1", 0, 0); - var value2 = new TestdataDeclarativeSimpleListValue("v2", 0, 0); - var value3 = new TestdataDeclarativeSimpleListValue("v3", 0, 0); - - entity1.getValues().add(value1); - entity2.getValues().add(value2); - entity2.getValues().add(value3); - - solution.setEntityList(List.of(entity1, entity2)); - solution.setValueList(List.of(value1, value2, value3)); - - var neighborhoodMoveRepository = mock(NeighborhoodsBasedMoveRepository.class); - var solutionDescriptor = TestdataDeclarativeSimpleListSolution.buildSolutionDescriptor(); - var solutionMetamodel = solutionDescriptor.getMetaModel(); - var variableMetamodel = solutionMetamodel.genuineEntity(TestdataDeclarativeSimpleListEntity.class) - .listVariable("values", TestdataDeclarativeSimpleListValue.class); - try (var scoreDirector = new BavetConstraintStreamScoreDirectorFactory<>(solutionDescriptor, - constraintFactory -> new Constraint[] { - constraintFactory.forEach(Object.class) - .penalize(SimpleScore.ONE) - .asConstraint("dummy constraint") - }, EnvironmentMode.FULL_ASSERT) - .buildScoreDirector()) { - scoreDirector.setMoveRepository(neighborhoodMoveRepository); - scoreDirector.setWorkingSolution(solution); - scoreDirector.calculateScore(); - - var move = Moves.change(variableMetamodel, ElementPosition.of(entity1, 0), ElementPosition.of(entity2, 0)); - scoreDirector.executeMove(move); - scoreDirector.calculateScore(); - } - - verify(neighborhoodMoveRepository, atLeastOnce()).update(value1); - verify(neighborhoodMoveRepository, atLeastOnce()).update(value2); - verify(neighborhoodMoveRepository, atLeastOnce()).update(value3); - } - -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java new file mode 100644 index 0000000000..54929dfd1c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/NeighborhoodsTest.java @@ -0,0 +1,251 @@ +package ai.timefold.solver.core.impl.neighborhood; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.config.localsearch.decider.acceptor.LocalSearchAcceptorConfig; +import ai.timefold.solver.core.config.localsearch.decider.forager.LocalSearchForagerConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; +import ai.timefold.solver.core.impl.localsearch.DefaultLocalSearchPhase; +import ai.timefold.solver.core.impl.localsearch.decider.LocalSearchDecider; +import ai.timefold.solver.core.impl.localsearch.decider.acceptor.AcceptorFactory; +import ai.timefold.solver.core.impl.localsearch.decider.forager.LocalSearchForagerFactory; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.impl.solver.AbstractSolver; +import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; +import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller; +import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.solver.termination.PhaseTermination; +import ai.timefold.solver.core.impl.solver.termination.TerminationFactory; +import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.builtin.ChangeMoveProvider; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; +import ai.timefold.solver.core.preview.api.move.builtin.SwapMove; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +import ai.timefold.solver.core.preview.api.neighborhood.NeighborhoodEvaluator; +import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListEntity; +import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListSolution; +import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListValue; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +class NeighborhoodsTest { + + @Test + void changeMoveBasedLocalSearch() { + var solutionDescriptor = TestdataSolution.buildSolutionDescriptor(); + var heuristicConfigPolicy = + new HeuristicConfigPolicy.Builder().withSolutionDescriptor(solutionDescriptor).build(); + var termination = (PhaseTermination) TerminationFactory + . create(new TerminationConfig().withBestScoreLimit("0")) // All entities are assigned to a particular value. + .buildTermination(heuristicConfigPolicy); + + var variableMetaModel = solutionDescriptor.getMetaModel().genuineEntity(TestdataEntity.class).basicVariable(); + var moveStreamFactory = new DefaultMoveStreamFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT); + // Random selection otherwise LS gets stuck in an endless loop. + var moveRepository = new NeighborhoodsBasedMoveRepository<>(moveStreamFactory, + List.of(new ChangeMoveProvider<>(variableMetaModel)), true); + + var acceptor = AcceptorFactory. create(new LocalSearchAcceptorConfig().withLateAcceptanceSize(400)) + .buildAcceptor(heuristicConfigPolicy); + var forager = LocalSearchForagerFactory + . create(new LocalSearchForagerConfig().withAcceptedCountLimit(1)).buildForager(); + var localSearchDecider = new LocalSearchDecider<>("", termination, moveRepository, acceptor, forager); + var localSearchPhase = new DefaultLocalSearchPhase.Builder<>(0, "", termination, localSearchDecider).build(); + + // Generates a solution whose entities' values are all set to the second value. + // The easy calculator penalizes this. + // The goal of the solver is to get all the entities to be assigned to the first value. + var solution = TestdataSolution.generateSolution(2, 5); + var secondValue = solution.getValueList().get(1); + solution.getEntityList().forEach(e -> e.setValue(secondValue)); + + var scoreDirector = + new EasyScoreDirectorFactory<>(solutionDescriptor, new TestingEasyScoreCalculator()).buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var score = scoreDirector.calculateScore(); + + var bestSolutionRecaller = new BestSolutionRecaller(); + var solver = mock(AbstractSolver.class); + doReturn(List.of(localSearchPhase)).when(solver).getPhaseList(); + doReturn(bestSolutionRecaller).when(solver).getBestSolutionRecaller(); + var solverEventSupport = new SolverEventSupport(solver); + bestSolutionRecaller.setSolverEventSupport(solverEventSupport); + var solverScope = new SolverScope(); + solverScope.setSolver(solver); + solverScope.setWorkingRandom(new Random()); + solverScope.setScoreDirector(scoreDirector); + solverScope.setBestScore(score); + solverScope.setBestSolution(scoreDirector.cloneSolution(solution)); + solverScope.setProblemSizeStatistics(scoreDirector.getValueRangeManager().getProblemSizeStatistics()); + solverScope.startingNow(); + + bestSolutionRecaller.solvingStarted(solverScope); + assertThatCode(() -> localSearchPhase.solve(solverScope)).doesNotThrowAnyException(); + } + + /** + * Penalizes the number of values which are not the first value. + */ + private static final class TestingEasyScoreCalculator implements EasyScoreCalculator { + + @Override + public @NonNull SimpleScore calculateScore(@NonNull TestdataSolution testdataSolution) { + var valueList = testdataSolution.getValueList(); + var firstValue = valueList.get(0); + var valueSet = new HashSet(valueList.size()); + testdataSolution.getEntityList().forEach(e -> { + if (e.getValue() != firstValue) { + valueSet.add(e.getValue()); + } + }); + return SimpleScore.of(-valueSet.size()); + } + + } + + @Test + void allowsNullValues() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable("value", + TestdataValue.class); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 3); + var firstEntity = solution.getEntityList().get(0); + firstEntity.setValue(solution.getValueList().get(0)); + var secondEntity = solution.getEntityList().get(1); + secondEntity.setValue(null); + var unchangingAssignedEntity = solution.getEntityList().get(2); + firstEntity.setValue(solution.getValueList().get(1)); + + // The above solution, together with the move provider below, should generate 2 swap moves: + // - firstEntity <-> secondEntity + // - unchangingAssignedEntity <-> secondEntity + var context = NeighborhoodEvaluator.build(new SwapAssignedAndUnassigned(variableMetaModel), solutionMetaModel) + .using(solution); + var moveList = + context.getMovesAsList(m -> (SwapMove) m); + assertThat(moveList).hasSize(2); + var move1 = moveList.get(0); + assertSoftly(softly -> { + assertThat(move1.getPlanningEntities()) + .containsExactly(firstEntity, secondEntity); + assertThat(move1.getPlanningValues()) + .containsExactly(firstEntity.getValue(), secondEntity.getValue()); + }); + var move2 = moveList.get(1); + assertSoftly(softly -> { + assertThat(move2.getPlanningEntities()) + .containsExactly(unchangingAssignedEntity, secondEntity); + assertThat(move2.getPlanningValues()) + .containsExactly(unchangingAssignedEntity.getValue(), secondEntity.getValue()); + }); + + // Execute one swap and verify the new moves: + // - unchangingAssignedEntity <-> firstEntity + // - secondEntity <-> firstEntity + context.getMoveRunContext().execute(move1); + moveList = context.getMovesAsList(m -> (SwapMove) m); + assertThat(moveList).hasSize(2); + var move3 = moveList.get(0); + assertSoftly(softly -> { + assertThat(move3.getPlanningEntities()) + .containsExactly(unchangingAssignedEntity, firstEntity); + assertThat(move3.getPlanningValues()) + .containsExactly(unchangingAssignedEntity.getValue(), firstEntity.getValue()); + }); + var move4 = moveList.get(1); + assertSoftly(softly -> { + assertThat(move4.getPlanningEntities()) + .containsExactly(secondEntity, firstEntity); + assertThat(move4.getPlanningValues()) + .containsExactly(secondEntity.getValue(), firstEntity.getValue()); + }); + + } + + private record SwapAssignedAndUnassigned( + PlanningVariableMetaModel variable) + implements + MoveProvider { + + @Override + public MoveStream + build(MoveStreamFactory moveStreamFactory) { + var assignedEntity = moveStreamFactory.forEach(TestdataAllowsUnassignedEntity.class, false) + .filter((solutionView, entity) -> entity.getValue() != null); + var unassignedEntity = moveStreamFactory.forEach(TestdataAllowsUnassignedEntity.class, false) + .filter((solutionView, entity) -> entity.getValue() == null); + + return moveStreamFactory.pick(assignedEntity) + .pick(unassignedEntity) + .asMove((schedule, assigned, unassigned) -> Moves.swap(variable, assigned, unassigned)); + } + } + + @Test + void notifyWhenValueIndexChangesOnSimpleModel() { + var solution = new TestdataDeclarativeSimpleListSolution(); + var entity1 = new TestdataDeclarativeSimpleListEntity("e1", 0, 0); + var entity2 = new TestdataDeclarativeSimpleListEntity("e2", 0, 0); + + var value1 = new TestdataDeclarativeSimpleListValue("v1", 0, 0); + var value2 = new TestdataDeclarativeSimpleListValue("v2", 0, 0); + var value3 = new TestdataDeclarativeSimpleListValue("v3", 0, 0); + + entity1.getValues().add(value1); + entity2.getValues().add(value2); + entity2.getValues().add(value3); + + solution.setEntityList(List.of(entity1, entity2)); + solution.setValueList(List.of(value1, value2, value3)); + + var neighborhoodMoveRepository = mock(NeighborhoodsBasedMoveRepository.class); + var solutionDescriptor = TestdataDeclarativeSimpleListSolution.buildSolutionDescriptor(); + var solutionMetamodel = solutionDescriptor.getMetaModel(); + var variableMetamodel = solutionMetamodel.genuineEntity(TestdataDeclarativeSimpleListEntity.class) + .listVariable("values", TestdataDeclarativeSimpleListValue.class); + try (var scoreDirector = new BavetConstraintStreamScoreDirectorFactory<>(solutionDescriptor, + constraintFactory -> new Constraint[] { + constraintFactory.forEach(Object.class).penalize(SimpleScore.ONE).asConstraint("dummy constraint") }, + EnvironmentMode.FULL_ASSERT).buildScoreDirector()) { + scoreDirector.setMoveRepository(neighborhoodMoveRepository); + scoreDirector.setWorkingSolution(solution); + scoreDirector.calculateScore(); + + var move = Moves.change(variableMetamodel, ElementPosition.of(entity1, 0), ElementPosition.of(entity2, 0)); + scoreDirector.executeMove(move); + scoreDirector.calculateScore(); + } + + verify(neighborhoodMoveRepository, atLeastOnce()).update(value1); + verify(neighborhoodMoveRepository, atLeastOnce()).update(value2); + verify(neighborhoodMoveRepository, atLeastOnce()).update(value3); + } + +}