From 1df8f67e7a036c378ee8e7299a852bcaefd33e3b Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 14:01:23 +0200 Subject: [PATCH 01/16] Implement basic var pillars --- CONSTITUTION.md | 11 +- .../index/MappingRetiringRandomIterator.java | 47 +++ .../common/index/RetiringRandomIterator.java | 13 +- .../DefaultGenuineEntityMetaModel.java | 14 +- .../DefaultPlanningListVariableMetaModel.java | 17 +- .../DefaultPlanningVariableMetaModel.java | 22 +- .../DefaultShadowEntityMetaModel.java | 8 + .../DefaultShadowVariableMetaModel.java | 8 + .../InnerPlanningEntityMetaModel.java | 3 + .../descriptor/SolutionDescriptor.java | 9 +- .../valuerange/NullAllowingValueRange.java | 2 +- .../solver/core/impl/move/MoveDirector.java | 9 +- .../DefaultNeighborhoodProvider.java | 5 +- .../DefaultNeighborhoodTestContext.java | 20 +- .../stream/DefaultMoveStreamFactory.java | 8 +- .../dataset/DefaultUniDatasetInstance.java | 13 + .../dataset/JustInTimeBiDatasetInstance.java | 9 +- .../dataset/sample/SampleAssembler.java | 96 +++++ .../common/AbstractLeftDatasetInstance.java | 4 +- .../DefaultBiPickingStream.java} | 8 +- .../DefaultUniPickingStream.java} | 12 +- .../stream/picking/InnerPickingStream.java | 13 + .../stream/picking/InnerUniPickingStream.java | 15 + .../stream/sampling/InnerSamplingStream.java | 13 - .../sampling/InnerUniSamplingStream.java | 15 - .../metamodel/GenuineVariableMetaModel.java | 13 + .../metamodel/PlanningEntityMetaModel.java | 16 + .../metamodel/ShadowEntityMetaModel.java | 28 +- .../metamodel/ShadowVariableMetaModel.java | 2 + .../domain/metamodel/VariableMetaModel.java | 4 + .../solver/core/preview/api/move/Move.java | 6 + .../core/preview/api/move/SolutionView.java | 37 +- .../api/move/builtin/AssignMoveProvider.java | 11 +- .../preview/api/move/builtin/ChangeMove.java | 17 +- .../api/move/builtin/ChangeMoveProvider.java | 46 ++- .../api/move/builtin/ListAssignMove.java | 2 +- .../move/builtin/ListAssignMoveProvider.java | 2 +- .../api/move/builtin/ListUnassignMove.java | 5 +- .../move/builtin/MassAssignMoveProvider.java | 71 ++++ .../api/move/builtin/MassChangeMove.java | 98 ++++++ .../move/builtin/MassChangeMoveProvider.java | 102 ++++++ .../builtin/MassDestinationMoveIterator.java | 135 +++++++ .../builtin/MassUnassignMoveProvider.java | 119 +++++++ .../api/move/builtin/MoveProviderUtil.java | 293 ++++++++++++++++ .../core/preview/api/move/builtin/Moves.java | 86 ++++- .../builtin/PillarChangeMoveProvider.java | 195 ++++++++++ .../api/move/builtin/PillarSwapMove.java | 181 ++++++++++ .../move/builtin/PillarSwapMoveProvider.java | 182 ++++++++++ .../builtin/PillarUnassignMoveProvider.java | 103 ++++++ .../api/move/builtin/SampleValueRanges.java | 178 ++++++++++ .../builtin/SubPillarChangeMoveProvider.java | 198 +++++++++++ .../builtin/SubPillarSwapMoveProvider.java | 236 +++++++++++++ .../SubPillarUnassignMoveProvider.java | 128 +++++++ .../preview/api/move/builtin/SwapMove.java | 61 ++-- .../api/move/builtin/SwapMoveProvider.java | 71 ++-- .../move/builtin/UnassignMoveProvider.java | 18 +- .../api/move/builtin/package-info.java | 20 ++ .../api/neighborhood/UniMoveConstructor.java | 4 +- .../stream/MoveStreamFactory.java | 6 +- .../stream/dataset/BiDatasetInstance.java | 28 ++ .../stream/dataset/UniDatasetInstance.java | 39 +- .../stream/dataset/sample/DefaultSample.java | 60 ++++ .../stream/dataset/sample/Sample.java | 77 ++++ .../stream/dataset/sample/Sampler.java | 57 +++ .../stream/dataset/sample/package-info.java | 18 + .../enumerating/BiEnumeratingStream.java | 3 + .../enumerating/UniEnumeratingStream.java | 8 +- .../BiPickingStream.java} | 4 +- .../PickingStream.java} | 4 +- .../UniPickingStream.java} | 14 +- core/src/main/java/module-info.java | 3 +- .../core/impl/move/MoveDirectorTest.java | 59 +++- .../neighborhood/bias/AbstractBiasIT.java | 28 +- .../impl/neighborhood/bias/BiasReport.java | 8 +- .../bias/DatasetBucketBiasIT.java | 4 +- .../neighborhood/bias/PairFairnessBiasIT.java | 5 +- .../bias/SamplingIteratorBiasIT.java | 140 ++++++++ .../dataset/sample/SamplingIteratorTest.java | 283 +++++++++++++++ .../move/builtin/ChangeMoveProviderTest.java | 101 +++++- .../api/move/builtin/ChangeMoveTest.java | 22 ++ .../builtin/MassAssignMoveProviderTest.java | 117 ++++++ .../builtin/MassChangeMoveProviderTest.java | 287 +++++++++++++++ .../api/move/builtin/MassChangeMoveTest.java | 192 ++++++++++ .../builtin/MassUnassignMoveProviderTest.java | 116 ++++++ .../builtin/PillarChangeMoveProviderTest.java | 294 ++++++++++++++++ .../builtin/PillarSwapMoveProviderTest.java | 304 ++++++++++++++++ .../api/move/builtin/PillarSwapMoveTest.java | 332 ++++++++++++++++++ .../PillarUnassignMoveProviderTest.java | 148 ++++++++ .../move/builtin/SampleValueRangesBiasIT.java | 79 +++++ .../move/builtin/SampleValueRangesTest.java | 171 +++++++++ .../SubPillarChangeMoveProviderTest.java | 169 +++++++++ .../SubPillarSwapMoveProviderTest.java | 293 ++++++++++++++++ .../SubPillarUnassignMoveProviderTest.java | 107 ++++++ .../move/builtin/SwapMoveProviderTest.java | 76 +++- .../api/move/builtin/SwapMoveTest.java | 20 +- .../stream/dataset/DatasetTest.java | 41 +-- .../stream/dataset/sample/SampleTest.java | 58 +++ .../stream/enumerating/PillarDatasetTest.java | 182 ++++++++++ ...signedMultiVarEntityProvidingSolution.java | 5 + .../core/testutil/NeighborhoodTestUtils.java | 68 ++++ docs/src/modules/ROOT/nav.adoc | 1 + .../neighborhoods.adoc | 62 ++-- .../.upgrading-timefold-solver.adoc | 1 + .../backwards-compatibility.adoc | 2 +- .../upgrade-from-v1.adoc | 2 +- .../upgrade-to-latest.adoc | 102 ++++++ .../solver/migration/ToLatestRecipe.java | 28 +- .../preview/NeighborhoodsMigrationRecipe.java | 40 +++ .../preview/PreviewToLatestRecipe.java | 32 ++ .../migration/preview/package-info.java | 7 + .../migration/{ => v1}/ToLatestV1Recipe.java | 17 +- .../solver/migration/v2/ToLatestV2Recipe.java | 46 +++ .../migration/src/main/java/module-info.java | 1 + .../NeighborhoodsMigrationRecipeTest.java | 99 ++++++ 114 files changed, 7145 insertions(+), 387 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java rename core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/{sampling/DefaultBiSamplingStream.java => picking/DefaultBiPickingStream.java} (71%) rename core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/{sampling/DefaultUniSamplingStream.java => picking/DefaultUniPickingStream.java} (74%) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerPickingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerUniPickingStream.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerSamplingStream.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerUniSamplingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/package-info.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java rename core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/{sampling/BiSamplingStream.java => picking/BiPickingStream.java} (80%) rename core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/{sampling/SamplingStream.java => picking/PickingStream.java} (73%) rename core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/{sampling/UniSamplingStream.java => picking/UniPickingStream.java} (68%) create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java create mode 100644 docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc create mode 100644 tools/migration/src/main/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipe.java create mode 100644 tools/migration/src/main/java/ai/timefold/solver/migration/preview/PreviewToLatestRecipe.java create mode 100644 tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java rename tools/migration/src/main/java/ai/timefold/solver/migration/{ => v1}/ToLatestV1Recipe.java (74%) create mode 100644 tools/migration/src/main/java/ai/timefold/solver/migration/v2/ToLatestV2Recipe.java create mode 100644 tools/migration/src/test/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipeTest.java diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 8dfa32038a6..d4980867a99 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -178,11 +178,12 @@ MUST use: ## Package Structure and API Stability -| Package type | Stability | -|---|---| -| `*.api.*` | 100% backwards compatible; breaking only in major versions | -| `*.config.*` | 100% backwards compatible; breaking only in major versions | -| All others | No guarantees | +| Package type | Stability | +|-------------------|-------------------------------------------------------------------------------| +| `*.api.*` | 100% backwards compatible; breaking only in major versions | +| `*.preview.api.*` | Best-effort backwards compatibility; may break in minor versions if necessary | +| `*.config.*` | 100% backwards compatible; breaking only in major versions | +| All others | No guarantees | **Versioning**: MAJOR = breaking API/config change; MINOR = new backwards-compat feature; PATCH = bug fix. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java new file mode 100644 index 00000000000..7710cb26986 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.impl.bavet.common.index; + +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.function.Function; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Adapts a {@link RetiringRandomIterator} of one type to another, + * by mapping each element through a function, + * without changing which element is retired: + * {@link #retire()} still retires whatever the delegate last handed out, + * keyed by the delegate's own identity, not by the mapped value. + */ +@NullMarked +final class MappingRetiringRandomIterator + implements RetiringRandomIterator { + + private final RetiringRandomIterator delegate; + private final Function mapper; + + MappingRetiringRandomIterator(RetiringRandomIterator delegate, Function mapper) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return mapper.apply(delegate.next()); + } + + @Override + public void retire() { + delegate.retire(); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java index 9ea67e0e2fe..98f977c9979 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java @@ -2,6 +2,7 @@ import java.util.Iterator; import java.util.function.Consumer; +import java.util.function.Function; import java.util.random.RandomGenerator; import ai.timefold.solver.core.impl.util.ElementAwareArrayList; @@ -30,12 +31,22 @@ @NullMarked public sealed interface RetiringRandomIterator extends Iterator - permits DefaultRetiringRandomIterator { + permits DefaultRetiringRandomIterator, MappingRetiringRandomIterator { static RetiringRandomIterator of(ElementAwareArrayList list, RandomGenerator random) { return new DefaultRetiringRandomIterator<>(list, random); } + /** + * Adapts an iterator of one type to another, without changing which element retirement + * targets: {@link #retire()} on the result still retires whatever the delegate itself last + * handed out. + */ + static RetiringRandomIterator mapping( + RetiringRandomIterator delegate, Function mapper) { + return new MappingRetiringRandomIterator<>(delegate, mapper); + } + /** * Returns whether there are any elements left to pick from. * Only turns {@code false} once every element has been retired, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultGenuineEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultGenuineEntityMetaModel.java index 3f05d155923..8a6b035b3b8 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultGenuineEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultGenuineEntityMetaModel.java @@ -2,12 +2,14 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Objects; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; 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.PlanningVariableMetaModel; @@ -20,6 +22,11 @@ public final class DefaultGenuineEntityMetaModel implements GenuineEntityMetaModel, InnerPlanningEntityMetaModel { + static final Comparator> ENTITY_META_MODEL_COMPARATOR = + Comparator.comparingInt( + (PlanningEntityMetaModel entityMetaModel) -> ((InnerPlanningEntityMetaModel) entityMetaModel) + .entityDescriptor().getOrdinal()); + private final EntityDescriptor entityDescriptor; private final PlanningSolutionMetaModel solution; private final Class type; @@ -59,7 +66,7 @@ public GenuineVariableMetaModel genuineVari return switch (genuineVariables.size()) { case 0 -> throw new IllegalStateException("The entity class (%s) has no genuine variables." .formatted(type().getCanonicalName())); - case 1 -> (GenuineVariableMetaModel) genuineVariables.get(0); + case 1 -> (GenuineVariableMetaModel) genuineVariables.getFirst(); default -> throw new IllegalStateException("The entity class (%s) has multiple genuine variables (%s)." .formatted(type().getCanonicalName(), genuineVariables)); }; @@ -182,6 +189,11 @@ public void addVariable(VariableMetaModel variable) { variables.add(variable); } + @Override + public int compareTo(PlanningEntityMetaModel other) { + return ENTITY_META_MODEL_COMPARATOR.compare(this, other); + } + @Override public String toString() { return "Genuine entity (%s) with variables (%s)" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java index eb07df177d2..a55be748ea3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java @@ -1,16 +1,19 @@ package ai.timefold.solver.core.impl.domain.solution.descriptor; +import static ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel.VARIABLE_META_MODEL_COMPARATOR; + import java.util.Objects; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; import org.jspecify.annotations.NullMarked; @NullMarked public record DefaultPlanningListVariableMetaModel( - PlanningEntityMetaModel entity, + GenuineEntityMetaModel entity, ListVariableDescriptor variableDescriptor) implements PlanningListVariableMetaModel, @@ -32,6 +35,11 @@ public boolean allowsUnassignedValues() { return variableDescriptor.allowsUnassignedValues(); } + @Override + public boolean isValueRangeOnSolution() { + return variableDescriptor.canExtractValueRangeFromSolution(); + } + @Override public boolean equals(Object o) { // Do not use entity in equality checks; @@ -48,6 +56,11 @@ public int hashCode() { return Objects.hash(variableDescriptor); } + @Override + public int compareTo(VariableMetaModel other) { + return VARIABLE_META_MODEL_COMPARATOR.compare(this, other); + } + @Override public String toString() { return "Genuine List Variable '%s %s.%s' (allowsUnassignedValues: %b)" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java index 9d1b351e083..e4994f3b9ac 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java @@ -1,21 +1,29 @@ package ai.timefold.solver.core.impl.domain.solution.descriptor; +import java.util.Comparator; import java.util.Objects; import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; import org.jspecify.annotations.NullMarked; @NullMarked public record DefaultPlanningVariableMetaModel( - PlanningEntityMetaModel entity, + GenuineEntityMetaModel entity, BasicVariableDescriptor variableDescriptor) implements PlanningVariableMetaModel, InnerGenuineVariableMetaModel { + static final Comparator> VARIABLE_META_MODEL_COMPARATOR = + Comparator.comparing((VariableMetaModel variableMetaModel) -> variableMetaModel.entity()) + .thenComparingInt( + (VariableMetaModel variableMetaModel) -> ((InnerVariableMetaModel) variableMetaModel) + .variableDescriptor().getOrdinal()); + @SuppressWarnings("unchecked") @Override public Class type() { @@ -32,6 +40,11 @@ public boolean allowsUnassigned() { return variableDescriptor.allowsUnassigned(); } + @Override + public boolean isValueRangeOnSolution() { + return variableDescriptor.canExtractValueRangeFromSolution(); + } + @Override public boolean equals(Object o) { // Do not use entity in equality checks; @@ -48,6 +61,11 @@ public int hashCode() { return Objects.hash(variableDescriptor); } + @Override + public int compareTo(VariableMetaModel other) { + return VARIABLE_META_MODEL_COMPARATOR.compare(this, other); + } + @Override public String toString() { return "Genuine Variable '%s %s.%s' (allowsUnassigned: %b)" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowEntityMetaModel.java index 586bab64df2..7f6a08afaf0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowEntityMetaModel.java @@ -1,11 +1,14 @@ package ai.timefold.solver.core.impl.domain.solution.descriptor; +import static ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultGenuineEntityMetaModel.ENTITY_META_MODEL_COMPARATOR; + import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.ShadowEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.ShadowVariableMetaModel; @@ -61,6 +64,11 @@ public void addVariable(VariableMetaModel variable) { variables.add(shadowVariable); } + @Override + public int compareTo(PlanningEntityMetaModel other) { + return ENTITY_META_MODEL_COMPARATOR.compare(this, other); + } + @Override public String toString() { return "Shadow entity (%s) with shadow variables (%s)" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java index dbfbfcccf55..8602957267c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java @@ -1,10 +1,13 @@ package ai.timefold.solver.core.impl.domain.solution.descriptor; +import static ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel.VARIABLE_META_MODEL_COMPARATOR; + import java.util.Objects; import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.ShadowVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -48,6 +51,11 @@ public int hashCode() { return Objects.hashCode(variableDescriptor); } + @Override + public int compareTo(VariableMetaModel other) { + return VARIABLE_META_MODEL_COMPARATOR.compare(this, other); + } + @Override public String toString() { return "Shadow Variable '%s %s.%s'" diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerPlanningEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerPlanningEntityMetaModel.java index 6850fc92bb2..1cf60c7552e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerPlanningEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/InnerPlanningEntityMetaModel.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.domain.solution.descriptor; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; import org.jspecify.annotations.NullMarked; @@ -10,4 +11,6 @@ sealed interface InnerPlanningEntityMetaModel void addVariable(VariableMetaModel variable); + EntityDescriptor entityDescriptor(); + } \ No newline at end of file diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java index 79174aeffc7..aa0024f3be3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java @@ -65,6 +65,7 @@ import ai.timefold.solver.core.impl.score.director.ScoreDirector; import ai.timefold.solver.core.impl.util.MutableInt; import ai.timefold.solver.core.impl.util.MutableLong; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; import org.jspecify.annotations.NullMarked; @@ -679,13 +680,13 @@ public PlanningSolutionMetaModel getMetaModel() { for (var variableDescriptor : entityDescriptor.getGenuineVariableDescriptorList()) { if (variableDescriptor.isListVariable()) { var listVariableDescriptor = (ListVariableDescriptor) variableDescriptor; - var listVariableMetaModel = - new DefaultPlanningListVariableMetaModel<>(entityMetaModel, listVariableDescriptor); + var listVariableMetaModel = new DefaultPlanningListVariableMetaModel<>( + (GenuineEntityMetaModel) entityMetaModel, listVariableDescriptor); entityMetaModel.addVariable(listVariableMetaModel); } else { var basicVariableDescriptor = (BasicVariableDescriptor) variableDescriptor; - var basicVariableMetaModel = - new DefaultPlanningVariableMetaModel<>(entityMetaModel, basicVariableDescriptor); + var basicVariableMetaModel = new DefaultPlanningVariableMetaModel<>( + (GenuineEntityMetaModel) entityMetaModel, basicVariableDescriptor); entityMetaModel.addVariable(basicVariableMetaModel); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/NullAllowingValueRange.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/NullAllowingValueRange.java index 4f188734e56..5b6e362c40b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/NullAllowingValueRange.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/NullAllowingValueRange.java @@ -25,7 +25,7 @@ public NullAllowingValueRange(ValueRange childValueRange) { size = childValueRange.getSize() + 1L; } - AbstractValueRange getChildValueRange() { + public AbstractValueRange getChildValueRange() { return childValueRange; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index db5bbe0ad5b..068df43bec7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -8,6 +8,7 @@ import java.util.function.Function; import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningListVariableMetaModel; @@ -383,15 +384,15 @@ public void swapValuesInList(PlanningListVariableMetaModel boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, - @Nullable Entity_ entity, @Nullable Value_ value) { + public ValueRange + getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity) { var innerGenuineVariableMetaModel = (InnerGenuineVariableMetaModel) variableMetaModel; var valueRangeDescriptor = innerGenuineVariableMetaModel.variableDescriptor().getValueRangeDescriptor(); if (valueRangeDescriptor.canExtractValueRangeFromSolution()) { - return backingScoreDirector.getValueRangeManager().getFromSolution(valueRangeDescriptor).contains(value); + return backingScoreDirector.getValueRangeManager().getFromSolution(valueRangeDescriptor); } else { return backingScoreDirector.getValueRangeManager() - .getFromEntity(valueRangeDescriptor, Objects.requireNonNull(entity)).contains(value); + .getFromEntity(valueRangeDescriptor, Objects.requireNonNull(entity)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java index 695ffe35aa6..c0338a240cc 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java @@ -39,7 +39,10 @@ public Neighborhood defineNeighborhood(NeighborhoodBuilder builder) { } } else if (variableMetaModel instanceof PlanningVariableMetaModel basicVariableMetaModel) { hasBasicVariable = true; - builder.add(new ChangeMoveProvider<>(basicVariableMetaModel)); + // ChangeMoveProvider's crossingNull=false: + // AssignMoveProvider/UnassignMoveProvider below already cover those moves, + // at a much higher rate than ChangeMoveProvider's flag would. + builder.add(new ChangeMoveProvider<>(basicVariableMetaModel, false)); if (basicVariableMetaModel.allowsUnassigned()) { builder.add(new AssignMoveProvider<>(basicVariableMetaModel)); builder.add(new UnassignMoveProvider<>(basicVariableMetaModel)); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java index ce7f12d421c..edf199150de 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java @@ -7,10 +7,10 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Predicate; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; -import ai.timefold.solver.core.impl.solver.random.RandomSource; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.test.MoveTestContext; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodMoveAsserter; @@ -84,19 +84,27 @@ public void producesNoneOf(Move... unexpectedMoves) { } /** - * Repeatedly draws moves from a fresh, seeded - * {@link NeighborhoodsBasedMoveRepository#iterator(java.util.random.RandomGenerator)} - * (restarting whenever it exhausts) until either {@code stopCondition} returns true, the iteration limit is - * reached, or an entire pass draws nothing. + * Repeatedly draws moves from {@link NeighborhoodsBasedMoveRepository#iterator(RandomGenerator)} + * (restarting whenever it exhausts) + * until either {@code stopCondition} returns true, + * the iteration limit is + * reached, + * or an entire pass draws nothing. + *

+ * Every pass shares the same {@link RandomGenerator} + * (seeded once via {@link DefaultNeighborhoodTester#using}) + * so that a restart advances to a new, still fully reproducible draw sequence + * instead of replaying the exhausted pass identically forever. * * @return whether {@code stopCondition} was met */ private boolean draw(Predicate> stopCondition) { + var random = phaseScope.getWorkingRandom().moveIteratorUsage(); var draws = 0; while (draws < iterationLimit) { var stepScope = new LocalSearchStepScope<>(phaseScope); moveRepository.stepStarted(stepScope); - var iterator = moveRepository.iterator(RandomSource.seeded(0L).moveIteratorUsage()); + var iterator = moveRepository.iterator(random); var drewAnythingThisPass = false; while (iterator.hasNext() && draws < iterationLimit) { var move = iterator.next(); 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 b7cf007c3d2..b52b66fd646 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 @@ -11,7 +11,7 @@ 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.uni.AbstractUniEnumeratingStream; -import ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultUniSamplingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.picking.DefaultUniPickingStream; import ai.timefold.solver.core.impl.score.director.SessionContext; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; @@ -26,7 +26,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsMapper; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsPredicate; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsPredicate; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; import org.jspecify.annotations.NullMarked; @@ -166,8 +166,8 @@ public UniEnumeratingStream forEachUnfiltered(Class sourceC } @Override - public UniSamplingStream pick(UniEnumeratingStream enumeratingStream) { - return new DefaultUniSamplingStream<>( + public UniPickingStream pick(UniEnumeratingStream enumeratingStream) { + return new DefaultUniPickingStream<>( ((AbstractUniEnumeratingStream) enumeratingStream).asCachedDataset()); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/DefaultUniDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/DefaultUniDatasetInstance.java index 02c46410650..0ffa23d8fbd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/DefaultUniDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/DefaultUniDatasetInstance.java @@ -6,7 +6,9 @@ import java.util.function.Consumer; import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractLeftDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDatasetInstance; @@ -37,6 +39,17 @@ public int size() { return new FactIterator<>(delegate.exhaustiveIterator(random)); } + /** + * Exposed for pillar move iterators, + * which need to retire a dead left value through {@link RetiringBiWalk}. + * Not part of {@link UniDatasetInstance}: + * it returns an impl type this dataset instance happens to wrap, + * and every pillar provider already downcasts to this class regardless. + */ + public RetiringRandomIterator retiringRandomIterator(RandomGenerator random) { + return RetiringRandomIterator.mapping(delegate.retiringRandomIterator(random), UniTuple::getA); + } + /** * Maps a tuple iterator to its fact. * Uniqueness or endlessness are entirely a property of the wrapped {@code tupleIterator}; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java index b6d5b354199..2294c9a3d5c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java @@ -120,8 +120,8 @@ public void forEachRemaining(Consumer action) { } /** - * Holds the pending/current-tuple state and the retirement walk shared by - * {@link RepeatingRandomBiIterator} and {@link UniqueRandomBiIterator}: + * Holds the pending/current-tuple state and the retirement walk + * shared by {@link RepeatingRandomBiIterator} and {@link UniqueRandomBiIterator}: * {@code retire()} is never called by either subclass directly, * it happens entirely inside {@link RetiringBiWalk#advance}, * driven by {@link #leftTupleIterator}. @@ -207,8 +207,9 @@ public Iterator> createRightIterator(UniTuple leftTuple) { if (filter == null) { return rightTupleIterator; } - // RetiringBiWalk.advance() retries this call up to PROBE_ATTEMPT_COUNT times before retiring the - // left, since a single bail-out is a false negative, not proof of emptiness. + // RetiringBiWalk.advance() retries this call + // up to PROBE_ATTEMPT_COUNT times before retiring the left, + // since a single bail-out is a false negative, not proof of emptiness. var bailOutSize = rightDatasetInstance.size(compositeKey) * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; return new FilteringIterator<>(rightTupleIterator, rightTuple -> filter.test(solutionView, leftTuple.getA(), rightTuple.getA()), bailOutSize); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java new file mode 100644 index 00000000000..a951fc806c6 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -0,0 +1,96 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; + +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.*; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.function.Supplier; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class SampleAssembler { + + /** + * Assembles one sample, eagerly. + *

+ * The source iterator must be an exhaustive (without-replacement) iterator: + * it retires every element it hands out, so a candidate is offered to a given sample exactly once, + * and it has no bail-out, so it does not report "nothing left" while elements remain. + * + * @return null if the source is empty + */ + public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, Sampler sampler) { + if (!sourceIterator.hasNext()) { + return null; + } + // The first draw is always accepted and seeds the sample. + var seed = sourceIterator.next(); + var memberList = new ArrayList<@Nullable A>(); + memberList.add(seed); + sampler.reset(seed); + while (sourceIterator.hasNext()) { + var candidate = sourceIterator.next(); + var decision = sampler.evaluate(memberList.size(), candidate); + if (decision == ACCEPT || decision == ACCEPT_AND_STOP) { + memberList.add(candidate); + } + if (decision == STOP || decision == ACCEPT_AND_STOP) { + break; + } + } + return Sample.of(memberList); + } + + /** + * Samples with replacement: + * never ends unless the source is empty; + * may return equal samples. + * Each sample is assembled in full before it is returned, + * so the sampler is never left mid-sample. + *

+ * {@link Iterator#hasNext()} does not call {@code size()}, + * which is documented as potentially very expensive; + * it assembles the next sample instead. + * + * @param sourceSupplier builds a fresh exhaustive iterator per sample, + * so retirement is local to one sample and the next sample starts from a full source + */ + public static Iterator> iterator(Supplier> sourceSupplier, + Sampler sampler) { + return new Iterator<>() { + + private @Nullable Sample pendingPillar = null; + + @Override + public boolean hasNext() { + if (pendingPillar == null) { + pendingPillar = assemble(sourceSupplier.get(), sampler); + } + return pendingPillar != null; + } + + @Override + public Sample next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var pillar = Objects.requireNonNull(pendingPillar); + pendingPillar = null; + return pillar; + } + + }; + } + + private SampleAssembler() { + // No external instances. + } + +} 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 53f7d3d84de..8e6d520ab78 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 @@ -55,8 +55,8 @@ public void retract(Tuple_ tuple) { } /** - * Not part of {@link UniDatasetInstance}: only satisfies {@link Iterable}, for callers - * (such as {@code JustInTimeBiDatasetInstance#size()}) that need a plain, + * Not part of {@link UniDatasetInstance}: only satisfies {@link Iterable}, + * for callers (such as {@code JustInTimeBiDatasetInstance#size()}) that need a plain, * non-random walk internally. */ @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/DefaultBiPickingStream.java similarity index 71% rename from core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/DefaultBiPickingStream.java index eb605266aed..97a26b799e9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultBiSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/DefaultBiPickingStream.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.sampling; +package ai.timefold.solver.core.impl.neighborhood.stream.picking; import java.util.Objects; @@ -7,17 +7,17 @@ import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniRightDataset; import ai.timefold.solver.core.preview.api.neighborhood.BiMoveConstructor; import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStream; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.BiSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.BiPickingStream; import org.jspecify.annotations.NullMarked; @NullMarked -public final class DefaultBiSamplingStream implements BiSamplingStream { +public final class DefaultBiPickingStream implements BiPickingStream { private final UniLeftDataset leftDataset; private final UniRightDataset rightDataset; - public DefaultBiSamplingStream(UniLeftDataset leftDataset, UniRightDataset rightDataset) { + public DefaultBiPickingStream(UniLeftDataset leftDataset, UniRightDataset rightDataset) { this.leftDataset = Objects.requireNonNull(leftDataset); this.rightDataset = Objects.requireNonNull(rightDataset); } 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/picking/DefaultUniPickingStream.java similarity index 74% rename from core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/DefaultUniSamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/DefaultUniPickingStream.java index e5380f5a6a1..fc51cfaac0b 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/picking/DefaultUniPickingStream.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.sampling; +package ai.timefold.solver.core.impl.neighborhood.stream.picking; import java.util.Objects; @@ -10,16 +10,16 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStream; import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.BiNeighborhoodsJoiner; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.BiSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.BiPickingStream; import org.jspecify.annotations.NullMarked; @NullMarked -public final class DefaultUniSamplingStream implements InnerUniSamplingStream { +public final class DefaultUniPickingStream implements InnerUniPickingStream { private final UniLeftDataset dataset; - public DefaultUniSamplingStream(UniLeftDataset dataset) { + public DefaultUniPickingStream(UniLeftDataset dataset) { this.dataset = Objects.requireNonNull(dataset); } @@ -29,10 +29,10 @@ public UniLeftDataset getDataset() { } @Override - public BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + public BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream, BiNeighborhoodsJoiner... joiners) { var comber = BiNeighborhoodsJoinerComber. comb(joiners); - return new DefaultBiSamplingStream<>(dataset, + return new DefaultBiPickingStream<>(dataset, ((AbstractUniEnumeratingStream) uniEnumeratingStream).asCachedDataset(comber)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerPickingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerPickingStream.java new file mode 100644 index 00000000000..273dbdf1662 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerPickingStream.java @@ -0,0 +1,13 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.picking; + +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.PickingStream; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface InnerPickingStream extends PickingStream { + + AbstractDataset getDataset(); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerUniPickingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerUniPickingStream.java new file mode 100644 index 00000000000..55bd3a09290 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/picking/InnerUniPickingStream.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.picking; + +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniLeftDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface InnerUniPickingStream + extends InnerPickingStream, UniPickingStream { + + @Override + UniLeftDataset getDataset(); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerSamplingStream.java deleted file mode 100644 index 431dca166f7..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerSamplingStream.java +++ /dev/null @@ -1,13 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.sampling; - -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractDataset; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.SamplingStream; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public interface InnerSamplingStream extends SamplingStream { - - AbstractDataset getDataset(); - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerUniSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerUniSamplingStream.java deleted file mode 100644 index 36e5d7a9f24..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/sampling/InnerUniSamplingStream.java +++ /dev/null @@ -1,15 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.sampling; - -import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniLeftDataset; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; - -import org.jspecify.annotations.NullMarked; - -@NullMarked -public interface InnerUniSamplingStream - extends InnerSamplingStream, UniSamplingStream { - - @Override - UniLeftDataset getDataset(); - -} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java index 1545e733461..958651c71d3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/GenuineVariableMetaModel.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.domain.metamodel; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import org.jspecify.annotations.NullMarked; @@ -39,4 +41,15 @@ default boolean isListVariable() { return this instanceof PlanningListVariableMetaModel; } + /** + * Whether this variable's value range is declared on {@link PlanningSolution}, + * as opposed to on the {@link PlanningEntity entity}. + * + * @return true if the value range is on the solution, false if it is on the entity + */ + boolean isValueRangeOnSolution(); + + @Override + GenuineEntityMetaModel entity(); + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningEntityMetaModel.java index 07a407d7e77..b3b0c17963f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PlanningEntityMetaModel.java @@ -10,6 +10,9 @@ * Represents the meta-model of an entity. * Gives access to the entity's variable meta-models. *

+ * Instances are {@link Comparable}, + * so that they can be sorted in a predictable iteration order. + *

* This package and all of its contents are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. * There are no guarantees for backward compatibility; @@ -27,6 +30,7 @@ */ @NullMarked public sealed interface PlanningEntityMetaModel + extends Comparable> permits GenuineEntityMetaModel, ShadowEntityMetaModel { /** @@ -50,6 +54,18 @@ public sealed interface PlanningEntityMetaModel */ List> variables(); + /** + * Returns the genuine variables declared by the entity. + * + * @return Genuine variables declared by the entity. + */ + default List> genuineVariables() { + return variables().stream() + .filter(VariableMetaModel::isGenuine) + .map(v -> (GenuineVariableMetaModel) v) + .toList(); + } + /** * Returns a {@link VariableMetaModel} for a variable with the given name. * For {@link GenuineEntityMetaModel genuine entities}, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java index 8833137dfef..b8a0cc58346 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java @@ -24,7 +24,8 @@ * @param The entity type. */ @NullMarked -public non-sealed interface ShadowEntityMetaModel extends PlanningEntityMetaModel { +public non-sealed interface ShadowEntityMetaModel + extends PlanningEntityMetaModel { /** * Returns the shadow variables declared by the entity. @@ -39,16 +40,10 @@ public non-sealed interface ShadowEntityMetaModel extends Pl * * @return A variable declared by the entity. */ - @SuppressWarnings("unchecked") @Override default ShadowVariableMetaModel variable(String variableName) { - for (var variableMetaModel : variables()) { - if (variableMetaModel.name().equals(variableName)) { - return (ShadowVariableMetaModel) variableMetaModel; - } - } - throw new IllegalArgumentException( - "The variableName (%s) does not exist in the variables (%s).".formatted(variableName, variables())); + return (ShadowVariableMetaModel) PlanningEntityMetaModel.super. variable( + variableName); } /** @@ -57,22 +52,11 @@ default ShadowVariableMetaModel variable(St * * @return A variable declared by the entity. */ - @SuppressWarnings("unchecked") @Override default ShadowVariableMetaModel variable(String variableName, Class variableClass) { - for (var variableMetaModel : variables()) { - if (variableMetaModel.name().equals(variableName)) { - if (!variableClass.isAssignableFrom(variableMetaModel.type())) { - throw new IllegalArgumentException( - "The variableName (%s) exists among variables (%s) but is not of type (%s).".formatted(variableName, - variables(), variableClass.getCanonicalName())); - } - return (ShadowVariableMetaModel) variableMetaModel; - } - } - throw new IllegalArgumentException( - "The variableName (%s) does not exist in the variables (%s).".formatted(variableName, variables())); + return (ShadowVariableMetaModel) PlanningEntityMetaModel.super.variable(variableName, + variableClass); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java index 3f980ebf9c0..3d0f03ed0d3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java @@ -31,4 +31,6 @@ public non-sealed interface ShadowVariableMetaModel extends VariableMetaModel { + @Override + PlanningEntityMetaModel entity(); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java index 0f8633afbca..68d7210b564 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java @@ -6,6 +6,9 @@ * Describes a variable in the domain model. * See extending interfaces for more specific types of variables. *

+ * Instances are {@link Comparable}, + * so that they can be sorted in a predictable iteration order. + *

* This package and all of its contents are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. * There are no guarantees for backward compatibility; @@ -24,6 +27,7 @@ */ @NullMarked public sealed interface VariableMetaModel + extends Comparable> permits GenuineVariableMetaModel, ShadowVariableMetaModel { /** diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java index 66b56fd4955..861b53554ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java @@ -30,6 +30,12 @@ * in a way which can distinguish individual move instances from each other * and only marks moves as equal if they result in the exact same solution. * This is used by several features, such as tabu search and {@link NeighborhoodTester}. + *

+ * Moves are created and executed often, + * which means their runtime performance is critical to the overall performance of the solver. + * Treat their methods as you would treat any other code on the hot path - + * avoid any work that does not need doing, especially expensive validation. + * Prefer doing that work during move selection, not during move execution. * * @param * @see MoveTester How to test {@link Move}s. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index 2f346c7a720..00354256437 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -4,6 +4,7 @@ import ai.timefold.solver.core.api.domain.entity.PlanningPin; import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; @@ -120,14 +121,20 @@ boolean isPinned(PlanningListVariableMetaModel generic type of the entity that the variable is defined on * @param generic type of the value that the variable can take + * @return true if the value is acceptable for the variable * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution; * use {@link #isValueInRange(GenuineVariableMetaModel, Object, Object)} to provide the entity instance. */ default boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Value_ value) { + if (!variableMetaModel.isValueRangeOnSolution()) { + throw new IllegalArgumentException(""" + The variableMetaModel (%s) has a value range on an entity, not on the solution. + Maybe use isValueInRange(GenuineVariableMetaModel, Object, Object) to provide the entity instance.""" + .formatted(variableMetaModel)); + } return isValueInRange(variableMetaModel, null, value); } @@ -140,12 +147,34 @@ default boolean isValueInRange(GenuineVariableMetaModel generic type of the entity that the variable is defined on + * @param generic type of the value that the variable can take * @return true if the value is acceptable for the variable + */ + default boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity, @Nullable Value_ value) { + return getValueRange(variableMetaModel, entity) + .contains(value); + } + + /** + * Returns the value range of a genuine planning variable, as it applies to the given entity. + * If the value range is defined on {@link PlanningSolution} rather than on the entity, + * the same range is returned regardless of which entity is given. + *

+ * The returned range may include {@code null} if the variable + * {@link PlanningVariable#allowsUnassigned() allows unassigned} values; + * use {@link ValueRange#contains(Object)} to check {@code null} the same way as any other value. + * + * @param variableMetaModel variable in question + * @param entity entity that the value would be applied to; + * must be of a type that the variable is defined on; + * may be null only if the value range is defined on {@link PlanningSolution} * @param generic type of the entity that the variable is defined on * @param generic type of the value that the variable can take - * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution, and the entity is null + * @return the value range of the variable */ - boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, - @Nullable Entity_ entity, @Nullable Value_ value); + ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, + @Nullable Entity_ entity); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java index f2358423a3a..e3b61fd09e8 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java @@ -15,11 +15,18 @@ * Assigns a non-null value to each entity whose planning variable is currently unassigned (null). * Only generates moves where the value is in range for that entity. *

- * For moving an already-assigned entity to a different non-null value, see {@link ChangeMoveProvider}. - * For moving an already-assigned entity to null (unassigning), see {@code UnassignMoveProvider}. + * {@link ChangeMoveProvider} makes this same move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only when the randomly drawn entity happens to already be unassigned, + * so it arrives rarely. + * This class exists to make it happen often. *

* Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @see ChangeMoveProvider Moving an already-assigned entity to a different value. + * @see UnassignMoveProvider Moving an already-assigned entity to null (unassigning). + * @see MassAssignMoveProvider Assigning several unassigned entities at once. */ @NullMarked public final class AssignMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java index 3b450ceacd3..69de7dacedd 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java @@ -22,24 +22,27 @@ * @param the variable type, the type of the property with the {@link PlanningVariable} annotation */ @NullMarked -public class ChangeMove extends AbstractMove { +public final class ChangeMove extends AbstractMove { - protected final PlanningVariableMetaModel variableMetaModel; - protected final Entity_ entity; - protected final @Nullable Value_ toPlanningValue; + private final PlanningVariableMetaModel variableMetaModel; + private final Entity_ entity; + private final @Nullable Value_ toPlanningValue; private @Nullable Value_ currentValue; + private boolean currentValueCached = false; - protected ChangeMove(PlanningVariableMetaModel variableMetaModel, Entity_ entity, + ChangeMove(PlanningVariableMetaModel variableMetaModel, Entity_ entity, @Nullable Value_ toPlanningValue) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.entity = Objects.requireNonNull(entity); this.toPlanningValue = toPlanningValue; } - protected @Nullable Value_ getValue() { - if (currentValue == null) { + @Nullable + private Value_ getValue() { + if (!currentValueCached) { currentValue = getVariableDescriptor(variableMetaModel).getValue(entity); + currentValueCached = true; } return currentValue; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java index 76d18c9344f..3adb8d324a2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java @@ -12,18 +12,52 @@ import org.jspecify.annotations.NullMarked; /** - * For each entity with a non-null value, creates a move to assign it a different non-null value. - * Null-to-non-null (assign) moves are handled by {@code AssignMoveProvider}. - * Non-null-to-null (unassign) moves are handled by {@code UnassignMoveProvider}. + * For each entity with a non-null value, + * creates a move to change it to a different non-null value. + *

+ * When {@code crossingNull} is {@code true} + * (the default whenever the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned values}), + * this provider also creates null-to-non-null (assign) and non-null-to-null (unassign) moves. + * This does not remove the need for {@code AssignMoveProvider} and {@code UnassignMoveProvider}: + * here, a null-crossing move is one candidate among many, so it arrives rarely - + * an unassign draw has probability {@code 1/(v+1)} where {@code v} is the number of registered values, + * and an assign draw only when the randomly drawn entity happens to already be unassigned. + * A configuration that wants such moves often should add {@code AssignMoveProvider}/{@code UnassignMoveProvider} + * in addition to turning this flag off to avoid further oversampling. + *

+ * For changing several entities at once, see {@link PillarChangeMoveProvider} and + * {@link SubPillarChangeMoveProvider}; + * members share a value; unassign only, never assign - + * a pillar's key is a value, and unassigned is the absence of one or {@link MassChangeMoveProvider} + * (members need not share anything, and may be unassigned). */ @NullMarked -public class ChangeMoveProvider +public final class ChangeMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; + private final boolean crossingNull; public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this(variableMetaModel, variableMetaModel.allowsUnassigned()); + } + + /** + * @param crossingNull if {@code true}, also creates assign and unassign moves; + * requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public ChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + boolean crossingNull) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (crossingNull && !variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; } @Override @@ -31,11 +65,11 @@ public MoveStream build(MoveStreamFactory moveStreamFactor var nodeSharingSupportFunctions = ((DefaultMoveStreamFactory) moveStreamFactory).getNodeSharingSupportFunctions(variableMetaModel); var entities = moveStreamFactory.forEach(variableMetaModel.entity().type(), false); - if (variableMetaModel.allowsUnassigned()) { + if (!crossingNull && variableMetaModel.allowsUnassigned()) { entities = entities.filter(nodeSharingSupportFunctions.assignedValueFilter()); } return moveStreamFactory.pick(entities) - .pick(moveStreamFactory.forEach(variableMetaModel.type(), false), + .pick(moveStreamFactory.forEach(variableMetaModel.type(), crossingNull), NeighborhoodsJoiners.filtering(nodeSharingSupportFunctions.differentValueFilter()), NeighborhoodsJoiners.filtering(nodeSharingSupportFunctions.valueInRangeFilter())) .asMove((solution, entity, value) -> Moves.change(variableMetaModel, Objects.requireNonNull(entity), value)); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java index ed15925f27e..a22697b6846 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java @@ -20,7 +20,7 @@ public class ListAssignMove extends AbstractMove variableMetaModel, Value_ planningValue, + protected ListAssignMove(PlanningListVariableMetaModel variableMetaModel, Value_ planningValue, Entity_ destinationEntity, int destinationIndex) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.planningValue = Objects.requireNonNull(planningValue); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java index e30fc2fa2db..4fb468771f6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java @@ -22,7 +22,7 @@ * otherwise the constructor throws {@link IllegalArgumentException}. */ @NullMarked -public final class ListAssignMoveProvider +public class ListAssignMoveProvider implements MoveProvider { private final PlanningListVariableMetaModel variableMetaModel; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java index 15fc0132522..e9637a69dd3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java @@ -15,7 +15,7 @@ import org.jspecify.annotations.Nullable; @NullMarked -public final class ListUnassignMove extends AbstractMove { +public class ListUnassignMove extends AbstractMove { private final PlanningListVariableMetaModel variableMetaModel; private final Entity_ sourceEntity; @@ -23,7 +23,8 @@ public final class ListUnassignMove extends Abstract private @Nullable Value_ unassignedValue; - ListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Entity_ sourceEntity, + protected ListUnassignMove(PlanningListVariableMetaModel variableMetaModel, + Entity_ sourceEntity, int sourceIndex) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.sourceEntity = Objects.requireNonNull(sourceEntity); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java new file mode 100644 index 00000000000..e9f0d95d161 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java @@ -0,0 +1,71 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Objects; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, out of the entities + * whose given basic planning variable is currently unassigned (null), + * and creates a move to assign every member to the same non-null destination value, + * one that is legal for every member. + * Members need not share anything beyond currently being unassigned; + * unlike the pillar family, this draws with no grouping key. + *

+ * {@link MassChangeMoveProvider} makes this same kind of move too, + * whenever its own {@code crossingNull} is {@code true}, + * but only as a side effect of a mixed-value sample happening to include an unassigned member, + * so it arrives rarely. + * This class exists to make it happen often. + *

+ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; + * otherwise the constructor throws {@link IllegalArgumentException}. + *

+ * Samples of size less than 2 are excluded: {@link AssignMoveProvider} already covers them, + * more cheaply. + * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * use {@link MassUnassignMoveProvider} or a sampler that grows past the seed. + * + * @see MassChangeMoveProvider Mixed-value sample that may include already-assigned entities. + * @see MassUnassignMoveProvider Unassigning an already-assigned sample. + * @see AssignMoveProvider Assigning a single entity at a time. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class MassAssignMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + + public MassAssignMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + this.sampler = Objects.requireNonNull(sampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var unassignedEntityDataset = moveStreamFactory.forEach(variableMetaModel.entity().type(), false) + .filter((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity) == null) + .asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new MassDestinationMoveIterator<>(session, random, + variableMetaModel, unassignedEntityDataset, sampler, false)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java new file mode 100644 index 00000000000..0d71e92b10e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java @@ -0,0 +1,98 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Changes the value of a {@link PlanningVariable} on every member of a {@link Sample} at once. + * An assign is a move whose members currently hold null; + * an unassign is a move whose {@link #toPlanningValue} is null. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + * @param the variable type, the type of the property with the {@link PlanningVariable} annotation + */ +@NullMarked +public final class MassChangeMove extends AbstractMove { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sample sample; + private final @Nullable Value_ toPlanningValue; + + MassChangeMove(PlanningVariableMetaModel variableMetaModel, + Sample sample, @Nullable Value_ toPlanningValue) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sample = Objects.requireNonNull(sample); + this.toPlanningValue = toPlanningValue; + } + + @Override + public List> variableMetaModels() { + return List.of(variableMetaModel); + } + + @Override + public void execute(MutableSolutionView solutionView) { + for (var entity : sample) { + solutionView.changeVariable(variableMetaModel, Objects.requireNonNull(entity), toPlanningValue); + } + } + + @Override + public MassChangeMove rebase(Lookup lookup) { + var rebasedEntityList = new ArrayList(sample.size()); + for (var entity : sample) { + rebasedEntityList.add(lookup.lookUpWorkingObject(entity)); + } + return new MassChangeMove<>(variableMetaModel, Sample.of(rebasedEntityList), + lookup.lookUpWorkingObject(toPlanningValue)); + } + + @Override + public SequencedCollection getPlanningEntities() { + var entityList = new ArrayList(sample.size()); + for (var entity : sample) { + entityList.add(entity); + } + return entityList; + } + + @Override + public SequencedCollection<@Nullable Object> getPlanningValues() { + return Collections.singletonList(toPlanningValue); + } + + @Override + public boolean equals(Object o) { + return o instanceof MassChangeMove other + && Objects.equals(variableMetaModel, other.variableMetaModel) + && Objects.equals(sample, other.sample) + && Objects.equals(toPlanningValue, other.toPlanningValue); + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModel, sample, toPlanningValue); + } + + @Override + public String toString() { + return sample + " -> " + toPlanningValue; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java new file mode 100644 index 00000000000..1d58e92b4de --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java @@ -0,0 +1,102 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Objects; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, + * out of the entities of the given basic planning variable's declaring class, + * and creates a move to change every member's value to a different value that is legal for every member. + * Members need not share a value. + * A sample that happens to hold a single shared value is legal too; + * its own value is never offered as a destination. + *

+ * When {@code crossingNull} is {@code true} + * (the default whenever the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned values}), + * This provider's source also admits unassigned entities, + * so a drawn sample may contain them and get them assigned as a side effect - + * a side effect, not a directed draw, so its rate follows the fraction of entities currently unassigned. + * The same flag also lets a drawn sample be unassigned as a whole - + * probability {@code 1/(s+1)}, where {@code s} is the size of the sample members' value range. + * When {@code false}, the source excludes unassigned entities and no unassign move is produced either: + * for more assign/unassign moves at a much higher rate, + * use {@link MassAssignMoveProvider}/{@link MassUnassignMoveProvider}. + *

+ * A mixed-value sample can produce a move that leaves some members unchanged; + * this is intentional. + * {@link MoveProviderUtil#sharedValueOf} excludes a destination only when + * every member of the sample already agrees on it; + * for a mixed sample it returns {@code null}, and {@code null} excludes nothing, + * so the destination may land on a value some (but not all) members already hold. + * Excluding per member instead is rejected on purpose: + * it would also block a legitimate move that collects a scattered sample onto a value one member holds already, + * it would need as many exclusions as there are members instead of one, + * and it would break the destination iterator's fast path. + * Each unchanged member still pays a full variable-change notification and shadow-variable recalculation, + * in the move and in its undo; + * that cost is accepted as the price of a single move over an otherwise arbitrary sample. + *

+ * For the pillar family, where members are keyed on a shared value, + * see {@link ChangeMoveProvider}, {@link PillarChangeMoveProvider} and {@link SubPillarChangeMoveProvider}. + *

+ * Samples of size less than 2 are excluded: + * {@link ChangeMoveProvider}/{@link AssignMoveProvider} already cover them, more cheaply. + * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * use {@link MassUnassignMoveProvider} or a sampler that grows past the seed. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class MassChangeMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + private final boolean crossingNull; + + public MassChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler) { + this(variableMetaModel, sampler, variableMetaModel.allowsUnassigned()); + } + + /** + * @param crossingNull if {@code true}, the source admits unassigned entities + * (so a drawn sample may get them assigned) + * and a drawn sample may be unassigned as a whole; + * requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public MassChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sampler = Objects.requireNonNull(sampler); + if (crossingNull && !variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var sourceDataset = crossingNull + ? MoveProviderUtil.allEntities(moveStreamFactory, variableMetaModel) + : MoveProviderUtil.assignedEntityDataset(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new MassDestinationMoveIterator<>(session, random, + variableMetaModel, sourceDataset, sampler, crossingNull)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java new file mode 100644 index 00000000000..b603d2ff9f7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java @@ -0,0 +1,135 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws {@link Sample}s of size 2 or more from {@code sourceDataset}, + * with no grouping key, + * and pairs each with a destination value legal for every member, + * producing a {@code MassChangeMove}. + *

+ * A plain {@link Iterator}, not a {@link RetiringBiWalk}: + * there is no left value to retire. + * {@code samplingIterator} never ends while {@code sourceDataset} is non-empty, + * so {@link #hasNext()} bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, + * matching the fixed-width-probe design of the pillar family. + * This is not neutral to every model, but it costs nothing on the two that matter: + * a solution-wide {@link ValueRange} is one deduplicated range, + * so {@link SampleValueRanges#findDestination} accepts the first candidate; + * overlapping entity-dependent ranges normally intersect too, + * so the first draw succeeds there as well. + * Only disjoint entity-dependent ranges fail systematically, + * and there the legal samples are so rare + * (about {@code r^(1-k)} for r regions and samples of size k) that + * a pool-scaled budget would pay for {@code n * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER} intersections every step + * to recover a fraction of them. + * That configuration wants {@link SubPillarChangeMoveProvider} or a region-aware {@link Sampler} instead. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +final class MassDestinationMoveIterator implements Iterator> { + + private final PlanningVariableMetaModel variableMetaModel; + private final boolean crossingNull; + private final SolutionView solutionView; + private final RandomGenerator random; + private final Iterator> sampleIterator; + + private @Nullable Move nextMove = null; + /** + * Remembers the last distinct ranges and excluded value proven to have no legal destination, + * so that redrawing an equal-signature sample + * (possible even under a real {@link Sampler}, + * since two different draws can land on the same distinct ranges and the same excluded value) + * does not repeat the exhaustive proof on every failed draw. + * Keyed on both fields together: + * the same ranges can be empty for one excluded value and non-empty for another. + */ + private @Nullable SampleValueRanges provenEmptyRanges = null; + private @Nullable Value_ provenEmptyExcludedValue = null; + + MassDestinationMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset sourceDataset, Sampler sampler, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.crossingNull = crossingNull; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var sourceInstance = session.getInstance(sourceDataset); + this.sampleIterator = sourceInstance.samplingIterator(Objects.requireNonNull(sampler), random); + } + + @Override + public boolean hasNext() { + // sampleIterator never ends while sourceDataset is non-empty + // (see UniDatasetInstance#samplingIterator's contract). + // A real Sampler can draw a different sample on every call, + // so failed draws are counted and this call gives up once they reach RetiringBiWalk.PROBE_ATTEMPT_COUNT. + var failedSampleDraws = 0; + while (nextMove == null && sampleIterator.hasNext() && failedSampleDraws < RetiringBiWalk.PROBE_ATTEMPT_COUNT) { + var sample = sampleIterator.next(); + if (sample.size() < 2) { + // Size-1 samples are excluded: ChangeMoveProvider/AssignMoveProvider already cover them, + // more cheaply than a full range intersection and destination probe would here. + failedSampleDraws++; + continue; + } + var excludedValue = MoveProviderUtil.sharedValueOf(sample, variableMetaModel, solutionView); + var ranges = SampleValueRanges.of(sample, variableMetaModel, solutionView); + var provenEmptyForThisSignature = + ranges.equals(provenEmptyRanges) && Objects.equals(excludedValue, provenEmptyExcludedValue); + // A sample whose non-null intersection is empty (or already proven so) + // still has a legal null destination (unassign), as long as it holds at least one entity to unassign. + // Tried before the latch-based skip below, and also taken whenever the latch is already set. + if (crossingNull && MoveProviderUtil.anyAssigned(sample, variableMetaModel, solutionView) + && (provenEmptyForThisSignature || ranges.rollNull(random))) { + nextMove = Moves.massChange(variableMetaModel, sample, null); + continue; + } + if (provenEmptyForThisSignature) { + // Already proven empty for this exact signature; no need to search again. + failedSampleDraws++; + continue; + } + var destination = ranges.findDestination(random, excludedValue); + if (destination == null) { + provenEmptyRanges = ranges; + provenEmptyExcludedValue = excludedValue; + failedSampleDraws++; + continue; + } + nextMove = Moves.massChange(variableMetaModel, sample, destination); + } + return nextMove != null; + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java new file mode 100644 index 00000000000..38a70b54836 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -0,0 +1,119 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, + * out of the entities whose given basic planning variable is currently assigned a non-null value, of any value, + * and creates a move to unassign every member at once + * (set the basic planning variable to null). + * Members need not share a value; + * unlike the pillar family, this draws with no grouping key. + *

+ * {@link MassChangeMoveProvider} makes this same kind of move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only with probability {@code 1/(s+1)} per drawn sample + * (where {@code s} is the size of the sample members' value range), + * so it arrives rarely. + * This class exists to make it happen often. + *

+ * For the pillar family, where members are keyed on a shared value, + * see {@link PillarUnassignMoveProvider} and {@link SubPillarUnassignMoveProvider}. + * For unassigning a single entity at a time, see {@link UnassignMoveProvider}. + * For assigning unassigned entities, see {@link MassAssignMoveProvider}. + *

+ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; + * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class MassUnassignMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + + public MassUnassignMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + this.sampler = Objects.requireNonNull(sampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var assignedEntityDataset = MoveProviderUtil.assignedEntityDataset(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new MassUnassignMoveIterator<>(session, random, + variableMetaModel, assignedEntityDataset, sampler)); + } + + /** + * Draws mixed-value samples of currently-assigned entities and unassigns every member, + * producing a {@code MassChangeMove} with a null destination. + * The destination is fixed at null, so nothing can ever be rejected: + * every drawn sample yields a valid move, with no probing. + *

+ * Unlike {@link MassAssignMoveProvider}/{@link MassChangeMoveProvider}, + * size-1 samples are not skipped here, and deliberately so: + * skipping would give this iterator its first way to reject a draw, + * but {@code samplingIterator} never ends while the source dataset is non-empty, + * and a size-1 sample can be all a given {@link Sampler} ever produces. + * Any guard against the resulting hang costs something - + * a source-size early-out still spins against a {@link Sampler} that stops after the seed, + * and a failed-draw counter is machinery this provider does not otherwise need. + * Emitting the size-1 sample costs nothing and removes the problem at its root, + * since unassigning one entity is no more expensive than unassigning several. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class MassUnassignMoveIterator + implements Iterator> { + + private final PlanningVariableMetaModel variableMetaModel; + private final Iterator> sampleIterator; + + MassUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset assignedEntityDataset, Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + var assignedEntityInstance = session.getInstance(assignedEntityDataset); + this.sampleIterator = assignedEntityInstance.samplingIterator(Objects.requireNonNull(sampler), random); + } + + @Override + public boolean hasNext() { + return sampleIterator.hasNext(); + } + + @Override + public Move next() { + return Moves.massChange(variableMetaModel, sampleIterator.next(), null); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java new file mode 100644 index 00000000000..035a58a15b2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java @@ -0,0 +1,293 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.collector.NeighborhoodsCollectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +final class MoveProviderUtil { + + /** + * Every basic planning variable of the entity class, + * in natural order, + * as defined by {@link PlanningVariableMetaModel#compareTo(Object)}. + * + * @param entityMetaModel + * @return The list is guaranteed to be non-empty. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static List> + basicVariablesOf(GenuineEntityMetaModel entityMetaModel) { + var variableMetaModelList = entityMetaModel.genuineVariables() + .stream() + .filter(v -> !v.isListVariable()) + .map(v -> (PlanningVariableMetaModel) v) + .sorted() + .toList(); + if (variableMetaModelList.isEmpty()) { + throw new IllegalArgumentException("The entityClass (%s) has no basic planning variables." + .formatted(entityMetaModel.type().getCanonicalName())); + } + return (List) variableMetaModelList; + } + + /** + * Return these variables in their natural order, + * as defined by {@link PlanningVariableMetaModel#compareTo(Object)}. + * + * + * @param variableMetaModelList Arbitrary list of variables. + * @return Deduplicated, non-empty, sorted list of variables. + * @throws IllegalArgumentException if the variableMetaModelList is empty, + * or if the variables come from multiple different entities. + */ + @SuppressWarnings({ "rawtypes" }) + public static List> + normalize(List> variableMetaModelList) { + var entityMetaModelList = Objects.requireNonNull(variableMetaModelList).stream() + .map(PlanningVariableMetaModel::entity) + .distinct() + .toList(); + return switch (entityMetaModelList.size()) { + case 0 -> throw new IllegalArgumentException("The variableMetaModelList (%s) is empty." + .formatted(variableMetaModelList)); + case 1 -> (List) variableMetaModelList.stream() + .distinct() + .sorted() + .toList(); + default -> throw new IllegalArgumentException( + "The variableMetaModelList (%s) contains variables from multiple entity classes." + .formatted(variableMetaModelList)); + }; + + } + + /** + * The entity's current value of every variable in {@code variableMetaModelList}, in that order. + * Two entities with equal keys agree on every listed variable. + * + * @param entity + * @param variableMetaModelList Assumed in a {@link #normalize(List) normalized} order. + */ + public static List compositeKeyOf(Entity_ entity, + List> variableMetaModelList) { + var size = variableMetaModelList.size(); + var values = new Object[size]; + for (var i = 0; i < size; i++) { + var defaultVariableMetaModel = + (DefaultPlanningVariableMetaModel) variableMetaModelList.get(i); + values[i] = defaultVariableMetaModel.variableDescriptor().getValue(entity); + } + // Arrays.asList, not List.of, since a value (and therefore an array slot) may be null. + return Arrays.asList(values); + } + + /** + * One cached row per distinct assigned value, + * each row a whole {@link Sample} of that value's members. + * Built once per settle per changed group, never re-assembled per draw - + * the drawing move providers read a row directly + * instead of running {@code SampleAssembler} over an index every time. + */ + public static UniDataset> assignedPillars( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + return assignedEntities(moveStreamFactory, variableMetaModel) + .groupBy((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity), + NeighborhoodsCollectors.collectAndThen( + NeighborhoodsCollectors. toList(), Sample::of)) + .map((solutionView, value, pillar) -> pillar) + .asCachedDataset(); + } + + public static UniDataset distinctAssignedValues( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + // groupBy yields one element per group, which is exactly the set of occupied values. + // No joiner can express "the distinct set of keys", so groupBy is required here. + return assignedEntities(moveStreamFactory, variableMetaModel) + .groupBy((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity)) + .asCachedDataset(); + } + + public static BiDataset entitiesByAssignedValue( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + var defaultVariableMetaModel = (DefaultPlanningVariableMetaModel) variableMetaModel; + var variableDescriptor = defaultVariableMetaModel.variableDescriptor(); + // A real equal-join: the joiner's plain Function reads the variable directly, bypassing SolutionView. + // UniDataset.join(...) resolves to JustInTimeBiDataset, an indexed lookup, + // so drawing a member of a slice costs O(1) to create rather than scanning every entity. + return distinctAssignedValues(moveStreamFactory, variableMetaModel) + .join(assignedEntities(moveStreamFactory, variableMetaModel), + NeighborhoodsJoiners.equal(Function.identity(), variableDescriptor::getValue)); + } + + /** + * Every entity of the class, assigned or not. + * Unlike {@link #assignedEntities}, this deliberately admits unassigned entities: + * a {@code Mass*} sample drawn from it may contain them, and the move built from that sample + * assigns them as a side effect, crossing null upward. Used by {@link MassChangeMoveProvider} + * only when it is crossing null; otherwise it uses {@link #assignedEntityDataset} instead. + */ + public static UniDataset allEntities( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + return moveStreamFactory.forEach(variableMetaModel.entity().type(), false).asCachedDataset(); + } + + /** + * Every entity currently assigned a non-null value, with no grouping - unlike + * {@link #assignedPillars}/{@link #entitiesByAssignedValue}, members of one drawn sample need not + * share a value. + */ + public static UniDataset assignedEntityDataset( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + return assignedEntities(moveStreamFactory, variableMetaModel).asCachedDataset(); + } + + /** + * @return the value every member of {@code sample} currently holds, or {@code null} if any two + * members disagree, or if {@code sample} is entirely unassigned. + * Either answer is the correct {@code excludedValue} for + * {@link SampleValueRanges#findDestination}: {@code null} is never itself a candidate + * destination, so excluding "no shared value" excludes nothing. + */ + public static @Nullable Value_ sharedValueOf(Sample sample, + PlanningVariableMetaModel variableMetaModel, SolutionView solutionView) { + Value_ sharedValue = null; + var first = true; + for (var entity : sample) { + var value = solutionView.getValue(variableMetaModel, Objects.requireNonNull(entity)); + if (first) { + sharedValue = value; + first = false; + } else if (!Objects.equals(sharedValue, value)) { + return null; + } + } + return sharedValue; + } + + /** + * @return {@code true} if at least one member of {@code sample} currently holds a non-null value; + * short-circuits on the first one. Used to keep a null destination from being offered for + * a sample that is already entirely unassigned, which would otherwise be a no-op move. + */ + public static boolean anyAssigned(Sample sample, + PlanningVariableMetaModel variableMetaModel, SolutionView solutionView) { + for (var entity : sample) { + if (solutionView.getValue(variableMetaModel, Objects.requireNonNull(entity)) != null) { + return true; + } + } + return false; + } + + /** + * Whether swapping {@code leftPillar} and {@code rightPillar} is legal + * across every variable in {@code variableMetaModelList}, + * checked against each pillar's own {@link SampleValueRanges} + * ({@code leftRangesPerVariable} for the left side, computed fresh for the right side) + * rather than an {@code isValueInRange} call per member. + * + * @return {@code true} if the swap changes at least one variable, + * and every changed variable is legal on both sides; + * {@code false} if any variable's swap falls out of range for either side. + */ + public static boolean isValidSwap(SolutionView solutionView, + List> variableMetaModelList, + Sample leftPillar, List> leftRangesPerVariable, Sample rightPillar) { + var change = false; + for (var i = 0; i < variableMetaModelList.size(); i++) { + var variableMetaModel = variableMetaModelList.get(i); + var oldLeftValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(leftPillar.first())); + var oldRightValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(rightPillar.first())); + if (Objects.equals(oldLeftValue, oldRightValue)) { + continue; + } + // Unlike leftRangesPerVariable, the right side is rebuilt from scratch on every call: the + // left pillar is stable for a whole settle-to-settle window and worth caching across + // candidates, but each rightPillar here is a fresh draw, so a cache on it would almost + // never hit and would cost more than it saves. + var rightRanges = SampleValueRanges.of(rightPillar, variableMetaModel, solutionView); + if (leftRangesPerVariable.get(i).containsInEvery(oldRightValue) && rightRanges.containsInEvery(oldLeftValue)) { + change = true; + } else { + // One of the swaps falls out of range, skip this pair altogether. + return false; + } + } + return change; + } + + /** + * The values {@code leftEntity} and {@code rightEntity} would take + * after swapping every variable in {@code variableMetaModelList}, + * one pair per variable in that order: + * for variables v1 and v2, the result is [left.v1, right.v1, left.v2, right.v2]. + */ + public static List<@Nullable Object> cachedValuesOf(Entity_ leftEntity, Entity_ rightEntity, + List> variableMetaModelList) { + var valueList = new ArrayList<@Nullable Object>(variableMetaModelList.size() * 2); + for (var variableMetaModel : variableMetaModelList) { + var defaultVariableMetaModel = + (DefaultPlanningVariableMetaModel) variableMetaModel; + var variableDescriptor = defaultVariableMetaModel.variableDescriptor(); + valueList.add(variableDescriptor.getValue(leftEntity)); + valueList.add(variableDescriptor.getValue(rightEntity)); + } + return valueList; + } + + /** + * Appends every other value of {@code cachedValues} + * (as produced by {@link #cachedValuesOf}) + * to {@code s}, comma-separated: + * the left value of each pair if {@code left}, the right value otherwise. + */ + public static void appendInterleavedRow(StringBuilder s, List<@Nullable Object> cachedValues, boolean left) { + for (var i = 0; i < cachedValues.size(); i += 2) { + var index = left ? i : i + 1; + var value = cachedValues.get(index); + if (i > 0) { + s.append(", "); + } + s.append(value == null ? "null" : value.toString()); + } + } + + private static UniEnumeratingStream assignedEntities( + MoveStreamFactory moveStreamFactory, + PlanningVariableMetaModel variableMetaModel) { + var entityStream = moveStreamFactory.forEach(variableMetaModel.entity().type(), false); + // Filtering to assigned entities keeps null out of the group key: + // a pillar is keyed on a shared value, and unassigned is the absence of a value, not one. + // This is why PillarChangeMoveProvider/SubPillarChangeMoveProvider can only ever cross null downward (unassign), + // regardless of their own crossingNull flag. + return entityStream.filter((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity) != null); + } + + private MoveProviderUtil() { + // No external instances. + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index c4ea208b80b..6720e6c5f38 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -15,18 +16,6 @@ /** * Factory class for creating built-in {@link Move} instances that mutate planning variables. *

- * This class provides static methods to create the standard moves used in optimization: - *

    - *
  • {@link #compose(Move[])} - combines multiple moves into one
  • - *
  • {@link #change(PlanningVariableMetaModel, Object, Object)} - changes a basic planning variable's value
  • - *
  • {@link #swap(PlanningVariableMetaModel, Object, Object)} - swaps values between two entities
  • - *
  • {@link #assign(PlanningListVariableMetaModel, Object, Object, int)} - assigns a value to a list variable
  • - *
  • {@link #unassign(PlanningListVariableMetaModel, Object, int)} - removes a value from a list variable
  • - *
  • {@link #change(PlanningListVariableMetaModel, PositionInList, PositionInList)} - moves an element within or between list - * variables
  • - *
  • {@link #swap(PlanningListVariableMetaModel, PositionInList, PositionInList)} - swaps two elements in list variables
  • - *
- *

* This package and all of its contents are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. * There are no guarantees for backward compatibility; @@ -135,7 +124,10 @@ public static Move swap( * for example, if one of the values is not in the value range of the other entity's variable, * then swapping would lead to an invalid solution. * - * @param variableMetaModelList the list of planning variables to swap; must not be empty + * @param variableMetaModelList the list of planning variables to swap; must not be empty. + * Keep the variableMetaModelList list in stable order, + * otherwise move equality will misbehave; + * the generic move providers guarantee that. * @param leftEntity the first entity participating in the swap * @param rightEntity the second entity participating in the swap * @param the solution type @@ -143,10 +135,74 @@ public static Move swap( * @return a move that, when executed, swaps all variable values between the two entities * @throws IllegalArgumentException if the list is empty or if leftEntity == rightEntity */ + @SuppressWarnings("unchecked") public static Move swap( - List> variableMetaModelList, Entity_ leftEntity, + List> variableMetaModelList, Entity_ leftEntity, Entity_ rightEntity) { - return new SwapMove<>(variableMetaModelList, leftEntity, rightEntity); + return new SwapMove<>((List>) variableMetaModelList, leftEntity, + rightEntity); + } + + /** + * Creates a move that changes a basic planning variable's value on every member of a {@link Sample} at once. + *

+ * This is the sample equivalent of {@link #change(PlanningVariableMetaModel, Object, Object)}: + * an assign is a move whose members currently hold null, and an unassign is a move whose destination is null. + * + * @param variableMetaModel describes the planning variable to be changed + * @param sample the sample whose members' variable value is to be changed + * @param toPlanningValue the new value to assign; may be null if the variable supports unassigned values + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, changes every member's variable to the given value + */ + public static Move massChange( + PlanningVariableMetaModel variableMetaModel, Sample sample, + @Nullable Value_ toPlanningValue) { + return new MassChangeMove<>(variableMetaModel, sample, toPlanningValue); + } + + /** + * As defined by {@link #pillarSwap(List, Sample, Sample)}, but for a single variable. + */ + @SuppressWarnings("unchecked") + public static Move pillarSwap( + PlanningVariableMetaModel variableMetaModel, Sample leftPillar, + Sample rightPillar) { + return pillarSwap(List.of((PlanningVariableMetaModel) variableMetaModel), leftPillar, + rightPillar); + } + + /** + * Creates a move that swaps the values of one or more planning variables between the members of two + * {@link Sample}s. + *

+ * This is the pillar equivalent of {@link #swap(List, Object, Object)}. + * The two pillars must not share any members; violating this throws {@link IllegalArgumentException}. + * The caller MUST only pass homogeneous pillars (every member of a pillar holding the same value + * for each listed variable) and pillars whose values can be swapped; + * for example, if one of the values is not in the value range of a member of the other pillar, + * swapping would lead to an invalid solution. Neither condition is re-checked by the move; + * see {@link PillarSwapMove} for what happens when a caller violates them. + * + * @param variableMetaModelList the list of planning variables to swap; must not be empty. + * Keep the variableMetaModelList list in stable order, + * otherwise move equality will misbehave; + * the generic move providers guarantee that. + * @param leftPillar the first pillar participating in the swap + * @param rightPillar the second pillar participating in the swap + * @param the solution type + * @param the entity type + * @return a move that, when executed, swaps all variable values between the two pillars + * @throws IllegalArgumentException if the list is empty or if the two pillars share any members + */ + @SuppressWarnings("unchecked") + public static Move pillarSwap( + List> variableMetaModelList, + Sample leftPillar, Sample rightPillar) { + return new PillarSwapMove<>((List>) variableMetaModelList, + leftPillar, rightPillar); } // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java new file mode 100644 index 00000000000..4bd0e114be3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -0,0 +1,195 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * For each pillar of entities sharing a non-null value of the given variable, + * creates a move to change every member's value to a different value that is legal for every member. + * The pillar is keyed on this one variable alone; + * members may differ in every other variable. + * The pillar's own current value is never offered as a destination. + *

+ * When {@code crossingNull} is {@code true} + * (the default whenever the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned values}), + * this provider also creates a move that unassigns the whole pillar - + * probability {@code 1/(s+1)} per drawn pillar, where {@code s} is the size of the pillar members' value range. + * This provider never assigns: + * a pillar's key is a value shared by its members, and unassigned is the absence of a value, not one. + * Use {@link PillarUnassignMoveProvider} for unassign moves at a much higher rate; + * there is no pillar equivalent for assign, since it would require drawing a pillar keyed on "unassigned" - + * w either duplicate {@link MassAssignMoveProvider} (a subset) or, for the whole pillar, normally intersect to nothing - + * so use {@link MassAssignMoveProvider} or {@link AssignMoveProvider} instead. + *

+ * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@link SubPillarChangeMoveProvider}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class PillarChangeMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final boolean crossingNull; + + public PillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this(variableMetaModel, variableMetaModel.allowsUnassigned()); + } + + /** + * @param crossingNull if {@code true}, also creates whole-pillar unassign moves; + * requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public PillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (crossingNull && !variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var pillarDataset = MoveProviderUtil.assignedPillars(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new PillarChangeMoveIterator<>(session, random, + variableMetaModel, pillarDataset, crossingNull)); + } + + /** + * Draws whole pillars (one cached row per assigned value) and pairs each with a destination value, + * producing a {@code MassChangeMove}. + * Left = pillar, right = destination value. + *

+ * The destination is drawn from the pillar members' own {@link ValueRange}s ({@link SampleValueRanges#findDestination}) + * rather than from a global candidate pool: + * every candidate offered is already legal for every member and different from the pillar's own value, + * so no {@code isValueInRange} filtering or bail-out sampling is needed. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class PillarChangeMoveIterator + implements Iterator>, RetiringBiWalk, Value_> { + + private final PlanningVariableMetaModel variableMetaModel; + private final boolean crossingNull; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator> sampleIterator; + + private @Nullable Move nextMove = null; + private @Nullable Sample cachedPillar = null; + /** + * Computed once per {@link #cachedPillar}, not once per probe: + * {@code createRightIterator} can be called up to {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} times + * for the same left pillar - + * an equal pillar every time, since a pillar row is stable for the whole settle-to-settle window - + * and the ranges (and, once found, the proven-empty verdict below) do not change between those calls. + *

+ * The {@link #cachedPillarProvenEmpty} latch is also safe without the slice value in its key, + * unlike {@code MassDestinationMoveIterator}'s equivalent memo: + * {@link MoveProviderUtil#assignedPillars} groups entities by their assigned value, + * so a pillar's members determine its slice value, + * and an equal pillar therefore has an equal slice value too. + */ + private @Nullable SampleValueRanges cachedRanges = null; + private boolean cachedPillarProvenEmpty = false; + + public PillarChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset> pillarDataset, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.crossingNull = crossingNull; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var pillarInstance = (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); + this.sampleIterator = pillarInstance.retiringRandomIterator(random); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(sampleIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Sample pillar) { + if (pillar.size() < 2) { + // Size-1 pillars are excluded from change: ChangeMoveProvider already covers them. + return Collections.emptyIterator(); + } + if (!Objects.equals(cachedPillar, pillar)) { + cachedPillar = pillar; + cachedRanges = SampleValueRanges.of(pillar, variableMetaModel, solutionView); + cachedPillarProvenEmpty = false; + } + var ranges = Objects.requireNonNull(cachedRanges); + // A pillar whose non-null intersection is empty still has a legal null destination (unassign), + // so the null branch is tried first and also whenever the latch below is set. + if (crossingNull && (cachedPillarProvenEmpty || ranges.rollNull(random))) { + // List.of(null) throws; Collections.singletonList allows a null element. + return Collections.singletonList((Value_) null).iterator(); + } + if (cachedPillarProvenEmpty) { + // Already proven empty for this pillar; no need to search again. + return Collections.emptyIterator(); + } + // The pillar is homogeneous by construction (one cached row per assigned value); + // recover the slice value from any one member to exclude it as a no-op destination. + var sliceValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(pillar.first())); + var destination = ranges.findDestination(random, sliceValue); + if (destination == null) { + cachedPillarProvenEmpty = true; + return Collections.emptyIterator(); + } + return List.of(destination).iterator(); + } + + @Override + public void accept(Sample pillar, Value_ destination) { + nextMove = Moves.massChange(variableMetaModel, pillar, destination); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java new file mode 100644 index 00000000000..f74b7ae4e3e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -0,0 +1,181 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Swaps values of one or more {@link PlanningVariable}s between the members of two {@link Sample}s. + * Requires to specify a (sub)set of variables to swap values of, + * all of which must belong to the same entity class. + *

+ * The two pillars must not share any members; + * the constructor throws {@link IllegalArgumentException} if they do. + * The constructor does not otherwise check that the pillars are safe to swap; see below. + *

+ * Every member of a pillar is assumed to hold the same value for each of the listed variables + * ({@link #getCachedValues()} reads the value off a single representative member, not off every member). + * The caller MUST only pass homogeneous pillars. + * A heterogeneous member either gets a no-op write, + * or gets overwritten with the wrong value and loses its own value; + * nothing detects either case. + * {@code PillarSwapMoveProvider} and {@code SubPillarSwapMoveProvider} only ever build homogeneous pillars. + *

+ * The caller MUST only provide pillars whose values can be swapped; + * for example, if one of the values is not in the value range of a member of the other pillar, + * swapping would lead to an invalid solution. + * This move does not re-check that at execution time, matching {@link Moves#pillarSwap}; + * if the pair is invalid, the move writes the out-of-range value anyway and the solution becomes invalid, + * with no exception. The built-in providers never propose such a pair. + *

+ * Similarly, a pair whose every listed variable already matches between the two pillars + * is accepted by the constructor and now performs writes that produce no net change; + * the built-in providers never propose such a pair either, + * since they key pillars on the composite of all listed variables and only pair distinct keys. + *

+ * The caller is responsible for ordering the given variables consistently; + * this constructor does not reorder them. + * Two moves over the same pillars and the same set of variables are only guaranteed to be equal + * if the caller lists the variables in the same order both times. + * {@code PillarSwapMoveProvider} and {@code SubPillarSwapMoveProvider} normalize the order for moves they build. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + */ +@NullMarked +public final class PillarSwapMove extends AbstractMove { + + private final List> variableMetaModelList; + private final Sample leftPillar; + private final Sample rightPillar; + + /** + * Cache of the values of the pillars' representative members at the time + * of the first call of {@link #getCachedValues()}. + * Ideally, the method would first be called before the values are changed by the move, + * so that the {@link #toString()} method shows the original values. + *

+ * The list is structured such that for each variable in {@link #variableMetaModelList}, + * in order, it contains first the value of {@link #leftPillar} and then the value of {@link #rightPillar}. + * Example: with two variables v1 and v2, the list contains [left.v1, right.v1, left.v2, right.v2]. + *

+ * Relies on pillar homogeneity: every member of a pillar is assumed to share the same value + * of a given variable, so reading the head of the pillar is enough. + */ + private @Nullable List<@Nullable Object> valueList; + + PillarSwapMove(List> variableMetaModelList, + Sample leftPillar, Sample rightPillar) { + if (Objects.requireNonNull(variableMetaModelList).isEmpty()) { + throw new IllegalArgumentException( + "Pillar swap move requires at least one planning variable to swap between pillars, but got (%s)." + .formatted(variableMetaModelList)); + } + this.variableMetaModelList = variableMetaModelList; + this.leftPillar = Objects.requireNonNull(leftPillar); + this.rightPillar = Objects.requireNonNull(rightPillar); + } + + @Override + public List> variableMetaModels() { + return variableMetaModelList; + } + + @Override + public void execute(MutableSolutionView solutionView) { + var cachedValues = getCachedValues(); // [left.v1, right.v1, left.v2, right.v2, ...] + for (var i = 0; i < cachedValues.size(); i += 2) { + var variableMetaModel = variableMetaModelList.get(i / 2); + var oldLeftValue = cachedValues.get(i); + var oldRightValue = cachedValues.get(i + 1); + for (var entity : leftPillar) { + solutionView.changeVariable(variableMetaModel, Objects.requireNonNull(entity), oldRightValue); + } + for (var entity : rightPillar) { + solutionView.changeVariable(variableMetaModel, Objects.requireNonNull(entity), oldLeftValue); + } + } + } + + private List<@Nullable Object> getCachedValues() { + if (valueList != null) { + return valueList; + } + var leftHead = Objects.requireNonNull(leftPillar.first()); + var rightHead = Objects.requireNonNull(rightPillar.first()); + valueList = MoveProviderUtil.cachedValuesOf(leftHead, rightHead, variableMetaModelList); + return valueList; + } + + @Override + public PillarSwapMove rebase(Lookup lookup) { + var rebasedLeftEntityList = new ArrayList(leftPillar.size()); + for (var entity : leftPillar) { + rebasedLeftEntityList.add(lookup.lookUpWorkingObject(entity)); + } + var rebasedRightEntityList = new ArrayList(rightPillar.size()); + for (var entity : rightPillar) { + rebasedRightEntityList.add(lookup.lookUpWorkingObject(entity)); + } + return new PillarSwapMove<>(variableMetaModelList, Sample.of(rebasedLeftEntityList), + Sample.of(rebasedRightEntityList)); + } + + @Override + public SequencedCollection getPlanningEntities() { + var entityList = new ArrayList<>(leftPillar.size() + rightPillar.size()); + for (var entity : leftPillar) { + entityList.add(entity); + } + for (var entity : rightPillar) { + entityList.add(entity); + } + return entityList; + } + + @Override + public SequencedCollection<@Nullable Object> getPlanningValues() { + return new LinkedHashSet<>(getCachedValues()); // Not using Set.of(), as values may be null. + } + + @Override + public boolean equals(Object o) { + return o instanceof PillarSwapMove other + && Objects.equals(variableMetaModelList, other.variableMetaModelList) + && Objects.equals(leftPillar, other.leftPillar) + && Objects.equals(rightPillar, other.rightPillar); + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModelList, leftPillar, rightPillar); + } + + @Override + public String toString() { + var s = new StringBuilder(variableMetaModelList.size() * 16); + var cachedValues = getCachedValues(); + s.append(leftPillar).append(" {"); + MoveProviderUtil.appendInterleavedRow(s, cachedValues, true); + s.append("} <-> "); + s.append(rightPillar).append(" {"); + MoveProviderUtil.appendInterleavedRow(s, cachedValues, false); + s.append("}"); + return s.toString(); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java new file mode 100644 index 00000000000..209976f07ef --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -0,0 +1,182 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; +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.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.collector.NeighborhoodsCollectors; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws two pillars, + * each a set of entities sharing the same combination of values across every variable given to the constructor, + * and swaps that combination between the two pillars, + * provided at least one variable differs and every differing variable is legal on both sides; + * if any differing variable is out of range, the pair is skipped entirely. + * A pillar's composite key is a list with one value per variable, in {@link PlanningEntityMetaModel#variables()} + * declaration order, regardless of the order the constructor was given. + * Size-1 pillars are legal on both sides of the swap, + * since we need to be able to swap 1-sized pillar with an n-sized pillar. + *

+ * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@link SubPillarSwapMoveProvider}. + * + * @param the solution type + * @param the entity type + */ +@NullMarked +public final class PillarSwapMoveProvider + implements MoveProvider { + + private final GenuineEntityMetaModel entityMetaModel; + private final List> variableMetaModelList; + + /** + * As defined by {@link #PillarSwapMoveProvider(List)}, + * but for every basic planning variable of {@code entityMetaModel}. + */ + public PillarSwapMoveProvider(GenuineEntityMetaModel entityMetaModel) { + this(MoveProviderUtil.basicVariablesOf(entityMetaModel)); + } + + /** + * As defined by {@link #PillarSwapMoveProvider(List)}, but for a single variable. + */ + public PillarSwapMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this(List.of(variableMetaModel)); + } + + /** + * Every listed variable participates in the pillar's composite key. + * A pair is proposed only when at least one listed variable differs + * and every differing variable is legal on both sides; + * if any differing variable is out of range, the pair is skipped entirely. + * All variables must belong to the same entity class. + * + * @param variableMetaModelList must not be empty + */ + public PillarSwapMoveProvider(List> variableMetaModelList) { + this.variableMetaModelList = MoveProviderUtil.normalize(variableMetaModelList); + this.entityMetaModel = variableMetaModelList.getFirst().entity(); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var pillarDataset = moveStreamFactory.forEach(entityMetaModel.type(), false) + .groupBy((solutionView, entity) -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList), + NeighborhoodsCollectors.collectAndThen( + NeighborhoodsCollectors. toList(), Sample::of)) + .map((solutionView, key, pillar) -> pillar) + .asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new PillarSwapMoveIterator<>(session, random, + variableMetaModelList, pillarDataset)); + } + + /** + * Draws two independently sampled whole pillars and swaps every listed variable's value between them, + * producing a {@code PillarSwapMove}. + * Left and right are both pillars, each independently drawn from the same cached dataset; + * the two pillars must differ. + *

+ * Legality is checked against the pillars' own {@link ValueRange}s ({@link SampleValueRanges#of}) + * rather than by an {@code isValueInRange} call per member: + * the candidate partner pillar still has to be searched for + * (its "value" is the other side's current value, not something drawn from a range), + * therefore the {@link FilteringIterator} search. + * + * @param the solution type + * @param the entity type + */ + @NullMarked + private static final class PillarSwapMoveIterator + implements Iterator>, RetiringBiWalk, Sample> { + + private final List> variableMetaModelList; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator> leftPillarIterator; + private final DefaultUniDatasetInstance> pillarInstance; + + private @Nullable Move nextMove = null; + private @Nullable Sample cachedLeftPillar = null; + /** + * Computed once per {@link #cachedLeftPillar}, + * one entry per variable in {@link #variableMetaModelList}. + */ + private @Nullable List> cachedLeftRangesPerVariable = null; + + public PillarSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, + List> variableMetaModelList, + UniDataset> pillarDataset) { + this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + this.pillarInstance = (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); + this.leftPillarIterator = pillarInstance.retiringRandomIterator(random); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(leftPillarIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator> createRightIterator(Sample leftPillar) { + if (!Objects.equals(cachedLeftPillar, leftPillar)) { + cachedLeftPillar = leftPillar; + var perVariableList = new ArrayList>(variableMetaModelList.size()); + for (var variableMetaModel : variableMetaModelList) { + perVariableList.add(SampleValueRanges.of(leftPillar, variableMetaModel, solutionView)); + } + cachedLeftRangesPerVariable = perVariableList; + } + var leftRangesPerVariable = Objects.requireNonNull(cachedLeftRangesPerVariable); + // Scaled to the candidate pool's size. + // Each candidate is read directly off a cached row + // so only isValidSwap costs anything per candidate. + var candidateIterator = pillarInstance.iterator(random); + var bailOutSize = pillarInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(candidateIterator, + candidate -> !candidate.equals(leftPillar) && + MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftPillar, leftRangesPerVariable, + candidate), + bailOutSize); + } + + @Override + public void accept(Sample leftPillar, Sample rightPillar) { + nextMove = Moves.pillarSwap(variableMetaModelList, leftPillar, rightPillar); + } + + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java new file mode 100644 index 00000000000..a95c2a3489d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java @@ -0,0 +1,103 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws pillars of entities sharing a non-null value ("slice value") of the given variable + * and creates a move to unassign every member at once + * (set the basic planning variable to null). + * The pillar is keyed on this one variable alone; members may differ in every other variable. + *

+ * {@link PillarChangeMoveProvider} makes this same move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only with probability {@code 1/(s+1)} per drawn pillar (where {@code s} + * is the size of the pillar members' value range), so it arrives rarely. + * This class exists to make it happen often. + *

+ * Draws whole pillars only. + * For a sampler-driven subset of a pillar, see {@link SubPillarUnassignMoveProvider}. + * For unassigning a single entity at a time, see {@link UnassignMoveProvider}. + * For assigning unassigned entities, see {@link MassAssignMoveProvider}. + *

+ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; + * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class PillarUnassignMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + + public PillarUnassignMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var pillarDataset = MoveProviderUtil.assignedPillars(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new PillarUnassignMoveIterator<>(session, random, + variableMetaModel, pillarDataset)); + } + + /** + * Draws whole pillars (one cached row per assigned value) and unassigns every member, + * producing a {@code MassChangeMove} with a null destination. + * The destination is fixed at null, so nothing can ever be rejected: + * every drawn pillar yields a valid move, with no probing and no left value to retire. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class PillarUnassignMoveIterator implements Iterator> { + + private final PlanningVariableMetaModel variableMetaModel; + private final Iterator> sampleIterator; + + public PillarUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset> pillarDataset) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + var pillarInstance = session.getInstance(pillarDataset); + // Plain sampling-with-replacement: nothing needs retiring, + // since a destination of null can never be rejected. + this.sampleIterator = pillarInstance.iterator(random); + } + + @Override + public boolean hasNext() { + return sampleIterator.hasNext(); + } + + @Override + public Move next() { + var pillar = sampleIterator.next(); + return Moves.massChange(variableMetaModel, pillar, null); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java new file mode 100644 index 00000000000..b83b0bc3252 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java @@ -0,0 +1,178 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.domain.valuerange.NullAllowingValueRange; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.solver.random.RandomUtils; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * The distinct {@link ValueRange}s of one {@link Sample}'s members, for one variable: + * everything a sample-drawing move iterator needs to find a destination legal for every member, + * without ever building a global candidate pool or calling {@code isValueInRange} once per member. + *

+ * Every {@link ValueRange} implementation has a content-based {@code equals}/{@code hashCode}, + * so the distinct set - and the smallest range within it - are both found in one O(sample size) pass; + * usually the set holds a single, shared range instance, + * since {@code ValueRangeState} already deduplicates equal ranges to one cached instance. + *

+ * Two instances are {@link #equals equal} when they hold the same distinct ranges, + * regardless of order - used to remember a proven-empty verdict across redraws of the same sample + * under a deterministic {@link Sampler}. + * + * @param the variable's value type + */ +@NullMarked +record SampleValueRanges(Set> distinctRangeSet, ValueRange smallestRange) { + + private static final long MAX_SAFE_RANGE_SIZE = Long.MAX_VALUE / FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + + /** + * @return the sample members' distinct {@link ValueRange}s for {@code variableMetaModel}, + * with any {@link NullAllowingValueRange} wrapper removed so + * {@code null} is never a candidate destination out of {@link #findDestination}/ {@link #pickExactly} - + * it would collide with their "not found" signal. + * A caller that wants a null destination decides on it separately with {@link #rollNull}, + * before calling either method. + */ + public static SampleValueRanges of(Sample sample, + GenuineVariableMetaModel variableMetaModel, SolutionView solutionView) { + var distinctRangeSet = new LinkedHashSet>(); + for (var entity : sample) { + distinctRangeSet.add(unwrap(solutionView.getValueRange(variableMetaModel, entity))); + } + return of(distinctRangeSet); + } + + /** + * @return an instance over an already-known set of distinct ranges; + * exposed mainly so the reservoir-sampling fallback ({@link #pickExactly}) can be bias-tested directly + * against plain {@link ValueRange} fixtures, + * without needing a {@link Sample} or a solution. + */ + public static SampleValueRanges of(Set> distinctRangeSet) { + ValueRange smallestRange = null; + for (var range : distinctRangeSet) { + if (smallestRange == null || range.getSize() < smallestRange.getSize()) { + smallestRange = range; + } + } + return new SampleValueRanges<>(distinctRangeSet, Objects.requireNonNull(smallestRange)); + } + + @SuppressWarnings("unchecked") + private static ValueRange unwrap(ValueRange range) { + if (range instanceof NullAllowingValueRange nullAllowingValueRange) { + return (ValueRange) nullAllowingValueRange.getChildValueRange(); + } + return range; + } + + static long bailOutSizeOf(ValueRange range) { + // Clamped since a range's {@link ValueRange#getSize()} can be large enough + // that multiplying it here would overflow a {@code long} negative - + // which {@link FilteringIterator} reads as "bail-out disabled", + // turning {@code hasNext()} into an infinite loop. + return Math.min(range.getSize(), MAX_SAFE_RANGE_SIZE) * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + } + + /** + * Finds one value legal for every distinct range and not equal to {@code excludedValue} + * (pass {@code null} when there is nothing to exclude). + *

+ * Samples {@link #smallestRange} first - + * for the common single-range case this is the whole cost, + * since every candidate it offers is already legal for every member and the first one is always accepted. + * Only when sampling bails out does this fall back to {@link #pickExactly}, + * which proves the answer exactly instead of guessing again. + * + * @return a value legal for every distinct range, or {@code null} if none exists + */ + public @Nullable Value_ findDestination(RandomGenerator random, @Nullable Value_ excludedValue) { + var bailOutSize = bailOutSizeOf(smallestRange); + var sampledCandidates = new FilteringIterator<>(smallestRange.createRandomIterator(random), + candidate -> !Objects.equals(candidate, excludedValue) && containsInEvery(candidate), bailOutSize); + if (sampledCandidates.hasNext()) { + return sampledCandidates.next(); + } + return pickExactly(random, excludedValue); + } + + /** + * The exhaustive fallback: proves whether a destination legal for every distinct range exists, + * without ever materializing the intersection. + * One pass over the smallest range, + * reservoir-sampling a single uniform pick among the values admitted by every distinct range + * and not equal to {@code excludedValue} - + * so the result is either a uniform draw from the true intersection, or a proof that no legal destination exists. + * + * @return {@code null} if no member of the smallest range is admitted by every distinct range + * (after excluding {@code excludedValue}), + * meaning the intersection is empty + */ + public @Nullable Value_ pickExactly(RandomGenerator random, @Nullable Value_ excludedValue) { + Value_ chosen = null; + var admittedCount = 0; + var iterator = smallestRange.createOriginalIterator(); + while (iterator.hasNext()) { + var candidate = iterator.next(); + if (Objects.equals(candidate, excludedValue) || !containsInEvery(candidate)) { + continue; + } + admittedCount++; + if (random.nextInt(admittedCount) == 0) { + chosen = candidate; + } + } + return chosen; + } + + /** + * @return {@code true} with probability {@code 1/(size+1)}, + * where {@code size} is {@link #smallestRange}'s clamped size - + * the same probability a {@link NullAllowingValueRange} wrapper on {@link #smallestRange} would have given + * {@code null} as a candidate, + * without ever handing {@code null} to {@link #findDestination}/{@link #pickExactly}, + * where it would collide with their "not found" signal. + * Null is legal for every member whenever the variable allows unassigned values, + * so no range intersection is needed here - only the coin flip. + */ + public boolean rollNull(RandomGenerator random) { + var size = Math.min(smallestRange.getSize(), MAX_SAFE_RANGE_SIZE); + return RandomUtils.nextLong(random, size + 1L) == 0L; + } + + /** + * @return true if {@code value} is legal for every distinct range in this instance + */ + public boolean containsInEvery(@Nullable Value_ value) { + for (var range : distinctRangeSet) { + if (!range.contains(value)) { + return false; + } + } + return true; + } + + @Override + public boolean equals(Object o) { + return o instanceof SampleValueRanges other && distinctRangeSet.equals(other.distinctRangeSet); + } + + @Override + public int hashCode() { + return distinctRangeSet.hashCode(); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java new file mode 100644 index 00000000000..6f1c93f6b14 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java @@ -0,0 +1,198 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.BiDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * For each subpillar of entities sharing a non-null value of the given variable, + * governed by a {@link Sampler}, + * creates a move to change every member's value to a different value that is legal for every member. + * The (sub)pillar is keyed on this one variable alone; + * members may differ in every other variable. + * The subpillar's own current value is never offered as a destination. + *

+ * When {@code crossingNull} is {@code true} + * (the default whenever the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned values}), + * this provider also creates a move that unassigns the whole subpillar - + * probability {@code 1/(s+1)} per drawn subpillar, + * where {@code s} is the size of the subpillar members' value range. + * This provider never assigns: + * its key is a value shared by its members, + * and unassigned is the absence of a value. + * Use {@link SubPillarUnassignMoveProvider} for unassign moves at a much higher rate; + * for assign, use {@link MassAssignMoveProvider} + * (which already draws sampler-chosen subsets of the unassigned entities) or {@link AssignMoveProvider}. + *

+ * For the whole pillar of every entity sharing the value at once, see {@link PillarChangeMoveProvider}. + * For a mixed-value sample with no shared key, which may include unassigned entities, see + * {@link MassChangeMoveProvider}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubPillarChangeMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + private final boolean crossingNull; + + public SubPillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler) { + this(variableMetaModel, sampler, variableMetaModel.allowsUnassigned()); + } + + /** + * @param crossingNull if {@code true}, also creates whole-subpillar unassign moves; + * requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public SubPillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sampler = Objects.requireNonNull(sampler); + if (crossingNull && !variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var distinctValueDataset = MoveProviderUtil.distinctAssignedValues(moveStreamFactory, variableMetaModel); + var pillarSourceDataset = MoveProviderUtil.entitiesByAssignedValue(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new SubPillarChangeMoveIterator<>(session, random, + variableMetaModel, distinctValueDataset, pillarSourceDataset, sampler, crossingNull)); + } + + /** + * Draws subpillars sharing an assigned value ("slice value") and pairs each with a destination + * value, producing a {@code MassChangeMove}. + * Left = slice value, right = destination value. + *

+ * Unlike the whole-pillar variant, + * a fresh subpillar is assembled on every {@link #createRightIterator} call, never cached across probes: + * a real {@link Sampler} can legitimately draw a different subpillar for the same slice value on each attempt, + * and caching the first one would turn {@link RetiringBiWalk}'s remaining probes into deterministic no-ops. + *

+ * The destination is drawn from the (sub)pillar members' own {@link ValueRange}s + * ({@link SampleValueRanges#findDestination}) rather than from a global candidate pool: + * every candidate offered is already legal for every member and different from the slice value, + * so no {@code isValueInRange} filtering or bail-out sampling is needed. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class SubPillarChangeMoveIterator + implements Iterator>, RetiringBiWalk { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + private final boolean crossingNull; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator sliceValueIterator; + private final BiDatasetInstance pillarSourceInstance; + + private @Nullable Move nextMove = null; + private @Nullable Sample pendingPillar = null; + + public SubPillarChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset distinctValueDataset, + BiDataset pillarSourceDataset, + Sampler sampler, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sampler = Objects.requireNonNull(sampler); + this.crossingNull = crossingNull; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var distinctValueInstance = + (DefaultUniDatasetInstance) session.getInstance(distinctValueDataset); + this.sliceValueIterator = distinctValueInstance.retiringRandomIterator(random); + this.pillarSourceInstance = session.getInstance(pillarSourceDataset); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(sliceValueIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Value_ sliceValue) { + // Fresh subpillar and fresh ranges on every call. + var pillars = pillarSourceInstance.samplingIterator(sliceValue, sampler, random); + var pillar = pillars.hasNext() ? pillars.next() : null; + if (pillar == null || pillar.size() < 2) { + // Size-1 subpillars are excluded from change: ChangeMoveProvider already covers them. + // Empty rather than rejecting in acceptLeft, + // which skips without retiring and would spin forever on a model where every subpillar has one member. + pendingPillar = null; + return Collections.emptyIterator(); + } + var ranges = SampleValueRanges.of(pillar, variableMetaModel, solutionView); + if (crossingNull && ranges.rollNull(random)) { + pendingPillar = pillar; + // List.of(null) throws; Collections.singletonList allows a null element. + return Collections.singletonList((Value_) null).iterator(); + } + var destination = ranges.findDestination(random, sliceValue); + if (destination == null) { + pendingPillar = null; + return Collections.emptyIterator(); + } + pendingPillar = pillar; + return List.of(destination).iterator(); + } + + @Override + public void accept(Value_ sliceValue, Value_ destination) { + nextMove = Moves.massChange(variableMetaModel, Objects.requireNonNull(pendingPillar), destination); + pendingPillar = null; + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java new file mode 100644 index 00000000000..5efea73b9b5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -0,0 +1,236 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +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.function.Function; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.BiDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws two subpillars, each governed by its own {@link Sampler}, + * out of entities sharing the same combination of values across every variable given to the constructor, + * and swaps that combination between the two subpillars, + * provided at least one variable differs and every differing variable is legal on both sides; + * if any differing variable is out of range, the pair is skipped entirely. + * A pillar's composite key is a list with one value per variable, + * in {@link GenuineEntityMetaModel#variables()} declaration order, + * regardless of the order the constructor was given. + *

+ * For the whole pillar on both sides at once, see {@link PillarSwapMoveProvider}. + *

+ * There is no single-sampler overload: + * a {@link Sampler} may hold state across {@code decide} calls, + * and nothing in its contract promises that a sampler written for one side behaves safely + * if silently reused, unreset, for the other side. + * So the two sides always take separate instances. + * + * @param the solution type + * @param the entity type + */ +@NullMarked +public final class SubPillarSwapMoveProvider + implements MoveProvider { + + private final GenuineEntityMetaModel entityMetaModel; + private final List> variableMetaModelList; + private final Sampler leftSampler; + private final Sampler rightSampler; + + /** + * As defined by {@link #SubPillarSwapMoveProvider(List, Sampler, Sampler)}, but for every basic + * planning variable of {@code entityMetaModel}. + */ + public SubPillarSwapMoveProvider(GenuineEntityMetaModel entityMetaModel, Sampler leftSampler, + Sampler rightSampler) { + this(MoveProviderUtil.basicVariablesOf(entityMetaModel), leftSampler, rightSampler); + } + + /** + * As defined by {@link #SubPillarSwapMoveProvider(List, Sampler, Sampler)}, but for a single variable. + */ + public SubPillarSwapMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler leftSampler, Sampler rightSampler) { + this(List.of(variableMetaModel), leftSampler, rightSampler); + } + + /** + * Every listed variable participates in the pillar's composite key. + * A pair is proposed only when at least one listed variable differs + * and every differing variable is legal on both sides; + * if any differing variable is out of range, the pair is skipped entirely. + * All variables must belong to the same entity class. + * + * @param variableMetaModelList must not be empty + */ + public SubPillarSwapMoveProvider(List> variableMetaModelList, + Sampler leftSampler, Sampler rightSampler) { + this.variableMetaModelList = MoveProviderUtil.normalize(variableMetaModelList); + this.entityMetaModel = variableMetaModelList.getFirst().entity(); + this.leftSampler = Objects.requireNonNull(leftSampler); + this.rightSampler = Objects.requireNonNull(rightSampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var entityStream = moveStreamFactory.forEach(entityMetaModel.type(), false); + // groupBy yields one element per group, + // which is exactly the set of distinct composite keys; + // no joiner can express "the distinct set of keys", so groupBy is required here. + var distinctKeys = entityStream + .groupBy((solutionView, entity) -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList)) + .asCachedDataset(); + // A real equal-join: + // the joiner's plain Function reads every variable directly, bypassing SolutionView, + // so UniDataset.join(...) resolves to an indexed JustInTimeBiDataset lookup. + var pillarSourceDataset = distinctKeys.join(entityStream, + NeighborhoodsJoiners.equal(Function.identity(), + entity -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList))); + return moveStreamFactory.buildMoveStream((session, random) -> new SubPillarSwapMoveIterator<>(session, random, + variableMetaModelList, distinctKeys, pillarSourceDataset, leftSampler, rightSampler)); + } + + /** + * Draws two independently sampled pillar-slice keys + * and swaps every listed variable's value between a subpillar drawn from each, + * producing a {@code PillarSwapMove}. + * Left and right are both composite keys, each independently drawn from the same {@code distinctKeys} dataset; + * the two keys must differ. + *

+ * Unlike the whole-pillar variant, + * a fresh left subpillar is assembled on every {@link #createRightIterator} call, + * never cached across probes: + * a real {@link Sampler} can legitimately draw a different subpillar for the same left key on each attempt + * (and a different subpillar can have different legal ranges, since fewer members mean fewer constraints), + * so caching the first one would turn {@link RetiringBiWalk}'s remaining probes into + * deterministic no-ops for the left side. + * The right side is drawn fresh per candidate probed, + * same as the whole-pillar variant. + *

+ * Legality is checked against the subpillars' own {@link ValueRange}s ({@link SampleValueRanges#of}) + * rather than by an {@code isValueInRange} call per member: + * the candidate partner subpillar still has to be searched for + * (its "value" is the other side's current value, not something drawn from a range), + * therefore the {@link FilteringIterator} search. + * + * @param the solution type + * @param the entity type + */ + @NullMarked + private static final class SubPillarSwapMoveIterator + implements Iterator>, RetiringBiWalk, List> { + + private final List> variableMetaModelList; + private final Sampler leftSampler; + private final Sampler rightSampler; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator> leftKeyIterator; + private final DefaultUniDatasetInstance> distinctKeysInstance; + private final BiDatasetInstance, Entity_> pillarSourceInstance; + + private @Nullable Move nextMove = null; + private @Nullable Sample pendingLeftPillar = null; + private @Nullable Sample pendingRightPillar = null; + + public SubPillarSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, + List> variableMetaModelList, + UniDataset> distinctKeys, + BiDataset, Entity_> pillarSourceDataset, + Sampler leftSampler, Sampler rightSampler) { + this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); + this.leftSampler = Objects.requireNonNull(leftSampler); + this.rightSampler = Objects.requireNonNull(rightSampler); + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + this.distinctKeysInstance = (DefaultUniDatasetInstance>) session.getInstance(distinctKeys); + this.leftKeyIterator = distinctKeysInstance.retiringRandomIterator(random); + this.pillarSourceInstance = session.getInstance(pillarSourceDataset); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(leftKeyIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator> createRightIterator(List leftKey) { + // Fresh left subpillar and fresh ranges on every call. + var leftPillars = pillarSourceInstance.samplingIterator(leftKey, leftSampler, random); + var leftPillar = leftPillars.hasNext() ? leftPillars.next() : null; + if (leftPillar == null) { + pendingLeftPillar = null; + return Collections.emptyIterator(); + } + var leftRangesPerVariable = new ArrayList>(variableMetaModelList.size()); + for (var variableMetaModel : variableMetaModelList) { + leftRangesPerVariable.add(SampleValueRanges.of(leftPillar, variableMetaModel, solutionView)); + } + // Scaled to the candidate pool's size. + // Each candidate key is checked cheaply first (it must differ from leftKey) + // before paying to assemble the right subpillar and run isValidSwap; + // a match sets pendingLeftPillar/pendingRightPillar as a side effect for accept() to consume. + var keyIterator = distinctKeysInstance.iterator(random); + var bailOutSize = distinctKeysInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(keyIterator, candidateKey -> { + if (Objects.equals(candidateKey, leftKey)) { + return false; + } + var rightPillars = pillarSourceInstance.samplingIterator(candidateKey, rightSampler, random); + var rightPillar = rightPillars.hasNext() ? rightPillars.next() : null; + if (rightPillar != null && + MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftPillar, leftRangesPerVariable, + rightPillar)) { + pendingLeftPillar = leftPillar; + pendingRightPillar = rightPillar; + return true; + } + return false; + }, bailOutSize); + } + + @Override + public void accept(List leftKey, List rightKey) { + nextMove = Moves.pillarSwap(variableMetaModelList, Objects.requireNonNull(pendingLeftPillar), + Objects.requireNonNull(pendingRightPillar)); + pendingLeftPillar = null; + pendingRightPillar = null; + } + + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java new file mode 100644 index 00000000000..478a833b142 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -0,0 +1,128 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.BiDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws subpillars, governed by a {@link Sampler}, + * of entities sharing a non-null value ("slice value") of the given variable + * and creates a move to unassign every member at once (set the basic planning variable to null). + * The (sub)pillar is keyed on this one variable alone; + * members may differ in every other variable. + *

+ * {@link SubPillarChangeMoveProvider} makes this same move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only with probability {@code 1/(s+1)} per drawn subpillar + * (where {@code s} is the size of the subpillar members' value range), + * so it arrives rarely. + * This class exists to make it happen often. + *

+ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; + * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @see UnassignMoveProvider Unassigning a single entity at a time. + * @see PillarUnassignMoveProvider Unassigning the whole pillar of every entity sharing the slice value at once. + * @see MassAssignMoveProvider Mass-assigning unassigned entities. + * @see MassUnassignMoveProvider Mass-unassigning a mixed-value sample with no shared key. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubPillarUnassignMoveProvider + implements MoveProvider { + + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + + public SubPillarUnassignMoveProvider(PlanningVariableMetaModel variableMetaModel, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassigned()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + this.sampler = Objects.requireNonNull(sampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var distinctValueDataset = MoveProviderUtil.distinctAssignedValues(moveStreamFactory, variableMetaModel); + var pillarSourceDataset = MoveProviderUtil.entitiesByAssignedValue(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new SubPillarUnassignMoveIterator<>(session, random, + variableMetaModel, distinctValueDataset, pillarSourceDataset, sampler)); + } + + /** + * Draws subpillars sharing an assigned value ("slice value") and unassigns every member, + * producing a {@code MassChangeMove} with a null destination. + * The destination is fixed at null, so nothing can ever be rejected: + * every drawn subpillar yields a valid move, + * with no probing and no left value to retire. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class SubPillarUnassignMoveIterator + implements Iterator> { + + private final Iterator sliceValueIterator; + private final BiDatasetInstance pillarSourceInstance; + private final PlanningVariableMetaModel variableMetaModel; + private final Sampler sampler; + private final RandomGenerator random; + + public SubPillarUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningVariableMetaModel variableMetaModel, + UniDataset distinctValueDataset, + BiDataset pillarSourceDataset, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sampler = Objects.requireNonNull(sampler); + this.random = Objects.requireNonNull(random); + var distinctValueInstance = session.getInstance(distinctValueDataset); + // Plain sampling-with-replacement, not exhaustiveIterator: + // nothing needs retiring at this outer level, + // since a destination of null can never be rejected. + // The subpillar's own members are still drawn without replacement inside samplingIterator below, + // per the sampler. + this.sliceValueIterator = distinctValueInstance.iterator(random); + this.pillarSourceInstance = session.getInstance(pillarSourceDataset); + } + + @Override + public boolean hasNext() { + return sliceValueIterator.hasNext(); + } + + @Override + public Move next() { + var sliceValue = sliceValueIterator.next(); + // Never ends while the slice is not empty, + // and a just-drawn slice value always has at least one member, + // so this draw always succeeds. + var pillar = pillarSourceInstance.samplingIterator(sliceValue, sampler, random).next(); + return Moves.massChange(variableMetaModel, pillar, null); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java index 97963b1e7b5..cb6e1008b70 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; @@ -23,17 +22,28 @@ * all of which must belong to the same entity class. * *

- * Only provide entities whose values can be swapped; + * The caller MUST only provide entities whose values can be swapped; * for example, if one of the values is not in the value range of the other entity's variable, - * then swapping would lead to an invalid solution. - * This move will skip that swap, - * but it is more efficient to not propose it in the first place. - * + * swapping would lead to an invalid solution. + * This move does not re-check that at execution time, matching {@link Moves#swap}; + * if the pair is invalid, the move writes the out-of-range value anyway and the solution becomes invalid, + * with no exception. + * The built-in {@code SwapMoveProvider} never proposes such a pair. + *

+ * Similarly, a move over two entities that already hold equal values on every listed variable now performs + * writes that produce no net change; the built-in {@code SwapMoveProvider} never proposes such a move either. + *

+ * The caller is responsible for ordering the given variables consistently; + * this constructor does not reorder them. + * Two moves over the same entities and the same set of variables are only guaranteed to be equal + * if the caller lists the variables in the same order both times. + * {@code SwapMoveProvider} normalizes the order for moves it builds. + * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the entity type, the class with the {@link PlanningEntity} annotation */ @NullMarked -public class SwapMove extends AbstractMove { +public final class SwapMove extends AbstractMove { private final List> variableMetaModelList; private final Entity_ leftEntity; @@ -50,14 +60,14 @@ public class SwapMove extends AbstractMove { */ private @Nullable List<@Nullable Object> valueCache; - protected SwapMove(List> variableMetaModelList, Entity_ leftEntity, + SwapMove(List> variableMetaModelList, Entity_ leftEntity, Entity_ rightEntity) { - this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); if (variableMetaModelList.isEmpty()) { throw new IllegalArgumentException( "Swap move requires at least one planning variable to swap between entities, but got (%s)." .formatted(variableMetaModelList)); } + this.variableMetaModelList = variableMetaModelList; this.leftEntity = Objects.requireNonNull(leftEntity); this.rightEntity = Objects.requireNonNull(rightEntity); if (leftEntity == rightEntity) { @@ -92,26 +102,14 @@ public void execute(MutableSolutionView solutionView) { var variableMetaModel = variableMetaModelList.get(i / 2); var oldLeftValue = cachedValues.get(i); var oldRightValue = cachedValues.get(i + 1); - if (Objects.equals(oldLeftValue, oldRightValue) - || !solutionView.isValueInRange(variableMetaModel, leftEntity, oldRightValue) - || !solutionView.isValueInRange(variableMetaModel, rightEntity, oldLeftValue)) { - // No change needed, skip it. - continue; - } solutionView.changeVariable(variableMetaModel, leftEntity, oldRightValue); solutionView.changeVariable(variableMetaModel, rightEntity, oldLeftValue); } } private List<@Nullable Object> getCachedValues() { - if (valueCache != null) { - return valueCache; - } - valueCache = new ArrayList<>(variableMetaModelList.size() * 2); - for (var variableMetaModel : variableMetaModelList) { - var variableDescriptor = getVariableDescriptor(variableMetaModel); - valueCache.add(variableDescriptor.getValue(leftEntity)); - valueCache.add(variableDescriptor.getValue(rightEntity)); + if (valueCache == null) { + valueCache = MoveProviderUtil.cachedValuesOf(leftEntity, rightEntity, variableMetaModelList); } return valueCache; } @@ -142,25 +140,14 @@ public int hashCode() { @Override public String toString() { var s = new StringBuilder(variableMetaModelList.size() * 16); + var cachedValues = getCachedValues(); s.append(leftEntity).append(" {"); - appendVariablesToString(s, true); + MoveProviderUtil.appendInterleavedRow(s, cachedValues, true); s.append("} <-> "); s.append(rightEntity).append(" {"); - appendVariablesToString(s, false); + MoveProviderUtil.appendInterleavedRow(s, cachedValues, false); s.append("}"); return s.toString(); } - private void appendVariablesToString(StringBuilder s, boolean isLeftEntity) { - var cachedValues = getCachedValues(); - for (var i = 0; i < cachedValues.size(); i += 2) { - var index = isLeftEntity ? i : i + 1; - var value = cachedValues.get(index); - if (i > 0) { - s.append(", "); - } - s.append(value == null ? "null" : value.toString()); - } - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java index 3de26e5892e..9e7403b3021 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java @@ -2,12 +2,10 @@ import java.util.List; import java.util.Objects; -import java.util.stream.Stream; import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel; -import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; +import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineEntityMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; -import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.SolutionView; import ai.timefold.solver.core.preview.api.neighborhood.BiMoveConstructor; @@ -18,44 +16,48 @@ import org.jspecify.annotations.NullMarked; +/** + * For every pair of distinct entities of the entity class, + * creates a move that swaps the values of every variable given to the constructor, + * provided at least one variable differs and every differing variable is legal on both entities; + * if any differing variable is out of range, the pair is skipped entirely. + * + * @param the solution type + * @param the entity type + */ @NullMarked -public class SwapMoveProvider +public final class SwapMoveProvider implements MoveProvider { - private final PlanningEntityMetaModel entityMetaModel; + private final GenuineEntityMetaModel entityMetaModel; private final List> variableMetaModelList; - @SuppressWarnings("unchecked") - public SwapMoveProvider(PlanningEntityMetaModel entityMetaModel) { - this.entityMetaModel = Objects.requireNonNull(entityMetaModel); - this.variableMetaModelList = entityMetaModel.variables().stream() - .flatMap(v -> { - if (v instanceof PlanningVariableMetaModel planningVariableMetaModel) { - return Stream.of((PlanningVariableMetaModel) planningVariableMetaModel); - } - return Stream.empty(); - }) - .toList(); - if (variableMetaModelList.isEmpty()) { - throw new IllegalArgumentException("The entityClass (%s) has no basic planning variables." - .formatted(entityMetaModel.type().getCanonicalName())); - } + /** + * As defined by {@link #SwapMoveProvider(List)}, + * but for every basic planning variable of {@code entityMetaModel}. + */ + public SwapMoveProvider(GenuineEntityMetaModel entityMetaModel) { + this(MoveProviderUtil.basicVariablesOf(entityMetaModel)); + } + + /** + * As defined by {@link #SwapMoveProvider(List)}, but for a single variable. + */ + public SwapMoveProvider(PlanningVariableMetaModel variableMetaModel) { + this(List.of(variableMetaModel)); } - public SwapMoveProvider(List> variableMetaModelList) { - this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); - var entityMetaModels = variableMetaModelList.stream() - .map(VariableMetaModel::entity) - .distinct() - .toList(); - this.entityMetaModel = switch (entityMetaModels.size()) { - case 0 -> throw new IllegalArgumentException("The variableMetaModelList (%s) is empty." - .formatted(variableMetaModelList)); - case 1 -> entityMetaModels.getFirst(); - default -> throw new IllegalArgumentException( - "The variableMetaModelList (%s) contains variables from multiple entity classes." - .formatted(variableMetaModelList)); - }; + /** + * A pair is proposed only when at least one listed variable differs + * and every differing variable is legal on both entities; + * if any differing variable is out of range, the pair is skipped entirely. + * All variables must belong to the same entity class. + * + * @param variableMetaModelList must not be empty + */ + public SwapMoveProvider(List> variableMetaModelList) { + this.variableMetaModelList = MoveProviderUtil.normalize(variableMetaModelList); + this.entityMetaModel = variableMetaModelList.getFirst().entity(); } @Override @@ -64,7 +66,6 @@ public MoveStream build(MoveStreamFactory moveStreamFactor var entityStream = moveStreamFactory.forEach(entityType, false); var moveConstructor = (BiMoveConstructor) this::buildMove; // We do not exclude duplicate swaps (A<>B and B<>A) to keep it simple and fast. - // Move selectors don't do anything about duplicate moves either. return moveStreamFactory.pick(entityStream) .pick(entityStream, NeighborhoodsJoiners.filtering(this::isValidSwap)) diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java index 4d67ac1036f..2a7d8deea73 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java @@ -14,20 +14,26 @@ * creates a move to unassign it (set the variable to null). *

* This provider only applies to planning variables that allow unassigned values. + *

+ * {@link ChangeMoveProvider} makes this same move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only with probability {@code 1/(v+1)} per draw + * (where {@code v} is the number of registered values), + * so it arrives rarely. + * This class exists to make it happen often. + *

* For the complementary moves: *

    *
  • Use {@link AssignMoveProvider} to assign a value to currently-unassigned entities.
  • *
  • Use {@link ChangeMoveProvider} to change an entity's value to a different non-null value.
  • *
*

- * This class is part of the Neighborhoods API, which is under development and is only offered as a preview - * feature. - * There are no guarantees for backward compatibility; - * any class, method, or field may change or be removed without prior notice, - * although we will strive to avoid this as much as possible. + * For unassigning several entities at once, + * see {@link PillarUnassignMoveProvider} and {@link SubPillarUnassignMoveProvider} (members share a value) + * or {@link MassUnassignMoveProvider} (members need not share anything). */ @NullMarked -public class UnassignMoveProvider +public final class UnassignMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/package-info.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/package-info.java new file mode 100644 index 00000000000..915bd2d074e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/package-info.java @@ -0,0 +1,20 @@ +/** + * Contains the built-in {@link Move} implementations, + * and their associated {@link ai.timefold.solver.core.preview.api.neighborhood.MoveProvider} implementations. + * Both are expected to be directly used, + * neither are designed for extensibility. + * Users can and should implement custom move providers more efficient than these, + * on account of their knowledge of the problem domain. + * There is a price we pay for generality, + * and specialization can lead to better performance. + * + *

+ * New instances of generic moves should be obtained via {@link Moves}. + * Moves are designed for performance; + * for that reason, they do not re-assert validity of inputs, + * and will corrupt your solution if used incorrectly. + * Check Javadoc of each move for the contract you are required to follow. + */ +package ai.timefold.solver.core.preview.api.move.builtin; + +import ai.timefold.solver.core.preview.api.move.Move; \ No newline at end of file diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/UniMoveConstructor.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/UniMoveConstructor.java index d55e62ef639..41c35e3c34f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/UniMoveConstructor.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/UniMoveConstructor.java @@ -2,7 +2,7 @@ import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.SolutionView; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -10,7 +10,7 @@ /** * A functional interface for constructing a {@link Move} from a single picked element. *

- * Use this with {@link UniSamplingStream#asMove(UniMoveConstructor)} + * Use this with {@link UniPickingStream#asMove(UniMoveConstructor)} * to build moves from a single pick, without requiring a second pick. * * @param the solution type 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 36388ba4f7e..d7e0da6b720 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 @@ -14,7 +14,7 @@ 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; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; import org.jspecify.annotations.NullMarked; @@ -123,11 +123,11 @@ public interface MoveStreamFactory { UniEnumeratingStream forEachDestinationIncludingUnassigned(PlanningListVariableMetaModel variableMetaModel); - UniSamplingStream pick(UniEnumeratingStream enumeratingStream); + UniPickingStream pick(UniEnumeratingStream enumeratingStream); /** * Terminal operation for datasets {@link UniEnumeratingStream#asCachedDataset() cached} from streams - * started on this factory, parallel to {@link UniSamplingStream#asMove}. + * started on this factory, parallel to {@link UniPickingStream#asMove}. * The move order of the given provider's iterator is never part of the API contract. */ MoveStream buildMoveStream(MoveIteratorProvider iteratorProvider); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java index ee02ce68bda..64697174248 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java @@ -3,6 +3,10 @@ import java.util.Iterator; import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -48,4 +52,28 @@ public interface BiDatasetInstance { */ Iterator<@Nullable B> exhaustiveIterator(@Nullable A a, RandomGenerator random); + /** + * As defined by {@link UniDatasetInstance#samplingIterator(Sampler, RandomGenerator)}, + * but restricted to rows paired with the given left value, + * which is a slice selector and not a key: + * the resulting {@link Sample} carries no key and promises nothing beyond its membership. + *

+ * On an {@code equal}-indexed slice, a sample of k members costs about k draws, + * and O(1) to create the draw. + * On a comparison- or range-indexed slice, add O(b), b being the number of matching buckets, + * because the underlying iterator walks every bucket up front. + * + * @param a the slice selector; may be null, as null rows are legal + * @param sampler decides which candidates join each sample + * @param random never null + * @return never null + */ + default Iterator> samplingIterator(@Nullable A a, Sampler sampler, RandomGenerator random) { + // Must be exhaustiveIterator, never iterator: + // the with-replacement draw wraps a filtering() join in a FilteringIterator that has a bail-out, + // which reports "nothing left" while elements remain, + // so a sample would end early and silently. + return SampleAssembler.iterator(() -> exhaustiveIterator(a, random), sampler); + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java index 2f36a4108eb..f1b936511a6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java @@ -3,6 +3,10 @@ import java.util.Iterator; import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -43,13 +47,46 @@ public interface UniDatasetInstance { * and then the iterator ends, without any cooperation from the caller. * {@link Iterator#remove()} is not supported and never needs to be called; * the caller cannot break uniqueness. - * Significantly more expensive to create and maintain than {@link #iterator(RandomGenerator)}, + * Draining it in full is significantly more expensive than {@link #iterator(RandomGenerator)}, * to the point where large datasets may become impractical in terms of memory and CPU, * especially in the case of large multi-leveled joins. + * Partial consumption is cheap, however: + * pulling k of n rows and abandoning the rest costs O(1) to create plus about k draws, + * because the underlying iterator grows its bookkeeping with the number of draws, + * not with the size of the source. * * @param random never null * @return never null */ Iterator<@Nullable A> exhaustiveIterator(RandomGenerator random); + /** + * Samples with replacement: sets of rows drawn together by the given sampler. + * Never ends while the dataset is not empty, and may return equal samples. + * Within one sample, members are drawn without replacement, + * and every candidate drawn is offered to the sampler exactly once, + * whether the sampler takes it or not. + * Retirement is local to one sample; the next sample starts from a full source. + *

+ * Each sample of k members costs about k draws plus whatever the sampler rejects; + * the framework never rejects a candidate itself. + * Prefer {@link BiDatasetInstance#samplingIterator(Object, Sampler, RandomGenerator)} + * when the grouping is known at build time, so that the index does the filtering; + * use this form when membership can only be decided at run time. + *

+ * Move generation is single-threaded; + * see {@link Sampler} for what that means for sampler state. + * + * @param sampler decides which candidates join each sample + * @param random never null + * @return never null + */ + default Iterator> samplingIterator(Sampler sampler, RandomGenerator random) { + // Must be exhaustiveIterator, never iterator: + // the with-replacement draw wraps a filtering() join in a FilteringIterator that has a bail-out, + // which reports "nothing left" while elements remain, + // so a sample would end early and silently. + return SampleAssembler.iterator(() -> exhaustiveIterator(random), sampler); + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java new file mode 100644 index 00000000000..2a7715ebf80 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java @@ -0,0 +1,60 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Objects; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +final class DefaultSample implements Sample { + + private final LinkedHashSet<@Nullable A> memberSet; + + DefaultSample(Collection<@Nullable A> memberCollection) { + this.memberSet = new LinkedHashSet<>(Objects.requireNonNull(memberCollection, "memberCollection")); + if (memberSet.isEmpty()) { + throw new IllegalArgumentException("The memberCollection (%s) of a sample must not be empty." + .formatted(memberCollection)); + } + } + + @Override + public int size() { + return memberSet.size(); + } + + @Override + public boolean contains(@Nullable A element) { + return memberSet.contains(element); + } + + @Override + public @Nullable A first() { + return memberSet.getFirst(); + } + + @Override + public Iterator<@Nullable A> iterator() { + return Collections.unmodifiableSet(memberSet).iterator(); + } + + @Override + public boolean equals(Object o) { + return o instanceof DefaultSample other && memberSet.equals(other.memberSet); + } + + @Override + public int hashCode() { + return memberSet.hashCode(); + } + + @Override + public String toString() { + return "Sample(" + memberSet + ")"; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java new file mode 100644 index 00000000000..2ff3ff63655 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -0,0 +1,77 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.Collection; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An unordered set of values drawn together by a {@link Sampler}, to be moved as one. + *

+ * A sample carries no key. + * It promises only that it is a set of members produced by a sampler; + * any shared property is the concern of whoever drew it. + * A sample is immutable and safe to hold in a move, + * but it says nothing about the solution, + * so every value a move needs must be read from the live solution. + *

+ * Two samples are equal when they hold the same members, + * whatever the order they were drawn in. + *

+ * An implementation must be immutable: + * {@link #size()} must always agree with {@link #iterator()}, + * and the iteration order must be stable across calls. + * A move holds a {@code Sample} across probes and undo, + * so it must read the same members every time. + * + * @param the type of the sample's members + */ +@NullMarked +public interface Sample + extends Iterable<@Nullable A> { + + /** + * @return the number of members; at least 1 for a drawn sample + */ + int size(); + + /** + * @param element may be null, as null members are legal + * @return true if the element is a member + */ + boolean contains(@Nullable A element); + + /** + * @return the first member; never null + */ + @Nullable + A first(); + + /** + * Creates a sample from a collection, copying it and removing duplicates, + * so that {@link #size()} never disagrees with {@link Object#equals(Object)}. + * + * @param memberCollection may contain nulls; not retained + * @param the type of the sample's members + * @return never null + * @throws NullPointerException if memberCollection is null + * @throws IllegalArgumentException if memberCollection is empty + */ + static Sample of(Collection<@Nullable A> memberCollection) { + return new DefaultSample<>(memberCollection); + } + + enum Decision { + + /** Take the candidate and continue. */ + ACCEPT, + /** Leave the candidate out and continue. */ + REJECT, + /** Take the candidate and finish the sample. */ + ACCEPT_AND_STOP, + /** Leave the candidate out and finish the sample. */ + STOP + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java new file mode 100644 index 00000000000..9cf5c563fe9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java @@ -0,0 +1,57 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.Iterator; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Decides which candidates join a {@link Sample}. + *

+ * The first candidate drawn is always accepted and becomes the seed; + * {@link #reset(Object)} is called with it, + * and {@link #evaluate(int, Object)} is first called for the second candidate, with {@code sizeSoFar == 1}. + * A sample ends on {@link Decision#STOP}, {@link Decision#ACCEPT_AND_STOP}, + * or when the source runs out. + * A sampler which never stops yields the whole dataset or the whole slice. + * A sampler that stops right at the seed - + * deciding {@link Decision#STOP} or {@link Decision#ACCEPT_AND_STOP} on the very first {@link #evaluate(int, Object)} call - + * yields only size-1 samples; + * move providers built on top of a {@code Sampler} may reject those. + *

+ * A sample is assembled in full before it is returned, + * so one sampler instance may serve several {@link Iterator}s in sequence, + * one sample at a time. + *

+ * Move generation is single-threaded, + * so a sampler may hold state across {@link #evaluate(int, Object)} calls + * and reset it in {@link #reset(Object)}. + * A sampler instance must not be shared between separate neighborhood providers, + * nor between the two sides of a swap: + * each side of a swap move provider always takes its own sampler instance. + * + * @param the type of the sample's members + */ +@NullMarked +public interface Sampler { + + /** + * Called once per sample, after the seed is accepted and before the first + * {@link #evaluate(int, Object)}. + * + * @param seed the first candidate drawn, already a member + */ + default void reset(@Nullable A seed) { + // Nothing to do by default. + } + + /** + * @param sizeSoFar the number of members already accepted; at least 1 + * @param candidate the candidate offered, drawn at most once per sample + * @return what to do with the candidate + */ + Decision evaluate(int sizeSoFar, @Nullable A candidate); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java new file mode 100644 index 00000000000..dc88850e5df --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java @@ -0,0 +1,18 @@ +/** + * This package contains classes and interfaces that are used to enumerate, sample and combine + * facts and entities of a {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} + * into {@link ai.timefold.solver.core.preview.api.move.Move}s. + *

+ * This package and all of its subpackages are part of the Neighborhoods API, + * which is under development and is only offered as a preview feature. + * There are no guarantees for backward compatibility; + * any class, method, or field may change or be removed without prior notice, + * although we will strive to avoid this as much as possible. + *

+ * We encourage you to try the API and give us feedback on your experience with it, + * before we finalize the API. + * Please direct your feedback to + * Timefold Solver GitHub + * or to Timefold Discord. + */ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java index 377b86b844f..9bee7e84ed2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java @@ -73,6 +73,9 @@ BiEnumeratingStream groupBy( * inside {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. *

* Repeated calls on the same stream return an equal handle, and the rows are materialized only once. + * + * @return Any operations called on the returned instance will not be cached. + * This method creates the boundary the in-memory caching from the just-in-time computations. */ BiDataset asCachedDataset(); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java index 9decf44e2c9..95245e98b47 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java @@ -10,7 +10,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsMapper; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsPredicate; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.BiNeighborhoodsJoiner; -import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; +import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; import org.jspecify.annotations.NullMarked; @@ -477,7 +477,7 @@ BiEnumeratingStream groupBy( * duplicate copies of the same tuple will be omitted at a performance cost. * * @return a stream that is guaranteed to have distinct tuples, - * at the cost of increased time and memory usage + * at the cost of increased time and memory usage. */ UniEnumeratingStream distinct(); @@ -491,7 +491,9 @@ BiEnumeratingStream groupBy( *

* Repeated calls on the same stream return an equal handle, and the rows are materialized only once. * - * @see UniSamplingStream For the declarative alternative, which reads from this stream directly. + * @see UniPickingStream For the declarative alternative, which reads from this stream directly. + * @return Any operations called on the returned instance will not be cached. + * This method creates the boundary the in-memory caching from the just-in-time computations. */ UniDataset asCachedDataset(); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/BiSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/BiPickingStream.java similarity index 80% rename from core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/BiSamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/BiPickingStream.java index 27c8e3038bd..bde3fd22903 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/BiSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/BiPickingStream.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; +package ai.timefold.solver.core.preview.api.neighborhood.stream.picking; import ai.timefold.solver.core.preview.api.neighborhood.BiMoveConstructor; import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStream; @@ -6,7 +6,7 @@ import org.jspecify.annotations.NullMarked; @NullMarked -public interface BiSamplingStream extends SamplingStream { +public interface BiPickingStream extends PickingStream { MoveStream asMove(BiMoveConstructor moveConstructor); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/SamplingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/PickingStream.java similarity index 73% rename from core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/SamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/PickingStream.java index ad6c47599b4..fa82ed785e9 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/SamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/PickingStream.java @@ -1,8 +1,8 @@ -package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; +package ai.timefold.solver.core.preview.api.neighborhood.stream.picking; import org.jspecify.annotations.NullMarked; @NullMarked -public interface SamplingStream { +public interface PickingStream { } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/UniSamplingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/UniPickingStream.java similarity index 68% rename from core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/UniSamplingStream.java rename to core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/UniPickingStream.java index a751c3bb279..f27bafd0b61 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/sampling/UniSamplingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/picking/UniPickingStream.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; +package ai.timefold.solver.core.preview.api.neighborhood.stream.picking; import ai.timefold.solver.core.preview.api.neighborhood.UniMoveConstructor; import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStream; @@ -8,32 +8,32 @@ import org.jspecify.annotations.NullMarked; @NullMarked -public interface UniSamplingStream extends SamplingStream { +public interface UniPickingStream extends PickingStream { @SuppressWarnings("unchecked") - default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream) { + default BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream) { return pick(uniEnumeratingStream, new BiNeighborhoodsJoiner[0]); } @SuppressWarnings("unchecked") - default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + default BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream, BiNeighborhoodsJoiner joiner) { return pick(uniEnumeratingStream, new BiNeighborhoodsJoiner[] { joiner }); } @SuppressWarnings("unchecked") - default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + default BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream, BiNeighborhoodsJoiner joiner1, BiNeighborhoodsJoiner joiner2) { return pick(uniEnumeratingStream, new BiNeighborhoodsJoiner[] { joiner1, joiner2 }); } @SuppressWarnings("unchecked") - default BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + default BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream, BiNeighborhoodsJoiner joiner1, BiNeighborhoodsJoiner joiner2, BiNeighborhoodsJoiner joiner3) { return pick(uniEnumeratingStream, new BiNeighborhoodsJoiner[] { joiner1, joiner2, joiner3 }); } - BiSamplingStream pick(UniEnumeratingStream uniEnumeratingStream, + BiPickingStream pick(UniEnumeratingStream uniEnumeratingStream, BiNeighborhoodsJoiner... joiners); MoveStream asMove(UniMoveConstructor moveConstructor); diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index c0b576039ab..a3eabc81213 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -66,13 +66,14 @@ exports ai.timefold.solver.core.preview.api.move.builtin; exports ai.timefold.solver.core.preview.api.move.test; exports ai.timefold.solver.core.preview.api.neighborhood; + exports ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; exports ai.timefold.solver.core.preview.api.neighborhood.stream; exports ai.timefold.solver.core.preview.api.neighborhood.stream.dataset; exports ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.collector; exports ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating; exports ai.timefold.solver.core.preview.api.neighborhood.stream.function; exports ai.timefold.solver.core.preview.api.neighborhood.stream.joiner; - exports ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; + exports ai.timefold.solver.core.preview.api.neighborhood.stream.picking; exports ai.timefold.solver.core.preview.api.neighborhood.test; // Exporting move selectors and associated code as semi-public API; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 3d4df58304f..4e3fa3db3db 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -53,6 +53,11 @@ import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedEntity; import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedOtherValue; import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEasyScoreCalculator; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -72,6 +77,43 @@ void readBasicVariable() { assertThat(actualValue).isEqualTo(expectedValue); } + @Test + void getValueRangeIncludesNullForUnassignedAllowingVariable() { + var solutionDescriptor = TestdataAllowsUnassignedSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + var scoreDirectorFactory = new EasyScoreDirectorFactory<>(solutionDescriptor, + new TestdataAllowsUnassignedEasyScoreCalculator(), EnvironmentMode.PHASE_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + var solution = TestdataAllowsUnassignedSolution.generateSolution(); + scoreDirector.setWorkingSolution(solution); + + var moveDirector = new MoveDirector<>(scoreDirector); + var entity = solution.getEntityList().getFirst(); + + // getValueRange must return the cached range with its NullAllowingValueRange wrapper intact + // (not unwrapped), or isValueInRange - now a default delegating to getValueRange(...).contains(...) - + // would silently start rejecting null for every variable that allows unassigned. + var range = moveDirector.getValueRange(variableMetaModel, entity); + assertThat(range.contains(null)).isTrue(); + assertThat(moveDirector.isValueInRange(variableMetaModel, entity, null)).isTrue(); + } + + @Test + void twoArgIsValueInRangeThrowsForEntityScopedRange() { + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class).basicVariable(); + + var mockScoreDirector = mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector(mockScoreDirector); + + // The 2-arg overload only works for a solution-scoped range; this variable's range is on + // the entity, so it must fail loudly instead of silently checking against a null entity. + assertThatThrownBy(() -> moveDirector.isValueInRange(variableMetaModel, new TestdataValue("value"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("has a value range on an entity, not on the solution"); + } + @Test void changeVariable() { var solutionMetaModel = TestdataSolution.buildSolutionDescriptor().getMetaModel(); @@ -1841,7 +1883,7 @@ void restoreWorkingScoreWithPostprocessorWithoutFreshScore() { Move move = solutionView -> solutionView.changeVariable(variableMetaModel, entity, badValue); var moveDirector = new MoveDirector<>(scoreDirector); var result = moveDirector.executeTemporary(move, - sol -> sol.getEntityList().get(0).getValue().getCode(), false); + sol -> sol.getEntityList().getFirst().getValue().getCode(), false); assertSoftly(softly -> { // Postprocessor ran while the move was still applied. @@ -1873,7 +1915,7 @@ void restoreWorkingScoreWithPostprocessorAndFreshScore() { Move move = solutionView -> solutionView.changeVariable(variableMetaModel, entity, badValue); var moveDirector = new MoveDirector<>(scoreDirector); var result = moveDirector.executeTemporary(move, - sol -> sol.getEntityList().get(0).getValue().getCode(), true); + sol -> sol.getEntityList().getFirst().getValue().getCode(), true); assertSoftly(softly -> { softly.assertThat(result).isEqualTo("bad"); @@ -1887,13 +1929,12 @@ void restoreWorkingScoreWithPostprocessorAndFreshScore() { private BavetConstraintStreamScoreDirector buildScoreDirector( SolutionDescriptor solutionDescriptor, TestdataSolution solution) { - BavetConstraintStreamScoreDirectorFactory f = - new BavetConstraintStreamScoreDirectorFactory<>(solutionDescriptor, - constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataEntity.class) - .filter(entity -> entity.getValue().getCode().equals("bad")) - .penalize(SimpleScore.ONE) - .asConstraint("Bad value") }, - EnvironmentMode.FULL_ASSERT); + var f = new BavetConstraintStreamScoreDirectorFactory(solutionDescriptor, + constraintFactory -> new Constraint[] { constraintFactory.forEach(TestdataEntity.class) + .filter(entity -> entity.getValue().getCode().equals("bad")) + .penalize(SimpleScore.ONE) + .asConstraint("Bad value") }, + EnvironmentMode.FULL_ASSERT); var scoreDirector = new BavetConstraintStreamScoreDirector.Builder<>(f).build(); scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java index 6e5e1c316c1..a2d5941743a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java @@ -4,12 +4,6 @@ import java.util.List; import java.util.Random; -import ai.timefold.solver.core.api.score.SimpleScore; -import ai.timefold.solver.core.config.solver.EnvironmentMode; -import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession; -import ai.timefold.solver.core.impl.score.director.SessionContext; -import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.impl.util.ElementAwareArrayList; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; @@ -41,7 +35,7 @@ * it stays next to the production class it protects. */ @Execution(ExecutionMode.CONCURRENT) -abstract class AbstractBiasIT { +public abstract class AbstractBiasIT { /** * How many standard deviations of sampling noise a category's observed count @@ -57,7 +51,7 @@ abstract class AbstractBiasIT { * and belongs either fixed or documented with a per-case override, * not hidden by a looser global constant. */ - static final double SIGMA_LIMIT = 5.0; + public static final double SIGMA_LIMIT = 5.0; /** * Builds one trial's seed from a root random, @@ -65,7 +59,7 @@ abstract class AbstractBiasIT { * {@link Random}'s first {@code nextInt(2)} call is constant across such small, close seeds (an LCG artifact), * which would make a first-draw bias undetectable no matter how large it is. */ - static Random splitFrom(Random root) { + public static Random splitFrom(Random root) { return new Random(root.nextLong()); } @@ -82,22 +76,6 @@ static Iterator> moveIterator(MoveProvider session(DefaultMoveStreamFactory moveStreamFactory, - TestdataSolution solution) { - var scoreDirector = new EasyScoreDirectorFactory<>(moveStreamFactory.getSolutionDescriptor(), - s -> SimpleScore.ZERO, EnvironmentMode.PHASE_ASSERT).buildScoreDirector(); - scoreDirector.setWorkingSolution(solution); - var session = moveStreamFactory.createSession(new SessionContext<>(scoreDirector)); - moveStreamFactory.getSolutionDescriptor().visitAll(solution, session::insert); - session.settle(); - return session; - } - /** * Picks (entity, value) pairs matched by the given joiner; reused for both the indexing * {@code equal} and the {@code filtering()} shape. diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java index 5f4ca188de2..80ac65e69ad 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java @@ -28,7 +28,7 @@ * @param what a single draw is classified into (a bucket, an entity code, a whole * draw order, ...) */ -final class BiasReport { +public final class BiasReport { private static final Logger LOG = LoggerFactory.getLogger(BiasReport.class); private static final int MAX_DETAIL_ROW_COUNT = 32; @@ -44,7 +44,7 @@ private BiasReport(String label, int sampleCount, Map countByCa this.countByCategory = Objects.requireNonNull(countByCategory); } - static BiasReport tally(String label, int sampleCount, IntFunction sampler) { + public static BiasReport tally(String label, int sampleCount, IntFunction sampler) { var countByCategory = new HashMap(); for (var i = 0; i < sampleCount; i++) { countByCategory.merge(sampler.apply(i), 1L, Long::sum); @@ -57,7 +57,7 @@ static BiasReport tally(String label, int sampleCount, In * ({@code 1 / expectedCategoryCollection.size()}). A category absent from the collection is * ignored by {@link #assertWithinSigma(double)}, even if it was drawn. */ - BiasReport expectUniform(Collection expectedCategoryCollection) { + public BiasReport expectUniform(Collection expectedCategoryCollection) { var share = 1.0 / expectedCategoryCollection.size(); var freshExpectedShareByCategory = new HashMap(); for (var category : expectedCategoryCollection) { @@ -83,7 +83,7 @@ BiasReport expectWeights(Map expectedShareByCatego * binomial sampling noise. Requires {@link #expectUniform} or {@link #expectWeights} to have * been called first. */ - void assertWithinSigma(double sigmaLimit) { + public void assertWithinSigma(double sigmaLimit) { assertThat(expectedShareByCategory) .as("call expectUniform() or expectWeights() before assertWithinSigma()") .isNotEmpty(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/DatasetBucketBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/DatasetBucketBiasIT.java index 2fdfca736b8..d498b920598 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/DatasetBucketBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/DatasetBucketBiasIT.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.bias; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; + import java.util.ArrayList; import java.util.EnumMap; import java.util.List; @@ -48,7 +50,7 @@ void bucketsAreWeightedByRemainingSizeAtDraw(int drawIndex) { solution.setEntityList(List.of(probe)); solution.setValueList(valueList); - var session = session(moveStreamFactory, solution); + var session = createSession(moveStreamFactory, solution); var instance = session.getInstance(justInTimeDataset); var root = new Random(0); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/PairFairnessBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/PairFairnessBiasIT.java index 58ee51f647f..308affa7a18 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/PairFairnessBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/PairFairnessBiasIT.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.impl.neighborhood.bias; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; @@ -74,7 +75,7 @@ void cachedJoin_sameSkewedShapeStaysUniformOverAllMaterializedPairs() { var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); var cachedDataset = entityStream.join(valueStream, fixture.joiner()).asCachedDataset(); - var session = session(moveStreamFactory, fixture.solution); + var session = createSession(moveStreamFactory, fixture.solution); var instance = session.getInstance(cachedDataset); var iterator = instance.iterator(new Random(0)); @@ -112,7 +113,7 @@ void justInTimeJoin_uniqueDrainCoversEveryPairExactlyOnce() { var entityDataset = entityStream.asCachedDataset(); var justInTimeDataset = entityDataset.join(valueStream, fixture.joiner()); - var session = session(moveStreamFactory, fixture.solution); + var session = createSession(moveStreamFactory, fixture.solution); var instance = session.getInstance(justInTimeDataset); var seenPairSet = new HashSet>(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java new file mode 100644 index 00000000000..507d0a1b8b7 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java @@ -0,0 +1,140 @@ +package ai.timefold.solver.core.impl.neighborhood.bias; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; + +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * End-to-end proof that {@code samplingIterator} inherits the underlying {@code exhaustiveIterator}'s + * guaranteed post-retirement uniformity, through the new sample-drawing surface rather than the raw + * iterator directly. Covers both the Uni and Bi forms. + */ +class SamplingIteratorBiasIT extends AbstractBiasIT { + + /** + * Each sample is assembled from a fresh {@code exhaustiveIterator} + * ({@code SampleAssembler.iterator}), so drawing many size-1 samples is, per row, equivalent to + * {@code IteratorBiasIT.repeatingRandomIteratorIsUniformAtDraw} at draw #1: every row must be + * seeded about equally often. + */ + @Test + void uniSamplingIteratorSeedsEveryRowUniformly() { + var trialCount = 200_000; + var rowCount = 10; + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueList = new ArrayList(); + for (var i = 0; i < rowCount; i++) { + valueList.add(new TestdataValue("v" + i)); + } + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of()); + solution.setValueList(valueList); + + var dataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(dataset); + + var root = new Random(0); + BiasReport.tally("Uni samplingIterator, seed uniform over rows", trialCount, trial -> { + var splitRandom = splitFrom(root); + var sample = instance.samplingIterator(sizeLimit(1), splitRandom).next(); + return sample.iterator().next().getCode(); + }).expectUniform(valueList.stream().map(TestdataValue::getCode).toList()).assertWithinSigma(SIGMA_LIMIT); + } + + /** + * One probing entity and three value buckets matched to it by an indexing {@code equal} + * joiner (sizes 2/3/5, weights 0.2/0.3/0.5) — mirrors {@code DatasetBucketBiasIT}'s fixture at + * the sample level instead of the raw dataset level. + */ + @ValueSource(ints = { 1, 2, 5 }) + @ParameterizedTest + void biSamplingIteratorMembersAreUniformAtEveryDrawPositionWithinASlice(int drawIndex) { + var trialCount = 200_000; + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + var justInTimeDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var probe = new TestdataEntity("probe"); + var valueList = new ArrayList(); + for (var bucket : Bucket.values()) { + for (var i = 0; i < bucket.size; i++) { + valueList.add(new TestdataValue("probe-" + bucket.name() + "-" + i)); + } + } + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + solution.setValueList(valueList); + + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(justInTimeDataset); + + var root = new Random(0); + BiasReport.tally("Bi samplingIterator, member uniform at draw #" + drawIndex, trialCount, trial -> { + var splitRandom = splitFrom(root); + var sample = instance.samplingIterator(probe, sizeLimit(drawIndex), splitRandom).next(); + var memberIterator = sample.iterator(); + TestdataValue member = null; + for (var i = 0; i < drawIndex; i++) { + member = memberIterator.next(); + } + return Bucket.of(Objects.requireNonNull(member)); + }).expectWeights(Bucket.weightMap()).assertWithinSigma(SIGMA_LIMIT); + } + + private enum Bucket { + + SMALL(2, 0.2), + MEDIUM(3, 0.3), + LARGE(5, 0.5); + + private final int size; + private final double weight; + + Bucket(int size, double weight) { + this.size = size; + this.weight = weight; + } + + static Bucket of(TestdataValue value) { + for (var bucket : values()) { + if (value.getCode().startsWith("probe-" + bucket.name() + "-")) { + return bucket; + } + } + throw new IllegalArgumentException("Unexpected value (%s).".formatted(value)); + } + + static Map weightMap() { + var weightByBucket = new EnumMap(Bucket.class); + for (var bucket : values()) { + weightByBucket.put(bucket, bucket.weight); + } + return weightByBucket; + } + + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java new file mode 100644 index 00000000000..0b9b8f45b07 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -0,0 +1,283 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimitFromTwo; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; +import ai.timefold.solver.core.impl.solver.random.RandomSource; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; + +import org.junit.jupiter.api.Test; + +class SamplingIteratorTest { + + @Test + void samplingIteratorDrainsSourceWhenSamplerNeverStops() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueDataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(5, 0); // 5 values, 0 entities. + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(valueDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + + // First sample should contain all values. + assertThat(sampleIterator.hasNext()).isTrue(); + var firstSample = sampleIterator.next(); + assertThat(firstSample.size()).isEqualTo(5); + assertThat(firstSample).contains(solution.getValueList().toArray(new TestdataValue[0])); + + // Second sample should also contain all values (fresh source). + assertThat(sampleIterator.hasNext()).isTrue(); + var secondSample = sampleIterator.next(); + assertThat(secondSample.size()).isEqualTo(5); + assertThat(secondSample).contains(solution.getValueList().toArray(new TestdataValue[0])); + } + + @Test + void emptyDatasetHasNoNextSample() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueDataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of()); + solution.setValueList(List.of()); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(valueDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + + assertThat(sampleIterator.hasNext()).isFalse(); + } + + @Test + void samplerResetAndDecideAreCalledCorrectly() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueDataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(3, 0); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(valueDataset); + + var resetCalls = new ArrayList(); + var decideCalls = new ArrayList(); + var recordingSampler = new Sampler() { + @Override + public void reset(TestdataValue seed) { + resetCalls.add(seed); + } + + @Override + public Decision evaluate(int sizeSoFar, TestdataValue candidate) { + decideCalls.add(sizeSoFar); + return sizeLimitFromTwo(2).evaluate(sizeSoFar, candidate); + } + }; + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(recordingSampler, random); + + // Draw first sample. + assertThat(sampleIterator.hasNext()).isTrue(); + var firstSample = sampleIterator.next(); + + // Reset should have been called exactly once. + assertThat(resetCalls).hasSize(1); + // decide should have been called for the remaining elements. + assertThat(decideCalls).isNotEmpty(); + // First decide call should have sizeSoFar == 1. + assertThat(decideCalls.get(0)).isEqualTo(1); + } + + @Test + void sampleContainsNoDuplicates() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueDataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(5, 0); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(valueDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + + var firstSample = sampleIterator.next(); + var seenElements = new ArrayList(); + firstSample.iterator().forEachRemaining(seenElements::add); + + assertThat(seenElements).doesNotHaveDuplicates(); + } + + @Test + void sampleCanContainAndCheckForNullability() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var valueDataset = moveStreamFactory.forEach(TestdataValue.class, false).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(3, 0); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(valueDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + + var sample = sampleIterator.next(); + + // Sample's contains method can be called with null without error (even though null is not in this sample). + assertThat(sample.contains(null)).isFalse(); + } + + // ===== Bi tests ===== + + @Test + void biSamplingIteratorEmptySliceHasNoNextSample() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + var biDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(new TestdataEntity("probe"))); + solution.setValueList(List.of(new TestdataValue("other-value"))); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var probe = new TestdataEntity("probe"); + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + + assertThat(sampleIterator.hasNext()).isFalse(); + } + + @Test + void biSamplingIteratorDrainsSliceWhenSamplerNeverStops() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + var biDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var probe = new TestdataEntity("probe"); + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + var probeValue1 = new TestdataValue("probe-1"); + var probeValue2 = new TestdataValue("probe-2"); + solution.setValueList(List.of( + probeValue1, + probeValue2, + new TestdataValue("other-1"))); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + + // First sample should contain only the matching values. + assertThat(sampleIterator.hasNext()).isTrue(); + var firstSample = sampleIterator.next(); + assertThat(firstSample.size()).isEqualTo(2); + assertThat(firstSample).contains(probeValue1, probeValue2); + + // Second sample should also contain the matching values (fresh source). + assertThat(sampleIterator.hasNext()).isTrue(); + var secondSample = sampleIterator.next(); + assertThat(secondSample.size()).isEqualTo(2); + assertThat(secondSample).contains(probeValue1, probeValue2); + } + + @Test + void biSamplingIteratorReachesAllMatchingMembersWithFilteredJoin() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + // Filtering joiner that rejects most pairs: "probe" entity only matches values starting with "probe-match-". + var joiner = NeighborhoodsJoiners. filtering( + (solutionView, entity, value) -> !entity.getCode().equals("probe") + || value.getCode().startsWith("probe-match-")); + // Just-in-time dataset with FilteringIterator in the path. + var biDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var probe = new TestdataEntity("probe"); + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + // Create a slice with 30 matching values and 70 non-matching. + // With iterator(a, random) and bail-out, many draws would fail, creating false negatives. + // With exhaustiveIterator(a, random), all 30 are guaranteed to be reached. + var valueList = new ArrayList(); + for (var i = 0; i < 30; i++) { + valueList.add(new TestdataValue("probe-match-" + i)); + } + for (var i = 0; i < 70; i++) { + valueList.add(new TestdataValue("other-" + i)); + } + solution.setValueList(valueList); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + + // Sample should contain all 30 matching values despite the 70% rejection rate. + assertThat(sampleIterator.hasNext()).isTrue(); + var sample = sampleIterator.next(); + assertThat(sample.size()).isEqualTo(30); + } + + @Test + void biSamplingIteratorDrainsCachedDatasetSliceWhenSamplerNeverStops() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + // Cached dataset (materialized in bavet): join first, then cache. + var biDataset = entityStream.join(valueStream, joiner).asCachedDataset(); + + var probe = new TestdataEntity("probe"); + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + var probeValue1 = new TestdataValue("probe-1"); + var probeValue2 = new TestdataValue("probe-2"); + solution.setValueList(List.of( + probeValue1, + probeValue2, + new TestdataValue("other-1"))); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + + // Cached dataset should also drain its slice correctly. + assertThat(sampleIterator.hasNext()).isTrue(); + var sample = sampleIterator.next(); + assertThat(sample.size()).isEqualTo(2); + assertThat(sample).contains(probeValue1, probeValue2); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java index 0eef63279d7..93ce8d72e5f 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java @@ -1,5 +1,8 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + import java.util.Collections; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; @@ -113,8 +116,9 @@ void fromEntityAllowsUnassigned() { // - secondEntity is assigned to secondValue, and the value range only contains firstValue; // so a change to firstValue is generated. // - firstEntity is assigned to firstValue, same as its only possible non-null value; no change. - // Null (unassign) moves are not generated by ChangeMoveProvider. - var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + // crossingNull=false locks in this pre-existing behaviour; see crossingNullDefaultTrueAssignsAndUnassigns + // for the default (true, since this variable allows unassigned values). + var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, secondEntity, firstValue)); context.producesNoneOf(Moves.change(variableMetaModel, firstEntity, firstValue)); // No-op. @@ -156,13 +160,98 @@ void fromSolutionAllowsUnassigned() { var firstValue = solution.getValueList().getFirst(); // Not assigned to any entity. var secondValue = solution.getValueList().get(1); - // First entity is assigned to null, so it is filtered out by ChangeMoveProvider. - // Second entity is assigned to secondValue, so the only applicable move assigns to firstValue. - // Null (unassign) moves are not generated by ChangeMoveProvider. - var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + // First entity is assigned to null, so it is filtered out of the source when crossingNull=false. + // Second entity is assigned to secondValue, so the only applicable move changes it to firstValue. + // crossingNull=false locks in this pre-existing behaviour; see crossingNullDefaultTrueAssignsAndUnassigns + // for the default (true, since this variable allows unassigned values). + var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, secondEntity, firstValue)); context.producesNoneOf(Moves.change(variableMetaModel, secondEntity, secondValue)); // No-op. } + @Test + void crossingNullDefaultTrueAssignsAndUnassigns() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + // generateSolution(2, 2): firstEntity=null, secondEntity=secondValue. + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 2); + var firstEntity = solution.getEntityList().get(0); + var secondEntity = solution.getEntityList().get(1); + var firstValue = solution.getValueList().getFirst(); + var secondValue = solution.getValueList().get(1); + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf( + Moves.change(variableMetaModel, secondEntity, firstValue), // Ordinary change, as before. + Moves.change(variableMetaModel, firstEntity, firstValue), // Assign: firstEntity was unassigned. + Moves.change(variableMetaModel, firstEntity, secondValue), // Assign: firstEntity was unassigned. + Moves.change(variableMetaModel, secondEntity, null)); // Unassign: secondEntity was assigned. + context.producesNoneOf( + Moves.change(variableMetaModel, secondEntity, secondValue), // No-op. + Moves.change(variableMetaModel, firstEntity, null)); // No-op: already null. + } + + @Test + void crossingNullUnassignMoveIsUndoable() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + // generateSolution(2, 2): firstEntity=null, secondEntity=secondValue. + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 2); + var secondEntity = solution.getEntityList().get(1); + var originalValue = secondEntity.getValue(); + assertThat(originalValue).isNotNull(); + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var unassignMove = context.getMovesAsStream() + .filter(move -> move.equals(Moves.change(variableMetaModel, secondEntity, null))) + .findFirst() + .orElseThrow(); + + context.getMoveTestContext().executeTemporarily(unassignMove, + view -> assertThat(secondEntity.getValue()).isNull()); + assertThat(secondEntity.getValue()).isEqualTo(originalValue); + } + + @Test + void crossingNullAssignMoveIsUndoable() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + // generateSolution(2, 2): firstEntity=null, secondEntity=secondValue. + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 2); + var firstEntity = solution.getEntityList().get(0); + var firstValue = solution.getValueList().getFirst(); + assertThat(firstEntity.getValue()).isNull(); + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var assignMove = context.getMovesAsStream() + .filter(move -> move.equals(Moves.change(variableMetaModel, firstEntity, firstValue))) + .findFirst() + .orElseThrow(); + + context.getMoveTestContext().executeTemporarily(assignMove, + view -> assertThat(firstEntity.getValue()).isEqualTo(firstValue)); + assertThat(firstEntity.getValue()).isNull(); + } + + @Test + void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new ChangeMoveProvider<>(variableMetaModel, true)); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java index 1308a6209fe..9a3e955d9c4 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java @@ -30,6 +30,28 @@ void changeMoveExecutesPermanently() { assertThat(entity.getValue()).isEqualTo(newValue); } + @Test + void toStringShowsTheOriginalValueAfterAssigningAnUnassignedEntity() { + // getValue() used null as its "not cached yet" sentinel, so it never cached a genuinely + // null current value and re-read the descriptor after execute() had already changed it. + var solution = TestdataSolution.generateSolution(2, 1); + var entity = solution.getEntityList().getFirst(); + entity.setValue(null); + var newValue = solution.getValueList().getFirst(); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var changeMove = Moves.change(variableMetaModel, entity, newValue); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(changeMove); + + assertThat(changeMove.toString()).contains("null -> " + newValue); + } + @Test void changeMoveToNull() { var solution = TestdataSolution.generateSolution(2, 1); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java new file mode 100644 index 00000000000..06f2d45519c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java @@ -0,0 +1,117 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassAssignMoveProviderTest { + + @Test + void sampleMembersAreAlwaysASubsetOfTheUnassignedEntities() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var value = new TestdataValue("v"); + var e0 = new TestdataAllowsUnassignedEntity("e0", null); + var e1 = new TestdataAllowsUnassignedEntity("e1", null); + var e2 = new TestdataAllowsUnassignedEntity("e2", null); + var e3 = new TestdataAllowsUnassignedEntity("e3", null); + var e4 = new TestdataAllowsUnassignedEntity("e4", null); + var allUnassigned = List.of(e0, e1, e2, e3, e4); + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(value)); + solution.setEntityList(new ArrayList<>(allUnassigned)); + + var context = NeighborhoodTester + .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + var unassignedEntitySet = new HashSet<>(allUnassigned); + for (var move : moves) { + var members = move.getPlanningEntities(); + assertThat(members).hasSizeLessThanOrEqualTo(2); + for (var member : members) { + assertThat(unassignedEntitySet.contains(member)).isTrue(); + } + assertThat(move.getPlanningValues().getFirst()).isEqualTo(value); + } + } + + @Test + void differentDrawsProduceDifferentSamples() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var value = new TestdataValue("v"); + var e0 = new TestdataAllowsUnassignedEntity("e0", null); + var e1 = new TestdataAllowsUnassignedEntity("e1", null); + var e2 = new TestdataAllowsUnassignedEntity("e2", null); + var e3 = new TestdataAllowsUnassignedEntity("e3", null); + var e4 = new TestdataAllowsUnassignedEntity("e4", null); + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(value)); + solution.setEntityList(new ArrayList<>(List.of(e0, e1, e2, e3, e4))); + + var context = NeighborhoodTester + .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var distinctMemberSets = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .map(move -> new HashSet<>(move.getPlanningEntities())) + .collect(Collectors.toSet()); + assertThat(distinctMemberSets).hasSizeGreaterThan(1); + } + + @Test + void pinnedUnassignedEntityNeverJoinsASample() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedEntity.class).basicVariable(); + + var value = new TestdataValue("v"); + var pinnedEntity = new TestdataPinnedAllowsUnassignedEntity("pinned", null, true); + var free1 = new TestdataPinnedAllowsUnassignedEntity("free1", null, false); + var free2 = new TestdataPinnedAllowsUnassignedEntity("free2", null, false); + + var solution = new TestdataPinnedAllowsUnassignedSolution("s"); + solution.setValueList(List.of(value)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester + .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java new file mode 100644 index 00000000000..a7e004fde5a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java @@ -0,0 +1,287 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.List; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassChangeMoveProviderTest { + + @Test + void mixedValueSampleYieldsOneMoveSettingEveryMemberToALegalValue() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + // generateSolution(2, 2) cycles distinct values across entities, so entity0 and entity1 differ. + var solution = TestdataSolution.generateSolution(2, 2); + var entity0 = solution.getEntityList().get(0); + var entity1 = solution.getEntityList().get(1); + assertThat(entity0.getValue()).isNotEqualTo(entity1.getValue()); + + var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getPlanningValues().getFirst(); + for (var member : move.getPlanningEntities()) { + assertThat(member).isIn(entity0, entity1); + } + assertThat(destination).isIn(solution.getValueList()); + } + } + + @Test + void assignSideDisabledExcludesUnassignedEntities() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + var unassigned = new TestdataAllowsUnassignedEntity("unassigned", null); + var assigned1 = new TestdataAllowsUnassignedEntity("assigned1", value1); + var assigned2 = new TestdataAllowsUnassignedEntity("assigned2", value2); + + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(value1, value2)); + solution.setEntityList(List.of(unassigned, assigned1, assigned2)); + + // crossingNull=false: the source excludes unassigned entities entirely - not "isolating" the + // assign side, but removing it, since false now governs the source too (see the class javadoc). + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler(), false), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(unassigned); + assertThat(move.getPlanningValues().getFirst()).isNotNull(); // No unassign either, without crossingNull. + } + } + + @Test + void mixedSampleMayAssignOrUnassign() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var value = new TestdataValue("v"); + var unassigned = new TestdataAllowsUnassignedEntity("unassigned", null); + var assigned = new TestdataAllowsUnassignedEntity("assigned", value); + + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(value)); + solution.setEntityList(List.of(unassigned, assigned)); + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + var sawAssign = false; + var sawUnassign = false; + for (var move : moves) { + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(unassigned, assigned); + var destination = move.getPlanningValues().getFirst(); + if (destination == null) { + sawUnassign = true; // Unassigns the assigned member; the already-unassigned one stays as is. + } else { + assertThat(destination).isEqualTo(value); + sawAssign = true; // Assigns the unassigned member. + } + } + // The relaxed assertion above would also pass if one branch never fired; these two lines + // make sure the relaxation isn't silently hiding a broken branch. + assertThat(sawAssign).isTrue(); + assertThat(sawUnassign).isTrue(); + } + + @Test + void crossingNullDefaultFalseWhenVariableDoesNotAllowUnassigned() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 2); + var entity0 = solution.getEntityList().get(0); + var entity1 = solution.getEntityList().get(1); + assertThat(entity0.getValue()).isNotEqualTo(entity1.getValue()); + + // Default constructor: crossingNull is false, because this variable does not allow unassigned + // values - the constructor must not throw for the default, unlike an explicit true would. + var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream(move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + } + + @Test + void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + } + + @Test + void homogeneousSampleNeverProducesANoOpMove() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 5); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); // Every entity shares one value -> every sample is homogeneous. + } + + var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningValues().getFirst()).isNotEqualTo(sharedValue); + } + } + + @Test + void sizeOneSampleProducesNoMove() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 5); + + var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream().limit(10).toList(); + assertThat(moves).isEmpty(); + } + + @Test + void emptyIntersectionEndsTheIteratorInsteadOfHanging() { + var solutionMetaModel = TestdataAllowsUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntityProvidingEntity.class) + .basicVariable(); + + var north1 = new TestdataValue("north1"); + var north2 = new TestdataValue("north2"); + var south1 = new TestdataValue("south1"); + var south2 = new TestdataValue("south2"); + // Disjoint per-entity ranges: no destination is ever legal for both at once. + var northEntity = new TestdataAllowsUnassignedEntityProvidingEntity("north", List.of(north1, north2), north1); + var southEntity = new TestdataAllowsUnassignedEntityProvidingEntity("south", List.of(south1, south2), south1); + + var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); + solution.setEntityList(List.of(northEntity, southEntity)); + + // neverStoppingSampler drains the whole 2-entity dataset every draw, so every sample is + // {north, south} and the intersection is always empty. crossingNull=false, explicitly: with + // the default (true, since this variable allows unassigned values) a null destination is + // still legal - see emptyIntersectionStillYieldsNullDestinationWhenCrossingNull below. + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler(), false), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream().limit(10).toList(); + assertThat(moves).isEmpty(); + } + + @Test + void emptyIntersectionStillYieldsNullDestinationWhenCrossingNull() { + var solutionMetaModel = TestdataAllowsUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntityProvidingEntity.class) + .basicVariable(); + + var north1 = new TestdataValue("north1"); + var north2 = new TestdataValue("north2"); + var south1 = new TestdataValue("south1"); + var south2 = new TestdataValue("south2"); + // Same disjoint-range fixture as emptyIntersectionEndsTheIteratorInsteadOfHanging above. + var northEntity = new TestdataAllowsUnassignedEntityProvidingEntity("north", List.of(north1, north2), north1); + var southEntity = new TestdataAllowsUnassignedEntityProvidingEntity("south", List.of(south1, south2), south1); + + var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); + solution.setEntityList(List.of(northEntity, southEntity)); + + // Default constructor: crossingNull is true. Regression test for the provenEmpty || rollNull + // ordering: the non-null intersection is still empty, but a null destination (unassigning the + // whole sample) is legal for both entities regardless, so it is offered instead of nothing. + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(10) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void pinnedEntitySharingAValueWithFreeEntitiesNeverJoinsASample() { + var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class).basicVariable(); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + var free1 = new TestdataPinnedEntity("free1", v0, false); + var free2 = new TestdataPinnedEntity("free2", v0, false); + + var solution = new TestdataPinnedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream(move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveTest.java new file mode 100644 index 00000000000..0ab29d8cb49 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveTest.java @@ -0,0 +1,192 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; + +import org.junit.jupiter.api.Test; + +class MassChangeMoveTest { + + @Test + void executeSetsEveryMemberToDestinationValue() { + var solution = TestdataSolution.generateSolution(3, 3); + var entity1 = solution.getEntityList().get(0); + var entity2 = solution.getEntityList().get(1); + var newValue = solution.getValueList().get(2); // Different value + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var sample = Sample.of(List.of(entity1, entity2)); + var massChangeMove = Moves.massChange(variableMetaModel, sample, newValue); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(massChangeMove); + + assertThat(entity1.getValue()).isEqualTo(newValue); + assertThat(entity2.getValue()).isEqualTo(newValue); + } + + @Test + void executeToNullUnassignsEveryMember() { + var solution = TestdataSolution.generateSolution(3, 2); + var entity1 = solution.getEntityList().get(0); + var entity2 = solution.getEntityList().get(1); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var sample = Sample.of(List.of(entity1, entity2)); + var massChangeMove = Moves.massChange(variableMetaModel, sample, null); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(massChangeMove); + + assertThat(entity1.getValue()).isNull(); + assertThat(entity2.getValue()).isNull(); + } + + @Test + void undoRestoresEveryMembersOriginalValue() { + var solution = TestdataSolution.generateSolution(3, 3); + var entity1 = solution.getEntityList().get(0); + var entity2 = solution.getEntityList().get(1); + var originalValue1 = entity1.getValue(); + var originalValue2 = entity2.getValue(); + var newValue = solution.getValueList().get(2); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var sample = Sample.of(List.of(entity1, entity2)); + var massChangeMove = Moves.massChange(variableMetaModel, sample, newValue); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(massChangeMove, view -> { + assertThat(entity1.getValue()).isEqualTo(newValue); + assertThat(entity2.getValue()).isEqualTo(newValue); + }); + + assertThat(entity1.getValue()).isEqualTo(originalValue1); + assertThat(entity2.getValue()).isEqualTo(originalValue2); + } + + @Test + void getPlanningEntitiesReturnsEveryMember() { + var entity1 = new TestdataEntity("e1"); + var entity2 = new TestdataEntity("e2"); + var value = new TestdataValue("v"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var sample = Sample.of(List.of(entity1, entity2)); + var massChangeMove = Moves.massChange(variableMetaModel, sample, value); + + assertThat(massChangeMove.getPlanningEntities()).containsExactlyInAnyOrder(entity1, entity2); + } + + @Test + void getPlanningValuesReturnsDestination() { + var entity1 = new TestdataEntity("e1"); + var value = new TestdataValue("v"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var sample = Sample.of(List.of(entity1)); + var massChangeMove = Moves.massChange(variableMetaModel, sample, value); + + assertThat(massChangeMove.getPlanningValues()).containsExactly(value); + } + + @Test + void equalsAndHashCodeIgnoreMemberDrawOrder() { + var entity1 = new TestdataEntity("e1"); + var entity2 = new TestdataEntity("e2"); + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var move1 = Moves.massChange(variableMetaModel, Sample.of(List.of(entity1, entity2)), value1); + var move2 = Moves.massChange(variableMetaModel, Sample.of(List.of(entity2, entity1)), value1); + var move3 = Moves.massChange(variableMetaModel, Sample.of(List.of(entity1, entity2)), value2); + + // Same members in a different draw order plus the same destination are equal. + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + + // A different destination is not equal. + assertThat(move1).isNotEqualTo(move3); + + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void describeIncludesEntityAndVariableName() { + var entity = new TestdataEntity("e1"); + var value = new TestdataValue("v"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var massChangeMove = Moves.massChange(variableMetaModel, Sample.of(List.of(entity)), value); + + assertThat(massChangeMove.describe()).isEqualTo("MassChangeMove(TestdataEntity.value)"); + } + + @Test + void rebaseReturnsMoveWhoseSampleHoldsWorkingSolutionCopies() { + var entity1 = new TestdataEntity("e1"); + var entity2 = new TestdataEntity("e2"); + var rebasedEntity1 = new TestdataEntity("e1"); + var rebasedEntity2 = new TestdataEntity("e2"); + var value = new TestdataValue("v"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var originalMove = + (MassChangeMove) Moves.massChange(variableMetaModel, + Sample.of(List.of(entity1, entity2)), value); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == entity1) { + return (T) rebasedEntity1; + } else if (object == entity2) { + return (T) rebasedEntity2; + } + return object; + } + }); + + assertThat(rebasedMove.getPlanningEntities()).containsExactlyInAnyOrder(rebasedEntity1, rebasedEntity2); + assertThat(rebasedMove.getPlanningValues()).containsExactly(value); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java new file mode 100644 index 00000000000..d485a899aad --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java @@ -0,0 +1,116 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.List; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassUnassignMoveProviderTest { + + @Test + void constructorRejectsNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2))); + } + + @Test + void mixedValueSampleYieldsOneMoveNullingEveryMember() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var e0 = new TestdataAllowsUnassignedEntity("e0", v0); + var e1 = new TestdataAllowsUnassignedEntity("e1", v1); + var unassignedEntity = new TestdataAllowsUnassignedEntity("unassigned", null); + + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(e0, e1, unassignedEntity)); + + var context = + NeighborhoodTester.build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + // Only already-assigned entities can be drawn; already-unassigned ones own no move here. + assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); + assertThat(move.getPlanningEntities()).isSubsetOf(e0, e1); + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + // A Sampler that always stops right after the seed, i.e. a Sampler that always produces + // size-1 samples: unlike MassChange/MassAssign, this must not end the provider early. + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 5); + + var context = + NeighborhoodTester.build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).hasSize(50); + for (var move : moves) { + assertThat(move.getPlanningEntities()).hasSize(1); + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void pinnedEntityNeverJoinsASample() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedEntity.class).basicVariable(); + + var v0 = new TestdataValue("v0"); + var pinnedEntity = new TestdataPinnedAllowsUnassignedEntity("pinned", v0, true); + var free1 = new TestdataPinnedAllowsUnassignedEntity("free1", v0, false); + var free2 = new TestdataPinnedAllowsUnassignedEntity("free2", v0, false); + + var solution = new TestdataPinnedAllowsUnassignedSolution("s"); + solution.setValueList(List.of(v0)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester + .build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java new file mode 100644 index 00000000000..822f2b7ab58 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java @@ -0,0 +1,294 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class PillarChangeMoveProviderTest { + + @Test + void homogeneityAndNoOpExcluded() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable(); + + var solution = TestdataSolution.generateSolution(3, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + var sharedValue = valueList.getFirst(); + // entity0 and entity1 share a value, forming a pillar of size 2. + entityList.get(0).setValue(sharedValue); + entityList.get(1).setValue(sharedValue); + // entity2 and entity3 each get their own distinct value, forming size-1 pillars. + entityList.get(2).setValue(valueList.get(1)); + entityList.get(3).setValue(valueList.get(2)); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var entities = move.getPlanningEntities(); + // Every generated pillar must be homogeneous: all members share the same (pre-move) value. + var currentValues = entities.stream() + .map(e -> ((TestdataEntity) e).getValue()) + .collect(Collectors.toSet()); + assertThat(currentValues).hasSize(1); + // No move to the pillar's own current value. + assertThat(move.getPlanningValues()).doesNotContain(currentValues.iterator().next()); + // Size-1 pillars are never generated for change. + assertThat(entities).hasSizeGreaterThanOrEqualTo(2); + } + } + + @Test + void pinnedEntityExcludedFromPillar() { + var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class) + .basicVariable(); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + // pinnedEntity shares v0 with free1 and free2, but forEach(..., false) excludes pinned + // entities from the entity source, so it must never join their pillar. + var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + var free1 = new TestdataPinnedEntity("free1", v0, false); + var free2 = new TestdataPinnedEntity("free2", v0, false); + + var solution = new TestdataPinnedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream(move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + + @Test + void entityDependentRangeRejectsOutOfRangeDestination() { + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class) + .basicVariable(); + + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + var value3 = new TestdataValue("v3"); + + // Pillar A: two entities sharing value1, each with a range that also includes value2. + var entityA1 = new TestdataEntityProvidingEntity("a1", List.of(value1, value2)); + entityA1.setValue(value1); + var entityA2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); + entityA2.setValue(value1); + // Pillar B: two entities sharing value3, each with a range restricted to value3 only. + var entityB1 = new TestdataEntityProvidingEntity("b1", List.of(value3)); + entityB1.setValue(value3); + var entityB2 = new TestdataEntityProvidingEntity("b2", List.of(value3)); + entityB2.setValue(value3); + + var solution = new TestdataEntityProvidingSolution("s"); + solution.setEntityList(List.of(entityA1, entityA2, entityB1, entityB2)); + + var pillarA = Sample.of(List.of(entityA1, entityA2)); + var pillarB = Sample.of(List.of(entityB1, entityB2)); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + // Pillar A can legally move to value2 (in range for both members). + context.producesAllOf(Moves.massChange(variableMetaModel, pillarA, value2)); + // Pillar B has no legal destination: value1/value2 are out of range for its members, + // and value3 is its own current value. + context.producesNoneOf( + Moves.massChange(variableMetaModel, pillarB, value1), + Moves.massChange(variableMetaModel, pillarB, value2), + Moves.massChange(variableMetaModel, pillarB, value3)); + } + + @Test + void retirementLeavesOnlyTheLegalPillarsMoves() { + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class) + .basicVariable(); + + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + var value3 = new TestdataValue("v3"); + + // Pillar A: legal destination value2 exists. + var entityA1 = new TestdataEntityProvidingEntity("a1", List.of(value1, value2)); + entityA1.setValue(value1); + var entityA2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); + entityA2.setValue(value1); + // Pillar B: no legal destination exists (range restricted to its own current value). + var entityB1 = new TestdataEntityProvidingEntity("b1", List.of(value3)); + entityB1.setValue(value3); + var entityB2 = new TestdataEntityProvidingEntity("b2", List.of(value3)); + entityB2.setValue(value3); + + var solution = new TestdataEntityProvidingSolution("s"); + solution.setEntityList(List.of(entityA1, entityA2, entityB1, entityB2)); + + var pillarA = Sample.of(List.of(entityA1, entityA2)); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var expectedMove = Moves.massChange(variableMetaModel, pillarA, value2); + var distinctMoves = context.getMovesAsStream() + .limit(200) + .collect(Collectors.toCollection(LinkedHashSet::new)); + // Pillar B is retired after its probes come back empty, so it never produces a move; + // Pillar A's only legal destination keeps recurring, so the distinct move set stays finite (size 1). + assertThat(distinctMoves).containsExactly(expectedMove); + } + + @Test + void lowLegalFractionDestinationIsStillFound() { + // Originally a regression test for the fixed-width probe (createRightIterator used to try a + // flat 9 draws regardless of pool size, so this 1-in-50 fixture would almost always miss). + // The destination search no longer draws from a 50-value pool at all: it samples from + // entityA2's range ([value1, value2], the smaller of the pillar's two distinct ranges), + // where value2 is found deterministically, not probabilistically. + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class) + .basicVariable(); + + var values = new ArrayList(); + for (var i = 1; i <= 50; i++) { + values.add(new TestdataValue("v" + i)); + } + var value1 = values.get(0); + var value2 = values.get(1); + + // entityA1 accepts every value; entityA2 accepts only value1 (current) and value2, so + // value2 is pillar A's only legal destination out of the 50-value pool. + var entityA1 = new TestdataEntityProvidingEntity("a1", values); + entityA1.setValue(value1); + var entityA2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); + entityA2.setValue(value1); + + var solution = new TestdataEntityProvidingSolution("s"); + solution.setEntityList(List.of(entityA1, entityA2)); + + var pillarA = Sample.of(List.of(entityA1, entityA2)); + var expectedMove = Moves.massChange(variableMetaModel, pillarA, value2); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream().limit(10).toList(); + assertThat(moves).contains(expectedMove); + } + + @Test + void crossingNullDefaultTrueAlsoUnassignsPillar() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + var sharedValue = valueList.getFirst(); + // entity0 and entity1 share a value, forming a pillar of size 2. + entityList.get(0).setValue(sharedValue); + entityList.get(1).setValue(sharedValue); + entityList.get(2).setValue(valueList.get(1)); + entityList.get(3).setValue(null); + var pillar = Sample.of(List.of(entityList.get(0), entityList.get(1))); + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf(Moves.massChange(variableMetaModel, pillar, null)); + } + + @Test + void crossingNullFalseNeverUnassignsPillar() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + var sharedValue = valueList.getFirst(); + entityList.get(0).setValue(sharedValue); + entityList.get(1).setValue(sharedValue); + entityList.get(2).setValue(valueList.get(1)); + entityList.get(3).setValue(null); + var pillar = Sample.of(List.of(entityList.get(0), entityList.get(1))); + + var context = + NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) + .using(solution); + context.producesNoneOf(Moves.massChange(variableMetaModel, pillar, null)); + } + + @Test + void emptyIntersectionStillYieldsNullDestination() { + var solutionMetaModel = TestdataAllowsUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntityProvidingEntity.class) + .basicVariable(); + + var value1 = new TestdataValue("v1"); + // Pillar: two entities sharing value1, each restricted to value1 only. No non-null + // destination exists (the pillar's own value is excluded), but crossingNull=true still + // offers the null destination - this is the provenEmpty || rollNull ordering: a pillar + // whose non-null intersection is empty still has a legal null destination. + var entityA1 = new TestdataAllowsUnassignedEntityProvidingEntity("a1", List.of(value1), value1); + var entityA2 = new TestdataAllowsUnassignedEntityProvidingEntity("a2", List.of(value1), value1); + + var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); + solution.setEntityList(List.of(entityA1, entityA2)); + + var pillar = Sample.of(List.of(entityA1, entityA2)); + + var context = NeighborhoodTester.build(new PillarChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf(Moves.massChange(variableMetaModel, pillar, null)); + context.producesNoneOf(Moves.massChange(variableMetaModel, pillar, value1)); // No-op. + } + + @Test + void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new PillarChangeMoveProvider<>(variableMetaModel, true)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java new file mode 100644 index 00000000000..aa2fb3bb397 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java @@ -0,0 +1,304 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +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.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testdomain.multivar.TestdataOtherValue; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class PillarSwapMoveProviderTest { + + @Test + void equalValuesProduceNoMove() { + var solution = TestdataSolution.generateSolution(1, 4); // Every entity on the same value. + var moveList = NeighborhoodTester + .build(new PillarSwapMoveProvider<>(TestdataSolution.buildMetaModel().genuineEntity(TestdataEntity.class)), + TestdataSolution.buildMetaModel()) + .using(solution) + .getMovesAsStream() + .limit(50) + .toList(); + // One value means one pillar; no partner can differ, so the only key retires and the + // iterator ends. This also doubles as the termination test. + assertThat(moveList).isEmpty(); + } + + @Test + void atLeastOneDifferingVariableProducesMove() { + var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataMultiVarEntity.class); + var variableMetaModelList = allVariables(entityMetaModel); + + var value0 = new TestdataValue("v0"); + var value1 = new TestdataValue("v1"); + var otherValue0 = new TestdataOtherValue("o0"); + + // Pillar A and pillar B agree on primary and tertiary, differing only on secondary. + var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); + var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); + var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); + var b2 = new TestdataMultiVarEntity("b2", value0, value1, otherValue0); + + var solution = new TestdataMultiVarSolution("s"); + solution.setValueList(List.of(value0, value1)); + solution.setOtherValueList(List.of(otherValue0)); + solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); + + var pillarA = Sample.of(List.of(a1, a2)); + var pillarB = Sample.of(List.of(b1, b2)); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + // Mirrored pairs are both legal and both kept, as SwapMoveProvider keeps them. + context.producesAllOf( + Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), + Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); + } + + @Test + void outOfRangeCrossValueRejectsWholePair() { + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class); + var variableMetaModelList = allVariables(entityMetaModel); + + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + + // Pillar A: both members accept either value. + var a1 = new TestdataEntityProvidingEntity("a1", List.of(value1, value2)); + a1.setValue(value1); + var a2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); + a2.setValue(value1); + // Pillar B: both members are restricted to value2 only, so accepting value1 (pillar A's + // current value) is out of range for every member. + var b1 = new TestdataEntityProvidingEntity("b1", List.of(value2)); + b1.setValue(value2); + var b2 = new TestdataEntityProvidingEntity("b2", List.of(value2)); + b2.setValue(value2); + + var solution = new TestdataEntityProvidingSolution("s"); + solution.setEntityList(List.of(a1, a2, b1, b2)); + + var pillarA = Sample.of(List.of(a1, a2)); + var pillarB = Sample.of(List.of(b1, b2)); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + // Pillar A could accept value2, but pillar B cannot accept value1: the whole pair is + // rejected, in either direction. + context.producesNoneOf( + Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), + Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); + } + + @Test + void mixedLegalityAcrossVariablesRejectsWholePair() { + var solutionMetaModel = TestdataAllowsUnassignedMultiVarEntityProvidingSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedMultiVarEntityProvidingEntity.class); + var variableMetaModelList = allVariables(entityMetaModel); + + var v1 = new TestdataValue("v1"); + var v3 = new TestdataValue("v3"); + var v4 = new TestdataValue("v4"); + + // Pillar A and pillar B differ on both "value" and "secondValue". The "value" swap is + // legal in both directions, but the "secondValue" swap is not: pillar A's secondValueRange + // does not contain pillar B's v3. One legal variable is not enough to save the pair; the + // whole swap is rejected. + var a1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("a1", List.of(v1, v4), List.of(v1, v4)); + a1.setValue(v1); + a1.setSecondValue(v1); + var b1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("b1", List.of(v1, v4), List.of(v1, v3, v4)); + b1.setValue(v4); + b1.setSecondValue(v3); + + // The third variable is solution-scoped and both entities leave it null, so it never + // differs and never decides the outcome. + var solution = new TestdataAllowsUnassignedMultiVarEntityProvidingSolution("s", List.of(v1)); + solution.setEntityList(List.of(a1, b1)); + + var pillarA = Sample.of(List.of(a1)); + var pillarB = Sample.of(List.of(b1)); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + context.producesNoneOf( + Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), + Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); + } + + @Test + void pinnedEntityNeverParticipatesInSwap() { + var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + // pinnedEntity shares v0 with free1, forming what would be pillar A; forEach(..., false) + // excludes pinned entities from the entity source, so it must never appear in a swap. + var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + var free1 = new TestdataPinnedEntity("free1", v0, false); + var free2 = new TestdataPinnedEntity("free2", v1, false); + var free3 = new TestdataPinnedEntity("free3", v1, false); + + var solution = new TestdataPinnedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(pinnedEntity, free1, free2, free3)); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + + @Test + void threeAgainstFiveSwapsEveryMember() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + var variableMetaModelList = allVariables(entityMetaModel); + + var solution = TestdataSolution.generateSolution(2, 8); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + // First three entities share value0 (pillar A, size 3); the remaining five share value1 + // (pillar B, size 5). The legal-swap fraction stays high: the only two keys in the pool + // both produce a legal swap in either direction. + for (var i = 0; i < 3; i++) { + entityList.get(i).setValue(valueList.get(0)); + } + for (var i = 3; i < 8; i++) { + entityList.get(i).setValue(valueList.get(1)); + } + var pillarA = Sample.of(List.copyOf(entityList.subList(0, 3))); + var pillarB = Sample.of(List.copyOf(entityList.subList(3, 8))); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf( + Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), + Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); + } + + @Test + void sizeOnePillarsAreGenerated() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + + // Every entity gets its own distinct value, so every pillar has exactly one member. + var solution = TestdataSolution.generateSolution(4, 4); + + var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution); + var moveList = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(100) + .toList(); + assertThat(moveList).isNotEmpty(); + for (var move : moveList) { + // Unlike PillarChangeMoveProvider, size-1 pillars are legal on both sides of a swap. + assertThat(move.getPlanningEntities()).hasSize(2); + } + } + + @Test + void excludedVariableIsIgnoredForKeyAndSwap() { + var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataMultiVarEntity.class); + var allowedVariableMetaModelList = allVariables(entityMetaModel).stream() + .filter(v -> !v.name().contains("secondary")) + .toList(); + + var value0 = new TestdataValue("v0"); + var value1 = new TestdataValue("v1"); + var otherValue0 = new TestdataOtherValue("o0"); + + // Same setup as atLeastOneDifferingVariableProducesMove: with the full variable list, A and B + // are distinct pillars (they differ on secondary). Excluding secondary from the key merges + // them into one pillar (all four share primary and tertiary), leaving no second distinct key + // to swap with. + var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); + var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); + var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); + var b2 = new TestdataMultiVarEntity("b2", value0, value1, otherValue0); + + var solution = new TestdataMultiVarSolution("s"); + solution.setValueList(List.of(value0, value1)); + solution.setOtherValueList(List.of(otherValue0)); + solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); + + var moveList = NeighborhoodTester + .build(new PillarSwapMoveProvider<>(allowedVariableMetaModelList), solutionMetaModel) + .using(solution) + .getMovesAsStream() + .limit(50) + .toList(); + assertThat(moveList).isEmpty(); + } + + @Test + void singleVariableConstructorMatchesOneElementList() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + entityList.get(0).setValue(valueList.get(0)); + entityList.get(1).setValue(valueList.get(0)); + entityList.get(2).setValue(valueList.get(1)); + entityList.get(3).setValue(valueList.get(1)); + var pillarA = Sample.of(List.of(entityList.get(0), entityList.get(1))); + var pillarB = Sample.of(List.of(entityList.get(2), entityList.get(3))); + + var expectedMoveAtoB = Moves.pillarSwap(variableMetaModel, pillarA, pillarB); + var expectedMoveBtoA = Moves.pillarSwap(variableMetaModel, pillarB, pillarA); + + NeighborhoodTester.build(new PillarSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution) + .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); + NeighborhoodTester.build(new PillarSwapMoveProvider<>(List.of(variableMetaModel)), solutionMetaModel) + .using(solution) + .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); + } + + @Test + void emptyListConstructorThrows() { + assertThatThrownBy(() -> new PillarSwapMoveProvider(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is empty"); + } + + @SuppressWarnings("unchecked") + private static List> allVariables( + PlanningEntityMetaModel entityMetaModel) { + return entityMetaModel.variables().stream() + .map(v -> (PlanningVariableMetaModel) v) + .toList(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java new file mode 100644 index 00000000000..d43427acb63 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java @@ -0,0 +1,332 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +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.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +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.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; + +import org.junit.jupiter.api.Test; + +class PillarSwapMoveTest { + + @Test + void bothOldValuesAreReadBeforeEitherPillarIsMutated() { + var solution = TestdataSolution.generateSolution(2, 2); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var v1 = solution.getValueList().get(0); + var v2 = solution.getValueList().get(1); + e1.setValue(v1); + e2.setValue(v2); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var leftPillar = Sample.of(List.of(e1)); + var rightPillar = Sample.of(List.of(e2)); + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), leftPillar, rightPillar); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(pillarSwapMove); + + // Correct: left ends up on v2, right ends up on v1. + assertThat(e1.getValue()).isEqualTo(v2); + assertThat(e2.getValue()).isEqualTo(v1); + // A naive write-left-then-read-right implementation would leave both entities on v2. + assertThat(e2.getValue()).isNotEqualTo(v2); + } + + @Test + void unequalSizedPillarsSwapEveryMember() { + var solution = TestdataSolution.generateSolution(2, 8); + var entityList = solution.getEntityList(); + var v1 = solution.getValueList().get(0); + var v2 = solution.getValueList().get(1); + + var leftEntities = entityList.subList(0, 3); // Size 3. + var rightEntities = entityList.subList(3, 8); // Size 5. + for (var entity : leftEntities) { + entity.setValue(v1); + } + for (var entity : rightEntities) { + entity.setValue(v2); + } + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var leftPillar = Sample.of(leftEntities); + var rightPillar = Sample.of(rightEntities); + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), leftPillar, rightPillar); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(pillarSwapMove); + + for (var entity : leftEntities) { + assertThat(entity.getValue()).isEqualTo(v2); + } + for (var entity : rightEntities) { + assertThat(entity.getValue()).isEqualTo(v1); + } + } + + @Test + void multiVariateSwapsEveryDifferingVariable() { + var solution = TestdataMultiVarSolution.generateSolution(4, 4, 2); + var entityList = solution.getMultiVarEntityList(); + var valueList = solution.getValueList(); + var leftA = entityList.get(0); + var leftB = entityList.get(1); + var rightA = entityList.get(2); + var rightB = entityList.get(3); + + leftA.setPrimaryValue(valueList.get(0)); + leftA.setSecondaryValue(valueList.get(1)); + leftB.setPrimaryValue(valueList.get(0)); + leftB.setSecondaryValue(valueList.get(1)); + rightA.setPrimaryValue(valueList.get(2)); + rightA.setSecondaryValue(valueList.get(3)); + rightB.setPrimaryValue(valueList.get(2)); + rightB.setSecondaryValue(valueList.get(3)); + + var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataMultiVarEntity.class); + var variableMetaModelList = allVariablesExceptTertiary(entityMetaModel); + + var leftPillar = Sample.of(List.of(leftA, leftB)); + var rightPillar = Sample.of(List.of(rightA, rightB)); + var pillarSwapMove = Moves.pillarSwap(variableMetaModelList, leftPillar, rightPillar); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(pillarSwapMove); + + assertThat(leftA.getPrimaryValue()).isEqualTo(valueList.get(2)); + assertThat(leftA.getSecondaryValue()).isEqualTo(valueList.get(3)); + assertThat(leftB.getPrimaryValue()).isEqualTo(valueList.get(2)); + assertThat(leftB.getSecondaryValue()).isEqualTo(valueList.get(3)); + assertThat(rightA.getPrimaryValue()).isEqualTo(valueList.get(0)); + assertThat(rightA.getSecondaryValue()).isEqualTo(valueList.get(1)); + assertThat(rightB.getPrimaryValue()).isEqualTo(valueList.get(0)); + assertThat(rightB.getSecondaryValue()).isEqualTo(valueList.get(1)); + } + + @Test + void outOfRangeCrossValueIsCallerResponsibility() { + // PillarSwapMove no longer re-checks range at execution time; that check is + // PillarSwapMoveProvider/SubPillarSwapMoveProvider's job. A hand-built move over an invalid + // pair now writes the out-of-range value, which the score director's value-range assertion + // catches. The built-in providers never propose such a pair. + var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class).basicVariable(); + + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + + // e1b's range does not include v2: this pair is invalid for a swap. + var e1a = new TestdataEntityProvidingEntity("e1a", List.of(v1, v2)); + e1a.setValue(v1); + var e1b = new TestdataEntityProvidingEntity("e1b", List.of(v1)); + e1b.setValue(v1); + var e2 = new TestdataEntityProvidingEntity("e2", List.of(v1, v2)); + e2.setValue(v2); + + var solution = new TestdataEntityProvidingSolution("s1"); + solution.setEntityList(List.of(e1a, e1b, e2)); + + var variableMetaModelList = singleVariableList(variableMetaModel); + + // The provider would never propose this move; it is constructed by hand to exercise caller misuse. + var invalidMove = Moves.pillarSwap(variableMetaModelList, Sample.of(List.of(e1a, e1b)), Sample.of(List.of(e2))); + + var context = MoveTester.build(solutionMetaModel).using(solution); + + assertThatIllegalStateException() + .isThrownBy(() -> context.execute(invalidMove)) + .withMessageContaining("outside of the related value range"); + } + + @Test + void undoRestoresEveryMembersOriginalValue() { + var solution = TestdataSolution.generateSolution(2, 4); + var entityList = solution.getEntityList(); + var v1 = solution.getValueList().get(0); + var v2 = solution.getValueList().get(1); + var left1 = entityList.get(0); + var left2 = entityList.get(1); + var right1 = entityList.get(2); + var right2 = entityList.get(3); + left1.setValue(v1); + left2.setValue(v1); + right1.setValue(v2); + right2.setValue(v2); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), + Sample.of(List.of(left1, left2)), Sample.of(List.of(right1, right2))); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(pillarSwapMove, view -> { + assertThat(left1.getValue()).isEqualTo(v2); + assertThat(left2.getValue()).isEqualTo(v2); + assertThat(right1.getValue()).isEqualTo(v1); + assertThat(right2.getValue()).isEqualTo(v1); + }); + + assertThat(left1.getValue()).isEqualTo(v1); + assertThat(left2.getValue()).isEqualTo(v1); + assertThat(right1.getValue()).isEqualTo(v2); + assertThat(right2.getValue()).isEqualTo(v2); + } + + @Test + void getPlanningEntitiesReturnsMembersOfBothPillars() { + var e1 = new TestdataEntity("e1"); + var e2 = new TestdataEntity("e2"); + var e3 = new TestdataEntity("e3"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), Sample.of(List.of(e1, e2)), + Sample.of(List.of(e3))); + + assertThat(pillarSwapMove.getPlanningEntities()).containsExactlyInAnyOrder(e1, e2, e3); + } + + @Test + void getPlanningValuesReturnsTheValuesThatChangedHands() { + var solution = TestdataSolution.generateSolution(2, 2); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var v1 = solution.getValueList().get(0); + var v2 = solution.getValueList().get(1); + e1.setValue(v1); + e2.setValue(v2); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), Sample.of(List.of(e1)), + Sample.of(List.of(e2))); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(pillarSwapMove); + + assertThat(pillarSwapMove.getPlanningValues()).containsExactlyInAnyOrder(v1, v2); + } + + @Test + void equalsAndHashCodeIgnoreMemberDrawOrderWithinEachPillar() { + var e1 = new TestdataEntity("e1"); + var e2 = new TestdataEntity("e2"); + var e3 = new TestdataEntity("e3"); + var e4 = new TestdataEntity("e4"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + var variableMetaModelList = singleVariableList(variableMetaModel); + + var move1 = Moves.pillarSwap(variableMetaModelList, Sample.of(List.of(e1, e2)), Sample.of(List.of(e3, e4))); + var move2 = Moves.pillarSwap(variableMetaModelList, Sample.of(List.of(e2, e1)), Sample.of(List.of(e4, e3))); + var move3 = Moves.pillarSwap(variableMetaModelList, Sample.of(List.of(e1, e2)), Sample.of(List.of(e3))); + + // Same members in a different draw order are equal. + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + + // A different right pillar is not equal. + assertThat(move1).isNotEqualTo(move3); + + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void describeIncludesEntityAndVariableName() { + var e1 = new TestdataEntity("e1"); + var e2 = new TestdataEntity("e2"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var pillarSwapMove = Moves.pillarSwap(singleVariableList(variableMetaModel), Sample.of(List.of(e1)), + Sample.of(List.of(e2))); + + assertThat(pillarSwapMove.describe()).isEqualTo("PillarSwapMove(TestdataEntity.value)"); + } + + @Test + void rebaseReturnsMoveWhosePillarsHoldWorkingSolutionCopies() { + var e1 = new TestdataEntity("e1"); + var e2 = new TestdataEntity("e2"); + var rebasedE1 = new TestdataEntity("e1"); + var rebasedE2 = new TestdataEntity("e2"); + + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var originalMove = + (PillarSwapMove) Moves.pillarSwap(singleVariableList(variableMetaModel), + Sample.of(List.of(e1)), Sample.of(List.of(e2))); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == e1) { + return (T) rebasedE1; + } else if (object == e2) { + return (T) rebasedE2; + } + return object; + } + }); + + assertThat(rebasedMove.getPlanningEntities()).containsExactlyInAnyOrder(rebasedE1, rebasedE2); + } + + @SuppressWarnings("unchecked") + private static List> + singleVariableList(PlanningVariableMetaModel variableMetaModel) { + return List.of((PlanningVariableMetaModel) variableMetaModel); + } + + @SuppressWarnings("unchecked") + private static List> + allVariablesExceptTertiary( + PlanningEntityMetaModel entityMetaModel) { + return entityMetaModel.variables().stream() + .filter(v -> !v.name().contains("tertiary")) + .map(v -> (PlanningVariableMetaModel) v) + .toList(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java new file mode 100644 index 00000000000..17f1d68fd4c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java @@ -0,0 +1,148 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class PillarUnassignMoveProviderTest { + + @Test + void constructorRejectsNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new PillarUnassignMoveProvider<>(variableMetaModel)); + } + + @Test + void onlyDrawsAssignedEntitiesAndAlwaysAssignsNull() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + // generateSolution(2, 2): entity0 starts null, entity1 starts assigned. + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 2); + var unassignedEntity = solution.getEntityList().get(0); + var assignedEntity = solution.getEntityList().get(1); + + var context = NeighborhoodTester.build(new PillarUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + // Only the already-assigned entity can be a pillar member. + assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); + assertThat(move.getPlanningEntities()).contains(assignedEntity); + // The destination is always null. + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void pinnedEntityNeverUnassigned() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedEntity.class) + .basicVariable(); + + var v0 = new TestdataValue("v0"); + // pinnedEntity shares v0 with freeEntity, but forEach(..., false) excludes pinned entities + // from the entity source, so it must never be unassigned. + var pinnedEntity = new TestdataPinnedAllowsUnassignedEntity("pinned", v0, true); + var freeEntity = new TestdataPinnedAllowsUnassignedEntity("free", v0, false); + + var solution = new TestdataPinnedAllowsUnassignedSolution("s"); + solution.setValueList(List.of(v0)); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + + var context = NeighborhoodTester.build(new PillarUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).containsExactly(freeEntity); + } + } + + @Test + void unassignPillarIsHomogeneous() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + var sharedValue = valueList.getFirst(); + // entity0 and entity1 share a value, forming a pillar of size 2. + entityList.get(0).setValue(sharedValue); + entityList.get(1).setValue(sharedValue); + // entity2 gets its own distinct value, forming a size-1 pillar. + entityList.get(2).setValue(valueList.get(1)); + // entity3 stays unassigned and must never appear in a move. + entityList.get(3).setValue(null); + + var context = NeighborhoodTester.build(new PillarUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var entities = move.getPlanningEntities(); + // Every generated pillar must be homogeneous: all members share the same (pre-move) value. + var currentValues = entities.stream() + .map(e -> ((TestdataAllowsUnassignedEntity) e).getValue()) + .collect(Collectors.toSet()); + assertThat(currentValues).hasSize(1).doesNotContainNull(); + // Unassign always sets the destination to null; unlike MassChangeMove, size-1 is legal. + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void undoRestoresOriginalValue() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class) + .basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 2); + var assignedEntity = solution.getEntityList().get(1); + var originalValue = assignedEntity.getValue(); + assertThat(originalValue).isNotNull(); + + var context = NeighborhoodTester.build(new PillarUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var move = context.getMovesAsStream().findFirst().orElseThrow(); + + context.getMoveTestContext().executeTemporarily(move, view -> assertThat(assignedEntity.getValue()).isNull()); + assertThat(assignedEntity.getValue()).isEqualTo(originalValue); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java new file mode 100644 index 00000000000..99424d01992 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java @@ -0,0 +1,79 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.List; +import java.util.Random; +import java.util.Set; + +import ai.timefold.solver.core.impl.domain.valuerange.ListValueRange; +import ai.timefold.solver.core.impl.neighborhood.bias.AbstractBiasIT; +import ai.timefold.solver.core.impl.neighborhood.bias.BiasReport; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * {@link SampleValueRanges#pickExactly} is new reservoir-sampling selection code, + * not a reuse of anything already bias-tested elsewhere in this package: + * an off-by-one in its {@code random.nextInt(admittedCount) == 0} pick would silently bias + * every exact-fallback draw a sample-drawing move iterator makes, + * and only a direct, algorithm-level test on the helper itself proves it is uniform. + */ +class SampleValueRangesBiasIT extends AbstractBiasIT { + + private static final int TRIAL_COUNT = 200_000; + + /** + * {@code excludedValue} is exercised by {@code MassChangeMoveProvider} whenever a drawn sample + * happens to be homogeneous; the {@code null} case is the ordinary, no-exclusion draw. + */ + @MethodSource("pickExactlyArguments") + @ParameterizedTest + void pickExactlyIsUniformOverTheIntersection(Integer excludedValue, List expectedValueList) { + var smallest = new ListValueRange<>(List.of(1, 2, 3, 4, 5)); + var other = new ListValueRange<>(List.of(2, 3, 4, 6, 7)); // intersection with smallest: {2, 3, 4} + var ranges = SampleValueRanges.of(Set.of(smallest, other)); + + var root = new Random(0); + BiasReport.tally("pickExactly, uniform over the intersection, excludedValue %s".formatted(excludedValue), + TRIAL_COUNT, trial -> ranges.pickExactly(splitFrom(root), excludedValue)) + .expectUniform(expectedValueList) + .assertWithinSigma(SIGMA_LIMIT); + } + + private static List pickExactlyArguments() { + return List.of( + Arguments.of(null, List.of(2, 3, 4)), + Arguments.of(3, List.of(2, 4))); + } + + /** + * {@code findDestination}'s FilteringIterator-based sampling path (distinct from + * {@code pickExactly}'s reservoir pass, bias-tested directly above) is exercised here with a + * high legal fraction, so the bail-out budget - ten times the smallest range's size - is never + * exhausted in practice: this isolates the sampling path's own predicate and range-selection + * wiring from the exact fallback. {@code excludedValue} is again the + * {@code MassChangeMoveProvider} homogeneous-sample path, untested by the {@code null} case. + */ + @MethodSource("findDestinationArguments") + @ParameterizedTest + void findDestinationIsUniformOverTheIntersectionViaSampling(Integer excludedValue, List expectedValueList) { + var smallest = new ListValueRange<>(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)); + var other = new ListValueRange<>(List.of(1, 2, 3, 4, 5, 6, 7, 8, 100, 200)); // intersection: 1..8 + var ranges = SampleValueRanges.of(Set.of(smallest, other)); + + var root = new Random(0); + BiasReport.tally( + "findDestination, uniform over the intersection via sampling, excludedValue %s".formatted(excludedValue), + TRIAL_COUNT, trial -> ranges.findDestination(splitFrom(root), excludedValue)) + .expectUniform(expectedValueList) + .assertWithinSigma(SIGMA_LIMIT); + } + + private static List findDestinationArguments() { + return List.of( + Arguments.of(null, List.of(1, 2, 3, 4, 5, 6, 7, 8)), + Arguments.of(5, List.of(1, 2, 3, 4, 6, 7, 8))); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java new file mode 100644 index 00000000000..134598837a1 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java @@ -0,0 +1,171 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Iterator; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.valuerange.ValueRange; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.domain.valuerange.ListValueRange; +import ai.timefold.solver.core.impl.move.MoveDirector; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingScoreCalculator; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; + +import org.junit.jupiter.api.Test; + +class SampleValueRangesTest { + + @Test + void of_sampleBased_excludesNullAndChecksLegalityAcrossEveryMember() { + var solutionDescriptor = TestdataAllowsUnassignedEntityProvidingSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedEntityProvidingEntity.class).basicVariable(); + var scoreDirectorFactory = new EasyScoreDirectorFactory<>(solutionDescriptor, + new TestdataAllowsUnassignedEntityProvidingScoreCalculator(), EnvironmentMode.PHASE_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var v3 = new TestdataValue("v3"); + var entityA = new TestdataAllowsUnassignedEntityProvidingEntity("a", List.of(v1, v2)); + var entityB = new TestdataAllowsUnassignedEntityProvidingEntity("b", List.of(v1, v2)); // content-equal to A's range + var entityC = new TestdataAllowsUnassignedEntityProvidingEntity("c", List.of(v1, v3)); // genuinely different + + var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); + solution.setEntityList(List.of(entityA, entityB, entityC)); + scoreDirector.setWorkingSolution(solution); + + var moveDirector = new MoveDirector<>(scoreDirector); + var sample = Sample.of(List.of(entityA, entityB, entityC)); + + var ranges = SampleValueRanges.of(sample, variableMetaModel, moveDirector); + + // v1 is legal for every member; v2 is not (out of range for C). + assertThat(ranges.containsInEvery(v1)).isTrue(); + assertThat(ranges.containsInEvery(v2)).isFalse(); + // The variable allows unassigned, so the cached range is null-wrapped; of(...) must unwrap + // it, or null would be treated as a legal destination. + assertThat(ranges.containsInEvery(null)).isFalse(); + } + + @Test + void bailOutSizeOf_clampsHugeSizeInsteadOfOverflowing() { + // Long.MAX_VALUE * BAIL_OUT_SAFETY_MULTIPLIER would overflow negative, and FilteringIterator + // reads a negative bailOutSize as "bail-out disabled" - turning hasNext() into an infinite loop. + assertThat(SampleValueRanges.bailOutSizeOf(new HugeValueRange())).isPositive(); + } + + @Test + void pickExactly_returnsNullWhenIntersectionIsEmpty() { + var smallest = new ListValueRange<>(List.of("a", "b")); + var other = new ListValueRange<>(List.of("c", "d")); + var random = new Random(0); + + var ranges = SampleValueRanges.of(Set.of(smallest, other)); + + assertThat(ranges.pickExactly(random, null)).isNull(); + } + + @Test + void pickExactly_excludesTheGivenValue() { + var only = new ListValueRange<>(List.of("only")); + var random = new Random(0); + + var ranges = SampleValueRanges.of(Set.of(only)); + + assertThat(ranges.pickExactly(random, "only")).isNull(); + } + + @Test + void findDestination_singleRangeReturnsAMember() { + var range = new ListValueRange<>(List.of("a", "b", "c")); + var random = new Random(0); + + var ranges = SampleValueRanges.of(Set.of(range)); + + assertThat(ranges.findDestination(random, null)).isIn("a", "b", "c"); + } + + @Test + void findDestination_singleRangeExcludesTheGivenValueEvenAsTheOnlyCandidate() { + var only = new ListValueRange<>(List.of("only")); + var random = new Random(0); + + // The single-distinct-range case still has to honor the exclusion (MassChange's "not the + // current value" rule): here the range's one element IS the excluded value, so no + // destination exists at all - this must come back null, not the excluded value itself. + var ranges = SampleValueRanges.of(Set.of(only)); + + assertThat(ranges.findDestination(random, "only")).isNull(); + } + + @Test + void findDestination_multiRangeReturnsAnIntersectionMemberOrNullWhenDisjoint() { + var overlapping = new ListValueRange<>(List.of("a", "b")); + var other = new ListValueRange<>(List.of("b", "c")); + var random = new Random(0); + + var overlappingRanges = SampleValueRanges.of(Set.of(overlapping, other)); + assertThat(overlappingRanges.findDestination(random, null)).isEqualTo("b"); + + var disjointA = new ListValueRange<>(List.of("a")); + var disjointB = new ListValueRange<>(List.of("b")); + var disjointRanges = SampleValueRanges.of(Set.of(disjointA, disjointB)); + assertThat(disjointRanges.findDestination(random, null)).isNull(); + } + + @Test + void equals_ignoresOrderButNotMembership() { + var rangeA = new ListValueRange<>(List.of("a")); + var rangeB = new ListValueRange<>(List.of("b")); + var rangeC = new ListValueRange<>(List.of("c")); + + assertThat(SampleValueRanges.of(Set.of(rangeA, rangeB))).isEqualTo(SampleValueRanges.of(Set.of(rangeB, rangeA))); + assertThat(SampleValueRanges.of(Set.of(rangeA, rangeB))).isNotEqualTo(SampleValueRanges.of(Set.of(rangeA, rangeC))); + assertThat(SampleValueRanges.of(Set.of(rangeA))).isNotEqualTo(SampleValueRanges.of(Set.of(rangeA, rangeB))); + } + + /** A range too large for {@code getSize() * BAIL_OUT_SAFETY_MULTIPLIER} to fit in a {@code long}. */ + private static final class HugeValueRange implements ValueRange { + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean contains(Long value) { + return true; + } + + @Override + public long getSize() { + return Long.MAX_VALUE; + } + + @Override + public Long get(long index) { + return index; + } + + @Override + public Iterator createOriginalIterator() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterator createRandomIterator(RandomGenerator workingRandom) { + throw new UnsupportedOperationException(); + } + + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java new file mode 100644 index 00000000000..12ba1df1bde --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java @@ -0,0 +1,169 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubPillarChangeMoveProviderTest { + + @Test + void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 5); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); // All 5 entities share one value -> one pillar of size 5. + } + + var context = NeighborhoodTester + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + var fullPillarMembers = new HashSet<>(entityList); + for (var move : moves) { + var members = move.getPlanningEntities(); + assertThat(members).hasSizeLessThanOrEqualTo(2); + for (var member : members) { + assertThat(fullPillarMembers).contains((TestdataEntity) member); + } + } + } + + @Test + void differentDrawsProduceDifferentSubpillars() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 5); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); + } + + var context = NeighborhoodTester + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var distinctMemberSets = context + .getMovesAsStream(move -> (MassChangeMove) move) + .limit(200) + .map(move -> new HashSet<>(move.getPlanningEntities())) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctMemberSets).hasSizeGreaterThan(1); + } + + @Test + void pinnedEntityExcludedFromSubpillar() { + var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class).basicVariable(); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + // pinnedEntity shares v0 with free1 and free2, but forEach(..., false) excludes pinned + // entities from the entity source, so it must never join their subpillar, regardless of rule. + var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + var free1 = new TestdataPinnedEntity("free1", v0, false); + var free2 = new TestdataPinnedEntity("free2", v0, false); + + var solution = new TestdataPinnedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream(move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + + @Test + void crossingNullDefaultTrueAlsoUnassignsSubpillar() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 6); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); // All entities share one value -> one pillar. + } + + // Default constructor: crossingNull is true, because this variable allows unassigned values. + var context = NeighborhoodTester + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(500) + .toList(); + assertThat(moves).anyMatch(move -> move.getPlanningValues().getFirst() == null); + } + + @Test + void crossingNullFalseNeverUnassignsSubpillar() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 6); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); + } + + var context = NeighborhoodTester + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2), false), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(move -> move.getPlanningValues().getFirst() == null); + } + + @Test + void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java new file mode 100644 index 00000000000..159b1ada01a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java @@ -0,0 +1,293 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +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.multivar.TestdataMultiVarEntity; +import ai.timefold.solver.core.testdomain.multivar.TestdataMultiVarSolution; +import ai.timefold.solver.core.testdomain.multivar.TestdataOtherValue; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedEntity; +import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubPillarSwapMoveProviderTest { + + @Test + void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + + // 5 entities on value0 (pillar A, size 5), 5 on value1 (pillar B, size 5). + var solution = TestdataSolution.generateSolution(2, 10); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + for (var i = 0; i < 5; i++) { + entityList.get(i).setValue(valueList.getFirst()); + } + for (var i = 5; i < 10; i++) { + entityList.get(i).setValue(valueList.get(1)); + } + var pillarA = new HashSet<>(entityList.subList(0, 5)); + var pillarB = new HashSet<>(entityList.subList(5, 10)); + + var context = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var members = move.getPlanningEntities(); + assertThat(members).hasSizeLessThanOrEqualTo(4); // At most 2 per side. + for (var member : members) { + assertThat(pillarA.contains(member) || pillarB.contains(member)).isTrue(); + } + } + } + + @Test + void differentDrawsProduceDifferentSubpillars() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + + var solution = TestdataSolution.generateSolution(2, 10); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + for (var i = 0; i < 5; i++) { + entityList.get(i).setValue(valueList.getFirst()); + } + for (var i = 5; i < 10; i++) { + entityList.get(i).setValue(valueList.get(1)); + } + + var context = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .using(solution); + + var distinctMemberSets = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(200) + .map(move -> new HashSet<>(move.getPlanningEntities())) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctMemberSets).hasSizeGreaterThan(1); + } + + @Test + void bothSidesRespectTheirOwnRule() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + + var solution = TestdataSolution.generateSolution(2, 10); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + for (var i = 0; i < 5; i++) { + entityList.get(i).setValue(valueList.getFirst()); + } + for (var i = 5; i < 10; i++) { + entityList.get(i).setValue(valueList.get(1)); + } + + var context = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(1), sizeLimit(3)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).hasSizeLessThanOrEqualTo(1 + 3); + } + } + + @Test + void pinnedEntityNeverParticipatesInSubpillarSwap() { + var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class); + + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + var free1 = new TestdataPinnedEntity("free1", v0, false); + var free2 = new TestdataPinnedEntity("free2", v1, false); + var free3 = new TestdataPinnedEntity("free3", v1, false); + + var solution = new TestdataPinnedSolution("s"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(List.of(pinnedEntity, free1, free2, free3)); + + var context = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + + @Test + void multivariateSwapUsesEveryListedVariable() { + var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.entity(TestdataMultiVarEntity.class); + var variableMetaModelList = entityMetaModel.variables().stream() + .map(v -> (PlanningVariableMetaModel) v) + .toList(); + + var value0 = new TestdataValue("v0"); + var value1 = new TestdataValue("v1"); + var otherValue0 = new TestdataOtherValue("o0"); + + // Pillar A and pillar B agree on primary and tertiary, differing only on secondary. Both + // groups have exactly 2 members, so a sizeLimit(2) sampler always draws the whole group, + // making the subpillar swap deterministic - same setup as + // PillarSwapMoveProviderTest.atLeastOneDifferingVariableProducesMove. + var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); + var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); + var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); + var b2 = new TestdataMultiVarEntity("b2", value0, value1, otherValue0); + + var solution = new TestdataMultiVarSolution("s"); + solution.setValueList(List.of(value0, value1)); + solution.setOtherValueList(List.of(otherValue0)); + solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); + + var pillarA = Sample.of(List.of(a1, a2)); + var pillarB = Sample.of(List.of(b1, b2)); + + NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .using(solution) + .producesAllOf( + Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), + Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); + } + + @Test + void excludedVariableIsIgnoredForKeyAndSwap() { + var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.entity(TestdataMultiVarEntity.class); + var allowedVariableMetaModelList = entityMetaModel.variables().stream() + .filter(v -> !v.name().contains("secondary")) + .map(v -> (PlanningVariableMetaModel) v) + .toList(); + + var value0 = new TestdataValue("v0"); + var value1 = new TestdataValue("v1"); + var otherValue0 = new TestdataOtherValue("o0"); + + // Same setup as multivariateSwapUsesEveryListedVariable. Excluding secondary from the key + // merges A and B into one pillar (all four share primary and tertiary), leaving no second + // distinct key to swap with. + var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); + var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); + var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); + var b2 = new TestdataMultiVarEntity("b2", value0, value1, otherValue0); + + var solution = new TestdataMultiVarSolution("s"); + solution.setValueList(List.of(value0, value1)); + solution.setOtherValueList(List.of(otherValue0)); + solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); + + var moveList = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(allowedVariableMetaModelList, sizeLimit(2), sizeLimit(2)), + solutionMetaModel) + .using(solution) + .getMovesAsStream() + .limit(50) + .toList(); + assertThat(moveList).isEmpty(); + } + + @Test + void mixedLegalityAcrossVariablesRejectsWholePair() { + var solutionMetaModel = TestdataAllowsUnassignedMultiVarEntityProvidingSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedMultiVarEntityProvidingEntity.class); + @SuppressWarnings("unchecked") + var variableMetaModelList = entityMetaModel.variables().stream() + .map(v -> (PlanningVariableMetaModel) v) + .toList(); + + var v1 = new TestdataValue("v1"); + var v3 = new TestdataValue("v3"); + var v4 = new TestdataValue("v4"); + + // Same setup as PillarSwapMoveProviderTest.mixedLegalityAcrossVariablesRejectsWholePair, + // drawn through a size-1 sampler on each side so the subpillar equals the whole pillar. + var a1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("a1", List.of(v1, v4), List.of(v1, v4)); + a1.setValue(v1); + a1.setSecondValue(v1); + var b1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("b1", List.of(v1, v4), List.of(v1, v3, v4)); + b1.setValue(v4); + b1.setSecondValue(v3); + + var solution = new TestdataAllowsUnassignedMultiVarEntityProvidingSolution("s", List.of(v1)); + solution.setEntityList(List.of(a1, b1)); + + var moveList = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, sizeLimit(1), sizeLimit(1)), solutionMetaModel) + .using(solution) + .getMovesAsStream() + .limit(50) + .toList(); + assertThat(moveList).isEmpty(); + } + + @Test + void singleVariableConstructorMatchesOneElementList() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 4); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + entityList.get(0).setValue(valueList.get(0)); + entityList.get(1).setValue(valueList.get(0)); + entityList.get(2).setValue(valueList.get(1)); + entityList.get(3).setValue(valueList.get(1)); + var pillarA = Sample.of(List.of(entityList.get(0), entityList.get(1))); + var pillarB = Sample.of(List.of(entityList.get(2), entityList.get(3))); + + var expectedMoveAtoB = Moves.pillarSwap(variableMetaModel, pillarA, pillarB); + var expectedMoveBtoA = Moves.pillarSwap(variableMetaModel, pillarB, pillarA); + + NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(variableMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .using(solution) + .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); + NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(List.of(variableMetaModel), sizeLimit(2), sizeLimit(2)), + solutionMetaModel) + .using(solution) + .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); + } + + @Test + void emptyListConstructorThrows() { + assertThatThrownBy(() -> new SubPillarSwapMoveProvider(List.of(), + sizeLimit(2), sizeLimit(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is empty"); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java new file mode 100644 index 00000000000..417c95a3412 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -0,0 +1,107 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedSolution; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubPillarUnassignMoveProviderTest { + + @Test + void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 6); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); // All entities share one value -> one pillar. + } + + var context = NeighborhoodTester + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .toList(); + assertThat(moves).isNotEmpty(); + var fullPillarMembers = new HashSet<>(entityList); + for (var move : moves) { + var members = move.getPlanningEntities(); + assertThat(members).hasSizeLessThanOrEqualTo(2); + for (var member : members) { + assertThat(fullPillarMembers).contains((TestdataAllowsUnassignedEntity) member); + } + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + + @Test + void differentDrawsProduceDifferentSubpillars() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + var solution = TestdataAllowsUnassignedSolution.generateSolution(2, 6); + var entityList = solution.getEntityList(); + var sharedValue = solution.getValueList().getFirst(); + for (var entity : entityList) { + entity.setValue(sharedValue); + } + + var context = NeighborhoodTester + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var distinctMemberSets = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(200) + .map(move -> new HashSet<>(move.getPlanningEntities())) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctMemberSets).hasSizeGreaterThan(1); + } + + @Test + void pinnedEntityNeverUnassigned() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedEntity.class).basicVariable(); + + var v0 = new TestdataValue("v0"); + var pinnedEntity = new TestdataPinnedAllowsUnassignedEntity("pinned", v0, true); + var free1 = new TestdataPinnedAllowsUnassignedEntity("free1", v0, false); + var free2 = new TestdataPinnedAllowsUnassignedEntity("free2", v0, false); + + var solution = new TestdataPinnedAllowsUnassignedSolution("s"); + solution.setValueList(List.of(v0)); + solution.setEntityList(List.of(pinnedEntity, free1, free2)); + + var context = NeighborhoodTester + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java index 1eb27704c62..ecb23bf6af9 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import java.util.List; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel; @@ -14,6 +16,8 @@ import ai.timefold.solver.core.testdomain.pinned.TestdataPinnedSolution; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingEntity; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.TestdataEntityProvidingSolution; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.valuerange.entityproviding.multivar.TestdataAllowsUnassignedMultiVarEntityProvidingSolution; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; @@ -24,7 +28,7 @@ class SwapMoveProviderTest { @Test void univariate() { var solutionMetaModel = TestdataSolution.buildMetaModel(); - var entityMetaModel = solutionMetaModel.entity(TestdataEntity.class); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); var solution = TestdataSolution.generateSolution(2, 3); @@ -51,7 +55,7 @@ void univariate() { @Test void multivariate() { var solutionMetaModel = TestdataMultiVarSolution.buildMetaModel(); - var entityMetaModel = solutionMetaModel.entity(TestdataMultiVarEntity.class); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataMultiVarEntity.class); var variableMetaModelList = allVariables(entityMetaModel); var solution = TestdataMultiVarSolution.generateSolution(3, 2, 2); @@ -77,7 +81,7 @@ void multivariate() { @Test void pinnedEntitySkipped() { var solutionMetaModel = TestdataPinnedSolution.buildMetaModel(); - var entityMetaModel = solutionMetaModel.entity(TestdataPinnedEntity.class); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedEntity.class).basicVariable(); var solution = TestdataPinnedSolution.generateSolution(2, 2); @@ -96,7 +100,7 @@ void pinnedEntitySkipped() { @Test void fromEntity() { var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); - var entityMetaModel = solutionMetaModel.entity(TestdataEntityProvidingEntity.class); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class).basicVariable(); var v1 = new TestdataValue("v1"); @@ -131,7 +135,7 @@ void multivariateWithExclusions() { var entityMetaModel = solutionMetaModel.entity(TestdataMultiVarEntity.class); var allowedVariableMetaModels = entityMetaModel.variables().stream() .filter(v -> !v.name().contains("tertiary")) - .map(v -> (PlanningVariableMetaModel) v) + .map(v -> (PlanningVariableMetaModel) v) .toList(); var solution = TestdataMultiVarSolution.generateSolution(3, 1, 2); var e1 = solution.getMultiVarEntityList().get(0); @@ -160,4 +164,66 @@ private static List (PlanningVariableMetaModel) v) + .toList(); + + var v1 = new TestdataValue("v1"); + var v3 = new TestdataValue("v3"); + var v4 = new TestdataValue("v4"); + + // e1 and e3 differ on both "value" and "secondValue". The "value" swap is legal in both + // directions (v1 and v4 are in both ranges), but the "secondValue" swap is not: e1's + // secondValueRange does not contain e3's v3. One legal variable is not enough to save the + // pair; the whole swap is rejected. + var e1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("e1", List.of(v1, v4), List.of(v1, v4)); + e1.setValue(v1); + e1.setSecondValue(v1); + var e3 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("e3", List.of(v1, v4), List.of(v1, v3, v4)); + e3.setValue(v4); + e3.setSecondValue(v3); + + // The third variable is solution-scoped and both entities leave it null, so it never + // differs and never decides the outcome. + var solution = new TestdataAllowsUnassignedMultiVarEntityProvidingSolution("s", List.of(v1)); + solution.setEntityList(List.of(e1, e3)); + + NeighborhoodTester.build(new SwapMoveProvider<>(entityMetaModel), solutionMetaModel) + .using(solution) + .producesNoneOf( + Moves.swap(variableMetaModelList, e1, e3), + Moves.swap(variableMetaModelList, e3, e1)); + } + + @Test + void singleVariableConstructorMatchesOneElementList() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); + + var solution = TestdataSolution.generateSolution(2, 3); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + + var expectedMove = Moves.swap(variableMetaModel, e1, e2); + + NeighborhoodTester.build(new SwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution) + .producesAllOf(expectedMove); + NeighborhoodTester.build(new SwapMoveProvider<>(List.of(variableMetaModel)), solutionMetaModel) + .using(solution) + .producesAllOf(expectedMove); + } + + @Test + void emptyListConstructorThrows() { + assertThatThrownBy(() -> new SwapMoveProvider(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is empty"); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java index 38d10c63e9a..b241b6118fe 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java @@ -1,7 +1,12 @@ package ai.timefold.solver.core.preview.api.move.builtin; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import ai.timefold.solver.core.preview.api.move.MutableSolutionView; import ai.timefold.solver.core.preview.api.move.test.MoveTester; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -35,7 +40,11 @@ void swapMoveExecutesPermanently() { } @Test - void swapMoveWithSameValue() { + @SuppressWarnings("unchecked") + void sameValueSwapWritesBothVariables() { + // SwapMove does not skip a variable whose values already match; that exclusion is the + // provider's job (SwapMoveProvider.isValidSwap). A hand-built move over equal values now + // performs two writes that produce no net change, instead of none. var solution = TestdataSolution.generateSolution(2, 2); var entity1 = solution.getEntityList().get(0); var entity2 = solution.getEntityList().get(1); @@ -50,13 +59,10 @@ void swapMoveWithSameValue() { var swapMove = Moves.swap(variableMetaModel, entity1, entity2); - MoveTester.build(solutionMetaModel) - .using(solution) - .execute(swapMove); + var solutionView = mock(MutableSolutionView.class); + swapMove.execute(solutionView); - // Assert - both should still have the same value - assertThat(entity1.getValue()).isEqualTo(value); - assertThat(entity2.getValue()).isEqualTo(value); + verify(solutionView, times(2)).changeVariable(any(), any(), any()); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/DatasetTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/DatasetTest.java index ec5737e4976..b461f5e6c44 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/DatasetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/DatasetTest.java @@ -1,5 +1,6 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -9,12 +10,8 @@ import java.util.List; import java.util.Set; -import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; -import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession; -import ai.timefold.solver.core.impl.score.director.SessionContext; -import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.impl.solver.random.RandomSource; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.BiNeighborhoodsJoiner; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; @@ -28,24 +25,10 @@ @NullMarked class DatasetTest { - private static DefaultMoveStreamFactory factory() { - return new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); - } - - private static DefaultNeighborhoodSession createSession( - DefaultMoveStreamFactory moveStreamFactory, TestdataSolution solution) { - var scoreDirector = new EasyScoreDirectorFactory<>(moveStreamFactory.getSolutionDescriptor(), - s -> SimpleScore.ZERO, EnvironmentMode.PHASE_ASSERT).buildScoreDirector(); - scoreDirector.setWorkingSolution(solution); - var session = moveStreamFactory.createSession(new SessionContext<>(scoreDirector)); - moveStreamFactory.getSolutionDescriptor().visitAll(solution, session::insert); - session.settle(); - return session; - } - @Test void asCachedDatasetUni_sizeAndIteratorsVisitEveryElementExactlyOnce() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var entityDataset = moveStreamFactory.forEach(TestdataEntity.class, false).asCachedDataset(); var solution = TestdataSolution.generateSolution(2, 3); // 2 values, 3 entities. @@ -65,7 +48,8 @@ void asCachedDatasetUni_sizeAndIteratorsVisitEveryElementExactlyOnce() { @Test void asCachedDatasetUni_randomIteratorNeverEndsAndCanRepeat() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var entityDataset = moveStreamFactory.forEach(TestdataEntity.class, false).asCachedDataset(); var solution = TestdataSolution.generateSolution(2, 2); // 2 values, 2 entities. @@ -93,7 +77,8 @@ void asCachedDatasetUni_randomIteratorNeverEndsAndCanRepeat() { @Test void asCachedDatasetSameStreamTwice_sharesOneDataset() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var stream = moveStreamFactory.forEach(TestdataEntity.class, false); assertThat(stream.asCachedDataset()).isEqualTo(stream.asCachedDataset()); @@ -101,7 +86,8 @@ void asCachedDatasetSameStreamTwice_sharesOneDataset() { @Test void cachedAndJustInTimeJoin_agreeOnEveryRow() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); BiNeighborhoodsJoiner joiner = @@ -130,7 +116,8 @@ void cachedAndJustInTimeJoin_agreeOnEveryRow() { @Test void cachedJoin_perALookupOnSkewedBucketsAlwaysFindsItsMatch() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var rareEntity = new TestdataEntity("rare"); var commonEntity = new TestdataEntity("common"); var valueList = new ArrayList(); @@ -177,7 +164,8 @@ void cachedJoin_perALookupOnSkewedBucketsAlwaysFindsItsMatch() { @Test void cachedJoin_lessThanProducesPairsOrderedLeftBeforeRight() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); var joiner = NeighborhoodsJoiners.lessThan(TestdataValue::getCode); var cachedDataset = valueStream.join(valueStream, joiner).asCachedDataset(); @@ -195,7 +183,8 @@ void cachedJoin_lessThanProducesPairsOrderedLeftBeforeRight() { @Test void justInTimeJoin_lessThanProducesPairsOrderedLeftBeforeRight() { - var moveStreamFactory = factory(); + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); var joiner = NeighborhoodsJoiners.lessThan(TestdataValue::getCode); var valueDataset = valueStream.asCachedDataset(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java new file mode 100644 index 00000000000..c55dd61162d --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class SampleTest { + + @Test + void ofRemovesDuplicates() { + var sample = Sample.of(List.of("a", "b", "a")); + assertThat(sample.size()).isEqualTo(2); + assertThat(sample).containsExactlyInAnyOrder("a", "b"); + } + + @Test + void ofCopiesSoLaterMutationDoesNotLeak() { + var memberList = new ArrayList<>(List.of("a", "b")); + var sample = Sample.of(memberList); + memberList.add("c"); + assertThat(sample.size()).isEqualTo(2); + assertThat(sample.contains("c")).isFalse(); + } + + @Test + void nullIsALegalMember() { + var sample = Sample.of(Arrays.asList("a", null)); + assertThat(sample.size()).isEqualTo(2); + assertThat(sample.contains(null)).isTrue(); + assertThat(sample.contains("zzz")).isFalse(); + } + + @Test + void equalityIsOrderInsensitive() { + var left = Sample.of(List.of("a", "b", "c")); + var right = Sample.of(List.of("c", "a", "b")); + assertThat(left).isEqualTo(right); + assertThat(left).hasSameHashCodeAs(right); + } + + @Test + void equalityToleratesNullMembers() { + var left = Sample.of(Arrays.asList("a", null)); + var right = Sample.of(Arrays.asList(null, "a")); + assertThat(left).isEqualTo(right); + assertThat(left).hasSameHashCodeAs(right); + } + + @Test + void differentMembersAreNotEqual() { + assertThat(Sample.of(List.of("a"))).isNotEqualTo(Sample.of(List.of("b"))); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java new file mode 100644 index 00000000000..6974ba677bc --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java @@ -0,0 +1,182 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.DatasetSession; +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.common.AbstractLeftDataset; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniLeftDataset; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniLeftDatasetInstance; +import ai.timefold.solver.core.impl.score.director.SessionContext; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.collector.NeighborhoodsCollectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsMapper; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; +import ai.timefold.solver.core.testdomain.TestdataValue; + +import org.junit.jupiter.api.Test; + +/** + * Proves that {@code groupBy(key, collectAndThen(toList(), Sample::of))} yields a cached + * {@code UniDataset>} that is safe for a pillar move to hold across a + * settle: each settle that changes a group produces a fresh, immutable {@link Sample}, never a live + * view into the group's mutating accumulator. This is the mechanism {@code PillarDatasetUtil} and the + * four full-pillar move providers are built on; this test predates and does not depend on either. + */ +class PillarDatasetTest { + + private static EnumeratingStreamFactory factory() { + return new EnumeratingStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + } + + @SuppressWarnings("unchecked") + private static UniLeftDatasetInstance> buildInstance( + EnumeratingStreamFactory factory, TestdataSolution solution, + DatasetSession[] sessionOut) { + var entityStream = (AbstractUniEnumeratingStream) factory + .forEachNonDiscriminating(TestdataEntity.class, false); + UniNeighborhoodsMapper byValue = + (view, entity) -> entity.getValue(); + var grouped = entityStream.groupBy(byValue, + NeighborhoodsCollectors.collectAndThen( + NeighborhoodsCollectors. toList(), + Sample::of)); + var mapped = (AbstractUniEnumeratingStream>) grouped + .map((view, value, pillar) -> pillar); + var dataset = (UniLeftDataset>) mapped.asCachedDataset(); + + var scoreDirector = new EasyScoreDirectorFactory<>(factory.getSolutionDescriptor(), + (TestdataSolution s) -> SimpleScore.ZERO, EnvironmentMode.PHASE_ASSERT).buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + DatasetSession session = + new DatasetSessionFactory<>(factory).buildSession(new SessionContext<>(scoreDirector)); + factory.getSolutionDescriptor().visitAll(solution, session::insert); + session.settle(); + sessionOut[0] = session; + + return (UniLeftDatasetInstance>) session + .getInstance((AbstractLeftDataset>>) dataset); + } + + private static List> rowsOf( + UniLeftDatasetInstance> instance) { + var rows = new ArrayList>(); + instance.iterator().forEachRemaining(tuple -> rows.add(tuple.getA())); + return rows; + } + + @Test + void oneRowPerDistinctValue() { + var factory = factory(); + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var e0 = new TestdataEntity("e0", v0); + var e1 = new TestdataEntity("e1", v0); + var e2 = new TestdataEntity("e2", v1); + var solution = new TestdataSolution("solution"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(new ArrayList<>(List.of(e0, e1, e2))); + + @SuppressWarnings("unchecked") + var sessionOut = (DatasetSession[]) new DatasetSession[1]; + var instance = buildInstance(factory, solution, sessionOut); + + var rows = rowsOf(instance); + assertThat(rows).hasSize(2); + var v0Pillar = rows.stream().filter(p -> p.size() == 2).findFirst().orElseThrow(); + var v1Pillar = rows.stream().filter(p -> p.size() == 1).findFirst().orElseThrow(); + assertThat(v0Pillar.contains(e0)).isTrue(); + assertThat(v0Pillar.contains(e1)).isTrue(); + assertThat(v1Pillar.contains(e2)).isTrue(); + } + + @Test + void pillarHeldAcrossASettleDoesNotChange() { + var factory = factory(); + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var e0 = new TestdataEntity("e0", v0); + var e1 = new TestdataEntity("e1", v0); + var e2 = new TestdataEntity("e2", v1); + var solution = new TestdataSolution("solution"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(new ArrayList<>(List.of(e0, e1, e2))); + + @SuppressWarnings("unchecked") + var sessionOut = (DatasetSession[]) new DatasetSession[1]; + var instance = buildInstance(factory, solution, sessionOut); + var session = sessionOut[0]; + + var held = rowsOf(instance).stream().filter(p -> p.size() == 2).findFirst().orElseThrow(); + + // Move e1 out of v0's group. + e1.setValue(v1); + session.update(e1); + session.settle(); + + // The held reference must be unaffected... + assertThat(held.size()).isEqualTo(2); + assertThat(held.contains(e0)).isTrue(); + assertThat(held.contains(e1)).isTrue(); + + // ...while the dataset's current row for v0 is a different, smaller object. + var current = rowsOf(instance).stream().filter(p -> p.size() == 1).findFirst().orElseThrow(); + assertThat(current).isNotSameAs(held); + assertThat(current.contains(e0)).isTrue(); + assertThat(current.contains(e1)).isFalse(); + } + + @Test + void emptyGroupProducesNoPillarRow() { + var factory = factory(); + var solution = new TestdataSolution("solution"); + solution.setValueList(List.of()); + solution.setEntityList(new ArrayList<>()); + + @SuppressWarnings("unchecked") + var sessionOut = (DatasetSession[]) new DatasetSession[1]; + var instance = buildInstance(factory, solution, sessionOut); + + assertThat(rowsOf(instance)).isEmpty(); + } + + @Test + void lastMemberRetractionDoesNotBuildAnEmptyPillar() { + var factory = factory(); + var v0 = new TestdataValue("v0"); + var v1 = new TestdataValue("v1"); + var e0 = new TestdataEntity("e0", v0); + var solution = new TestdataSolution("solution"); + solution.setValueList(List.of(v0, v1)); + solution.setEntityList(new ArrayList<>(List.of(e0))); + + @SuppressWarnings("unchecked") + var sessionOut = (DatasetSession[]) new DatasetSession[1]; + var instance = buildInstance(factory, solution, sessionOut); + var session = sessionOut[0]; + + assertThat(rowsOf(instance)).hasSize(1); + + // e0 was the only member of v0's group; move it away entirely. + e0.setValue(v1); + assertThatCode(() -> { + session.update(e0); + session.settle(); + }).doesNotThrowAnyException(); + + assertThat(rowsOf(instance)).hasSize(1); // v1's group now, v0's group is gone. + assertThat(rowsOf(instance).getFirst().contains(e0)).isTrue(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataAllowsUnassignedMultiVarEntityProvidingSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataAllowsUnassignedMultiVarEntityProvidingSolution.java index 1f39a1b0c29..1b1de36df75 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataAllowsUnassignedMultiVarEntityProvidingSolution.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/valuerange/entityproviding/multivar/TestdataAllowsUnassignedMultiVarEntityProvidingSolution.java @@ -12,6 +12,7 @@ import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; import ai.timefold.solver.core.testdomain.TestdataObject; import ai.timefold.solver.core.testdomain.TestdataValue; @@ -23,6 +24,10 @@ public static SolutionDescriptor buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + public static TestdataAllowsUnassignedMultiVarEntityProvidingSolution generateSolution() { var value1 = new TestdataValue("1"); var value2 = new TestdataValue("2"); diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java b/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java new file mode 100644 index 00000000000..1699f74c73f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java @@ -0,0 +1,68 @@ +package ai.timefold.solver.core.testutil; + +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT_AND_STOP; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.STOP; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; +import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession; +import ai.timefold.solver.core.impl.score.director.SessionContext; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class NeighborhoodTestUtils { + + /** Accepts everything, so a sample drains its whole source. */ + public static Sampler neverStoppingSampler() { + return (sizeSoFar, candidate) -> ACCEPT; + } + + /** + * Stops the sample at exactly {@code limit} members ({@code limit <= 1} stops right after the + * seed, since the seed itself is never subject to {@code decide}). + */ + public static Sampler sizeLimit(int limit) { + if (limit <= 1) { + return (sizeSoFar, candidate) -> STOP; + } + return sizeLimitFromTwo(limit); + } + + /** + * As defined by {@link #sizeLimit(int)}, + * but without the {@code limit <= 1} guard. + * Requires {@code limit >= 2}; + * a smaller limit does not stop the sample where the caller expects. + */ + public static Sampler sizeLimitFromTwo(int limit) { + return (sizeSoFar, candidate) -> sizeSoFar + 1 >= limit + ? ACCEPT_AND_STOP + : ACCEPT; + } + + /** + * Builds and settles a {@code DatasetSession} directly + * (bypassing a real {@code ScoreDirector} and solver), + * for the cached and just-in-time dataset cases + * that have no {@code MoveProvider} route of their own. + */ + public static DefaultNeighborhoodSession createSession( + DefaultMoveStreamFactory moveStreamFactory, Solution_ solution) { + var scoreDirector = new EasyScoreDirectorFactory<>(moveStreamFactory.getSolutionDescriptor(), + s -> SimpleScore.ZERO, EnvironmentMode.PHASE_ASSERT).buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var session = moveStreamFactory.createSession(new SessionContext<>(scoreDirector)); + moveStreamFactory.getSolutionDescriptor().visitAll(solution, session::insert); + session.settle(); + return session; + } + + private NeighborhoodTestUtils() { + } + +} diff --git a/docs/src/modules/ROOT/nav.adoc b/docs/src/modules/ROOT/nav.adoc index df232bd6538..7e75cc46a8e 100644 --- a/docs/src/modules/ROOT/nav.adoc +++ b/docs/src/modules/ROOT/nav.adoc @@ -72,6 +72,7 @@ * Upgrading Timefold Solver ** xref:upgrading-timefold-solver/overview.adoc[leveloffset=+1] +** xref:upgrading-timefold-solver/upgrade-to-latest.adoc[leveloffset=+1] ** xref:upgrading-timefold-solver/upgrade-from-v1.adoc[leveloffset=+1] ** https://docs.timefold.ai/timefold-solver/1.x/upgrading-timefold-solver/upgrade-from-optaplanner[Upgrading from OptaPlanner][leveloffset=+1] ** xref:upgrading-timefold-solver/backwards-compatibility.adoc[leveloffset=+1] diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc index bd04768abbc..54a7a5cae38 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc @@ -430,16 +430,16 @@ to define and efficiently cache a set of values to generate moves from. The product of this layer is an in-memory dataset of potential move elements. For example, you would enumerate a dataset of entities which your moves may want to change, based on some specific criteria. -Sampling:: +Picking:: Defines how to pick from the in-memory datasets generated by the enumeration layer. -Typically, this involves picking a random combination of values from these datasets. +Typically, this involves selecting a random combination of values from these datasets. Unlike the enumeration layer, which keeps its state in memory at all times, -sampling happens just-in-time when the solver requests a new move. +picking happens just-in-time when the solver requests a new move. This avoids the creation of expensive and potentially huge cross-products. For example, if you've enumerated all entities and all possible values they can take, -the sampling layer would randomly select one entity and one value to create a move. +the picking layer would randomly select one entity and one value to create a move. Move generation:: -Takes the sample of enumerated values and creates a move out of them. +Takes the picked items and creates a move out of them. This move is then returned to the solver for execution. For example, having picked an entity and a value, you'd generate a change move to assign the value to that entity's variable. @@ -475,17 +475,17 @@ public class TimeslotChangeMoveProvider We will explore each of these concepts in more detail below. -[#neighborhoodsEnumerateVsSample] -==== When to enumerate and when to sample? +[#neighborhoodsEnumerateVsPick] +==== When to enumerate and when to pick? -Operations such as `join()` are available both on <<#neighborhoodsEnumeration,enumerating stream>> and <<#neighborhoodsSampling,sampling streams>>. -The decision between enumeration and sampling is effectively +Operations such as `join()` are available both on <<#neighborhoodsEnumeration,enumerating stream>> and <<#neighborhoodsPicking,picking streams>>. +The decision between enumeration and picking is effectively a trade-off between what is more important to you – solver speed or memory consumption: - Enumerating streams will result in data structures which are fully expanded in memory, and efficiently incrementally updated by the solver. -- Sampling streams, on the other hand, +- Picking streams, on the other hand, will be expanded just-in-time when the solver first needs to access them and will not be cached in any way. @@ -495,22 +495,22 @@ such as joins over large datasets, nested joins, or pillars. With such data structures, you run the risk of consuming so much memory that full enumeration may no longer be practically possible. In those cases, -just-in-time capabilities provided by sampling streams are the way to go, +just-in-time capabilities provided by picking streams are the way to go, even if they result in comparatively slower solver performance; a slower optimization beats no optimization at all. -Some capabilities (such as pillars) may only be provided as sampling streams, +Some capabilities (such as pillars) may only be provided as picking streams, with the explicit assumption that every non-trivial dataset will already produce too many combinations. In cases where the API does give you a choice, you do not need to decide right away. See if your problem can handle being implemented mostly with enumerating streams, -and convert to sampling if/when you see the solver exceeding available memory. +and convert to picking if/when you see the solver exceeding available memory. [#neighborhoodsEnumeration] === Dataset enumeration -The goal of dataset enumeration is to produce in-memory collections of values for the sampling layer to choose from. +The goal of dataset enumeration is to produce in-memory collections of values for the picking layer to choose from. This in-memory collection is kept up-to-date incrementally as the working solution changes. The entry point for enumeration is the `MoveStreamFactory.forEach` method, which operates much like the xref:constraints-and-score/score-calculation.adoc#constraintStreamsForEach[`forEach()` method of Constraint Streams]. @@ -519,8 +519,8 @@ Additionally, it allows to specify whether it should include `null` in the resul this is useful when generating moves for nullable planning variables. Every enumerating stream is terminated one of two ways: `MoveStreamFactory.pick()`, handing the rows -to the <> below, or `asCachedDataset()`, handing you the dataset handle to resolve and -sample yourself. Both terminal operations keep the dataset in memory and up to date; +to the <> below, or `asCachedDataset()`, handing you the dataset handle to resolve and +pick yourself. Both terminal operations keep the dataset in memory and up to date; they are not a choice between a cached and an uncached path, only between who reads the cached rows. @@ -573,22 +573,22 @@ The same pattern applies to other building blocks as well, such as `join`, `ifEx essentially, the `solutionView` argument was added to every predicate or function where it could be useful. -[#neighborhoodsSampling] -=== Sampling the datasets +[#neighborhoodsPicking] +=== Picking from the datasets Once we have defined our enumerations, we need to define how to pick from them to create moves. This is done using the `MoveStreamFactory.pick` method, which takes an enumeration as argument and returns another builder to continue picking from more enumerations. -The difference between enumeration and sampling is that enumeration keeps its state in memory at all times, -while sampling happens just-in-time when the solver requests a new move. +The difference between enumeration and picking is that enumeration keeps its state in memory at all times, +while picking happens just-in-time when the solver requests a new move. This is important to avoid creating cross-products of enumerated datasets, which would make move generation practically impossible within the constraints of today's hardware. Picking happens randomly; that is, each time the solver requests a new move, -the sampling phase randomly selects one item from each enumeration. +the picking phase randomly selects one item from each enumeration. This random selection is uniform across the entire dataset; that is, each item has an equal chance of being selected. -However, we can apply filtering to the sampling phase to avoid certain combinations of items, +However, we can apply filtering to the picking phase to avoid certain combinations of items, as we've already seen in the simple example above: [source,java,options="nowrap"] @@ -615,26 +615,26 @@ This prevents generating a move which would not change the solution at all. It is the responsibility of the move provider to avoid generating no-op moves; if the solver receives a no-op move, it will execute it anyway, wasting time and resources. -The end result of the sampling phase is a random combination of items from the enumerations; +The end result of the picking phase is a random combination of items from the enumerations; in the example above, this would be a random `Lesson` and a random `Timeslot`. This pair of picked items is then passed to the move generation phase, -and the process of sampling is repeated each time the solver requests a new move to be generated. +and the process of picking is repeated each time the solver requests a new move to be generated. -The sampling phase currently only supports applying filtering and a limited selection of joiners, +The picking phase currently only supports applying filtering and a limited selection of joiners, found in the `ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners` class. More advanced selection strategies, such as xref:optimization-algorithms/move-selector-reference.adoc#nearbySelection[nearby selection], are likely to materialize here in the future as well. -Should these methods of sampling be too restrictive for you, +Should these methods of picking be too restrictive for you, refer to <> -which give you full control over what is sampled and how, +which give you full control over what is picked and how, making your move providers more powerful but also more complex. [#neighborhoodsMoveGeneration] === Move Generation -Having already enumerated datasets and defined how to sample from them, +Having already enumerated datasets and defined how to pick from them, the final step is to generate a move from the picked items. This is done using the `asMove` method on the builder, which takes a function that creates a move from the picked items. @@ -716,7 +716,7 @@ public class BestShiftMoveProvider implements MoveProvider { ---- `UniEnumeratingStream#asCachedDataset()` and `BiEnumeratingStream#asCachedDataset()` turn a stream into a `UniDataset`/`BiDataset` handle, -mirroring `pick()` but without committing to the declarative sampling approach. +mirroring `pick()` but without committing to the declarative picking approach. Call `UniDataset#join(...)` to correlate a cached dataset with another stream, using the same joiners as `pick()`. This will have the same effect as joining inside of a `pick()` - @@ -726,7 +726,7 @@ as opposed to fully expanded and persisted in memory. Inside `buildMoveStream(...)`, the `MoveIteratorSession` resolves each dataset handle to a `UniDatasetInstance` or `BiDatasetInstance`, which exposes `size()`, `iterator(RandomGenerator)` and `exhaustiveIterator(RandomGenerator)` - - the same random-walk sampling used throughout Move Streams. + the same random-walk picking used throughout Move Streams. A `BiDatasetInstance` additionally exposes the same three operations keyed by a left-side value, for datasets produced by a join. @@ -742,7 +742,7 @@ there is no original/deterministic-order variant. Having created a custom `MoveProvider`, it is essential to verify that it correctly generates moves and interacts properly with the solver's infrastructure. The `NeighborhoodTester` API provides a simple testing utility -to enumerate and sample moves from a move provider in isolation, +to enumerate and pick moves from a move provider in isolation, making it easy to write unit tests for your move providers. IMPORTANT: The `NeighborhoodTester` API is designed exclusively for testing purposes. diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/.upgrading-timefold-solver.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/.upgrading-timefold-solver.adoc index 66429de3e3b..04724062baf 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/.upgrading-timefold-solver.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/.upgrading-timefold-solver.adoc @@ -5,5 +5,6 @@ :icons: font include::overview.adoc[leveloffset=+1] +include::upgrade-to-latest.adoc[leveloffset=+1] include::upgrade-from-v1.adoc[leveloffset=+1] include::backwards-compatibility.adoc[leveloffset=+1] \ No newline at end of file diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/backwards-compatibility.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/backwards-compatibility.adoc index 8252d10fe39..b700c4b1e4d 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/backwards-compatibility.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/backwards-compatibility.adoc @@ -16,7 +16,7 @@ Timefold Solver separates its API from its implementation: They will change in future major or minor releases, but probably not in hotfix releases. -Backwards incompatible changes for a new major version are clearly documented in xref:upgrading-timefold-solver/upgrade-from-v1.adoc#manualUpgrade[the upgrade recipe]. +Backwards incompatible changes for a new major version are clearly documented in xref:upgrading-timefold-solver/upgrade-from-v1.adoc#manualUpgrade1to2[the upgrade recipe]. [#previewFeatures] diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc index 850c3bd1dd7..e9cea86b3c7 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc @@ -228,7 +228,7 @@ removed (`getConstraintStreamImplType()`, `setConstraintStreamImplType()`, `with * `PlannerBenchmark.benchmarkAndShowReportInBrowser()` renamed to `benchmark()`. -[#manualUpgrade] +[#manualUpgrade1to2] == Manual upgrade recipe In addition to the automated changes listed above, there are some changes that require manual intervention. diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc new file mode 100644 index 00000000000..0fd6b2964ab --- /dev/null +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-to-latest.adoc @@ -0,0 +1,102 @@ +[#upgradeToLatest2] += Upgrade Timefold Solver to the latest version +:doctype: book +:sectnums: +:icons: font + +As Timefold Solver 2.x continues developing, +we occasionally have to bring changes to our APIs. +We will not break backwards compatibility during the lifetime of Timefold Solver 2.x, and that means your current code will not break. +But we still want you to upgrade to the new APIs easily and quickly. +We provide this upgrade recipe for users who choose to keep up to date with the latest and greatest in the solver space. + +Many of the upgrade steps can be applied automatically using our migration tooling, +but some manual changes may still be required. +This upgrade recipe is intended to help you navigate those changes and upgrade your codebase with confidence. + +[#upgradeBeforeYouStart] +== Before you start + +Throughout this document, we assume that you are upgrading from the latest available version of the 1.x line. +Make sure to first upgrade to the 2.x version and only then follow this recipe. + +Please refer to +link:https://docs.timefold.ai/timefold-solver/1.x/upgrading-timefold-solver/upgrade-to-latest-version[upgrade recipe for Timefold Solver 1.x] or even +link:https://docs.timefold.ai/timefold-solver/1.x/upgrading-timefold-solver/upgrade-from-optaplanner[upgrade recipe for OptaPlanner] if you go way back. + +[#automaticUpgradeToLatest] +== Automatic upgrade to latest Timefold Solver + +For many of the upgrade steps mentioned later, +we provide a migration tool that can automatically apply those changes to Java files and Maven POMs. +This tool is based on OpenRewrite and can be run as a Maven or Gradle plugin. +To run the tool, execute the following command in your project directory: + +[tabs] +==== +Maven:: ++ +-- +[source,shell,subs=attributes+] +---- +mvn org.openrewrite.maven:rewrite-maven-plugin:{rewrite-maven-plugin-version}:run -Drewrite.recipeArtifactCoordinates=ai.timefold.solver:timefold-solver-migration:{timefold-solver-version} -Drewrite.activeRecipes=ai.timefold.solver.migration.ToLatest +---- +-- + +Gradle:: ++ +-- +[source,shell,subs=attributes+] +---- +curl https://raw.githubusercontent.com/TimefoldAI/timefold-solver/refs/tags/v{timefold-solver-version}/tools/migration/upgrade-timefold.gradle > upgrade-timefold.gradle ; gradle -Dorg.gradle.jvmargs=-Xmx2G --init-script upgrade-timefold.gradle rewriteRun -DtimefoldSolverVersion={timefold-solver-version} ; rm upgrade-timefold.gradle +---- +-- +==== + +include::framework-version-warning.adoc[leveloffset=+1] + +Having done that, you can check the local changes and commit them. + +For the time being, Kotlin users need to follow the upgrade recipe and apply the steps manually. + +=== Summary of automated changes + +The following includes a summary of all changes automated in the OpenRewrite recipe, for reference. +They include both upgrades to address deprecations, +and upgrades to preview features, where we are allowed to break backwards compatibility. + +==== Neighborhoods API package and class renames + +As the Neighborhoods API develops in its preview status, +we are tweaking names of components. +Specifically: + +- Sampling streams were renamed to picking streams. + +[#manualUpgradeLatest] +== Manual upgrade recipe + +In addition to the automated changes listed above, there are some changes that require manual intervention. +Every upgrade note indicates how likely your code will be affected by that change: + +- icon:exclamation-triangle[role=red,set=fas] *Major*: Likely to affect your code. +- icon:info-circle[role=yellow,set=fas] *Minor*: Less likely to affect your code, especially if you've been upgrading Timefold Solver regularly, paying attention to new deprecations. +- icon:eye[set=far] *Recommended*: We think this won't affect you, but we're listing this just to be safe. + +The upgrade recipe often lists the changes as they apply to Java code. +We kindly ask Kotlin users to translate the changes accordingly. + +=== Conceptual changes + +These changes are most likely to require you to change your code in a non-mechanical way, +and they can only be automated partially, if at all. + +So far, there are no such changes. + +=== Changes likely requiring manual intervention + +For the following changes, we either provide an OpenRewrite recipe which does most but not all of the necessary changes, +or we don't have an automated recipe at all. +Manual review and adjustments may still be necessary. + +So far, there are no such changes. diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestRecipe.java b/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestRecipe.java index aa2db210f72..503a7ffdffa 100644 --- a/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestRecipe.java +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestRecipe.java @@ -2,17 +2,9 @@ import java.util.List; -import ai.timefold.solver.migration.v2.ConstraintArgRemovalMigrationRecipe; -import ai.timefold.solver.migration.v2.ConstraintMetadataMigrationRecipe; -import ai.timefold.solver.migration.v2.GeneralDependencyDeleteMigrationRecipe; -import ai.timefold.solver.migration.v2.GeneralMethodChangeNameMigrationRecipe; -import ai.timefold.solver.migration.v2.GeneralMethodDeleteInvocationMigrationRecipe; -import ai.timefold.solver.migration.v2.GeneralPackageRenameMigrationRecipe; -import ai.timefold.solver.migration.v2.GeneralTypeChangeMigrationRecipe; -import ai.timefold.solver.migration.v2.PlanningSolutionAnnotationCleanupMigrationRecipe; -import ai.timefold.solver.migration.v2.ProblemIdDeletionMigrationRecipe; -import ai.timefold.solver.migration.v2.SolverConfigOverrideSolutionDeletionMigrationRecipe; -import ai.timefold.solver.migration.v2.TestingAPIsMigrationRecipe; +import ai.timefold.solver.migration.preview.PreviewToLatestRecipe; +import ai.timefold.solver.migration.v1.ToLatestV1Recipe; +import ai.timefold.solver.migration.v2.ToLatestV2Recipe; import org.openrewrite.Recipe; import org.openrewrite.java.RemoveUnusedImports; @@ -38,18 +30,8 @@ public String getDescription() { public List getRecipeList() { return List.of( new ToLatestV1Recipe(), - new ChangeVersionRecipe(), - new ConstraintArgRemovalMigrationRecipe(), - new ConstraintMetadataMigrationRecipe(), - new PlanningSolutionAnnotationCleanupMigrationRecipe(), - new GeneralMethodDeleteInvocationMigrationRecipe(), - new GeneralMethodChangeNameMigrationRecipe(), - new GeneralTypeChangeMigrationRecipe(), - new ProblemIdDeletionMigrationRecipe(), - new TestingAPIsMigrationRecipe(), - new GeneralDependencyDeleteMigrationRecipe(), - new GeneralPackageRenameMigrationRecipe(), - new SolverConfigOverrideSolutionDeletionMigrationRecipe(), + new ToLatestV2Recipe(), + new PreviewToLatestRecipe(), new RemoveUnusedImports()); } diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipe.java b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipe.java new file mode 100644 index 00000000000..af7b827ea8b --- /dev/null +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipe.java @@ -0,0 +1,40 @@ +package ai.timefold.solver.migration.preview; + +import java.util.List; + +import ai.timefold.solver.migration.AbstractRecipe; + +import org.openrewrite.Recipe; +import org.openrewrite.java.ChangeType; + +public class NeighborhoodsMigrationRecipe extends AbstractRecipe { + @Override + public String getDisplayName() { + return "Migrate the Neighborhoods preview API"; + } + + @Override + public String getDescription() { + return "Migrate the Neighborhoods preview API to its new class structure."; + } + + @Override + public List getRecipeList() { + return List.of( + // Sampling streams renamed to picking streams + new ChangeType("ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.SamplingStream", + "ai.timefold.solver.core.preview.api.neighborhood.stream.picking.PickingStream", true), + new ChangeType("ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream", + "ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream", true), + new ChangeType("ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.BiSamplingStream", + "ai.timefold.solver.core.preview.api.neighborhood.stream.picking.BiPickingStream", true), + new ChangeType("ai.timefold.solver.core.impl.neighborhood.stream.sampling.InnerSamplingStream", + "ai.timefold.solver.core.impl.neighborhood.stream.picking.InnerPickingStream", true), + new ChangeType("ai.timefold.solver.core.impl.neighborhood.stream.sampling.InnerUniSamplingStream", + "ai.timefold.solver.core.impl.neighborhood.stream.picking.InnerUniPickingStream", true), + new ChangeType("ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultUniSamplingStream", + "ai.timefold.solver.core.impl.neighborhood.stream.picking.DefaultUniPickingStream", true), + new ChangeType("ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultBiSamplingStream", + "ai.timefold.solver.core.impl.neighborhood.stream.picking.DefaultBiPickingStream", true)); + } +} diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/preview/PreviewToLatestRecipe.java b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/PreviewToLatestRecipe.java new file mode 100644 index 00000000000..3ee3ba00692 --- /dev/null +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/PreviewToLatestRecipe.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.migration.preview; + +import java.util.List; + +import ai.timefold.solver.migration.AbstractRecipe; + +import org.openrewrite.Recipe; + +public final class PreviewToLatestRecipe extends AbstractRecipe { + + @Override + public String getName() { + return "ai.timefold.solver.migration.PreviewToLatest"; + } + + @Override + public String getDisplayName() { + return "Upgrade to the latest Timefold Solver preview APIs"; + } + + @Override + public String getDescription() { + return "Replace all your calls to renamed/removed preview API types and methods of Timefold Solver with their proper alternatives."; + } + + @Override + public List getRecipeList() { + return List.of( + new NeighborhoodsMigrationRecipe()); + } + +} diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java new file mode 100644 index 00000000000..aa1bd436e85 --- /dev/null +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java @@ -0,0 +1,7 @@ +/** + * This package contains recipes to migrate preview features of Timefold Solver. + * Preview APIs carry no backwards-compatibility guarantee and may change or be removed + * in any release, so their migrations are kept separate from the version-keyed + * {@link ai.timefold.solver.migration.v1} and {@link ai.timefold.solver.migration.v2} packages. + */ +package ai.timefold.solver.migration.preview; diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestV1Recipe.java b/tools/migration/src/main/java/ai/timefold/solver/migration/v1/ToLatestV1Recipe.java similarity index 74% rename from tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestV1Recipe.java rename to tools/migration/src/main/java/ai/timefold/solver/migration/v1/ToLatestV1Recipe.java index c9f56050944..d2194c56fb7 100644 --- a/tools/migration/src/main/java/ai/timefold/solver/migration/ToLatestV1Recipe.java +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/v1/ToLatestV1Recipe.java @@ -1,21 +1,13 @@ -package ai.timefold.solver.migration; +package ai.timefold.solver.migration.v1; import java.util.List; -import ai.timefold.solver.migration.v1.AsConstraintRecipe; -import ai.timefold.solver.migration.v1.ConstraintRefRecipe; -import ai.timefold.solver.migration.v1.NullableRecipe; -import ai.timefold.solver.migration.v1.RemoveConstraintPackageRecipe; -import ai.timefold.solver.migration.v1.ScoreGettersRecipe; -import ai.timefold.solver.migration.v1.ScoreManagerMethodsRecipe; -import ai.timefold.solver.migration.v1.SingleConstraintAssertionMethodsRecipe; -import ai.timefold.solver.migration.v1.SolutionManagerRecommendAssignmentRecipe; -import ai.timefold.solver.migration.v1.SolverManagerBuilderRecipe; -import ai.timefold.solver.migration.v1.SortingMigrationRecipe; +import ai.timefold.solver.migration.AbstractRecipe; import org.openrewrite.Recipe; import org.openrewrite.java.ChangeMethodName; import org.openrewrite.java.ChangeType; +import org.openrewrite.java.RemoveUnusedImports; import org.openrewrite.properties.ChangePropertyKey; public final class ToLatestV1Recipe extends AbstractRecipe { @@ -60,7 +52,8 @@ public List getRecipeList() { new AsConstraintRecipe(), new RemoveConstraintPackageRecipe(), new SolutionManagerRecommendAssignmentRecipe(), - new SortingMigrationRecipe()); + new SortingMigrationRecipe(), + new RemoveUnusedImports()); } } \ No newline at end of file diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/v2/ToLatestV2Recipe.java b/tools/migration/src/main/java/ai/timefold/solver/migration/v2/ToLatestV2Recipe.java new file mode 100644 index 00000000000..ce1e978c030 --- /dev/null +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/v2/ToLatestV2Recipe.java @@ -0,0 +1,46 @@ +package ai.timefold.solver.migration.v2; + +import java.util.List; + +import ai.timefold.solver.migration.AbstractRecipe; +import ai.timefold.solver.migration.ChangeVersionRecipe; + +import org.openrewrite.Recipe; +import org.openrewrite.java.RemoveUnusedImports; + +public final class ToLatestV2Recipe extends AbstractRecipe { + + @Override + public String getName() { + return "ai.timefold.solver.migration.ToLatestV2"; + } + + @Override + public String getDisplayName() { + return "Upgrade to the latest Timefold Solver 2.x"; + } + + @Override + public String getDescription() { + return "Replace all your calls to deleted/deprecated types and methods of Timefold Solver with their proper alternatives."; + } + + @Override + public List getRecipeList() { + return List.of( + new ChangeVersionRecipe(), + new ConstraintArgRemovalMigrationRecipe(), + new ConstraintMetadataMigrationRecipe(), + new PlanningSolutionAnnotationCleanupMigrationRecipe(), + new GeneralMethodDeleteInvocationMigrationRecipe(), + new GeneralMethodChangeNameMigrationRecipe(), + new GeneralTypeChangeMigrationRecipe(), + new ProblemIdDeletionMigrationRecipe(), + new TestingAPIsMigrationRecipe(), + new GeneralDependencyDeleteMigrationRecipe(), + new GeneralPackageRenameMigrationRecipe(), + new SolverConfigOverrideSolutionDeletionMigrationRecipe(), + new RemoveUnusedImports()); + } + +} diff --git a/tools/migration/src/main/java/module-info.java b/tools/migration/src/main/java/module-info.java index 7bc43c3d99a..3d16ca2e951 100644 --- a/tools/migration/src/main/java/module-info.java +++ b/tools/migration/src/main/java/module-info.java @@ -2,6 +2,7 @@ exports ai.timefold.solver.migration; exports ai.timefold.solver.migration.common; + exports ai.timefold.solver.migration.preview; exports ai.timefold.solver.migration.v1; exports ai.timefold.solver.migration.v2; diff --git a/tools/migration/src/test/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipeTest.java b/tools/migration/src/test/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipeTest.java new file mode 100644 index 00000000000..a44031295e5 --- /dev/null +++ b/tools/migration/src/test/java/ai/timefold/solver/migration/preview/NeighborhoodsMigrationRecipeTest.java @@ -0,0 +1,99 @@ +package ai.timefold.solver.migration.preview; + +import static org.openrewrite.java.Assertions.java; + +import java.util.List; + +import ai.timefold.solver.migration.NoWildCardImportStyle; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openrewrite.java.JavaParser; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; +import org.openrewrite.test.TypeValidation; + +@Execution(ExecutionMode.CONCURRENT) +class NeighborhoodsMigrationRecipeTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipes(new NeighborhoodsMigrationRecipe()) + .typeValidationOptions(TypeValidation.builder().allowMissingType(ignore -> true).build()) + .parser(JavaParser.fromJavaVersion() + .styles(List.of(new NoWildCardImportStyle())) + // We must add all old classes as stubs to the JavaTemplate + .dependsOn( + "package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; public interface SamplingStream {}", + "package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; public interface UniSamplingStream {}", + "package ai.timefold.solver.core.preview.api.neighborhood.stream.sampling; public interface BiSamplingStream {}", + "package ai.timefold.solver.core.impl.neighborhood.stream.sampling; public interface InnerSamplingStream {}", + "package ai.timefold.solver.core.impl.neighborhood.stream.sampling; public interface InnerUniSamplingStream {}", + "package ai.timefold.solver.core.impl.neighborhood.stream.sampling; public class DefaultUniSamplingStream {}", + "package ai.timefold.solver.core.impl.neighborhood.stream.sampling; public class DefaultBiSamplingStream {}")); + } + + @Test + void migratePickingStream() { + rewriteRun(java( + """ + package timefold; + + import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.BiSamplingStream; + import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.SamplingStream; + import ai.timefold.solver.core.preview.api.neighborhood.stream.sampling.UniSamplingStream; + + public class Test { + SamplingStream samplingStream; + UniSamplingStream uniSamplingStream; + BiSamplingStream biSamplingStream; + }""", + """ + package timefold; + + import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.BiPickingStream; + import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.PickingStream; + import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; + + public class Test { + PickingStream samplingStream; + UniPickingStream uniSamplingStream; + BiPickingStream biSamplingStream; + }""")); + } + + @Test + void migrateInnerPickingStream() { + rewriteRun(java( + """ + package timefold; + + import ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultBiSamplingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.sampling.DefaultUniSamplingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.sampling.InnerSamplingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.sampling.InnerUniSamplingStream; + + public class Test { + InnerSamplingStream innerSamplingStream; + InnerUniSamplingStream innerUniSamplingStream; + DefaultUniSamplingStream defaultUniSamplingStream; + DefaultBiSamplingStream defaultBiSamplingStream; + }""", + """ + package timefold; + + import ai.timefold.solver.core.impl.neighborhood.stream.picking.DefaultBiPickingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.picking.DefaultUniPickingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.picking.InnerPickingStream; + import ai.timefold.solver.core.impl.neighborhood.stream.picking.InnerUniPickingStream; + + public class Test { + InnerPickingStream innerSamplingStream; + InnerUniPickingStream innerUniSamplingStream; + DefaultUniPickingStream defaultUniSamplingStream; + DefaultBiPickingStream defaultBiSamplingStream; + }""")); + } + +} From 1d140e2f92318ccb9c0ae6e4510285e87c12b1f3 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 10:24:34 +0200 Subject: [PATCH 02/16] Clean up list moves and introduce sublists and mass moves --- .../selector/list/RandomSubListSelector.java | 2 + .../selector/list/TriangleElementFactory.java | 53 -- .../solver/core/impl/move/MoveDirector.java | 157 ++++++ .../DefaultNeighborhoodProvider.java | 5 +- .../stream/DefaultMoveStreamFactory.java | 7 +- .../impl/util/TriangleElementFactory.java | 70 +++ .../list => util}/TriangularNumbers.java | 2 +- .../api/domain/metamodel/ElementPosition.java | 3 + .../preview/api/move/MutableSolutionView.java | 92 ++++ .../core/preview/api/move/SolutionView.java | 26 +- .../api/move/builtin/AssignMoveProvider.java | 2 +- .../api/move/builtin/ChangeMoveProvider.java | 12 +- .../move/builtin/ListAssignMoveProvider.java | 7 +- .../move/builtin/ListChangeMoveProvider.java | 106 +++- .../move/builtin/ListSwapMoveProvider.java | 46 +- .../builtin/ListUnassignMoveProvider.java | 17 +- .../move/builtin/MassAssignMoveProvider.java | 13 +- .../move/builtin/MassChangeMoveProvider.java | 15 +- .../builtin/MassListAssignMoveProvider.java | 68 +++ .../api/move/builtin/MassListChangeMove.java | 143 ++++++ .../builtin/MassListChangeMoveProvider.java | 90 ++++ .../MassListDestinationMoveIterator.java | 121 +++++ .../builtin/MassListUnassignMoveProvider.java | 114 +++++ .../builtin/MassUnassignMoveProvider.java | 12 +- .../core/preview/api/move/builtin/Moves.java | 92 ++++ .../builtin/PillarChangeMoveProvider.java | 13 +- .../move/builtin/PillarSwapMoveProvider.java | 4 +- .../builtin/PillarUnassignMoveProvider.java | 13 +- .../api/move/builtin/SubListChangeMove.java | 137 +++++ .../builtin/SubListChangeMoveProvider.java | 230 +++++++++ .../api/move/builtin/SubListSwapMove.java | 164 ++++++ .../move/builtin/SubListSwapMoveProvider.java | 211 ++++++++ .../api/move/builtin/SubListUnassignMove.java | 102 ++++ .../builtin/SubListUnassignMoveProvider.java | 139 +++++ .../builtin/SubPillarChangeMoveProvider.java | 16 +- .../builtin/SubPillarSwapMoveProvider.java | 4 +- .../SubPillarUnassignMoveProvider.java | 3 +- .../move/builtin/UnassignMoveProvider.java | 16 +- .../stream/dataset/sample/DefaultRange.java | 56 +++ .../stream/dataset/sample/Range.java | 69 +++ .../stream/dataset/sample/SubListSampler.java | 77 +++ .../stream/dataset/sample/package-info.java | 6 + .../list/RandomSubListSelectorTest.java | 2 +- .../RandomSubListChangeMoveSelectorTest.java | 2 +- .../RandomSubListSwapMoveSelectorTest.java | 2 +- .../core/impl/move/MoveDirectorTest.java | 475 ++++++++++++++++++ .../bias/SubListSamplingBiasIT.java | 139 +++++ .../TriangleElementFactoryTest.java | 26 +- .../list => util}/TriangularNumbersTest.java | 2 +- .../builtin/ListChangeMoveProviderTest.java | 77 ++- .../builtin/ListSwapMoveProviderTest.java | 78 ++- .../MassListAssignMoveProviderTest.java | 171 +++++++ .../MassListChangeMoveProviderTest.java | 325 ++++++++++++ .../move/builtin/MassListChangeMoveTest.java | 230 +++++++++ .../MassListUnassignMoveProviderTest.java | 165 ++++++ .../SubListChangeMoveProviderTest.java | 289 +++++++++++ .../move/builtin/SubListChangeMoveTest.java | 289 +++++++++++ .../builtin/SubListSwapMoveProviderTest.java | 238 +++++++++ .../api/move/builtin/SubListSwapMoveTest.java | 252 ++++++++++ .../SubListUnassignMoveProviderTest.java | 136 +++++ .../move/builtin/SubListUnassignMoveTest.java | 175 +++++++ 61 files changed, 5425 insertions(+), 183 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactory.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java rename core/src/main/java/ai/timefold/solver/core/impl/{heuristic/selector/list => util}/TriangularNumbers.java (94%) create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java rename core/src/test/java/ai/timefold/solver/core/impl/{heuristic/selector/list => util}/TriangleElementFactoryTest.java (77%) rename core/src/test/java/ai/timefold/solver/core/impl/{heuristic/selector/list => util}/TriangularNumbersTest.java (97%) create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java index 05df568df39..00bea366af6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelector.java @@ -12,6 +12,8 @@ import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.heuristic.selector.value.IterableValueSelector; import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +import ai.timefold.solver.core.impl.util.TriangularNumbers; public class RandomSubListSelector extends AbstractSelector implements SubListSelector { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactory.java deleted file mode 100644 index 2e74ec8dbfc..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactory.java +++ /dev/null @@ -1,53 +0,0 @@ -package ai.timefold.solver.core.impl.heuristic.selector.list; - -import static ai.timefold.solver.core.impl.heuristic.selector.list.TriangularNumbers.nthTriangle; -import static ai.timefold.solver.core.impl.heuristic.selector.list.TriangularNumbers.triangularRoot; - -import java.util.random.RandomGenerator; - -final class TriangleElementFactory { - - private final int minimumSubListSize; - private final int maximumSubListSize; - private final RandomGenerator workingRandom; - - TriangleElementFactory(int minimumSubListSize, int maximumSubListSize, RandomGenerator workingRandom) { - if (minimumSubListSize > maximumSubListSize) { - throw new IllegalArgumentException("The minimumSubListSize (" + minimumSubListSize - + ") must be less than or equal to the maximumSubListSize (" + maximumSubListSize + ")."); - } - if (minimumSubListSize < 1) { - throw new IllegalArgumentException( - "The minimumSubListSize (" + minimumSubListSize + ") must be greater than 0."); - } - this.minimumSubListSize = minimumSubListSize; - this.maximumSubListSize = maximumSubListSize; - this.workingRandom = workingRandom; - } - - /** - * Produce next random element of Triangle(listSize) observing the given minimum and maximum subList size. - * - * @param listSize determines the Triangle to select an element from - * @return next random triangle element - * @throws IllegalArgumentException if {@code listSize} is less than {@code minimumSubListSize} - */ - TriangleElement nextElement(int listSize) throws IllegalArgumentException { - // Reduce the triangle base by the minimum subList size. - int subListCount = nthTriangle(listSize - minimumSubListSize + 1); - // The top triangle represents all subLists of size greater or equal to maximum subList size. Remove them all. - int topTriangleSize = listSize <= maximumSubListSize ? 0 : nthTriangle(listSize - maximumSubListSize); - // Triangle elements are indexed from 1. - int subListIndex = workingRandom.nextInt(subListCount - topTriangleSize) + topTriangleSize + 1; - return TriangleElement.valueOf(subListIndex); - } - - record TriangleElement(int index, int level, int indexOnLevel) { - - static TriangleElement valueOf(int index) { - int level = (int) Math.ceil(triangularRoot(index)); - return new TriangleElement(index, level, index - nthTriangle(level - 1)); - } - - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index 068df43bec7..30f43a16348 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -19,6 +19,7 @@ import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector; +import ai.timefold.solver.core.impl.util.CollectionUtils; 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; @@ -383,6 +384,156 @@ public void swapValuesInList(PlanningListVariableMetaModel List moveValuesInList( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity, + int fromIndex, int toIndex, int destinationIndex, boolean reversing) { + requireNonEmptySpan(fromIndex, toIndex); + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + var list = variableDescriptor.getValue(entity); + var length = toIndex - fromIndex; + var planningValues = CollectionUtils.copy(list.subList(fromIndex, toIndex), reversing); + + var bracketFromIndex = Math.min(fromIndex, destinationIndex); + var bracketToIndex = Math.max(fromIndex, destinationIndex) + length; + externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, bracketFromIndex, bracketToIndex); + list.subList(fromIndex, toIndex).clear(); + list.addAll(destinationIndex, planningValues); + externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, bracketFromIndex, bracketToIndex); + externalScoreDirector.updateShadowVariables(); + return (List) planningValues; + } + + @SuppressWarnings("unchecked") + @Override + public final List moveValuesBetweenLists( + PlanningListVariableMetaModel variableMetaModel, Entity_ sourceEntity, + int sourceFromIndex, int sourceToIndex, Entity_ destinationEntity, int destinationIndex, + boolean reversing) { + if (sourceEntity == destinationEntity) { + // Moving within the same list is not supported by this method. + // This avoids confusion about the shifting of indices when removing and adding within the same list. + throw new IllegalArgumentException( + "Source entity (%s) and destination entity (%s) must be different when moving values between lists." + .formatted(sourceEntity, destinationEntity)); + } + requireNonEmptySpan(sourceFromIndex, sourceToIndex); + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + var sourceList = variableDescriptor.getValue(sourceEntity); + var length = sourceToIndex - sourceFromIndex; + var planningValues = CollectionUtils.copy(sourceList.subList(sourceFromIndex, sourceToIndex), reversing); + + externalScoreDirector.beforeListVariableChanged(variableDescriptor, sourceEntity, sourceFromIndex, sourceToIndex); + sourceList.subList(sourceFromIndex, sourceToIndex).clear(); + externalScoreDirector.afterListVariableChanged(variableDescriptor, sourceEntity, sourceFromIndex, sourceFromIndex); + + externalScoreDirector.beforeListVariableChanged(variableDescriptor, destinationEntity, destinationIndex, + destinationIndex); + variableDescriptor.getValue(destinationEntity).addAll(destinationIndex, planningValues); + externalScoreDirector.afterListVariableChanged(variableDescriptor, destinationEntity, destinationIndex, + destinationIndex + length); + + externalScoreDirector.updateShadowVariables(); + return (List) planningValues; + } + + @Override + public void swapValuesInList( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity, + int leftFromIndex, int leftToIndex, int rightFromIndex, int rightToIndex, boolean reversing) { + requireNonEmptySpan(leftFromIndex, leftToIndex); + requireNonEmptySpan(rightFromIndex, rightToIndex); + if (leftToIndex > rightFromIndex) { + throw new IllegalArgumentException( + ("The leftToIndex (%d) must be less than or equal to the rightFromIndex (%d); the caller must order " + + "the left span before the right span.").formatted(leftToIndex, rightFromIndex)); + } + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + var list = variableDescriptor.getValue(entity); + var leftLength = leftToIndex - leftFromIndex; + var rightLength = rightToIndex - rightFromIndex; + var leftPlanningValues = CollectionUtils.copy(list.subList(leftFromIndex, leftToIndex), reversing); + var rightPlanningValues = CollectionUtils.copy(list.subList(rightFromIndex, rightToIndex), reversing); + var leftDestinationIndex = rightToIndex - leftLength; + + externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, leftFromIndex, rightToIndex); + // The right span is cleared first: + // clearing it does not shift the left span's indices, + // since the right span is entirely after the left one. + // The left span must be re-derived as a fresh subList view after that clear, + // since the earlier view of it was invalidated by the structural change to the backing list. + list.subList(rightFromIndex, rightToIndex).clear(); + list.subList(leftFromIndex, leftToIndex).clear(); + list.addAll(leftFromIndex, rightPlanningValues); + list.addAll(leftDestinationIndex, leftPlanningValues); + externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, leftFromIndex, rightToIndex); + externalScoreDirector.updateShadowVariables(); + } + + @Override + public void swapValuesBetweenLists( + PlanningListVariableMetaModel variableMetaModel, Entity_ leftEntity, + int leftFromIndex, int leftToIndex, Entity_ rightEntity, int rightFromIndex, int rightToIndex, + boolean reversing) { + if (leftEntity == rightEntity) { + throw new IllegalArgumentException(""" + Left entity (%s) and right entity (%s) must be different when swapping spans between lists. + Use swapValuesInList(...) instead.""" + .formatted(leftEntity, rightEntity)); + } + requireNonEmptySpan(leftFromIndex, leftToIndex); + requireNonEmptySpan(rightFromIndex, rightToIndex); + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + var leftList = variableDescriptor.getValue(leftEntity); + var rightList = variableDescriptor.getValue(rightEntity); + var leftLength = leftToIndex - leftFromIndex; + var rightLength = rightToIndex - rightFromIndex; + var leftPlanningValues = CollectionUtils.copy(leftList.subList(leftFromIndex, leftToIndex), reversing); + var rightPlanningValues = CollectionUtils.copy(rightList.subList(rightFromIndex, rightToIndex), reversing); + + externalScoreDirector.beforeListVariableChanged(variableDescriptor, leftEntity, leftFromIndex, leftToIndex); + externalScoreDirector.beforeListVariableChanged(variableDescriptor, rightEntity, rightFromIndex, rightToIndex); + rightList.subList(rightFromIndex, rightToIndex).clear(); + leftList.subList(leftFromIndex, leftToIndex).clear(); + leftList.addAll(leftFromIndex, rightPlanningValues); + rightList.addAll(rightFromIndex, leftPlanningValues); + externalScoreDirector.afterListVariableChanged(variableDescriptor, leftEntity, leftFromIndex, + leftFromIndex + rightLength); + externalScoreDirector.afterListVariableChanged(variableDescriptor, rightEntity, rightFromIndex, + rightFromIndex + leftLength); + externalScoreDirector.updateShadowVariables(); + } + + @SuppressWarnings("unchecked") + @Override + public final List unassignValues( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity, + int fromIndex, int toIndex) { + requireNonEmptySpan(fromIndex, toIndex); + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + var list = variableDescriptor.getValue(entity); + var values = List.copyOf(list.subList(fromIndex, toIndex)); + for (var value : values) { + externalScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, value); + } + externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); + list.subList(fromIndex, toIndex).clear(); + externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, fromIndex, fromIndex); + for (var value : values) { + externalScoreDirector.afterListVariableElementUnassigned(variableDescriptor, value); + } + externalScoreDirector.updateShadowVariables(); + return (List) values; + } + @Override public ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity) { @@ -477,6 +628,12 @@ public int countValues(PlanningListVariableMetaModel int getFirstUnpinnedIndex( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity) { + return extractVariableDescriptor(variableMetaModel).getFirstUnpinnedIndex(entity); + } + @SuppressWarnings("unchecked") @Override public final Value_ getValueAtIndex( diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java index c0338a240cc..78ffc17bcb3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java @@ -31,7 +31,10 @@ public Neighborhood defineNeighborhood(NeighborhoodBuilder builder) { for (var variableMetaModel : entityMetaModel.genuineVariables()) { if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { // TODO Implement 2-opt and 3-opt moves for list variables. - builder.add(new ListChangeMoveProvider<>(listVariableMetaModel)); + // ListChangeMoveProvider's crossingNull=false: + // ListAssignMoveProvider/ListUnassignMoveProvider below already cover those moves, + // at a much higher rate than ListChangeMoveProvider's flag would. + builder.add(new ListChangeMoveProvider<>(listVariableMetaModel, false)); builder.add(new ListSwapMoveProvider<>(listVariableMetaModel)); if (listVariableMetaModel.allowsUnassignedValues()) { builder.add(new ListAssignMoveProvider<>(listVariableMetaModel)); 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 b52b66fd646..1baee243c13 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 @@ -215,9 +215,10 @@ public ListVariableNodeSharingSupportFunctions( (solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList, (solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof UnassignedElement, (solutionView, entity, value) -> { - if (value == null) { - // Necessary for the null to survive until the later stage, - // where we will use it as a special marker to move it to the end of list. + // Null entity means we need to unassign the value; unassigning is always in range. + // Null value is necessary for the null to survive until the later stage, + // where we will use it as a special marker to move it to the end of list. + if (entity == null || value == null) { return true; } return solutionView.isValueInRange(variableMetaModel, entity, value); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java new file mode 100644 index 00000000000..0861a58ef65 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java @@ -0,0 +1,70 @@ +package ai.timefold.solver.core.impl.util; + +import static ai.timefold.solver.core.impl.util.TriangularNumbers.nthTriangle; +import static ai.timefold.solver.core.impl.util.TriangularNumbers.triangularRoot; + +import java.util.random.RandomGenerator; + +public final class TriangleElementFactory { + + private final int minimumSubListSize; + private final int maximumSubListSize; + private final RandomGenerator workingRandom; + + /** + * @throws IllegalArgumentException as defined by {@link #validateSizes(int, int)} + */ + public TriangleElementFactory(int minimumSubListSize, int maximumSubListSize, RandomGenerator workingRandom) { + validateSizes(minimumSubListSize, maximumSubListSize); + this.minimumSubListSize = minimumSubListSize; + this.maximumSubListSize = maximumSubListSize; + this.workingRandom = workingRandom; + } + + /** + * Validates a {@code (minimumSubListSize, maximumSubListSize)} pair without needing a + * {@link RandomGenerator}, so that a caller which only creates a {@link TriangleElementFactory} later + * (once a {@link RandomGenerator} becomes available) can still fail fast at construction time. + * + * @throws IllegalArgumentException if {@code minimumSubListSize > maximumSubListSize}, + * or if {@code minimumSubListSize < 1} + */ + public static void validateSizes(int minimumSubListSize, int maximumSubListSize) { + if (minimumSubListSize > maximumSubListSize) { + throw new IllegalArgumentException( + "The minimumSubListSize (%d) must be less than or equal to the maximumSubListSize (%d)." + .formatted(minimumSubListSize, maximumSubListSize)); + } + if (minimumSubListSize < 1) { + throw new IllegalArgumentException( + "The minimumSubListSize (%d) must be greater than 0." + .formatted(minimumSubListSize)); + } + } + + /** + * Produce next random element of Triangle(listSize) observing the given minimum and maximum subList size. + * + * @param listSize determines the Triangle to select an element from + * @return next random triangle element + * @throws IllegalArgumentException if {@code listSize} is less than {@code minimumSubListSize} + */ + public TriangleElement nextElement(int listSize) throws IllegalArgumentException { + // Reduce the triangle base by the minimum subList size. + var subListCount = nthTriangle(listSize - minimumSubListSize + 1); + // The top triangle represents all subLists of size greater or equal to maximum subList size. Remove them all. + var topTriangleSize = listSize <= maximumSubListSize ? 0 : nthTriangle(listSize - maximumSubListSize); + // Triangle elements are indexed from 1. + var subListIndex = workingRandom.nextInt(subListCount - topTriangleSize) + topTriangleSize + 1; + return TriangleElement.valueOf(subListIndex); + } + + public record TriangleElement(int index, int level, int indexOnLevel) { + + static TriangleElement valueOf(int index) { + var level = (int) Math.ceil(triangularRoot(index)); + return new TriangleElement(index, level, index - nthTriangle(level - 1)); + } + + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbers.java b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java similarity index 94% rename from core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbers.java rename to core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java index 62fcad52b50..5aade30fb4b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.heuristic.selector.list; +package ai.timefold.solver.core.impl.util; public final class TriangularNumbers { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java index ea6435b1ed9..14426d9ee61 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java @@ -18,6 +18,9 @@ * If user code already guarantees that unassigned values will never appear, * {@link ElementPosition#ensureAssigned()} can be used to avoid instanceof checks and casting. *

+ * An {@link ElementPosition} is valid only at the time it is obtained. + * It does not update if the list variable changes afterward. + *

* This package and all of its contents are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. * There are no guarantees for backward compatibility; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java index e752dc6d3cd..dc9a4543e5f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java @@ -334,4 +334,96 @@ void swapValuesBetweenLists(PlanningListVariableMetaModel void swapValuesInList(PlanningListVariableMetaModel variableMetaModel, Entity_ entity, int leftIndex, int rightIndex); + /** + * Moves a contiguous span of values within one entity's {@link PlanningListVariable planning list variable}. + * Behaves as if the span is first removed, shifting all later values to the left, + * and then inserted at the destination index. + * + * @param variableMetaModel Describes the variable to be changed. + * @param entity The entity whose variable value is to be changed. + * @param fromIndex The index of the first value of the span to be moved, inclusive. + * @param toIndex The index one past the last value of the span to be moved, exclusive. + * @param destinationIndex The index, after removal of the span, at which the span is to be re-inserted; + * Acceptable values range from zero to the list size minus the span length. + * @param reversing if {@code true}, the span is inserted in reverse element order + * @return the moved values, in the order they were inserted + * @throws IllegalArgumentException if {@code toIndex <= fromIndex} + */ + List moveValuesInList( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity, + int fromIndex, int toIndex, int destinationIndex, boolean reversing); + + /** + * Moves a contiguous span of values from one entity's {@link PlanningListVariable planning list variable} + * to another. + * + * @param variableMetaModel Describes the variable to be changed. + * @param sourceEntity The entity from which the span will be removed. + * @param sourceFromIndex The index of the first value of the span to be moved, inclusive. + * @param sourceToIndex The index one past the last value of the span to be moved, exclusive. + * @param destinationEntity The entity to which the span will be added. + * @param destinationIndex The index in the destination entity's list variable at which the span is to be + * inserted; all values at or after the index are shifted to the right. + * @param reversing if {@code true}, the span is inserted in reverse element order + * @return the moved values, in the order they were inserted + * @throws IllegalArgumentException if {@code sourceEntity == destinationEntity} or {@code sourceToIndex <= + * sourceFromIndex} + */ + List moveValuesBetweenLists( + PlanningListVariableMetaModel variableMetaModel, Entity_ sourceEntity, + int sourceFromIndex, int sourceToIndex, Entity_ destinationEntity, int destinationIndex, + boolean reversing); + + /** + * Swaps two contiguous, non-overlapping spans of values within one entity's + * {@link PlanningListVariable planning list variable}. + * + * @param variableMetaModel Describes the variable to be changed. + * @param entity The entity whose variable values are to be swapped. + * @param leftFromIndex The index of the first value of the left span, inclusive. + * @param leftToIndex The index one past the last value of the left span, exclusive. + * @param rightFromIndex The index of the first value of the right span, inclusive. + * @param rightToIndex The index one past the last value of the right span, exclusive. + * @param reversing if {@code true}, both spans are inserted in reverse element order + * @throws IllegalArgumentException if either span is empty, or if {@code leftToIndex > rightFromIndex}; + * the left span may end at the same index where the right span starts + */ + void swapValuesInList(PlanningListVariableMetaModel variableMetaModel, + Entity_ entity, int leftFromIndex, int leftToIndex, int rightFromIndex, int rightToIndex, + boolean reversing); + + /** + * Swaps two contiguous spans of values between two entities' {@link PlanningListVariable planning list variable}. + * + * @param variableMetaModel Describes the variable to be changed. + * @param leftEntity The first entity whose variable values are to be swapped. + * @param leftFromIndex The index of the first value of the left span, inclusive. + * @param leftToIndex The index one past the last value of the left span, exclusive. + * @param rightEntity The second entity whose variable values are to be swapped. + * @param rightFromIndex The index of the first value of the right span, inclusive. + * @param rightToIndex The index one past the last value of the right span, exclusive. + * @param reversing if {@code true}, both spans are inserted in reverse element order + * @throws IllegalArgumentException if either span is empty, or if {@code leftEntity == rightEntity}; + * use {@link #swapValuesInList(PlanningListVariableMetaModel, Object, int, int, int, int, boolean)} instead + */ + void swapValuesBetweenLists( + PlanningListVariableMetaModel variableMetaModel, Entity_ leftEntity, + int leftFromIndex, int leftToIndex, Entity_ rightEntity, int rightFromIndex, int rightToIndex, + boolean reversing); + + /** + * Removes a contiguous span of values from a given entity's {@link PlanningListVariable planning list variable}. + * Shifts any subsequent values to the left. + * + * @param variableMetaModel Describes the variable to be changed. + * @param entity The entity whose span is to be removed from its list variable. + * @param fromIndex The index of the first value of the span to be removed, inclusive. + * @param toIndex The index one past the last value of the span to be removed, exclusive. + * @return the removed values, in list order + * @throws IllegalArgumentException if {@code toIndex <= fromIndex} + */ + List unassignValues( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity, + int fromIndex, int toIndex); + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index 00354256437..2fa05d2c3bd 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -60,7 +60,6 @@ public interface SolutionView { * @param entity The entity whose variable is to be read. * @return The number of values in the list variable. * @throws NullPointerException if the value of the list variable is null - * @throws IndexOutOfBoundsException if the index is out of bounds */ int countValues(PlanningListVariableMetaModel variableMetaModel, Entity_ entity); @@ -71,7 +70,8 @@ int countValues(PlanningListVariableMetaModel= 0 - * @return The value at the given index in the list variable. + * @return The value at the given index in the list variable, valid only at the time of this call; + * it does not update if the list variable changes afterward * @throws NullPointerException if the value of the list variable is null * @throws IndexOutOfBoundsException if the index is out of bounds */ @@ -83,12 +83,32 @@ Value_ getValueAtIndex(PlanningListVariableMetaModel ElementPosition getPositionOf( PlanningListVariableMetaModel variableMetaModel, Value_ value); + /** + * Reads the index of the first element of a {@link PlanningListVariable list planning variable} + * that is not pinned. + * The pinned portion of a list variable, if any, is always a prefix: every index below the + * returned value is pinned, every index at or above it is not. + *

+ * Caveat: for an entity that is entirely immovable due to {@link PlanningPin}, + * this method returns {@code 0} even though every element of its list is pinned; + * such an entity is excluded from the pinning-filtered enumeration methods on + * {@link ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory}, + * so a caller which only ever enumerates through those methods will never see this case. + * + * @param variableMetaModel Describes the variable whose value is to be read. + * @param entity The entity whose variable is to be read. + * @return 0 or higher; 0 if nothing is pinned; at most {@link #countValues} if the entire list is pinned + */ + int getFirstUnpinnedIndex( + PlanningListVariableMetaModel variableMetaModel, Entity_ entity); + /** * Checks if a {@link PlanningEntity} with a basic {@link PlanningVariable} is pinned. * diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java index e3b61fd09e8..3bf2aded48f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/AssignMoveProvider.java @@ -15,7 +15,7 @@ * Assigns a non-null value to each entity whose planning variable is currently unassigned (null). * Only generates moves where the value is in range for that entity. *

- * {@link ChangeMoveProvider} makes this same move too, + * {@code ChangeMoveProvider} makes this same move too, * whenever its own {@code crossingNull} is {@code true} - * but there, only when the randomly drawn entity happens to already be unassigned, * so it arrives rarely. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java index 3adb8d324a2..0175c87fc86 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProvider.java @@ -24,12 +24,12 @@ * and an assign draw only when the randomly drawn entity happens to already be unassigned. * A configuration that wants such moves often should add {@code AssignMoveProvider}/{@code UnassignMoveProvider} * in addition to turning this flag off to avoid further oversampling. - *

- * For changing several entities at once, see {@link PillarChangeMoveProvider} and - * {@link SubPillarChangeMoveProvider}; - * members share a value; unassign only, never assign - - * a pillar's key is a value, and unassigned is the absence of one or {@link MassChangeMoveProvider} - * (members need not share anything, and may be unassigned). + * + * @see AssignMoveProvider Assigning a single entity at a time. + * @see UnassignMoveProvider Unassigning a single entity at a time. + * @see PillarChangeMoveProvider Changing several entities at once, keyed on a shared value. + * @see SubPillarChangeMoveProvider A sampler-driven subset of such a pillar. + * @see MassChangeMoveProvider A mixed-value sample with no shared key, which may include unassigned entities. */ @NullMarked public final class ChangeMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java index 4fb468771f6..06c4f40a0ca 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java @@ -15,11 +15,12 @@ * Assigns each unassigned value to a position in some entity's list variable. * Only generates moves where the value is in range for the destination entity. *

- * For moving an already-assigned value to a different position, see {@link ListChangeMoveProvider}. - * For removing an already-assigned value from its position (unassigning), see {@code ListUnassignMoveProvider}. - *

* Requires that the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @see ListChangeMoveProvider Moving an already-assigned value to a different position. + * @see ListUnassignMoveProvider Removing an already-assigned value from its position. + * @see MassListAssignMoveProvider A set of unassigned values drawn together. */ @NullMarked public class ListAssignMoveProvider 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 c344c6ea03a..05ab1fd8ef8 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 @@ -2,20 +2,29 @@ import java.util.Objects; +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.move.SolutionView; import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; 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.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; import org.jspecify.annotations.NullMarked; /** * For each assigned value that is not pinned, creates a move to reassign it to a different position in a list variable. - * Unassigned-to-list (assign) moves are handled by {@code ListAssignMoveProvider}. - * List-to-unassigned (unassign) moves are handled by {@code ListUnassignMoveProvider}. + *

+ * When {@code crossingNull} is {@code true} + * (the default when the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}), + * this provider also creates unassigned-to-list (assign) and list-to-unassigned (unassign) moves. + *

+ * This does not remove the need for {@code ListAssignMoveProvider} and {@code ListUnassignMoveProvider}: + * here, a null-crossing move is one candidate among many, so it arrives rarely. + * A configuration that wants such moves often should add {@code ListAssignMoveProvider}/{@code ListUnassignMoveProvider} + * in addition to turning this flag off to avoid further oversampling. * *

* To reassign a value, creates: @@ -24,53 +33,102 @@ *

  • A move for every unpinned position in every entity's list variable to reassign the value before that position.
  • *
  • A move for every entity to reassign the value to the last position in the list variable.
  • * - * + *

    * This is a generic move provider that works with any list variable; * user-defined change move providers needn't be this complex, as they understand the specifics of the domain. + *

    + * For a set of values drawn together and gathered at one destination, see {@code MassListChangeMoveProvider}. + * + * @see ListAssignMoveProvider Assigning a single unassigned value at a time. + * @see ListUnassignMoveProvider Unassigning a single value at a time. + * @see MassListChangeMoveProvider A set of values drawn together and gathered at one destination. */ @NullMarked public class ListChangeMoveProvider implements MoveProvider { private final PlanningListVariableMetaModel variableMetaModel; + private final boolean crossingNull; public ListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, variableMetaModel.allowsUnassignedValues()); + } + + /** + * @param crossingNull if {@code true}, also creates assign and unassign moves; + * variable must {@link PlanningListVariableMetaModel#allowsUnassignedValues() allow unassigned values}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public ListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, + boolean crossingNull) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (crossingNull && !variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; } @Override public MoveStream build(MoveStreamFactory moveStreamFactory) { - var entityValuePairs = moveStreamFactory.forEachDestination(variableMetaModel); - var assignedValues = moveStreamFactory.forEachAssignedValue(variableMetaModel); - return moveStreamFactory.pick(entityValuePairs) - .pick(assignedValues, + // Only widen to forEachDestinationIncludingUnassigned when crossingNull: + // unlike forEachDestination, it represents the unassigned destination with a null entity internally, + // which entity-provided value ranges cannot resolve - + // avoid tripping that path when this provider has no use for it anyway. + UniEnumeratingStream destinations = crossingNull + ? moveStreamFactory.forEachDestinationIncludingUnassigned(variableMetaModel) + : moveStreamFactory.forEachDestination(variableMetaModel).map((solutionView, position) -> position); + // Unassigned values are admitted too when crossingNull, unconditionally otherwise excluded, + // mirroring ChangeMoveProvider's own source-side widening. + var sources = crossingNull ? moveStreamFactory.forEach(variableMetaModel.type(), false) + : moveStreamFactory.forEachAssignedValue(variableMetaModel); + return moveStreamFactory.pick(destinations) + .pick(sources, NeighborhoodsJoiners.filtering(this::isValidChange)) .asMove((solutionView, targetPosition, value) -> { - var currentPosition = solutionView.getPositionOf(variableMetaModel, Objects.requireNonNull(value)) - .ensureAssigned(); - return Moves.change(variableMetaModel, currentPosition, targetPosition); + var currentPosition = solutionView.getPositionOf(variableMetaModel, Objects.requireNonNull(value)); + if (currentPosition instanceof PositionInList currentAssigned) { + if (targetPosition instanceof PositionInList targetAssigned) { + return Moves.change(variableMetaModel, currentAssigned, targetAssigned); + } else { // Assigned value moving to the unassigned pool. + return Moves.unassign(variableMetaModel, currentAssigned); + } + } else { // Unassigned value moving into the list; isValidChange already excluded both-unassigned. + return Moves.assign(variableMetaModel, value, (PositionInList) targetPosition); + } }); } - private boolean isValidChange(SolutionView solutionView, PositionInList targetPosition, Value_ value) { - var currentPosition = solutionView.getPositionOf(variableMetaModel, value).ensureAssigned(); - if (currentPosition.equals(targetPosition)) { // No change needed. + private boolean isValidChange(SolutionView solutionView, ElementPosition targetPosition, Value_ value) { + var currentPosition = solutionView.getPositionOf(variableMetaModel, value); + if (currentPosition.equals(targetPosition)) { // No change needed; also excludes both-unassigned. return false; } - if (currentPosition.entity() == targetPosition.entity()) { // The value is already in the list. - var valueCount = solutionView.countValues(variableMetaModel, currentPosition.entity()); - if (valueCount == 1) { // The value is the only value in the list; no change. - return false; - } else if (targetPosition.index() == valueCount) { // Trying to move the value past the end of the list. - return false; - } else { // Same list, same position; ignore. - return currentPosition.index() != targetPosition.index(); + if (currentPosition instanceof PositionInList currentAssigned) { + if (targetPosition instanceof PositionInList targetAssigned) { + if (currentAssigned.entity() == targetAssigned.entity()) { // The value is already in the list. + var valueCount = solutionView.countValues(variableMetaModel, currentAssigned.entity()); + if (valueCount == 1) { // The value is the only value in the list; no change. + return false; + } else if (targetAssigned.index() == valueCount) { // Trying to move the value past the end of the list. + return false; + } else { // Same list, same position; ignore. + return currentAssigned.index() != targetAssigned.index(); + } + } + // We can move freely between entities, assuming the target entity accepts the value. + return solutionView.isValueInRange(variableMetaModel, targetAssigned.entity(), value); + } else { // Unassigning an assigned value never violates a value range. + return true; } + } else { // Assigning a currently unassigned value; targetPosition is a PositionInList (checked above). + var targetAssigned = (PositionInList) targetPosition; + return solutionView.isValueInRange(variableMetaModel, targetAssigned.entity(), value); } - - // We can move freely between entities, assuming the target entity accepts the value. - return solutionView.isValueInRange(variableMetaModel, targetPosition.entity(), value); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java index f5441ce1f63..7a161465e37 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java @@ -2,6 +2,7 @@ import java.util.Objects; +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.move.Move; @@ -25,23 +26,36 @@ public ListSwapMoveProvider(PlanningListVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var assignedValueStream = moveStreamFactory.forEach(variableMetaModel.type(), false) - .filter((solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList) + // Unassigned values are admitted too, unconditionally, mirroring SwapMoveProvider: + // for a basic variable, unassigned is just the value null, and swap handles it for free. + var valueStream = moveStreamFactory.forEach(variableMetaModel.type(), false) .map((solutionView, value) -> new FullElementPosition<>(value, - solutionView.getPositionOf(variableMetaModel, value).ensureAssigned())); + solutionView.getPositionOf(variableMetaModel, value))); var predicate = (BiNeighborhoodsPredicate, FullElementPosition>) this::isValidSwap; // We do not exclude duplicate swaps (A<>B and B<>A) to keep it simple and fast. // Move selectors don't do anything about duplicate moves either. - return moveStreamFactory.pick(assignedValueStream) - .pick(assignedValueStream, + return moveStreamFactory.pick(valueStream) + .pick(valueStream, NeighborhoodsJoiners.filtering(predicate)) .asMove(this::buildMove); } private Move buildMove(SolutionView solutionView, FullElementPosition a, FullElementPosition b) { - return Moves.swap(variableMetaModel, a.elementPosition, b.elementPosition); + if (a.elementPosition() instanceof PositionInList aPosition + && b.elementPosition() instanceof PositionInList bPosition) { + return Moves.swap(variableMetaModel, aPosition, bPosition); + } + // Exactly one side is unassigned (isValidSwap already excluded both-unassigned): + // unassign the assigned side, then assign the incoming value at the same index. + var assignedPosition = (PositionInList) (a.elementPosition() instanceof PositionInList + ? a.elementPosition() + : b.elementPosition()); + var incomingValue = a.elementPosition() instanceof PositionInList ? b.value() : a.value(); + return Moves.compose( + Moves.unassign(variableMetaModel, assignedPosition), + Moves.assign(variableMetaModel, incomingValue, assignedPosition)); } private boolean isValidSwap(SolutionView solutionView, FullElementPosition leftPosition, @@ -49,16 +63,22 @@ private boolean isValidSwap(SolutionView solutionView, FullElementPos if (Objects.equals(leftPosition, rightPosition)) { return false; } - return solutionView.isValueInRange(variableMetaModel, rightPosition.entity(), leftPosition.value()) - && solutionView.isValueInRange(variableMetaModel, leftPosition.entity(), rightPosition.value()); + var left = leftPosition.elementPosition(); + var right = rightPosition.elementPosition(); + if (left instanceof PositionInList leftAssigned && right instanceof PositionInList rightAssigned) { + return solutionView.isValueInRange(variableMetaModel, rightAssigned.entity(), leftPosition.value()) + && solutionView.isValueInRange(variableMetaModel, leftAssigned.entity(), rightPosition.value()); + } else if (left instanceof PositionInList leftAssigned) { + return solutionView.isValueInRange(variableMetaModel, leftAssigned.entity(), rightPosition.value()); + } else if (right instanceof PositionInList rightAssigned) { + return solutionView.isValueInRange(variableMetaModel, rightAssigned.entity(), leftPosition.value()); + } else { + return false; // Both unassigned: a no-op, never emitted. + } } @NullMarked - private record FullElementPosition(Value_ value, PositionInList elementPosition) { - - public Entity_ entity() { - return elementPosition.entity(); - } + private record FullElementPosition(Value_ value, ElementPosition elementPosition) { @Override public String toString() { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java index b7469c4738f..1e4f5113e96 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java @@ -13,22 +13,18 @@ * For each value currently assigned to any entity's list variable, * creates a move to unassign it (remove it from the list). *

    - * This provider only applies to list variables that allow unassigned values. - * For the complementary moves: - *

      - *
    • Use {@link ListAssignMoveProvider} to assign currently-unassigned values to list positions.
    • - *
    • Use {@link ListChangeMoveProvider} to move an assigned value to a different position.
    • - *
    - *

    * This class is part of the Neighborhoods API, which is under development and is only offered as a preview * feature. * There are no guarantees for backward compatibility; * any class, method, or field may change or be removed without prior notice, * although we will strive to avoid this as much as possible. + * + * @see ListAssignMoveProvider Assigning currently unassigned values to list positions. + * @see ListChangeMoveProvider Moving an assigned value to a different position. + * @see MassListUnassignMoveProvider A set of values drawn together. */ @NullMarked -public class ListUnassignMoveProvider - implements MoveProvider { +public class ListUnassignMoveProvider implements MoveProvider { private final PlanningListVariableMetaModel variableMetaModel; @@ -36,8 +32,7 @@ public ListUnassignMoveProvider(PlanningListVariableMetaModel - * {@link MassChangeMoveProvider} makes this same kind of move too, + * {@code MassChangeMoveProvider} makes this same kind of move too, * whenever its own {@code crossingNull} is {@code true}, * but only as a side effect of a mixed-value sample happening to include an unassigned member, * so it arrives rarely. @@ -28,18 +28,17 @@ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. *

    - * Samples of size less than 2 are excluded: {@link AssignMoveProvider} already covers them, + * Samples of size less than 2 are excluded: {@code AssignMoveProvider} already covers them, * more cheaply. * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@link MassUnassignMoveProvider} or a sampler that grows past the seed. - * - * @see MassChangeMoveProvider Mixed-value sample that may include already-assigned entities. - * @see MassUnassignMoveProvider Unassigning an already-assigned sample. - * @see AssignMoveProvider Assigning a single entity at a time. + * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed. * * @param the solution type * @param the entity type * @param the variable type + * @see MassChangeMoveProvider Mixed-value sample that may include already-assigned entities. + * @see MassUnassignMoveProvider Unassigning an already-assigned sample. + * @see AssignMoveProvider Assigning a single entity at a time. */ @NullMarked public final class MassAssignMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java index 1d58e92b4de..18b8109ec2a 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java @@ -28,7 +28,7 @@ * probability {@code 1/(s+1)}, where {@code s} is the size of the sample members' value range. * When {@code false}, the source excludes unassigned entities and no unassign move is produced either: * for more assign/unassign moves at a much higher rate, - * use {@link MassAssignMoveProvider}/{@link MassUnassignMoveProvider}. + * use {@code MassAssignMoveProvider}/{@code MassUnassignMoveProvider}. *

    * A mixed-value sample can produce a move that leaves some members unchanged; * this is intentional. @@ -44,17 +44,20 @@ * in the move and in its undo; * that cost is accepted as the price of a single move over an otherwise arbitrary sample. *

    - * For the pillar family, where members are keyed on a shared value, - * see {@link ChangeMoveProvider}, {@link PillarChangeMoveProvider} and {@link SubPillarChangeMoveProvider}. - *

    * Samples of size less than 2 are excluded: - * {@link ChangeMoveProvider}/{@link AssignMoveProvider} already cover them, more cheaply. + * {@code ChangeMoveProvider}/{@code AssignMoveProvider} already cover them, more cheaply. * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@link MassUnassignMoveProvider} or a sampler that grows past the seed. + * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed. * * @param the solution type * @param the entity type * @param the variable type + * @see MassAssignMoveProvider Every member is currently unassigned, with no mixed-value side effect. + * @see MassUnassignMoveProvider Unassigning a mixed-value sample at a much higher rate. + * @see ChangeMoveProvider Changing a single entity at a time. + * @see PillarChangeMoveProvider Changing the whole pillar of every entity sharing a value at once. + * @see SubPillarChangeMoveProvider A sampler-driven subset of such a pillar. + * @see AssignMoveProvider Assigning a single entity at a time. */ @NullMarked public final class MassChangeMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java new file mode 100644 index 00000000000..74eac3b343b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java @@ -0,0 +1,68 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Objects; + +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.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, + * out of the values currently unassigned to any entity's {@link PlanningListVariable list variable}, + * and creates a move to insert every member consecutively, in sample iteration order, + * at one destination position legal for every member. + * Members need not share anything beyond currently being unassigned. + *

    + * Requires that the variable + * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; + * otherwise the constructor throws {@link IllegalArgumentException}. + *

    + * Samples of size less than 2 are excluded: {@code ListAssignMoveProvider} already covers them, more cheaply. + *

    + * {@code MassListChangeMoveProvider} never produces this kind of move at all, + * since its own source is currently assigned values only. + * This class exists to make it happen. + * + * @param the solution type + * @param the entity type + * @param the variable type + * @see MassListChangeMoveProvider Mixed-value sample that may include already-assigned values. + * @see MassListUnassignMoveProvider Unassigning an already-assigned sample. + * @see ListAssignMoveProvider Assigning a single value at a time. + */ +@NullMarked +public class MassListAssignMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Sampler sampler; + + public MassListAssignMoveProvider(PlanningListVariableMetaModel variableMetaModel, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + this.sampler = Objects.requireNonNull(sampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var sourceDataset = moveStreamFactory.forEachUnassignedValue(variableMetaModel).asCachedDataset(); + var destinationDataset = moveStreamFactory.forEachDestination(variableMetaModel) + .map((solutionView, position) -> (ElementPosition) position) + .asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new MassListDestinationMoveIterator<>(session, random, + variableMetaModel, sourceDataset, destinationDataset, sampler)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java new file mode 100644 index 00000000000..a200b5c8b10 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -0,0 +1,143 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +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.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Gathers every member of a {@link Sample} of a {@link PlanningListVariable list variable} - + * wherever each one currently is, assigned or not - + * and inserts them consecutively, + * in sample iteration order, + * at one destination position. + * A {@code null} destination unassigns every member instead of inserting them anywhere. + *

    + * This is the list-variable equivalent of {@code MassChangeMove}: + * an assign is a move whose members currently hold no position, + * and an unassign is a move whose destination is {@code null}, + * so neither needs a class of its own. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + * @param the variable type, the type of the property with the {@link PlanningListVariable} annotation + */ +@NullMarked +public final class MassListChangeMove extends AbstractMove { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Sample sample; + private final @Nullable PositionInList destination; + + MassListChangeMove(PlanningListVariableMetaModel variableMetaModel, Sample sample, + @Nullable PositionInList destination) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sample = Objects.requireNonNull(sample); + this.destination = destination; + } + + public Sample getSample() { + return sample; + } + + public @Nullable PositionInList getDestination() { + return destination; + } + + @Override + public List> variableMetaModels() { + return List.of(variableMetaModel); + } + + @Override + public void execute(MutableSolutionView solutionView) { + // Two passes, deliberately: pass 1 reads every member's ORIGINAL position before anything is removed. + // Reading positions inside a single mutate-as-you-go loop is WRONG - a member sitting at or after + // destination.index() can have its live index shift below destination.index() once earlier same-entity + // members are removed, double-counting it as "before" and corrupting the adjusted index. Worked + // counterexample: entity [a,b,c,d,e], destination index 2 (before c), sample {a,b,c} (c sits AT the + // destination, not before it) - a single-pass implementation gives removedBeforeDestination=3 and an + // adjusted index of -1. + var valueList = new ArrayList(sample.size()); + var removedBeforeDestination = 0; + for (var member : sample) { + var value = Objects.requireNonNull(member); + valueList.add(value); + if (destination != null + && solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList assigned) { + Entity_ sourceEntity = assigned.entity(); + Entity_ destinationEntity = destination.entity(); + if (sourceEntity == destinationEntity && assigned.index() < destination.index()) { + removedBeforeDestination++; + } + } + } + for (var value : valueList) { + if (solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList) { + solutionView.unassignValue(variableMetaModel, value); + } + } + if (destination != null) { + solutionView.assignValuesAndAdd(variableMetaModel, valueList, destination.entity(), + destination.index() - removedBeforeDestination); + } + } + + @Override + public MassListChangeMove rebase(Lookup lookup) { + var rebasedValueList = new ArrayList(sample.size()); + for (var value : sample) { + rebasedValueList.add(lookup.lookUpWorkingObject(value)); + } + var rebasedDestination = destination == null ? null + : ElementPosition.of(lookup.lookUpWorkingObject(destination. entity()), destination.index()); + return new MassListChangeMove<>(variableMetaModel, Sample.of(rebasedValueList), rebasedDestination); + } + + @Override + public SequencedCollection getPlanningEntities() { + return destination == null ? List.of() : List.of(destination. entity()); + } + + @Override + public SequencedCollection getPlanningValues() { + var valueList = new ArrayList<>(sample.size()); + for (var member : sample) { + valueList.add(Objects.requireNonNull(member)); + } + return valueList; + } + + @Override + public boolean equals(Object o) { + return o instanceof MassListChangeMove other + && Objects.equals(variableMetaModel, other.variableMetaModel) + && Objects.equals(sample, other.sample) + && Objects.equals(destination, other.destination); + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModel, sample, destination); + } + + @Override + public String toString() { + return sample + " -> " + destination; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java new file mode 100644 index 00000000000..5206bd4d610 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java @@ -0,0 +1,90 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Objects; + +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.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, + * out of the values currently assigned to any entity's {@link PlanningListVariable list variable}, + * and creates a move to gather every member consecutively, + * in sample iteration order, + * at one destination position legal for every member. + * Members need not share an entity or be adjacent; + * unlike {@code SubListChangeMoveProvider}, this draws a scattered sample, not a contiguous span. + *

    + * This provider never assigns: its source is currently-assigned values only. + * Samples of size less than 2 are excluded: {@code ListChangeMoveProvider} already covers them, more cheaply. + * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all. + *

    + * A sample already sitting consecutively at the destination produces a move that changes nothing; + * this is accepted, in the same spirit as a mixed-value {@code MassChangeMoveProvider} sample that may leave some + * members unchanged. + * + * @param the solution type + * @param the entity type + * @param the variable type + * @see SubListChangeMoveProvider A contiguous span drawn instead of a scattered sample. + * @see MassListUnassignMoveProvider Unassigning a sample at a much higher rate. + * @see MassListAssignMoveProvider A sampled set of currently-unassigned values. + * @see ListChangeMoveProvider Moving a single already-assigned value at a time. + */ +@NullMarked +public class MassListChangeMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Sampler sampler; + private final boolean crossingNull; + + public MassListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, + Sampler sampler) { + this(variableMetaModel, sampler, variableMetaModel.allowsUnassignedValues()); + } + + /** + * @param crossingNull if {@code true}, also creates a move that unassigns the whole drawn sample; + * requires that the variable + * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}, + * otherwise the constructor throws {@link IllegalArgumentException} + */ + public MassListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, + Sampler sampler, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.sampler = Objects.requireNonNull(sampler); + if (crossingNull && !variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var sourceDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + // Only widen to forEachDestinationIncludingUnassigned when crossingNull: + // unlike forEachDestination, it represents the unassigned destination with a null entity internally, + // which entity-provided value ranges cannot resolve - + // avoid tripping that path when this provider has no use for it anyway. + var destinationDataset = (crossingNull + ? moveStreamFactory.forEachDestinationIncludingUnassigned(variableMetaModel) + : moveStreamFactory.forEachDestination(variableMetaModel) + .map((solutionView, position) -> (ElementPosition) position)) + .asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new MassListDestinationMoveIterator<>(session, random, + variableMetaModel, sourceDataset, destinationDataset, sampler)); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java new file mode 100644 index 00000000000..e0ee8e92d08 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java @@ -0,0 +1,121 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +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.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws {@link Sample}s of size 2 or more from {@code sourceDataset}, with no grouping key, + * and pairs each with a destination position legal for every member, + * producing a {@code MassListChangeMove}. + *

    + * A plain {@link Iterator}, not a {@link RetiringBiWalk}: there is no left value to retire. + * {@code samplingIterator} never ends while {@code sourceDataset} is non-empty, + * so {@link #hasNext()} bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, + * matching {@code MassDestinationMoveIterator}. + * The destination search itself is a separate, pool-scaled budget, + * matching {@code SubListChangeMoveProvider.SubListChangeMoveIterator.createRightIterator}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +final class MassListDestinationMoveIterator implements Iterator> { + + private final PlanningListVariableMetaModel variableMetaModel; + private final SolutionView solutionView; + private final Iterator> sampleIterator; + private final UniDatasetInstance destinationInstance; + private final RandomGenerator random; + + private @Nullable Move nextMove = null; + + MassListDestinationMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset sourceDataset, UniDataset destinationDataset, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var sourceInstance = session.getInstance(sourceDataset); + this.sampleIterator = sourceInstance.samplingIterator(Objects.requireNonNull(sampler), random); + this.destinationInstance = session.getInstance(destinationDataset); + } + + @Override + public boolean hasNext() { + // sampleIterator never ends while sourceDataset is non-empty (see UniDatasetInstance#samplingIterator's + // contract), so failed draws are counted and this call gives up once they reach + // RetiringBiWalk.PROBE_ATTEMPT_COUNT. + var failedSampleDraws = 0; + while (nextMove == null && sampleIterator.hasNext() && failedSampleDraws < RetiringBiWalk.PROBE_ATTEMPT_COUNT) { + var sample = sampleIterator.next(); + if (sample.size() < 2) { + // Size-1 samples are excluded: ListChangeMoveProvider/ListAssignMoveProvider already cover them, + // more cheaply than a full destination search would here. + failedSampleDraws++; + continue; + } + var bailOutSize = destinationInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + var destinationIterator = new FilteringIterator<>(destinationInstance.iterator(random), + destination -> isValidDestination(sample, destination), bailOutSize); + if (!destinationIterator.hasNext()) { + failedSampleDraws++; + continue; + } + var destination = destinationIterator.next(); + nextMove = (destination instanceof UnassignedElement) + ? Moves.massChange(variableMetaModel, sample, null) + : Moves.massChange(variableMetaModel, sample, (PositionInList) destination); + } + return nextMove != null; + } + + private boolean isValidDestination(Sample sample, ElementPosition destination) { + if (destination instanceof UnassignedElement) { + return true; + } + var targetAssigned = (PositionInList) destination; + Entity_ destinationEntity = targetAssigned.entity(); + if (variableMetaModel.isValueRangeOnSolution()) { + // We can move freely between entities, no per-entity value range to violate. + return true; + } + for (var member : sample) { + if (!solutionView.isValueInRange(variableMetaModel, destinationEntity, member)) { + return false; + } + } + return true; + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java new file mode 100644 index 00000000000..5c5842cfd69 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java @@ -0,0 +1,114 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; + +import org.jspecify.annotations.NullMarked; + +/** + * Draws {@link Sample}s, governed by a {@link Sampler}, + * out of the values currently assigned to any entity's {@link PlanningListVariable list variable}, + * and creates a move to unassign every member at once. + * Members need not share an entity or be adjacent; + * unlike {@code SubListUnassignMoveProvider}, this draws a scattered sample, not a contiguous span. + *

    + * Requires that the variable + * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; + * otherwise the constructor throws {@link IllegalArgumentException}. + *

    + * {@code MassListChangeMoveProvider} makes this same kind of move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only as one destination row among many, so it arrives rarely. + * This class exists to make it happen often. + * + * @param the solution type + * @param the entity type + * @param the variable type + * @see SubListUnassignMoveProvider A contiguous span drawn instead of a scattered sample. + * @see MassListChangeMoveProvider Unassigning a whole drawn sample as one destination row among many. + * @see ListUnassignMoveProvider Unassigning a single value at a time. + * @see MassListAssignMoveProvider Assigning unassigned values. + */ +@NullMarked +public final class MassListUnassignMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Sampler sampler; + + public MassListUnassignMoveProvider(PlanningListVariableMetaModel variableMetaModel, + Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + this.sampler = Objects.requireNonNull(sampler); + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var assignedValueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new MassListUnassignMoveIterator<>(session, random, + variableMetaModel, assignedValueDataset, sampler)); + } + + /** + * Draws mixed samples of currently-assigned values and unassigns every member, + * producing a {@code MassListChangeMove} with a null destination. + * The destination is fixed at null, so nothing can ever be rejected: + * every drawn sample yields a valid move, with no probing. + *

    + * Unlike {@link MassListAssignMoveProvider}/{@link MassListChangeMoveProvider}, + * size-1 samples are not skipped here, and deliberately so: + * skipping would give this iterator its first way to reject a draw, + * but {@code samplingIterator} never ends while the source dataset is non-empty, + * and a size-1 sample can be all a given {@link Sampler} ever produces. + * Emitting the size-1 sample costs nothing and removes the problem at its root, + * since unassigning one value is no more expensive than unassigning several. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class MassListUnassignMoveIterator + implements Iterator> { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Iterator> sampleIterator; + + MassListUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset assignedValueDataset, Sampler sampler) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + var assignedValueInstance = session.getInstance(assignedValueDataset); + this.sampleIterator = assignedValueInstance.samplingIterator(Objects.requireNonNull(sampler), random); + } + + @Override + public boolean hasNext() { + return sampleIterator.hasNext(); + } + + @Override + public Move next() { + return Moves.massChange(variableMetaModel, sampleIterator.next(), null); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java index 38a70b54836..d91305eea52 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -24,24 +24,24 @@ * Members need not share a value; * unlike the pillar family, this draws with no grouping key. *

    - * {@link MassChangeMoveProvider} makes this same kind of move too, + * {@code MassChangeMoveProvider} makes this same kind of move too, * whenever its own {@code crossingNull} is {@code true} - * but there, only with probability {@code 1/(s+1)} per drawn sample * (where {@code s} is the size of the sample members' value range), * so it arrives rarely. * This class exists to make it happen often. *

    - * For the pillar family, where members are keyed on a shared value, - * see {@link PillarUnassignMoveProvider} and {@link SubPillarUnassignMoveProvider}. - * For unassigning a single entity at a time, see {@link UnassignMoveProvider}. - * For assigning unassigned entities, see {@link MassAssignMoveProvider}. - *

    * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. * * @param the solution type * @param the entity type * @param the variable type + * @see MassChangeMoveProvider Unassigning a sample too, as one candidate among many. + * @see PillarUnassignMoveProvider Unassigning the whole pillar of every entity sharing a value at once. + * @see SubPillarUnassignMoveProvider A sampler-driven subset of such a pillar. + * @see UnassignMoveProvider Unassigning a single entity at a time. + * @see MassAssignMoveProvider Assigning unassigned entities. */ @NullMarked public final class MassUnassignMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index 6720e6c5f38..546b47d6d81 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import org.jspecify.annotations.NullMarked; @@ -332,6 +333,97 @@ public static Move swap( return new ListSwapMove<>(variableMetaModel, leftEntity, leftIndex, rightEntity, rightIndex); } + /** + * Creates a move that relocates a contiguous span of a list variable to a different position, + * possibly on a different entity. + *

    + * The span is identified by a {@link Range}. + * It is first removed from its source position (shifting later elements left), + * then inserted at the destination position, + * optionally in reverse element order. + *

    + * Neither overlap between the span and the destination, + * nor destination value-range legality, + * is checked by this move; + * that is the caller's responsibility. + * + * @param variableMetaModel describes the list variable to be changed + * @param source the span to move + * @param destination the destination position at which to insert the span + * @param reversing if {@code true}, the span is inserted in reverse element order + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, relocates the span to the destination position + */ + public static Move change( + PlanningListVariableMetaModel variableMetaModel, Range source, + PositionInList destination, boolean reversing) { + return new SubListChangeMove<>(variableMetaModel, source, destination, reversing); + } + + /** + * Creates a move that swaps two contiguous spans of a list variable, + * possibly on different entities. + *

    + * Each span is identified by a {@link Range}. + * When both spans are on the same entity, they must not overlap; + * this move does not check this. + * + * @param variableMetaModel describes the list variable to be changed + * @param left the first span participating in the swap + * @param right the second span participating in the swap + * @param reversing if {@code true}, both spans are inserted in reverse element order + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, swaps the two spans + */ + public static Move swap( + PlanningListVariableMetaModel variableMetaModel, Range left, Range right, + boolean reversing) { + return new SubListSwapMove<>(variableMetaModel, left, right, reversing); + } + + /** + * Creates a move that unassigns a contiguous span of a list variable, + * that is, removes every value of the span from the list, leaving it unassigned. + * + * @param variableMetaModel describes the list variable to be changed + * @param range the span to unassign + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, removes every value of the span from the list variable + */ + public static Move unassign( + PlanningListVariableMetaModel variableMetaModel, Range range) { + return new SubListUnassignMove<>(variableMetaModel, range); + } + + /** + * Creates a move that inserts every member of a {@link Sample} of a list variable consecutively, + * in sample iteration order, at one destination position. + *

    + * This is the list equivalent of {@link #massChange(PlanningVariableMetaModel, Sample, Object)}: + * an assign is a move whose members currently hold no position, + * and an unassign is a move whose destination is {@code null}. + * + * @param variableMetaModel describes the list variable to be changed + * @param sample the sample whose members are to be gathered + * @param destination the destination position at which to insert every member; + * {@code null} unassigns every member instead + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, gathers every member at the destination position, or unassigns them all + */ + public static Move massChange( + PlanningListVariableMetaModel variableMetaModel, Sample sample, + @Nullable PositionInList destination) { + return new MassListChangeMove<>(variableMetaModel, sample, destination); + } + private Moves() { // No external instances. } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java index 4bd0e114be3..92ad5280314 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -37,12 +37,17 @@ * probability {@code 1/(s+1)} per drawn pillar, where {@code s} is the size of the pillar members' value range. * This provider never assigns: * a pillar's key is a value shared by its members, and unassigned is the absence of a value, not one. - * Use {@link PillarUnassignMoveProvider} for unassign moves at a much higher rate; + * Use {@code PillarUnassignMoveProvider} for unassign moves at a much higher rate; * there is no pillar equivalent for assign, since it would require drawing a pillar keyed on "unassigned" - - * w either duplicate {@link MassAssignMoveProvider} (a subset) or, for the whole pillar, normally intersect to nothing - - * so use {@link MassAssignMoveProvider} or {@link AssignMoveProvider} instead. + * w either duplicate {@code MassAssignMoveProvider} (a subset) or, for the whole pillar, normally intersect to nothing - + * so use {@code MassAssignMoveProvider} or {@code AssignMoveProvider} instead. *

    - * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@link SubPillarChangeMoveProvider}. + * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@code SubPillarChangeMoveProvider}. + * + * @see PillarUnassignMoveProvider Unassigning the whole pillar at a much higher rate. + * @see MassAssignMoveProvider A sampler-chosen subset of the unassigned entities. + * @see AssignMoveProvider Assigning a single entity at a time. + * @see SubPillarChangeMoveProvider A sampler-driven subset of the pillar. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java index 209976f07ef..7ac1c791274 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -39,7 +39,9 @@ * Size-1 pillars are legal on both sides of the swap, * since we need to be able to swap 1-sized pillar with an n-sized pillar. *

    - * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@link SubPillarSwapMoveProvider}. + * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@code SubPillarSwapMoveProvider}. + * + * @see SubPillarSwapMoveProvider A sampler-driven subset of the pillar. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java index a95c2a3489d..eecfe5ceff6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java @@ -21,20 +21,25 @@ * (set the basic planning variable to null). * The pillar is keyed on this one variable alone; members may differ in every other variable. *

    - * {@link PillarChangeMoveProvider} makes this same move too, + * {@code PillarChangeMoveProvider} makes this same move too, * whenever its own {@code crossingNull} is {@code true} - * but there, only with probability {@code 1/(s+1)} per drawn pillar (where {@code s} * is the size of the pillar members' value range), so it arrives rarely. * This class exists to make it happen often. *

    * Draws whole pillars only. - * For a sampler-driven subset of a pillar, see {@link SubPillarUnassignMoveProvider}. - * For unassigning a single entity at a time, see {@link UnassignMoveProvider}. - * For assigning unassigned entities, see {@link MassAssignMoveProvider}. + * For a sampler-driven subset of a pillar, see {@code SubPillarUnassignMoveProvider}. + * For unassigning a single entity at a time, see {@code UnassignMoveProvider}. + * For assigning unassigned entities, see {@code MassAssignMoveProvider}. *

    * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. * + * @see PillarChangeMoveProvider Changing the whole pillar too, as one candidate among many. + * @see SubPillarUnassignMoveProvider A sampler-driven subset of the pillar. + * @see UnassignMoveProvider Unassigning a single entity at a time. + * @see MassAssignMoveProvider Assigning unassigned entities. + * * @param the solution type * @param the entity type * @param the variable type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java new file mode 100644 index 00000000000..f39253315b2 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java @@ -0,0 +1,137 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +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.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Moves a contiguous span of a {@link PlanningListVariable list variable} to a different position, + * possibly on a different entity. + * The moved span is identified by a {@link Range}. + * The span is inserted starting at the given destination position, + * optionally in reverse element order. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + * @param the variable type, the type of the property with the {@link PlanningVariable} annotation + */ +@NullMarked +public final class SubListChangeMove extends AbstractMove { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Range source; + private final PositionInList destination; + private final boolean reversing; + + private @Nullable List movedValues; + + SubListChangeMove(PlanningListVariableMetaModel variableMetaModel, Range source, + PositionInList destination, boolean reversing) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.source = Objects.requireNonNull(source); + this.destination = Objects.requireNonNull(destination); + this.reversing = reversing; + } + + public Range getSource() { + return source; + } + + public PositionInList getDestination() { + return destination; + } + + public boolean isReversing() { + return reversing; + } + + @SuppressWarnings("unchecked") + private List getMovedValues() { + if (movedValues == null) { + var sourceList = (List) getVariableDescriptor(variableMetaModel).getValue(source.entity()); + movedValues = List.copyOf(sourceList.subList(source.fromIndex(), source.toIndex())); + } + return movedValues; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public void execute(MutableSolutionView solutionView) { + Entity_ sourceEntity = source.entity(); + Entity_ destinationEntity = destination.entity(); + movedValues = (sourceEntity == destinationEntity) + ? solutionView.moveValuesInList(variableMetaModel, sourceEntity, source.fromIndex(), source.toIndex(), + destination.index(), reversing) + : solutionView.moveValuesBetweenLists(variableMetaModel, sourceEntity, source.fromIndex(), source.toIndex(), + destinationEntity, destination.index(), reversing); + } + + @Override + public SubListChangeMove rebase(Lookup lookup) { + return new SubListChangeMove<>(variableMetaModel, + Range.of(lookup.lookUpWorkingObject(source. entity()), source.fromIndex(), source.toIndex()), + ElementPosition.of(lookup.lookUpWorkingObject(destination. entity()), destination.index()), + reversing); + } + + @Override + public SequencedCollection getPlanningEntities() { + Entity_ sourceEntity = source.entity(); + Entity_ destinationEntity = destination.entity(); + if (sourceEntity == destinationEntity) { + return List.of(sourceEntity); + } else { + return List.of(sourceEntity, destinationEntity); + } + } + + @Override + public SequencedCollection getPlanningValues() { + return List.copyOf(getMovedValues()); + } + + @Override + public List> variableMetaModels() { + return List.of(variableMetaModel); + } + + @Override + public boolean equals(Object o) { + return o instanceof SubListChangeMove other + && Objects.equals(variableMetaModel, other.variableMetaModel) + && Objects.equals(source, other.source) + && Objects.equals(destination, other.destination) + && reversing == other.reversing; + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModel, source, destination, reversing); + } + + @Override + public String toString() { + return "|%d| {%s[%d..%d] -%s> %s[%d]}".formatted( + source.length(), source. entity(), source.fromIndex(), source.toIndex() - 1, + reversing ? "reversing-" : "", destination. entity(), destination.index()); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java new file mode 100644 index 00000000000..86626778e88 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java @@ -0,0 +1,230 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +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.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * For each contiguous span ("sub-list") of an assigned run of a list variable, + * creates a move to relocate it to a different position, + * possibly on a different entity, + * possibly in reverse element order. + *

    + * When {@code crossingNull} is {@code true}, + * this provider also creates a move that unassigns the whole drawn span - + * one destination row among many, so it arrives rarely. + * Use {@code SubListUnassignMoveProvider} for unassign moves at a much higher rate. + *

    + * This provider never assigns: + * a drawn span's identity is a contiguous run of positions, + * which the unassigned pool does not have. + * For a set of unassigned values drawn together, + * see {@code MassListAssignMoveProvider} instead. + * + * @see SubListUnassignMoveProvider Unassigning the whole span at a much higher rate. + * @see MassListAssignMoveProvider A set of unassigned values drawn together. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubListChangeMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final int minimumSubListSize; + private final int maximumSubListSize; + private final boolean selectReversingMoveToo; + private final boolean crossingNull; + + public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, 1, Integer.MAX_VALUE, true, variableMetaModel.allowsUnassignedValues()); + } + + public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize) { + this(variableMetaModel, minimumSubListSize, maximumSubListSize, true, variableMetaModel.allowsUnassignedValues()); + } + + /** + * @param crossingNull if {@code true}, also creates whole-span unassign moves; + * requires that the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows + * unassigned values}, otherwise the constructor throws {@link IllegalArgumentException} + */ + public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize, boolean selectReversingMoveToo, boolean crossingNull) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + TriangleElementFactory.validateSizes(minimumSubListSize, maximumSubListSize); + this.minimumSubListSize = minimumSubListSize; + this.maximumSubListSize = maximumSubListSize; + this.selectReversingMoveToo = selectReversingMoveToo; + if (crossingNull && !variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException(""" + The crossingNull (true) of variableMetaModel (%s) requires a variable \ + which allows unassigned values, but this variable does not. + Maybe set crossingNull to false.""" + .formatted(variableMetaModel)); + } + this.crossingNull = crossingNull; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var sourceDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + // Only widen to forEachDestinationIncludingUnassigned when crossingNull: + // unlike forEachDestination, it represents the unassigned destination with a null entity internally, + // which entity-provided value ranges cannot resolve - + // avoid tripping that path when this provider has no use for it anyway. + var destinationDataset = (crossingNull + ? moveStreamFactory.forEachDestinationIncludingUnassigned(variableMetaModel) + : moveStreamFactory.forEachDestination(variableMetaModel) + .map((solutionView, position) -> (ElementPosition) position)) + .asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new SubListChangeMoveIterator<>(session, random, + variableMetaModel, sourceDataset, destinationDataset, minimumSubListSize, maximumSubListSize, + selectReversingMoveToo)); + } + + /** + * Draws a span sharing an assigned seed value and pairs it with a destination position, + * producing a {@code SubListChangeMove} + * or, for an unassigned destination, a {@code SubListUnassignMove}. + * Left = seed value, right = destination position. + *

    + * A fresh span is drawn on every {@link #createRightIterator} call, never cached + * across probes: caching the first draw would turn {@link RetiringBiWalk}'s remaining probes into + * deterministic no-ops, the same reasoning {@link SubPillarChangeMoveProvider} documents. + *

    + * Known ceiling: the left pool is seed values, but a value only picks its entity, + * so deadness is per-entity while retirement is per-value. + * An entity holding {@code k} unpinned values is reachable via {@code k} distinct left rows, + * so a genuinely dead entity costs up to {@code 3k} probes before its last seed retires. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class SubListChangeMoveIterator + implements Iterator>, RetiringBiWalk { + + private final PlanningListVariableMetaModel variableMetaModel; + private final boolean selectReversingMoveToo; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator sliceValueIterator; + private final UniDatasetInstance destinationInstance; + private final SubListSampler sampler; + + private @Nullable Move nextMove = null; + private @Nullable Range pendingRange = null; + + SubListChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset sourceDataset, UniDataset destinationDataset, + int minimumSubListSize, int maximumSubListSize, boolean selectReversingMoveToo) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.selectReversingMoveToo = selectReversingMoveToo; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var sourceInstance = (DefaultUniDatasetInstance) session.getInstance(sourceDataset); + this.sliceValueIterator = sourceInstance.retiringRandomIterator(random); + this.destinationInstance = session.getInstance(destinationDataset); + this.sampler = new SubListSampler<>(variableMetaModel, minimumSubListSize, maximumSubListSize, random); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(sliceValueIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Value_ sliceValue) { + // Fresh span on every call; see the class javadoc. + var range = sampler.byValue(solutionView, sliceValue); + if (range == null) { + pendingRange = null; + return Collections.emptyIterator(); + } + pendingRange = range; + var bailOutSize = destinationInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(destinationInstance.iterator(random), + destination -> isValidChange(range, destination), bailOutSize); + } + + private boolean isValidChange(Range range, ElementPosition destination) { + if (destination instanceof UnassignedElement) { + return true; + } + var targetAssigned = (PositionInList) destination; + Entity_ sourceEntity = range.entity(); + Entity_ destinationEntity = targetAssigned.entity(); + if (sourceEntity == destinationEntity) { + return targetAssigned.index() != range.fromIndex() + && targetAssigned.index() + range.length() <= solutionView.countValues(variableMetaModel, + sourceEntity); + } + if (variableMetaModel.isValueRangeOnSolution()) { + // We can move freely between entities, no per-entity value range to violate. + return true; + } + for (var index = range.fromIndex(); index < range.toIndex(); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, sourceEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, destinationEntity, value)) { + return false; + } + } + return true; + } + + @Override + public void accept(Value_ sliceValue, ElementPosition destination) { + var range = Objects.requireNonNull(pendingRange); + pendingRange = null; + if (destination instanceof UnassignedElement) { + nextMove = Moves.unassign(variableMetaModel, range); + } else { + var reversing = selectReversingMoveToo && range.length() > 1 && random.nextBoolean(); + nextMove = Moves.change(variableMetaModel, range, (PositionInList) destination, reversing); + } + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java new file mode 100644 index 00000000000..9879a3b9170 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java @@ -0,0 +1,164 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Swaps two contiguous, non-overlapping spans of a {@link PlanningListVariable list variable}, + * possibly on different entities. + * Each span is identified by a {@link Range}. + * Left and right entity can be the same instance, + * in which case the two spans must not overlap. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + * @param the variable type, the type of the property with the {@link PlanningVariable} annotation + */ +@NullMarked +public final class SubListSwapMove extends AbstractMove { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Range leftRange; + private final Range rightRange; + private final boolean reversing; + + private @Nullable List leftValues; + private @Nullable List rightValues; + + SubListSwapMove(PlanningListVariableMetaModel variableMetaModel, Range leftRange, + Range rightRange, boolean reversing) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + // Normalize so that, when both spans are on the same entity, left always precedes right; + // this is what makes the spans satisfy MutableSolutionView.swapValuesInList's ordering precondition. + if (leftRange.entity() == rightRange.entity() && leftRange.fromIndex() > rightRange.fromIndex()) { + this.leftRange = rightRange; + this.rightRange = leftRange; + } else { + this.leftRange = leftRange; + this.rightRange = rightRange; + } + this.reversing = reversing; + } + + public Range getLeftRange() { + return leftRange; + } + + public Range getRightRange() { + return rightRange; + } + + public boolean isReversing() { + return reversing; + } + + @SuppressWarnings("unchecked") + private List getLeftValues() { + if (leftValues == null) { + var list = (List) getVariableDescriptor(variableMetaModel).getValue(leftRange.entity()); + leftValues = List.copyOf(list.subList(leftRange.fromIndex(), leftRange.toIndex())); + } + return leftValues; + } + + @SuppressWarnings("unchecked") + private List getRightValues() { + if (rightValues == null) { + var list = (List) getVariableDescriptor(variableMetaModel).getValue(rightRange.entity()); + rightValues = List.copyOf(list.subList(rightRange.fromIndex(), rightRange.toIndex())); + } + return rightValues; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public void execute(MutableSolutionView solutionView) { + // Cache the pre-move values now, before either side is mutated. + getLeftValues(); + getRightValues(); + Entity_ leftEntity = leftRange.entity(); + Entity_ rightEntity = rightRange.entity(); + if (leftEntity == rightEntity) { + solutionView.swapValuesInList(variableMetaModel, leftEntity, leftRange.fromIndex(), leftRange.toIndex(), + rightRange.fromIndex(), rightRange.toIndex(), reversing); + } else { + solutionView.swapValuesBetweenLists(variableMetaModel, leftEntity, leftRange.fromIndex(), leftRange.toIndex(), + rightEntity, rightRange.fromIndex(), rightRange.toIndex(), reversing); + } + } + + @Override + public SubListSwapMove rebase(Lookup lookup) { + return new SubListSwapMove<>(variableMetaModel, + Range.of(lookup.lookUpWorkingObject(leftRange. entity()), leftRange.fromIndex(), + leftRange.toIndex()), + Range.of(lookup.lookUpWorkingObject(rightRange. entity()), rightRange.fromIndex(), + rightRange.toIndex()), + reversing); + } + + @Override + public List> variableMetaModels() { + return List.of(variableMetaModel); + } + + @Override + public SequencedCollection getPlanningEntities() { + // Use LinkedHashSet for predictable iteration order. + Entity_ leftEntity = leftRange.entity(); + Entity_ rightEntity = rightRange.entity(); + var entities = LinkedHashSet. newLinkedHashSet(2); + entities.add(leftEntity); + entities.add(rightEntity); + return entities; + } + + @Override + public SequencedCollection getPlanningValues() { + var leftValues = getLeftValues(); + var rightValues = getRightValues(); + var values = new ArrayList<>(leftValues.size() + rightValues.size()); + values.addAll(leftValues); + values.addAll(rightValues); + return values; + } + + @Override + public boolean equals(Object o) { + return o instanceof SubListSwapMove other + && Objects.equals(variableMetaModel, other.variableMetaModel) + && Objects.equals(leftRange, other.leftRange) + && Objects.equals(rightRange, other.rightRange) + && reversing == other.reversing; + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModel, leftRange, rightRange, reversing); + } + + @Override + public String toString() { + return "{" + leftRange + "} <-" + (reversing ? "reversing-" : "") + "> {" + rightRange + "}"; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java new file mode 100644 index 00000000000..ef5cb492599 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java @@ -0,0 +1,211 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Collections; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * For two contiguous spans ("sub-lists") of assigned runs of a list variable, + * each governed by its own size bounds, + * creates a move to swap them, + * possibly on different entities, + * possibly in reverse element order. + *

    + * There is no {@code crossingNull} flag: + * a swap of two spans cannot cross null by construction, since + * both spans are drawn from assigned runs. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubListSwapMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final int leftMinimumSubListSize; + private final int leftMaximumSubListSize; + private final int rightMinimumSubListSize; + private final int rightMaximumSubListSize; + private final boolean selectReversingMoveToo; + + public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, 1, Integer.MAX_VALUE, 1, Integer.MAX_VALUE, true); + } + + public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize) { + this(variableMetaModel, minimumSubListSize, maximumSubListSize, minimumSubListSize, maximumSubListSize, true); + } + + public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int leftMinimumSubListSize, int leftMaximumSubListSize, int rightMinimumSubListSize, + int rightMaximumSubListSize) { + this(variableMetaModel, leftMinimumSubListSize, leftMaximumSubListSize, rightMinimumSubListSize, + rightMaximumSubListSize, true); + } + + public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int leftMinimumSubListSize, int leftMaximumSubListSize, int rightMinimumSubListSize, + int rightMaximumSubListSize, boolean selectReversingMoveToo) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + TriangleElementFactory.validateSizes(leftMinimumSubListSize, leftMaximumSubListSize); + TriangleElementFactory.validateSizes(rightMinimumSubListSize, rightMaximumSubListSize); + this.leftMinimumSubListSize = leftMinimumSubListSize; + this.leftMaximumSubListSize = leftMaximumSubListSize; + this.rightMinimumSubListSize = rightMinimumSubListSize; + this.rightMaximumSubListSize = rightMaximumSubListSize; + this.selectReversingMoveToo = selectReversingMoveToo; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var valueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new SubListSwapMoveIterator<>(session, random, + variableMetaModel, valueDataset, leftMinimumSubListSize, leftMaximumSubListSize, + rightMinimumSubListSize, rightMaximumSubListSize, selectReversingMoveToo)); + } + + /** + * Draws two spans sharing no particular relationship other than both being seeded by an assigned value, + * producing a {@code SubListSwapMove}. + * Left = seed value, right = candidate span. + *

    + * A fresh left span is drawn on every {@link #createRightIterator} call, + * and a fresh right span is drawn per candidate probed, never cached: + * the same reasoning {@link SubPillarSwapMoveProvider} documents. + *

    + * Known ceiling: see {@link SubListChangeMoveProvider}'s class javadoc for the same ceiling, + * accepted for the same reason. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class SubListSwapMoveIterator + implements Iterator>, RetiringBiWalk { + + private final PlanningListVariableMetaModel variableMetaModel; + private final boolean selectReversingMoveToo; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator leftValueIterator; + private final DefaultUniDatasetInstance valueInstance; + private final SubListSampler leftSampler; + private final SubListSampler rightSampler; + + private @Nullable Move nextMove = null; + private @Nullable Range pendingLeftRange = null; + private @Nullable Range pendingRightRange = null; + + SubListSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset valueDataset, int leftMinimumSubListSize, int leftMaximumSubListSize, + int rightMinimumSubListSize, int rightMaximumSubListSize, boolean selectReversingMoveToo) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.selectReversingMoveToo = selectReversingMoveToo; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + this.valueInstance = (DefaultUniDatasetInstance) session.getInstance(valueDataset); + this.leftValueIterator = valueInstance.retiringRandomIterator(random); + this.leftSampler = new SubListSampler<>(variableMetaModel, leftMinimumSubListSize, leftMaximumSubListSize, random); + this.rightSampler = + new SubListSampler<>(variableMetaModel, rightMinimumSubListSize, rightMaximumSubListSize, random); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(leftValueIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Value_ leftValue) { + // Fresh left span on every call; see the class javadoc. + var leftRange = leftSampler.byValue(solutionView, leftValue); + if (leftRange == null) { + pendingLeftRange = null; + return Collections.emptyIterator(); + } + pendingLeftRange = leftRange; + var bailOutSize = valueInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(valueInstance.iterator(random), candidateValue -> { + var rightRange = rightSampler.byValue(solutionView, candidateValue); + if (rightRange != null && isValidSwap(leftRange, rightRange)) { + pendingRightRange = rightRange; + return true; + } + return false; + }, bailOutSize); + } + + private boolean isValidSwap(Range left, Range right) { + Entity_ leftEntity = left.entity(); + Entity_ rightEntity = right.entity(); + if (leftEntity == rightEntity) { + // Must not overlap. + return left.toIndex() <= right.fromIndex() || right.toIndex() <= left.fromIndex(); + } + if (variableMetaModel.isValueRangeOnSolution()) { + return true; + } + for (var index = left.fromIndex(); index < left.toIndex(); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, leftEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, rightEntity, value)) { + return false; + } + } + for (var index = right.fromIndex(); index < right.toIndex(); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, rightEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, leftEntity, value)) { + return false; + } + } + return true; + } + + @Override + public void accept(Value_ leftValue, Value_ rightValue) { + var left = Objects.requireNonNull(pendingLeftRange); + var right = Objects.requireNonNull(pendingRightRange); + var reversing = selectReversingMoveToo && (left.length() > 1 || right.length() > 1) && random.nextBoolean(); + nextMove = Moves.swap(variableMetaModel, left, right, reversing); + pendingLeftRange = null; + pendingRightRange = null; + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java new file mode 100644 index 00000000000..dbadc624a33 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java @@ -0,0 +1,102 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.List; +import java.util.Objects; +import java.util.SequencedCollection; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.move.AbstractMove; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.MutableSolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Unassigns a contiguous span of a {@link PlanningListVariable list variable}, that is, removes every value of + * the span from the list, leaving it unassigned. The span is identified by a {@link Range}. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the entity type, the class with the {@link PlanningEntity} annotation + * @param the variable type, the type of the property with the {@link PlanningVariable} annotation + */ +@NullMarked +public class SubListUnassignMove extends AbstractMove { + + private final PlanningListVariableMetaModel variableMetaModel; + private final Range range; + + private @Nullable List unassignedValues; + + protected SubListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Range range) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.range = Objects.requireNonNull(range); + } + + public Range getRange() { + return range; + } + + @SuppressWarnings("unchecked") + private List getUnassignedValues() { + if (unassignedValues == null) { + var list = (List) getVariableDescriptor(variableMetaModel).getValue(range.entity()); + unassignedValues = List.copyOf(list.subList(range.fromIndex(), range.toIndex())); + } + return unassignedValues; + } + + // ************************************************************************ + // Worker methods + // ************************************************************************ + + @Override + public void execute(MutableSolutionView solutionView) { + unassignedValues = + solutionView.unassignValues(variableMetaModel, range. entity(), range.fromIndex(), range.toIndex()); + } + + @Override + public SubListUnassignMove rebase(Lookup lookup) { + return new SubListUnassignMove<>(variableMetaModel, + Range.of(lookup.lookUpWorkingObject(range. entity()), range.fromIndex(), range.toIndex())); + } + + @Override + public SequencedCollection getPlanningEntities() { + return List.of(range. entity()); + } + + @Override + public SequencedCollection getPlanningValues() { + return List.copyOf(getUnassignedValues()); + } + + @Override + public List> variableMetaModels() { + return List.of(variableMetaModel); + } + + @Override + public boolean equals(Object o) { + return o instanceof SubListUnassignMove other + && Objects.equals(variableMetaModel, other.variableMetaModel) + && Objects.equals(range, other.range); + } + + @Override + public int hashCode() { + return Objects.hash(variableMetaModel, range); + } + + @Override + public String toString() { + return "|%d| {%s -> null}".formatted(range.length(), range); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java new file mode 100644 index 00000000000..2a3d526d48f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java @@ -0,0 +1,139 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws contiguous spans ("sub-lists") of assigned runs of a list variable + * and creates a move to unassign every member of the drawn span at once. + *

    + * {@code SubListChangeMoveProvider} makes this same move too, + * whenever its own {@code crossingNull} is {@code true} - + * but there, only as one destination row among many, + * so it arrives rarely. + * This class exists to make it happen often. + *

    + * The variable must {@link PlanningListVariableMetaModel#allowsUnassignedValues() allow unassigned values}; + * otherwise the constructor throws {@link IllegalArgumentException}. + * + * @see SubListChangeMoveProvider Unassigning the whole span too, as one candidate among many. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubListUnassignMoveProvider + implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final int minimumSubListSize; + private final int maximumSubListSize; + + public SubListUnassignMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, 1, Integer.MAX_VALUE); + } + + public SubListUnassignMoveProvider(PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + if (!variableMetaModel.allowsUnassignedValues()) { + throw new IllegalArgumentException( + "The variableMetaModel (%s) must allow unassigned values, but it does not." + .formatted(variableMetaModel)); + } + TriangleElementFactory.validateSizes(minimumSubListSize, maximumSubListSize); + this.minimumSubListSize = minimumSubListSize; + this.maximumSubListSize = maximumSubListSize; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var valueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new SubListUnassignMoveIterator<>(session, random, + variableMetaModel, valueDataset, minimumSubListSize, maximumSubListSize)); + } + + /** + * Draws a span sharing an assigned seed value and unassigns every member, + * producing a {@code SubListUnassignMove}. + * The destination is fixed at null, so nothing can ever be rejected: + * no {@code RetiringBiWalk} is needed, + * and a failed draw is proof that the seed's entity has fewer unpinned values + * than the minimum sub-list size, not a bail-out false negative - + * so the seed is retired immediately rather than probed N times. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class SubListUnassignMoveIterator + implements Iterator> { + + private final PlanningListVariableMetaModel variableMetaModel; + private final SolutionView solutionView; + private final RetiringRandomIterator valueIterator; + private final SubListSampler sampler; + + private @Nullable Move nextMove = null; + + SubListUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset valueDataset, int minimumSubListSize, int maximumSubListSize) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.solutionView = session.getSolutionView(); + var valueInstance = (DefaultUniDatasetInstance) session.getInstance(valueDataset); + this.valueIterator = valueInstance.retiringRandomIterator(random); + this.sampler = new SubListSampler<>(variableMetaModel, minimumSubListSize, maximumSubListSize, random); + } + + @Override + public boolean hasNext() { + if (nextMove != null) { + return true; + } + while (valueIterator.hasNext()) { + var value = valueIterator.next(); + var range = sampler.byValue(solutionView, value); + if (range == null) { + valueIterator.retire(); + continue; + } + nextMove = Moves.unassign(variableMetaModel, range); + return true; + } + return false; + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java index 6f1c93f6b14..9e9bd636354 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java @@ -43,13 +43,19 @@ * This provider never assigns: * its key is a value shared by its members, * and unassigned is the absence of a value. - * Use {@link SubPillarUnassignMoveProvider} for unassign moves at a much higher rate; - * for assign, use {@link MassAssignMoveProvider} - * (which already draws sampler-chosen subsets of the unassigned entities) or {@link AssignMoveProvider}. + * Use {@code SubPillarUnassignMoveProvider} for unassign moves at a much higher rate; + * for assign, use {@code MassAssignMoveProvider} + * (which already draws sampler-chosen subsets of the unassigned entities) or {@code AssignMoveProvider}. *

    - * For the whole pillar of every entity sharing the value at once, see {@link PillarChangeMoveProvider}. + * For the whole pillar of every entity sharing the value at once, see {@code PillarChangeMoveProvider}. * For a mixed-value sample with no shared key, which may include unassigned entities, see - * {@link MassChangeMoveProvider}. + * {@code MassChangeMoveProvider}. + * + * @see SubPillarUnassignMoveProvider Unassigning the whole subpillar at a much higher rate. + * @see MassAssignMoveProvider A sampler-chosen subset of the unassigned entities. + * @see AssignMoveProvider Assigning a single entity at a time. + * @see PillarChangeMoveProvider The whole pillar of every entity sharing the value at once. + * @see MassChangeMoveProvider A mixed-value sample with no shared key, which may include unassigned entities. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 5efea73b9b5..0097237e461 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -42,7 +42,7 @@ * in {@link GenuineEntityMetaModel#variables()} declaration order, * regardless of the order the constructor was given. *

    - * For the whole pillar on both sides at once, see {@link PillarSwapMoveProvider}. + * For the whole pillar on both sides at once, see {@code PillarSwapMoveProvider}. *

    * There is no single-sampler overload: * a {@link Sampler} may hold state across {@code decide} calls, @@ -50,6 +50,8 @@ * if silently reused, unreset, for the other side. * So the two sides always take separate instances. * + * @see PillarSwapMoveProvider The whole pillar on both sides at once. + * * @param the solution type * @param the entity type */ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java index 478a833b142..f11c13a61ee 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -24,7 +24,7 @@ * The (sub)pillar is keyed on this one variable alone; * members may differ in every other variable. *

    - * {@link SubPillarChangeMoveProvider} makes this same move too, + * {@code SubPillarChangeMoveProvider} makes this same move too, * whenever its own {@code crossingNull} is {@code true} - * but there, only with probability {@code 1/(s+1)} per drawn subpillar * (where {@code s} is the size of the subpillar members' value range), @@ -34,6 +34,7 @@ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. * + * @see SubPillarChangeMoveProvider Changing the whole subpillar too, as one candidate among many. * @see UnassignMoveProvider Unassigning a single entity at a time. * @see PillarUnassignMoveProvider Unassigning the whole pillar of every entity sharing the slice value at once. * @see MassAssignMoveProvider Mass-assigning unassigned entities. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java index 2a7d8deea73..b863f383b2c 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/UnassignMoveProvider.java @@ -15,7 +15,7 @@ *

    * This provider only applies to planning variables that allow unassigned values. *

    - * {@link ChangeMoveProvider} makes this same move too, + * {@code ChangeMoveProvider} makes this same move too, * whenever its own {@code crossingNull} is {@code true} - * but there, only with probability {@code 1/(v+1)} per draw * (where {@code v} is the number of registered values), @@ -24,13 +24,19 @@ *

    * For the complementary moves: *

      - *
    • Use {@link AssignMoveProvider} to assign a value to currently-unassigned entities.
    • - *
    • Use {@link ChangeMoveProvider} to change an entity's value to a different non-null value.
    • + *
    • Use {@code AssignMoveProvider} to assign a value to currently-unassigned entities.
    • + *
    • Use {@code ChangeMoveProvider} to change an entity's value to a different non-null value.
    • *
    *

    * For unassigning several entities at once, - * see {@link PillarUnassignMoveProvider} and {@link SubPillarUnassignMoveProvider} (members share a value) - * or {@link MassUnassignMoveProvider} (members need not share anything). + * see {@code PillarUnassignMoveProvider} and {@code SubPillarUnassignMoveProvider} (members share a value) + * or {@code MassUnassignMoveProvider} (members need not share anything). + * + * @see ChangeMoveProvider Changing a single entity to a different non-null value too, as one candidate among many. + * @see AssignMoveProvider Assigning a value to currently-unassigned entities. + * @see PillarUnassignMoveProvider Unassigning the whole pillar of every entity sharing a value at once. + * @see SubPillarUnassignMoveProvider A sampler-driven subset of such a pillar. + * @see MassUnassignMoveProvider A mixed-value sample with no shared key. */ @NullMarked public final class UnassignMoveProvider diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java new file mode 100644 index 00000000000..dd9337e209d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java @@ -0,0 +1,56 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.Objects; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +record DefaultRange(Object entity, int fromIndex, int toIndex) + implements + Range { + + DefaultRange { + Objects.requireNonNull(entity, "entity"); + if (fromIndex < 0) { + throw new IllegalArgumentException("The fromIndex (%d) must not be negative." + .formatted(fromIndex)); + } + if (toIndex <= fromIndex) { + throw new IllegalArgumentException("The toIndex (%d) must be greater than the fromIndex (%d)." + .formatted(toIndex, fromIndex)); + } + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof DefaultRange that)) { + return false; + } + return fromIndex == that.fromIndex && toIndex == that.toIndex && entity == that.entity; + } + + @Override + public int hashCode() { + var result = 1; + result = 31 * result + System.identityHashCode(entity); + result = 31 * result + Integer.hashCode(fromIndex); + result = 31 * result + Integer.hashCode(toIndex); + return result; + } + + @Override + public String toString() { + return entity + "[" + fromIndex + ".." + (toIndex - 1) + "]"; + } + + @Override + public int compareTo(Range other) { + var result = Integer.compare(fromIndex, other.fromIndex()); + if (result != 0) { + return result; + } + return Integer.compare(toIndex, other.toIndex()); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java new file mode 100644 index 00000000000..35b7453e3a1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java @@ -0,0 +1,69 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; + +import org.jspecify.annotations.NullMarked; + +/** + * A contiguous span of positions in a list variable, drawn together to be moved as one. + * Unlike {@link Sample}, which is an unordered member set with no key, + * a {@code Range} is positionally identified: two ranges are equal only when they point + * at the same entity and the same {@code [fromIndex, toIndex)} span. + *

    + * A range is immutable and safe to hold in a move, + * but it says nothing about the solution, + * so every value a move needs must be read from the live solution. + * Instances are produced by {@link SubListSampler}. + *

    + * This package and all of its subpackages are part of the Neighborhoods API, + * which is under development and is only offered as a preview feature. + * There are no guarantees for backward compatibility; + * any class, method, or field may change or be removed without prior notice, + * although we will strive to avoid this as much as possible. + */ +@NullMarked +public sealed interface Range extends Comparable + permits DefaultRange { + + /** + * @param entity never null + * @param fromIndex inclusive, 0 or higher + * @param toIndex exclusive, greater than {@code fromIndex} + * @return never null + */ + static Range of(Object entity, int fromIndex, int toIndex) { + return new DefaultRange(entity, fromIndex, toIndex); + } + + /** + * @param from the start of the range, inclusive + * @param length 1 or higher + * @return never null + */ + static Range of(PositionInList from, int length) { + return new DefaultRange(from.entity(), from.index(), from.index() + length); + } + + /** + * @return the entity whose list variable this range points into; never null + */ + Entity_ entity(); + + /** + * @return the index of the first element of this range, inclusive + */ + int fromIndex(); + + /** + * @return the index one past the last element of this range, exclusive + */ + int toIndex(); + + /** + * @return {@link #toIndex()} minus {@link #fromIndex()}; always 1 or higher + */ + default int length() { + return toIndex() - fromIndex(); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java new file mode 100644 index 00000000000..c096a04a612 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -0,0 +1,77 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +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.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws a contiguous span ("sub-list") of a list variable, + * seeded by an assigned value. + * the seed value only picks the entity, + * and a fresh {@code (fromIndex, length)} is then drawn uniformly + * over every admissible sub-list of the entity's unpinned window, + * via {@link TriangleElementFactory}. + *

    + * Caveat: a fully {@code @PlanningPin}-immovable entity reports + * {@link SolutionView#getFirstUnpinnedIndex} as {@code 0} even though its whole list is pinned. + * This drawer is only safe when every seed value comes from an enumeration + * that already excludes immovable entities, + * such as {@link MoveStreamFactory#forEachAssignedValue}. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class SubListSampler { + + private final PlanningListVariableMetaModel variableMetaModel; + private final int minimumSubListSize; + private final TriangleElementFactory triangleElementFactory; + + public SubListSampler(PlanningListVariableMetaModel variableMetaModel, int minimumSubListSize, + int maximumSubListSize, RandomGenerator random) { + this.variableMetaModel = variableMetaModel; + this.minimumSubListSize = minimumSubListSize; + this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, random); + } + + /** + * @param seedValue an assigned value; only used to pick the entity, never anchored to its own position + * @return a freshly drawn span over the entity's whole unpinned window, or {@code null} if that window + * is smaller than the minimum sub-list size + */ + @Nullable + public Range byValue(SolutionView solutionView, Value_ seedValue) { + var position = solutionView.getPositionOf(variableMetaModel, seedValue); + if (!(position instanceof PositionInList assigned)) { + throw new IllegalArgumentException("The seedValue (%s) is not assigned.".formatted(seedValue)); + } + return byEntity(solutionView, assigned.entity()); + } + + /** + * @return a freshly drawn span over the entity's whole unpinned window, + * or {@code null} if that window is smaller than the minimum sub-list size + */ + @Nullable + public Range byEntity(SolutionView solutionView, Entity_ entity) { + var firstUnpinned = solutionView.getFirstUnpinnedIndex(variableMetaModel, entity); + var listSize = solutionView.countValues(variableMetaModel, entity) - firstUnpinned; + if (listSize < minimumSubListSize) { + return null; + } + var element = triangleElementFactory.nextElement(listSize); + var length = listSize - element.level() + 1; + var fromIndex = element.indexOnLevel() - 1 + firstUnpinned; + return Range.of(entity, fromIndex, fromIndex + length); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java index dc88850e5df..0a18ce023f5 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java @@ -2,6 +2,12 @@ * This package contains classes and interfaces that are used to enumerate, sample and combine * facts and entities of a {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} * into {@link ai.timefold.solver.core.preview.api.move.Move}s. + * A drawn set of members with no key is a + * {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample}, + * produced by {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler}; + * a drawn contiguous span of list variable positions is a + * {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range}, + * produced by {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler}. *

    * This package and all of its subpackages are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java index 5fb7b0c035b..d4750150f14 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/RandomSubListSelectorTest.java @@ -3,7 +3,7 @@ import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.phaseStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.solvingStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.stepStarted; -import static ai.timefold.solver.core.impl.heuristic.selector.list.TriangularNumbers.nthTriangle; +import static ai.timefold.solver.core.impl.util.TriangularNumbers.nthTriangle; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getAllowsUnassignedvaluesListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getPinnedListVariableDescriptor; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java index 4d106a2ad16..217e3142622 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListChangeMoveSelectorTest.java @@ -3,7 +3,7 @@ import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.phaseStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.solvingStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.stepStarted; -import static ai.timefold.solver.core.impl.heuristic.selector.list.TriangularNumbers.nthTriangle; +import static ai.timefold.solver.core.impl.util.TriangularNumbers.nthTriangle; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getAllowsUnassignedvaluesListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.listSize; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java index b6f84690b2f..a9eb47b1276 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java @@ -3,7 +3,7 @@ import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.phaseStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.solvingStarted; import static ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils.stepStarted; -import static ai.timefold.solver.core.impl.heuristic.selector.list.TriangularNumbers.nthTriangle; +import static ai.timefold.solver.core.impl.util.TriangularNumbers.nthTriangle; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getAllowsUnassignedvaluesListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.getListVariableDescriptor; import static ai.timefold.solver.core.testdomain.list.TestdataListUtils.listSize; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 4e3fa3db3db..c99eaf54eea 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; @@ -50,6 +51,9 @@ import ai.timefold.solver.core.testdomain.list.TestdataListEntity; import ai.timefold.solver.core.testdomain.list.TestdataListSolution; import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListValue; import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedEntity; import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedOtherValue; import ai.timefold.solver.core.testdomain.mixed.singleentity.TestdataMixedSolution; @@ -795,6 +799,40 @@ void assignValueAndSetFailsWhenValueAlreadyAssigned() { } + @Nested + class Pinning { + + @Test + void getFirstUnpinnedIndexDefaultsToZero() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entity = new TestdataListEntity("A", new TestdataListValue("value1"), new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThat(moveDirector.getFirstUnpinnedIndex(variableMetaModel, entity)).isZero(); + } + + @Test + void getFirstUnpinnedIndexReadsPlanningPinToIndex() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + var value1 = new TestdataPinnedWithIndexListValue("value1"); + var value2 = new TestdataPinnedWithIndexListValue("value2"); + var value3 = new TestdataPinnedWithIndexListValue("value3"); + var entity = new TestdataPinnedWithIndexListEntity("A", value1, value2, value3); + entity.setPinIndex(2); + + var mockScoreDirector = + (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThat(moveDirector.getFirstUnpinnedIndex(variableMetaModel, entity)).isEqualTo(2); + } + + } + @Nested class ValueMove { @@ -1250,6 +1288,193 @@ void moveValueInListRotate() { } + @Nested + class SubListMove { + + @Test + void moveValuesInListForward() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); + + // Move the span [1, 3) (value2, value3) to the end of the (post-removal) list. + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + var movedValues = moveDirector.moveValuesInList(variableMetaModel, entity, 1, 3, 3, false); + assertThat(movedValues).containsExactly(value2, value3); + assertThat(entity.getValueList()).containsExactly(value1, value4, value5, value2, value3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 1, 5); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 1, 5); + + // Undo it. + reset(mockScoreDirector); + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 1, 5); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 1, 5); + } + + @Test + void moveValuesInListBackwardReversing() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); + + // Move the span [3, 5) (value4, value5) to the start of the list, reversed. + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + var movedValues = moveDirector.moveValuesInList(variableMetaModel, entity, 3, 5, 0, true); + assertThat(movedValues).containsExactly(value5, value4); + assertThat(entity.getValueList()).containsExactly(value5, value4, value1, value2, value3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 0, 5); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 0, 5); + + // Undo it. + reset(mockScoreDirector); + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 0, 5); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 0, 5); + } + + @Test + void moveValuesInListThrowsOnEmptySpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entity = new TestdataListEntity("A", new TestdataListValue("value1"), new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy(() -> moveDirector.moveValuesInList(variableMetaModel, entity, 1, 1, 0, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toIndex (1)") + .hasMessageContaining("fromIndex (1)"); + } + + @Test + void moveValuesBetweenLists() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var valueA1 = new TestdataListValue("valueA1"); + var valueA2 = new TestdataListValue("valueA2"); + var valueA3 = new TestdataListValue("valueA3"); + var valueA4 = new TestdataListValue("valueA4"); + var entityA = new TestdataListEntity("A", valueA1, valueA2, valueA3, valueA4); + var valueB1 = new TestdataListValue("valueB1"); + var valueB2 = new TestdataListValue("valueB2"); + var valueB3 = new TestdataListValue("valueB3"); + var entityB = new TestdataListEntity("B", valueB1, valueB2, valueB3); + + // Move the span [1, 3) (valueA2, valueA3) from A into B at index 1. + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + var movedValues = moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 1, 3, entityB, 1, false); + assertThat(movedValues).containsExactly(valueA2, valueA3); + assertThat(entityA.getValueList()).containsExactly(valueA1, valueA4); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityA, 1, 3); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 1); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueA2, valueA3, valueB2, valueB3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityB, 1, 1); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityB, 1, 3); + + // Undo it. + reset(mockScoreDirector); + moveDirector.close(); + assertThat(entityA.getValueList()).containsExactly(valueA1, valueA2, valueA3, valueA4); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueB2, valueB3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityB, 1, 3); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityB, 1, 1); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityA, 1, 1); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 3); + } + + @Test + void moveValuesBetweenListsReversingToEnd() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var valueA1 = new TestdataListValue("valueA1"); + var valueA2 = new TestdataListValue("valueA2"); + var valueA3 = new TestdataListValue("valueA3"); + var entityA = new TestdataListEntity("A", valueA1, valueA2, valueA3); + var valueB1 = new TestdataListValue("valueB1"); + var valueB2 = new TestdataListValue("valueB2"); + var entityB = new TestdataListEntity("B", valueB1, valueB2); + + // Move the span [0, 2) (valueA1, valueA2) from A to the end of B, reversed. + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + var movedValues = moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 0, 2, entityB, 2, true); + assertThat(movedValues).containsExactly(valueA2, valueA1); + assertThat(entityA.getValueList()).containsExactly(valueA3); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueB2, valueA2, valueA1); + + // Undo it. + moveDirector.close(); + assertThat(entityA.getValueList()).containsExactly(valueA1, valueA2, valueA3); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueB2); + } + + @Test + void moveValuesBetweenListsThrowsOnSameEntity() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entity = new TestdataListEntity("A", new TestdataListValue("value1"), new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy(() -> moveDirector.moveValuesBetweenLists(variableMetaModel, entity, 0, 1, entity, 0, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be different"); + } + + @Test + void moveValuesBetweenListsThrowsOnEmptySpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entityA = new TestdataListEntity("A", new TestdataListValue("value1")); + var entityB = new TestdataListEntity("B", new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy( + () -> moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 1, 1, entityB, 0, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toIndex (1)") + .hasMessageContaining("fromIndex (1)"); + } + + } + @Nested class ValueSwap { @@ -1408,6 +1633,199 @@ void swapFirstAndLastValuesBetweenLists() { } + @Nested + class SubListSwap { + + @Test + void swapValuesInListNonAdjacent() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var value6 = new TestdataListValue("value6"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5, value6); + + // Swap the span [1, 3) (value2, value3) with the span [4, 6) (value5, value6). + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + moveDirector.swapValuesInList(variableMetaModel, entity, 1, 3, 4, 6, false); + assertThat(entity.getValueList()).containsExactly(value1, value5, value6, value4, value2, value3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 1, 6); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 1, 6); + + // Undo it. + reset(mockScoreDirector); + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5, value6); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 1, 6); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 1, 6); + } + + @Test + void swapValuesInListAdjacent() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); + + // Swap the touching spans [0, 2) (value1, value2) and [2, 4) (value3, value4). + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + moveDirector.swapValuesInList(variableMetaModel, entity, 0, 2, 2, 4, false); + assertThat(entity.getValueList()).containsExactly(value3, value4, value1, value2, value5); + + // Undo it. + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5); + } + + @Test + void swapValuesInListReversing() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var value6 = new TestdataListValue("value6"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5, value6); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + moveDirector.swapValuesInList(variableMetaModel, entity, 1, 3, 4, 6, true); + assertThat(entity.getValueList()).containsExactly(value1, value6, value5, value4, value3, value2); + + // Undo it. + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5, value6); + } + + @Test + void swapValuesInListThrowsWhenSpansOutOfOrder() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var values = new TestdataListValue[5]; + for (var i = 0; i < 5; i++) { + values[i] = new TestdataListValue("value" + (i + 1)); + } + var entity = new TestdataListEntity("A", values); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy(() -> moveDirector.swapValuesInList(variableMetaModel, entity, 2, 4, 1, 3, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("leftToIndex (4)") + .hasMessageContaining("rightFromIndex (1)"); + } + + @Test + void swapValuesInListThrowsOnEmptySpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var values = new TestdataListValue[4]; + for (var i = 0; i < 4; i++) { + values[i] = new TestdataListValue("value" + (i + 1)); + } + var entity = new TestdataListEntity("A", values); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy(() -> moveDirector.swapValuesInList(variableMetaModel, entity, 1, 1, 2, 3, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void swapValuesBetweenListsDifferingLengths() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var valueA1 = new TestdataListValue("valueA1"); + var valueA2 = new TestdataListValue("valueA2"); + var valueA3 = new TestdataListValue("valueA3"); + var valueA4 = new TestdataListValue("valueA4"); + var entityA = new TestdataListEntity("A", valueA1, valueA2, valueA3, valueA4); + var valueB1 = new TestdataListValue("valueB1"); + var valueB2 = new TestdataListValue("valueB2"); + var valueB3 = new TestdataListValue("valueB3"); + var entityB = new TestdataListEntity("B", valueB1, valueB2, valueB3); + + // Swap the span [1, 3) (valueA2, valueA3) on A with the span [0, 1) (valueB1) on B. + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + moveDirector.swapValuesBetweenLists(variableMetaModel, entityA, 1, 3, entityB, 0, 1, false); + assertThat(entityA.getValueList()).containsExactly(valueA1, valueB1, valueA4); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityA, 1, 3); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 2); + assertThat(entityB.getValueList()).containsExactly(valueA2, valueA3, valueB2, valueB3); + verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityB, 0, 1); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityB, 0, 2); + + // Undo it: the ranges close back to the ones from the very first notification for each entity. + reset(mockScoreDirector); + moveDirector.close(); + assertThat(entityA.getValueList()).containsExactly(valueA1, valueA2, valueA3, valueA4); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueB2, valueB3); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 3); + verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityB, 0, 1); + } + + @Test + void swapValuesBetweenListsThrowsOnSameEntity() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entity = new TestdataListEntity("A", new TestdataListValue("value1"), new TestdataListValue("value2"), + new TestdataListValue("value3"), new TestdataListValue("value4")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy( + () -> moveDirector.swapValuesBetweenLists(variableMetaModel, entity, 0, 1, entity, 2, 3, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be different") + .hasMessageContaining("swapValuesInList"); + } + + @Test + void swapValuesBetweenListsThrowsOnEmptySpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entityA = new TestdataListEntity("A", new TestdataListValue("value1")); + var entityB = new TestdataListEntity("B", new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy( + () -> moveDirector.swapValuesBetweenLists(variableMetaModel, entityA, 0, 0, entityB, 0, 1, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + } + @Nested class ValueReplace { @@ -1540,6 +1958,63 @@ void replaceValueBetweenLists() { } + @Nested + class SubListUnassign { + + @Test + void unassignValuesRemovesSpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var value4 = new TestdataListValue("value4"); + var value5 = new TestdataListValue("value5"); + var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); + + // Unassign the span [1, 4) (value2, value3, value4). + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + var unassignedValues = moveDirector.unassignValues(variableMetaModel, entity, 1, 4); + assertThat(unassignedValues).containsExactly(value2, value3, value4); + assertThat(entity.getValueList()).containsExactly(value1, value5); + var inOrder = inOrder(mockScoreDirector); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, value2); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, value3); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, value4); + inOrder.verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entity, 1, 4); + inOrder.verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 1, 1); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, value2); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, value3); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, value4); + + // Undo it. + moveDirector.close(); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4, value5); + } + + @Test + void unassignValuesThrowsOnEmptySpan() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var entity = new TestdataListEntity("A", new TestdataListValue("value1"), new TestdataListValue("value2")); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + assertThatThrownBy(() -> moveDirector.unassignValues(variableMetaModel, entity, 1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toIndex (1)") + .hasMessageContaining("fromIndex (1)"); + } + + } + @Test void lookUpWorkingObject() { var mockScoreDirector = mock(InnerScoreDirector.class); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java new file mode 100644 index 00000000000..fa9b476935c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java @@ -0,0 +1,139 @@ +package ai.timefold.solver.core.impl.neighborhood.bias; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Random; + +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.move.MoveDirector; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEasyScoreCalculator; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; + +import org.junit.jupiter.api.Test; + +/** + * {@code SubListSampler} composes {@code TriangleElementFactory}'s copied triangle bias with the + * pinned-prefix offset and the value-seeded entity choice. The triangle math itself is copied + * verbatim from the legacy selector and is not retested here; what is new is the composition, and + * only a direct statistical test proves the offset and the seed-to-entity mapping did not + * introduce a bias of their own. Drives {@link SubListSampler} directly, against a + * {@link MoveDirector} built from a real score director, so no move provider or + * {@code NeighborhoodTester} is involved. + */ +class SubListSamplingBiasIT extends AbstractBiasIT { + + private static final int TRIAL_COUNT = 200_000; + + /** + * One entity, list size 7, minimum 2, maximum 5: the worked example in + * {@code TriangleElementFactoryTest} (nthTriangle(6) - nthTriangle(2) = 21 - 3 = 18 admissible + * (fromIndex, length) pairs, each expected to be drawn with equal probability). + */ + @Test + void drawnSpanIsUniformOverEveryAdmissiblePair() { + var solutionDescriptor = TestdataAllowsUnassignedValuesListSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[7]; + for (var i = 0; i < 7; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var scoreDirectorFactory = new EasyScoreDirectorFactory<>(solutionDescriptor, + new TestdataAllowsUnassignedValuesListEasyScoreCalculator(), EnvironmentMode.PHASE_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var solutionView = new MoveDirector<>(scoreDirector); + + var sampler = new SubListSampler<>(variableMetaModel, 2, 5, new Random(0)); + var seedValue = values[0]; + + var expectedPairSet = new HashSet(); + for (var length = 2; length <= 5; length++) { + for (var fromIndex = 0; fromIndex <= 7 - length; fromIndex++) { + expectedPairSet.add(fromIndex + ".." + (fromIndex + length)); + } + } + assertThat(expectedPairSet).hasSize(18); + + BiasReport.tally("SubListSampler, uniform over (fromIndex, length)", TRIAL_COUNT, trial -> { + var range = Objects.requireNonNull(sampler.byValue(solutionView, seedValue)); + return range.fromIndex() + ".." + range.toIndex(); + }).expectUniform(expectedPairSet).assertWithinSigma(SIGMA_LIMIT); + } + + /** + * Three entities with unpinned windows of 2, 3 and 5 values (10 total), minimum sub-list size 1 + * so every draw succeeds and cannot skew the tally: proves the value-seeded entity choice is + * weighted by unpinned size, exactly as {@code RandomSubListSelector}'s own javadoc documents + * for the legacy selector ("entities with bigger list variables will be selected more often"). + * The seed value is drawn uniformly over every assigned value, mirroring how + * {@code MoveStreamFactory#forEachAssignedValue} feeds a real move provider. + */ + @Test + void drawnEntityIsWeightedByUnpinnedSize() { + var solutionDescriptor = TestdataAllowsUnassignedValuesListSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var smallValues = newValues("s", 2); + var smallEntity = new TestdataAllowsUnassignedValuesListEntity("small", smallValues); + var mediumValues = newValues("m", 3); + var mediumEntity = new TestdataAllowsUnassignedValuesListEntity("medium", mediumValues); + var largeValues = newValues("l", 5); + var largeEntity = new TestdataAllowsUnassignedValuesListEntity("large", largeValues); + + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(smallEntity, mediumEntity, largeEntity)); + var allValues = new ArrayList(); + allValues.addAll(List.of(smallValues)); + allValues.addAll(List.of(mediumValues)); + allValues.addAll(List.of(largeValues)); + solution.setValueList(allValues); + + var scoreDirectorFactory = new EasyScoreDirectorFactory<>(solutionDescriptor, + new TestdataAllowsUnassignedValuesListEasyScoreCalculator(), EnvironmentMode.PHASE_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var solutionView = new MoveDirector<>(scoreDirector); + + var sampler = new SubListSampler<>(variableMetaModel, 1, Integer.MAX_VALUE, new Random(0)); + var seedPicker = new Random(1); + + var weightByEntity = new HashMap(); + weightByEntity.put(smallEntity, 0.2); + weightByEntity.put(mediumEntity, 0.3); + weightByEntity.put(largeEntity, 0.5); + + BiasReport.tally("SubListSampler, entity weighted by unpinned size", TRIAL_COUNT, trial -> { + var seedValue = allValues.get(seedPicker.nextInt(allValues.size())); + var range = Objects.requireNonNull(sampler.byValue(solutionView, seedValue)); + return range. entity(); + }).expectWeights(weightByEntity).assertWithinSigma(SIGMA_LIMIT); + } + + private static TestdataAllowsUnassignedValuesListValue[] newValues(String prefix, int count) { + var values = new TestdataAllowsUnassignedValuesListValue[count]; + for (var i = 0; i < count; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue(prefix + i); + } + return values; + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java similarity index 77% rename from core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactoryTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java index 196be410f96..ae026ed55bf 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangleElementFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.heuristic.selector.list; +package ai.timefold.solver.core.impl.util; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -7,7 +7,7 @@ import java.util.Random; import java.util.stream.Stream; -import ai.timefold.solver.core.impl.heuristic.selector.list.TriangleElementFactory.TriangleElement; +import ai.timefold.solver.core.impl.util.TriangleElementFactory.TriangleElement; import ai.timefold.solver.core.testutil.TestRandom; import org.junit.jupiter.api.Test; @@ -34,7 +34,7 @@ static Stream triangleElementValueOf() { @ParameterizedTest @MethodSource void triangleElementValueOf(int index, int level, int indexOnLevel) { - TriangleElement triangleElement = TriangleElement.valueOf(index); + var triangleElement = TriangleElement.valueOf(index); assertThat(triangleElement.index()).isEqualTo(index); assertThat(triangleElement.level()).isEqualTo(level); assertThat(triangleElement.indexOnLevel()).isEqualTo(indexOnLevel); @@ -42,28 +42,28 @@ void triangleElementValueOf(int index, int level, int indexOnLevel) { @Test void nextElement() { - final int listSize = 7; - int subListCount = listSize * (listSize + 1) / 2; + var listSize = 7; + var subListCount = listSize * (listSize + 1) / 2; assertThat(subListCount).isEqualTo(28); // There is 1 subList of size 7 and 2 subLists of size 6. - final int maxSize = 5; + var maxSize = 5; subListCount -= 3; // There are 7 subLists of size 1. - final int minSize = 2; + var minSize = 2; subListCount -= 7; assertThat(subListCount).isEqualTo(18); - TestRandom testRandom = new TestRandom(0, subListCount - 1); - TriangleElementFactory factory = new TriangleElementFactory(minSize, maxSize, testRandom); + var testRandom = new TestRandom(0, subListCount - 1); + var factory = new TriangleElementFactory(minSize, maxSize, testRandom); - TriangleElement first = factory.nextElement(listSize); + var first = factory.nextElement(listSize); testRandom.assertIntBoundJustRequested(subListCount); assertThat(first.index()).isEqualTo(4); // Triangle element index. assertThat(first.level()).isEqualTo(3); // 3rd level, there are 3 subLists of size 5. assertThat(first.indexOnLevel()).isEqualTo(1); // It's the 1st element on level 3. - TriangleElement last = factory.nextElement(listSize); + var last = factory.nextElement(listSize); testRandom.assertIntBoundJustRequested(subListCount); assertThat(last.index()).isEqualTo(21); // Triangle element index. assertThat(last.level()).isEqualTo(6); // 6th level, there are 6 subLists of size 2. @@ -80,8 +80,8 @@ void constructor_invalidBounds() { @Test void nextElement_invalidListSize() { - int minSize = 5; - TriangleElementFactory factory = new TriangleElementFactory(minSize, minSize + 1, new Random()); + var minSize = 5; + var factory = new TriangleElementFactory(minSize, minSize + 1, new Random()); assertThatIllegalArgumentException().isThrownBy(() -> factory.nextElement(minSize - 1)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbersTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangularNumbersTest.java similarity index 97% rename from core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbersTest.java rename to core/src/test/java/ai/timefold/solver/core/impl/util/TriangularNumbersTest.java index 7bbff65b541..0b5010bc0a6 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/list/TriangularNumbersTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangularNumbersTest.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.impl.heuristic.selector.list; +package ai.timefold.solver.core.impl.util; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProviderTest.java index 1662c4de558..d3750d24c4d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMoveProviderTest.java @@ -1,5 +1,9 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; @@ -100,9 +104,9 @@ void fromEntityAllowsUnassigned() { e2.getValueList().add(v1); // v1 is assigned to e2 and can be moved to e1, because it's in the range for both. - // Unassign moves are not generated by ListChangeMoveProvider. + // crossingNull=false: unassign and assign moves are not generated. // v2 and v3 are unassigned; assign moves are not generated by ListChangeMoveProvider. - var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, e2, 0, e1, 0)); context.producesNoneOf( @@ -123,9 +127,9 @@ void fromSolutionAllowsUnassigned() { SolutionManager.updateShadowVariables(solution); // v1 is assigned to e2 and can be moved to e1. - // Unassign moves are not generated by ListChangeMoveProvider. + // crossingNull=false: unassign and assign moves are not generated. // v2 is unassigned; assign moves are not generated by ListChangeMoveProvider. - var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, e2, 0, e1, 0)); context.producesNoneOf( @@ -133,4 +137,69 @@ void fromSolutionAllowsUnassigned() { Moves.change(variableMetaModel, e2, 0, e2, 1)); } + @Test + void crossingNullDefaultsToAllowsUnassignedValues() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable(); + + assertThat(variableMetaModel.allowsUnassignedValues()).isTrue(); + // The single-argument constructor defaults crossingNull to the variable's own allowsUnassignedValues(), + // mirroring ChangeMoveProvider's own constructor exactly. + assertThatCode(() -> new ListChangeMoveProvider<>(variableMetaModel, true)).doesNotThrowAnyException(); + } + + @Test + void crossingNullTrueRequiresAllowsUnassignedValues() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable(); + + assertThat(variableMetaModel.allowsUnassignedValues()).isFalse(); + assertThatThrownBy(() -> new ListChangeMoveProvider<>(variableMetaModel, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("crossingNull"); + } + + @Test + void crossingNullTrueProducesAssignAndUnassign() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable(); + var solution = TestdataAllowsUnassignedValuesListSolution.generateUninitializedSolution(2, 2); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var assignedValue = solution.getValueList().get(0); + var unassignedValue = solution.getValueList().get(1); + e1.getValueList().add(assignedValue); + SolutionManager.updateShadowVariables(solution); + + // crossingNull=true: the assigned value can be unassigned, and the unassigned value can be assigned. + var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel, true), solutionMetaModel) + .using(solution); + context.producesAllOf( + Moves.unassign(variableMetaModel, e1, 0), + Moves.assign(variableMetaModel, unassignedValue, e2, 0)); + } + + @Test + void crossingNullFalseProducesNeitherAssignNorUnassign() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable(); + var solution = TestdataAllowsUnassignedValuesListSolution.generateUninitializedSolution(2, 2); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var assignedValue = solution.getValueList().get(0); + var unassignedValue = solution.getValueList().get(1); + e1.getValueList().add(assignedValue); + SolutionManager.updateShadowVariables(solution); + + var context = NeighborhoodTester.build(new ListChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) + .using(solution); + context.producesNoneOf( + Moves.unassign(variableMetaModel, e1, 0), + Moves.assign(variableMetaModel, unassignedValue, e2, 0)); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java index e8c1afaa2f9..20776676b45 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import static org.assertj.core.api.Assertions.assertThat; + import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; @@ -8,6 +10,8 @@ import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListSolution; import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingEntity; import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingSolution; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingSolution; import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; @@ -52,11 +56,16 @@ void fromSolution() { e2.getValueList().add(assignedValue3); SolutionManager.updateShadowVariables(solution); - // We have 4 values. One is unassigned, therefore isn't included in the swaps. - // Three other values can be mutually swapped, each pair produced in both directions: + var unassignedValue = solution.getValueList().getFirst(); + + // Three assigned values can be mutually swapped, each pair produced in both directions: // - assignedValue1 (e1@0) <-> assignedValue2 (e2@0) // - assignedValue1 (e1@0) <-> assignedValue3 (e2@1) // - assignedValue2 (e2@0) <-> assignedValue3 (e2@1), within e2 + // The fourth value is unassigned: it swaps against every assigned position too, as a + // composite unassign-then-assign at that same position (this domain disallows unassigned + // values, but nothing in the raw state prevents this - real solving would never leave it + // unassigned in the first place, so the composite would never actually arise in practice). NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) .using(solution) .producesAllOf( @@ -65,7 +74,70 @@ void fromSolution() { Moves.swap(variableMetaModel, e1, 0, e2, 1), Moves.swap(variableMetaModel, e2, 1, e1, 0), Moves.swap(variableMetaModel, e2, 0, e2, 1), - Moves.swap(variableMetaModel, e2, 1, e2, 0)); + Moves.swap(variableMetaModel, e2, 1, e2, 0), + Moves.compose( + Moves.unassign(variableMetaModel, e1, 0), + Moves.assign(variableMetaModel, unassignedValue, e1, 0)), + Moves.compose( + Moves.unassign(variableMetaModel, e2, 0), + Moves.assign(variableMetaModel, unassignedValue, e2, 0)), + Moves.compose( + Moves.unassign(variableMetaModel, e2, 1), + Moves.assign(variableMetaModel, unassignedValue, e2, 1))); + } + + @Test + void crossingNullProducesCompositeWhenValueInRange() { + var solutionMetaModel = TestdataListUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListUnassignedEntityProvidingEntity.class) + .listVariable(); + var solution = TestdataListUnassignedEntityProvidingSolution.generateSolution(); // e1:[v1,v2], e2:[v1,v3] + var e2 = solution.getEntityList().get(1); + var v1 = e2.getValueRange().get(0); // Shared with e1; will be assigned to e2. + var v3 = e2.getValueRange().get(1); // In e2's own range; stays unassigned. + e2.getValueList().add(v1); + SolutionManager.updateShadowVariables(solution); + + // v1 is assigned at e2@0. v3 is unassigned but within e2's own range, so it may take v1's place. + NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution) + .producesAllOf(Moves.compose( + Moves.unassign(variableMetaModel, e2, 0), + Moves.assign(variableMetaModel, v3, e2, 0))); + } + + @Test + void crossingNullSkipsWhenValueOutOfRange() { + var solutionMetaModel = TestdataListUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListUnassignedEntityProvidingEntity.class) + .listVariable(); + var solution = TestdataListUnassignedEntityProvidingSolution.generateSolution(); // e1:[v1,v2], e2:[v1,v3] + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var v1 = e2.getValueRange().getFirst(); + var v2 = e1.getValueRange().get(1); // In e1's range only, not e2's. + e2.getValueList().add(v1); + SolutionManager.updateShadowVariables(solution); + + // v2 is unassigned, but out of range for e2 - no composite is produced for it. + NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution) + .producesNoneOf(Moves.compose( + Moves.unassign(variableMetaModel, e2, 0), + Moves.assign(variableMetaModel, v2, e2, 0))); + } + + @Test + void bothUnassignedProducesNoMoves() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable(); + var solution = TestdataListSolution.generateUninitializedSolution(2, 2); // Nothing assigned. + + // Every value is unassigned: a swap needs at least one assigned side. + var context = NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + assertThat(context.getMovesAsStream()).isEmpty(); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java new file mode 100644 index 00000000000..c738138704d --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java @@ -0,0 +1,171 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.List; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassListAssignMoveProviderTest { + + @Test + void constructorRejectsNonUnassignedVariable() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2))); + } + + @Test + void sampleMembersAreAlwaysASubsetOfTheUnassignedValues() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var assigned = new TestdataAllowsUnassignedValuesListValue("assigned"); + var unassigned1 = new TestdataAllowsUnassignedValuesListValue("unassigned1"); + var unassigned2 = new TestdataAllowsUnassignedValuesListValue("unassigned2"); + var entity = new TestdataAllowsUnassignedValuesListEntity("A", assigned); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(assigned, unassigned1, unassigned2)); + + var context = NeighborhoodTester + .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getDestination()).isNotNull(); + for (var member : move.getSample()) { + assertThat(member).isIn(unassigned1, unassigned2).isNotEqualTo(assigned); + } + } + } + + @Test + void destinationRespectsEveryMembersValueRange() { + var solutionMetaModel = TestdataListUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListUnassignedEntityProvidingEntity.class) + .listVariable("valueList", TestdataValue.class); + + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var entityA = new TestdataListUnassignedEntityProvidingEntity("A", List.of(v1, v2)); + var entityB = new TestdataListUnassignedEntityProvidingEntity("B", List.of(v1)); + var solution = new TestdataListUnassignedEntityProvidingSolution(); + solution.setEntityList(List.of(entityA, entityB)); + + var context = NeighborhoodTester + .build(new MassListAssignMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); + for (var member : move.getSample()) { + assertThat(destinationEntity.getValueRange()).contains(member); + } + } + } + + @Test + void destinationNeverFallsInThePinnedPrefix() { + var solutionMetaModel = TestdataPinnedUnassignedValuesListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedUnassignedValuesListValue.class); + + var pinned1 = new TestdataPinnedUnassignedValuesListValue("pinned1"); + var pinned2 = new TestdataPinnedUnassignedValuesListValue("pinned2"); + var free1 = new TestdataPinnedUnassignedValuesListValue("free1"); + var unassigned1 = new TestdataPinnedUnassignedValuesListValue("unassigned1"); + var unassigned2 = new TestdataPinnedUnassignedValuesListValue("unassigned2"); + var entity = new TestdataPinnedUnassignedValuesListEntity("A", pinned1, pinned2, free1); + entity.setPlanningPinToIndex(2); + var solution = new TestdataPinnedUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(pinned1, pinned2, free1, unassigned1, unassigned2)); + + var context = NeighborhoodTester + .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + assertThat(destination.index()).isGreaterThanOrEqualTo(2); + } + } + + @Test + void fullyPinnedEntityIsNeverAssignedInto() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedAllowsUnassignedValuesListValue.class); + + var pinnedValues = List.of(new TestdataPinnedAllowsUnassignedValuesListValue("p0")); + var pinnedEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("pinned", pinnedValues); + pinnedEntity.setPinned(true); + var freeEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("free"); + var unassigned1 = new TestdataPinnedAllowsUnassignedValuesListValue("u1"); + var unassigned2 = new TestdataPinnedAllowsUnassignedValuesListValue("u2"); + + var solution = new TestdataPinnedAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + solution.setValueList(List.of(pinnedValues.get(0), unassigned1, unassigned2)); + + var context = NeighborhoodTester + .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + assertThat(destination. entity()).isNotEqualTo(pinnedEntity); + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java new file mode 100644 index 00000000000..9b0f09aebdd --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java @@ -0,0 +1,325 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.unassignedvar.TestdataListUnassignedEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassListChangeMoveProviderTest { + + @Test + void sizeOneSampleProducesNoMove() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[5]; + for (var i = 0; i < 5; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream().limit(10).toList(); + assertThat(moves).isEmpty(); + } + + @Test + void sampleNeverIncludesAnUnassignedValue() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var assigned1 = new TestdataAllowsUnassignedValuesListValue("assigned1"); + var assigned2 = new TestdataAllowsUnassignedValuesListValue("assigned2"); + var unassigned = new TestdataAllowsUnassignedValuesListValue("unassigned"); + var entity = new TestdataAllowsUnassignedValuesListEntity("A", assigned1, assigned2); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(assigned1, assigned2, unassigned)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + for (var member : move.getSample()) { + assertThat(member).isNotEqualTo(unassigned); + } + } + } + + @Test + void destinationRespectsEveryMembersValueRange() { + var solutionMetaModel = TestdataListUnassignedEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListUnassignedEntityProvidingEntity.class) + .listVariable("valueList", TestdataValue.class); + + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + // entityA's range admits both values, so any destination on entityA is legal for a sample drawn + // from entityA and/or entityB (v1 is shared). + var entityA = new TestdataListUnassignedEntityProvidingEntity("A", List.of(v1, v2), List.of(v1, v2)); + var entityB = new TestdataListUnassignedEntityProvidingEntity("B", List.of(v1)); + var solution = new TestdataListUnassignedEntityProvidingSolution(); + solution.setEntityList(List.of(entityA, entityB)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getDestination(); + if (destination == null) { + continue; + } + TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); + for (var member : move.getSample()) { + assertThat(destinationEntity.getValueRange()).contains(member); + } + } + } + + @Test + void crossingNullDefaultTrueAlsoUnassignsWholeSample() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(1000) + .toList(); + assertThat(moves).anyMatch(move -> move.getDestination() == null); + } + + @Test + void crossingNullFalseNeverUnassignsSample() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2), false), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(500) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(move -> move.getDestination() == null); + } + + @Test + void constructorRejectsExplicitCrossingNullOnVariableWithoutUnassignedValues() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + } + + @Test + void differentDrawsProduceDifferentSamples() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(3)), solutionMetaModel) + .using(solution); + + var distinctSamples = context + .getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .map(move -> move.getSample().toString()) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctSamples).hasSizeGreaterThan(1); + } + + @Test + void pinnedValueNeverJoinsASample() { + var solutionMetaModel = TestdataPinnedUnassignedValuesListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedUnassignedValuesListValue.class); + + var pinned1 = new TestdataPinnedUnassignedValuesListValue("pinned1"); + var pinned2 = new TestdataPinnedUnassignedValuesListValue("pinned2"); + var free1 = new TestdataPinnedUnassignedValuesListValue("free1"); + var free2 = new TestdataPinnedUnassignedValuesListValue("free2"); + var entity = new TestdataPinnedUnassignedValuesListEntity("A", pinned1, pinned2, free1, free2); + entity.setPlanningPinToIndex(2); + var solution = new TestdataPinnedUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(pinned1, pinned2, free1, free2)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + for (var member : move.getSample()) { + assertThat(member).isNotIn(pinned1, pinned2); + } + } + } + + @Test + void destinationNeverFallsInThePinnedPrefix() { + var solutionMetaModel = TestdataPinnedUnassignedValuesListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedUnassignedValuesListValue.class); + + var pinned1 = new TestdataPinnedUnassignedValuesListValue("pinned1"); + var pinned2 = new TestdataPinnedUnassignedValuesListValue("pinned2"); + var free1 = new TestdataPinnedUnassignedValuesListValue("free1"); + var free2 = new TestdataPinnedUnassignedValuesListValue("free2"); + var entity = new TestdataPinnedUnassignedValuesListEntity("A", pinned1, pinned2, free1, free2); + entity.setPlanningPinToIndex(2); + var solution = new TestdataPinnedUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(pinned1, pinned2, free1, free2)); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var destination = move.getDestination(); + if (destination != null) { + assertThat(destination.index()).isGreaterThanOrEqualTo(2); + } + } + } + + @Test + void fullyPinnedEntityIsNeverDrawnFromOrInto() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedAllowsUnassignedValuesListValue.class); + + var pinnedValues = new TestdataPinnedAllowsUnassignedValuesListValue[3]; + for (var i = 0; i < 3; i++) { + pinnedValues[i] = new TestdataPinnedAllowsUnassignedValuesListValue("p" + i); + } + var pinnedEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("pinned", List.of(pinnedValues)); + pinnedEntity.setPinned(true); + + var freeValues = new TestdataPinnedAllowsUnassignedValuesListValue[5]; + for (var i = 0; i < 5; i++) { + freeValues[i] = new TestdataPinnedAllowsUnassignedValuesListValue("f" + i); + } + var freeEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("free", List.of(freeValues)); + + var solution = new TestdataPinnedAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + var allValues = new ArrayList(); + allValues.addAll(List.of(pinnedValues)); + allValues.addAll(List.of(freeValues)); + solution.setValueList(allValues); + + var context = NeighborhoodTester + .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(100) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + for (var member : move.getSample()) { + assertThat(pinnedEntity.getValueList()).doesNotContain(member); + } + var destination = move.getDestination(); + if (destination != null) { + assertThat(destination. entity()).isNotEqualTo(pinnedEntity); + } + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java new file mode 100644 index 00000000000..420395edefc --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java @@ -0,0 +1,230 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; + +import org.junit.jupiter.api.Test; + +class MassListChangeMoveTest { + + @Test + void executeGathersEveryMemberConsecutivelyAtDestination() { + var a0 = new TestdataListValue("a0"); + var a1 = new TestdataListValue("a1"); + var b0 = new TestdataListValue("b0"); + var b1 = new TestdataListValue("b1"); + var c0 = new TestdataListValue("c0"); + var c1 = new TestdataListValue("c1"); + var c2 = new TestdataListValue("c2"); + var entityA = new TestdataListEntity("A", a0, a1); + var entityB = new TestdataListEntity("B", b0, b1); + var entityC = new TestdataListEntity("C", c0, c1, c2); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB, entityC)); + solution.setValueList(List.of(a0, a1, b0, b1, c0, c1, c2)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + // Draw order a1, b0 - the destination gathers them in that order, not sorted by origin. + var sample = Sample.of(List.of(a1, b0)); + var move = Moves.massChange(variableMetaModel, sample, ElementPosition.of(entityC, 1)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entityA.getValueList()).containsExactly(a0); + assertThat(entityB.getValueList()).containsExactly(b1); + assertThat(entityC.getValueList()).containsExactly(c0, a1, b0, c1, c2); + } + + @Test + void destinationIndexIsAdjustedOnlyByMembersOriginallyBeforeIt() { + // A single-pass (mutate-while-reading) implementation double-counts c and d (which sit AT and + // AFTER the destination index, not before it) once a has already been removed and their live + // indices have shifted down: it would count all three of a, c, d as "before" (removedBeforeDestination + // = 3), driving the adjusted index to -1 - an out-of-bounds insertion. The two-pass fix reads every + // member's ORIGINAL position first, correctly counting only a (removedBeforeDestination = 1), + // and produces a real, verifiable rearrangement rather than a coincidental no-op: b - the one + // untouched value before the destination - ends up first, with a, c, d gathered right after it. + var a = new TestdataListValue("a"); + var b = new TestdataListValue("b"); + var c = new TestdataListValue("c"); + var d = new TestdataListValue("d"); + var e = new TestdataListValue("e"); + var entity = new TestdataListEntity("C", a, b, c, d, e); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(a, b, c, d, e)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var sample = Sample.of(List.of(a, c, d)); + var move = Moves.massChange(variableMetaModel, sample, ElementPosition.of(entity, 2)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(b, a, c, d, e); + } + + @Test + void executeAssignsCurrentlyUnassignedMembersToo() { + var assigned = new TestdataAllowsUnassignedValuesListValue("assigned"); + var unassigned = new TestdataAllowsUnassignedValuesListValue("unassigned"); + var entityA = new TestdataAllowsUnassignedValuesListEntity("A", assigned); + var entityB = new TestdataAllowsUnassignedValuesListEntity("B"); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + solution.setValueList(List.of(assigned, unassigned)); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var sample = Sample.of(List.of(unassigned, assigned)); + var move = Moves.massChange(variableMetaModel, sample, ElementPosition.of(entityB, 0)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entityA.getValueList()).isEmpty(); + assertThat(entityB.getValueList()).containsExactly(unassigned, assigned); + } + + @Test + void executeToNullDestinationUnassignsEveryMember() { + var value1 = new TestdataAllowsUnassignedValuesListValue("v1"); + var value2 = new TestdataAllowsUnassignedValuesListValue("v2"); + var entity = new TestdataAllowsUnassignedValuesListEntity("A", value1, value2); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2)); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var sample = Sample.of(List.of(value1, value2)); + var move = Moves.massChange(variableMetaModel, sample, null); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).isEmpty(); + } + + @Test + void undoRestoresEveryMembersOriginalPosition() { + var a0 = new TestdataListValue("a0"); + var a1 = new TestdataListValue("a1"); + var b0 = new TestdataListValue("b0"); + var entityA = new TestdataListEntity("A", a0, a1); + var entityB = new TestdataListEntity("B", b0); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + solution.setValueList(List.of(a0, a1, b0)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var sample = Sample.of(List.of(a1)); + var move = Moves.massChange(variableMetaModel, sample, ElementPosition.of(entityB, 0)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(move, + view -> assertThat(entityB.getValueList()).containsExactly(a1, b0)); + + assertThat(entityA.getValueList()).containsExactly(a0, a1); + assertThat(entityB.getValueList()).containsExactly(b0); + } + + @Test + void equalsAndHashCodeIgnoreMemberDrawOrder() { + var value1 = new TestdataListValue("v1"); + var value2 = new TestdataListValue("v2"); + var entity = new TestdataListEntity("A", value1, value2); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var destination = ElementPosition.of(entity, 0); + var otherDestination = ElementPosition.of(entity, 1); + var move1 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), destination); + var move2 = Moves.massChange(variableMetaModel, Sample.of(List.of(value2, value1)), destination); + var move3 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), otherDestination); + var move4 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), null); + + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isNotEqualTo(move3); + assertThat(move1).isNotEqualTo(move4); + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void rebaseReturnsMoveWhoseSampleHoldsWorkingSolutionCopies() { + var value1 = new TestdataListValue("v1"); + var value2 = new TestdataListValue("v2"); + var rebasedValue1 = new TestdataListValue("v1"); + var rebasedValue2 = new TestdataListValue("v2"); + var entity = new TestdataListEntity("A", value1, value2); + var rebasedEntity = new TestdataListEntity("A", rebasedValue1, rebasedValue2); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var originalMove = + (MassListChangeMove) Moves.massChange( + variableMetaModel, Sample.of(List.of(value1, value2)), ElementPosition.of(entity, 0)); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == value1) { + return (T) rebasedValue1; + } else if (object == value2) { + return (T) rebasedValue2; + } else if (object == entity) { + return (T) rebasedEntity; + } + return object; + } + }); + + var rebasedSample = new ArrayList<>(); + for (var value : rebasedMove.getSample()) { + rebasedSample.add(value); + } + assertThat(rebasedSample).containsExactlyInAnyOrder(rebasedValue1, rebasedValue2); + assertThat(rebasedMove.getDestination()).isNotNull(); + assertThat(rebasedMove.getDestination(). entity()).isEqualTo(rebasedEntity); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java new file mode 100644 index 00000000000..4ded43f0f83 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java @@ -0,0 +1,165 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.unassignedvar.TestdataPinnedAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListValue; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class MassListUnassignMoveProviderTest { + + @Test + void constructorRejectsNonUnassignedVariable() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2))); + } + + @Test + void everyMoveHasANullDestination() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(3)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getDestination()).isNull(); + } + } + + @Test + void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + // sizeLimit(1) stops right at the seed, so every sample this sampler produces is size-1; + // the provider must not treat that as a rejection, or the iterator would spin (or run dry) instead + // of steadily producing moves. + var context = NeighborhoodTester + .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream().limit(50).toList(); + assertThat(moves).hasSize(50); + } + + @Test + void pinnedValueNeverJoinsASample() { + var solutionMetaModel = TestdataPinnedUnassignedValuesListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedUnassignedValuesListValue.class); + + var pinned1 = new TestdataPinnedUnassignedValuesListValue("pinned1"); + var pinned2 = new TestdataPinnedUnassignedValuesListValue("pinned2"); + var free1 = new TestdataPinnedUnassignedValuesListValue("free1"); + var free2 = new TestdataPinnedUnassignedValuesListValue("free2"); + var entity = new TestdataPinnedUnassignedValuesListEntity("A", pinned1, pinned2, free1, free2); + entity.setPlanningPinToIndex(2); + var solution = new TestdataPinnedUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(pinned1, pinned2, free1, free2)); + + var context = NeighborhoodTester + .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + for (var member : move.getSample()) { + assertThat(member).isNotIn(pinned1, pinned2); + } + } + } + + @Test + void fullyPinnedEntityContributesNoMembers() { + var solutionMetaModel = TestdataPinnedAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedAllowsUnassignedValuesListValue.class); + + var pinnedValues = List.of(new TestdataPinnedAllowsUnassignedValuesListValue("p0"), + new TestdataPinnedAllowsUnassignedValuesListValue("p1")); + var pinnedEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("pinned", pinnedValues); + pinnedEntity.setPinned(true); + + var freeValues = List.of(new TestdataPinnedAllowsUnassignedValuesListValue("f0"), + new TestdataPinnedAllowsUnassignedValuesListValue("f1"), + new TestdataPinnedAllowsUnassignedValuesListValue("f2")); + var freeEntity = new TestdataPinnedAllowsUnassignedValuesListEntity("free", freeValues); + + var solution = new TestdataPinnedAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + var allValues = new ArrayList(); + allValues.addAll(pinnedValues); + allValues.addAll(freeValues); + solution.setValueList(allValues); + + var context = NeighborhoodTester + .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassListChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + for (var member : move.getSample()) { + assertThat(pinnedValues).doesNotContain(member); + } + } + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java new file mode 100644 index 00000000000..3ead1ac8d88 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java @@ -0,0 +1,289 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubListChangeMoveProviderTest { + + @Test + void drawnSpanLengthsRespectMinAndMax() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 2, 5), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getSource().length()).isBetween(2, 5); + } + } + + @Test + void drawnSpanNeverTouchesPinnedPrefix() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + + var values = new TestdataPinnedWithIndexListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataPinnedWithIndexListValue("v" + i); + } + var entity = new TestdataPinnedWithIndexListEntity("A", values); + entity.setPinIndex(3); + var solution = new TestdataPinnedWithIndexListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3); + assertThat(move.getDestination().index()).isGreaterThanOrEqualTo(3); + } + } + + @Test + void fullyPinnedEntityNeverDrawnFromOrInto() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + + var pinnedValues = new TestdataPinnedWithIndexListValue[3]; + for (var i = 0; i < 3; i++) { + pinnedValues[i] = new TestdataPinnedWithIndexListValue("p" + i); + } + var pinnedEntity = new TestdataPinnedWithIndexListEntity("pinned", pinnedValues); + pinnedEntity.setPinned(true); + + var freeValues = new TestdataPinnedWithIndexListValue[5]; + for (var i = 0; i < 5; i++) { + freeValues[i] = new TestdataPinnedWithIndexListValue("f" + i); + } + var freeEntity = new TestdataPinnedWithIndexListEntity("free", freeValues); + + var solution = new TestdataPinnedWithIndexListSolution(); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + var allValues = new ArrayList(); + allValues.addAll(List.of(pinnedValues)); + allValues.addAll(List.of(freeValues)); + solution.setValueList(allValues); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getSource(). entity()).isNotEqualTo(pinnedEntity); + assertThat(move.getDestination(). entity()).isNotEqualTo(pinnedEntity); + } + } + + @Test + void differentDrawsProduceDifferentSpans() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 1, 5), solutionMetaModel) + .using(solution); + + var distinctSpans = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .map(move -> move.getSource().fromIndex() + ".." + move.getSource().toIndex()) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctSpans).hasSizeGreaterThan(1); + } + + @Test + void reversingAppearsByDefaultAndCanBeDisabled() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var contextWithReversing = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 2, 5, true, false), solutionMetaModel) + .using(solution); + var movesWithReversing = contextWithReversing + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(movesWithReversing).anyMatch(SubListChangeMove::isReversing); + + var contextWithoutReversing = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 2, 5, false, false), solutionMetaModel) + .using(solution); + var movesWithoutReversing = contextWithoutReversing + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(movesWithoutReversing).noneMatch(SubListChangeMove::isReversing); + } + + @Test + void reversingNeverHappensForSingleElementSpan() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[6]; + for (var i = 0; i < 6; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 1, 1, true, false), solutionMetaModel) + .using(solution); + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(SubListChangeMove::isReversing); + } + + @Test + void crossingNullDefaultTrueAlsoUnassignsSpan() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream().limit(1000).toList(); + assertThat(moves).anyMatch(move -> move instanceof SubListUnassignMove); + } + + @Test + void crossingNullFalseNeverUnassignsSpan() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 1, Integer.MAX_VALUE, true, false), + solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream().limit(500).toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(move -> move instanceof SubListUnassignMove); + } + + @Test + void constructorRejectsExplicitCrossingNullOnVariableWithoutUnassignedValues() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListChangeMoveProvider<>(variableMetaModel, 1, 5, true, true)); + } + + @Test + void constructorRejectsInvalidSizes() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListChangeMoveProvider<>(variableMetaModel, 0, 5)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListChangeMoveProvider<>(variableMetaModel, 5, 2)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java new file mode 100644 index 00000000000..367994f6edf --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java @@ -0,0 +1,289 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SubListChangeMoveTest { + + @Nested + class MoveWithinSameEntity { + + @Test + void moveSpanForward() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var value4 = new TestdataListValue("D"); + var value5 = new TestdataListValue("E"); + var entity = new TestdataListEntity("Entity", value1, value2, value3, value4, value5); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4, value5)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.change(variableMetaModel, Range.of(entity, 1, 3), ElementPosition.of(entity, 3), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value1, value4, value5, value2, value3); + } + + @Test + void moveSpanBackwardReversing() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var value4 = new TestdataListValue("D"); + var value5 = new TestdataListValue("E"); + var entity = new TestdataListEntity("Entity", value1, value2, value3, value4, value5); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4, value5)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.change(variableMetaModel, Range.of(entity, 3, 5), ElementPosition.of(entity, 0), true); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value5, value4, value1, value2, value3); + } + + } + + @Nested + class MoveBetweenEntities { + + @Test + void moveSpanToOtherEntity() { + var valueA1 = new TestdataListValue("A1"); + var valueA2 = new TestdataListValue("A2"); + var valueA3 = new TestdataListValue("A3"); + var valueA4 = new TestdataListValue("A4"); + var entityA = new TestdataListEntity("EntityA", valueA1, valueA2, valueA3, valueA4); + var valueB1 = new TestdataListValue("B1"); + var valueB2 = new TestdataListValue("B2"); + var valueB3 = new TestdataListValue("B3"); + var entityB = new TestdataListEntity("EntityB", valueB1, valueB2, valueB3); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + solution.setValueList(List.of(valueA1, valueA2, valueA3, valueA4, valueB1, valueB2, valueB3)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = + Moves.change(variableMetaModel, Range.of(entityA, 1, 3), ElementPosition.of(entityB, 1), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entityA.getValueList()).containsExactly(valueA1, valueA4); + assertThat(entityB.getValueList()).containsExactly(valueB1, valueA2, valueA3, valueB2, valueB3); + } + + @Test + void moveSpanToUnassignedIsSubListUnassignMove() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var entity = new TestdataListEntity("Entity", value1, value2, value3); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value3); + } + + } + + @Nested + class TemporaryExecution { + + @Test + void executeTemporarilyThenUndo() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var entity = new TestdataListEntity("Entity", value1, value2, value3); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + // destinationIndex is post-removal: after removing [A, B], the list is [C] (size 1), so index 1 appends. + var move = Moves.change(variableMetaModel, Range.of(entity, 0, 2), ElementPosition.of(entity, 1), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(move, view -> assertThat(entity.getValueList()).containsExactly(value3, value1, + value2)); + + // Undone automatically after the callback returns. + assertThat(entity.getValueList()).containsExactly(value1, value2, value3); + } + + } + + @Nested + class MoveProperties { + + @Test + void getPlanningEntitiesForSameEntityMove() { + var entity = new TestdataListEntity("Entity", new TestdataListValue("A"), new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = (SubListChangeMove) Moves.change( + variableMetaModel, Range.of(entity, 0, 1), ElementPosition.of(entity, 2), false); + + assertThat(move.getPlanningEntities()).containsExactly(entity); + } + + @Test + void getPlanningEntitiesForDifferentEntitiesMove() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = (SubListChangeMove) Moves.change( + variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityA, entityB); + } + + @Test + void getPlanningValuesReturnsMovedValues() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var entity = new TestdataListEntity("Entity", value1, value2, value3); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = (SubListChangeMove) Moves.change( + variableMetaModel, Range.of(entity, 0, 2), ElementPosition.of(entity, 1), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(move.getPlanningValues()).containsExactly(value1, value2); + } + + @Test + void equalsAndHashCode() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move1 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + var move2 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + var move3 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), true); + + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isNotEqualTo(move3); + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void toStringContainsMoveDetails() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A"), new TestdataListValue("B")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("C")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.change(variableMetaModel, Range.of(entityA, 0, 2), ElementPosition.of(entityB, 1), true); + + var toString = move.toString(); + assertThat(toString).contains("EntityA").contains("EntityB").contains("reversing-"); + } + + } + + @Nested + class Rebase { + + @Test + void rebaseCreatesNewMoveWithRebasedEntities() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var rebasedEntityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var rebasedEntityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var originalMove = (SubListChangeMove) Moves + .change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == entityA) { + return (T) rebasedEntityA; + } else if (object == entityB) { + return (T) rebasedEntityB; + } + return object; + } + }); + + assertThat(rebasedMove.getSource(). entity()).isEqualTo(rebasedEntityA); + assertThat(rebasedMove.getDestination(). entity()).isEqualTo(rebasedEntityB); + } + + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java new file mode 100644 index 00000000000..445ac37ed95 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java @@ -0,0 +1,238 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListValue; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubListSwapMoveProviderTest { + + @Test + void swappedSpansNeverOverlapOnSameEntity() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 1, 4), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var left = move.getLeftRange(); + var right = move.getRightRange(); + if (left. entity() == right. entity()) { + assertThat(left.toIndex() <= right.fromIndex() || right.toIndex() <= left.fromIndex()).isTrue(); + } + } + } + + @Test + void eachSideRespectsItsOwnSizeBounds() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var valuesA = new TestdataListValue[12]; + for (var i = 0; i < 12; i++) { + valuesA[i] = new TestdataListValue("a" + i); + } + var entityA = new TestdataListEntity("A", valuesA); + var valuesB = new TestdataListValue[12]; + for (var i = 0; i < 12; i++) { + valuesB[i] = new TestdataListValue("b" + i); + } + var entityB = new TestdataListEntity("B", valuesB); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + var allValues = new java.util.ArrayList(); + allValues.addAll(List.of(valuesA)); + allValues.addAll(List.of(valuesB)); + solution.setValueList(allValues); + + // One side is always drawn at length 1, the other always in [3, 5] - but SubListSwapMove's + // constructor normalizes same-entity pairs so that left precedes right, which can swap which + // getter reports which drawn side. Assert on the unordered pair of lengths instead. + var context = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 1, 1, 3, 5), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + var lengths = List.of(move.getLeftRange().length(), move.getRightRange().length()); + assertThat(lengths).anyMatch(length -> length == 1); + assertThat(lengths).anyMatch(length -> length >= 3 && length <= 5); + } + } + + @Test + void differentDrawsProduceDifferentSwaps() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 1, 4), solutionMetaModel) + .using(solution); + + var distinctSwaps = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .map(move -> move.getLeftRange() + "<->" + move.getRightRange()) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctSwaps).hasSizeGreaterThan(1); + } + + @Test + void reversingAppearsByDefaultAndCanBeDisabled() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var contextWithReversing = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 2, 4, 2, 4, true), solutionMetaModel) + .using(solution); + var movesWithReversing = contextWithReversing + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(movesWithReversing).anyMatch(SubListSwapMove::isReversing); + + var contextWithoutReversing = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 2, 4, 2, 4, false), solutionMetaModel) + .using(solution); + var movesWithoutReversing = contextWithoutReversing + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(movesWithoutReversing).noneMatch(SubListSwapMove::isReversing); + } + + @Test + void reversingNeverHappensWhenBothSpansAreSingleElement() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel, 1, 1, 1, 1, true), solutionMetaModel) + .using(solution); + var moves = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(SubListSwapMove::isReversing); + } + + @Test + void drawnSpansNeverTouchPinnedPrefix() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + + var values = new TestdataPinnedWithIndexListValue[9]; + for (var i = 0; i < 9; i++) { + values[i] = new TestdataPinnedWithIndexListValue("v" + i); + } + var entity = new TestdataPinnedWithIndexListEntity("A", values); + entity.setPinIndex(4); + var solution = new TestdataPinnedWithIndexListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var moves = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getLeftRange().fromIndex()).isGreaterThanOrEqualTo(4); + assertThat(move.getRightRange().fromIndex()).isGreaterThanOrEqualTo(4); + } + } + + @Test + void constructorRejectsInvalidSizes() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListSwapMoveProvider<>(variableMetaModel, 0, 5)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListSwapMoveProvider<>(variableMetaModel, 5, 2)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListSwapMoveProvider<>(variableMetaModel, 1, 5, 0, 5)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListSwapMoveProvider<>(variableMetaModel, 1, 5, 5, 2)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java new file mode 100644 index 00000000000..fbf06ea2033 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java @@ -0,0 +1,252 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SubListSwapMoveTest { + + @Nested + class SwapWithinSameEntity { + + @Test + void swapNonOverlappingSpans() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var value4 = new TestdataListValue("D"); + var value5 = new TestdataListValue("E"); + var value6 = new TestdataListValue("F"); + var entity = new TestdataListEntity("Entity", value1, value2, value3, value4, value5, value6); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4, value5, value6)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.swap(variableMetaModel, Range.of(entity, 1, 3), Range.of(entity, 4, 6), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value1, value5, value6, value4, value2, value3); + } + + @Test + void swapNormalizesOutOfOrderSpans() { + // The same swap as swapNonOverlappingSpans, but with left/right passed in reverse order. + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var value4 = new TestdataListValue("D"); + var value5 = new TestdataListValue("E"); + var value6 = new TestdataListValue("F"); + var entity = new TestdataListEntity("Entity", value1, value2, value3, value4, value5, value6); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4, value5, value6)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.swap(variableMetaModel, Range.of(entity, 4, 6), Range.of(entity, 1, 3), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value1, value5, value6, value4, value2, value3); + } + + } + + @Nested + class SwapBetweenEntities { + + @Test + void swapSpansOfDifferingLength() { + var valueA1 = new TestdataListValue("A1"); + var valueA2 = new TestdataListValue("A2"); + var valueA3 = new TestdataListValue("A3"); + var valueA4 = new TestdataListValue("A4"); + var entityA = new TestdataListEntity("EntityA", valueA1, valueA2, valueA3, valueA4); + var valueB1 = new TestdataListValue("B1"); + var valueB2 = new TestdataListValue("B2"); + var valueB3 = new TestdataListValue("B3"); + var entityB = new TestdataListEntity("EntityB", valueB1, valueB2, valueB3); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + solution.setValueList(List.of(valueA1, valueA2, valueA3, valueA4, valueB1, valueB2, valueB3)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.swap(variableMetaModel, Range.of(entityA, 1, 3), Range.of(entityB, 0, 1), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entityA.getValueList()).containsExactly(valueA1, valueB1, valueA4); + assertThat(entityB.getValueList()).containsExactly(valueA2, valueA3, valueB2, valueB3); + } + + } + + @Nested + class TemporaryExecution { + + @Test + void executeTemporarilyThenUndo() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var value3 = new TestdataListValue("C"); + var value4 = new TestdataListValue("D"); + var entity = new TestdataListEntity("Entity", value1, value2, value3, value4); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.swap(variableMetaModel, Range.of(entity, 0, 1), Range.of(entity, 3, 4), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(move, + view -> assertThat(entity.getValueList()).containsExactly(value4, value2, value3, value1)); + + assertThat(entity.getValueList()).containsExactly(value1, value2, value3, value4); + } + + } + + @Nested + class MoveProperties { + + @Test + void getPlanningEntitiesForDifferentEntitiesSwap() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = (SubListSwapMove) Moves.swap( + variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityA, entityB); + } + + @Test + void getPlanningValuesReturnsPreMoveValues() { + var value1 = new TestdataListValue("A"); + var value2 = new TestdataListValue("B"); + var entity = new TestdataListEntity("Entity", value1, value2); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2)); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = (SubListSwapMove) Moves.swap( + variableMetaModel, Range.of(entity, 0, 1), Range.of(entity, 1, 2), false); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(move.getPlanningValues()).containsExactly(value1, value2); + } + + @Test + void equalsAndHashCode() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move1 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + var move2 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + var move3 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), true); + + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isNotEqualTo(move3); + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void toStringContainsMoveDetails() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var move = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), true); + + var toString = move.toString(); + assertThat(toString).contains("EntityA").contains("EntityB").contains("reversing-"); + } + + } + + @Nested + class Rebase { + + @Test + void rebaseCreatesNewMoveWithRebasedEntities() { + var entityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var entityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + var rebasedEntityA = new TestdataListEntity("EntityA", new TestdataListValue("A")); + var rebasedEntityB = new TestdataListEntity("EntityB", new TestdataListValue("B")); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var originalMove = (SubListSwapMove) Moves + .swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == entityA) { + return (T) rebasedEntityA; + } else if (object == entityB) { + return (T) rebasedEntityB; + } + return object; + } + }); + + assertThat(rebasedMove.getLeftRange(). entity()).isEqualTo(rebasedEntityA); + assertThat(rebasedMove.getRightRange(). entity()).isEqualTo(rebasedEntityB); + } + + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java new file mode 100644 index 00000000000..19c4facb60b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java @@ -0,0 +1,136 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.pinned.TestdataPinnedUnassignedValuesListValue; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class SubListUnassignMoveProviderTest { + + @Test + void unassignsSpansOfBoundedLength() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListUnassignMoveProvider<>(variableMetaModel, 2, 5), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListUnassignMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getRange().length()).isBetween(2, 5); + } + } + + @Test + void differentDrawsProduceDifferentSpans() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var values = new TestdataAllowsUnassignedValuesListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataAllowsUnassignedValuesListValue("v" + i); + } + var entity = new TestdataAllowsUnassignedValuesListEntity("A", values); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var distinctSpans = context + .getMovesAsStream( + move -> (SubListUnassignMove) move) + .limit(300) + .map(move -> move.getRange().fromIndex() + ".." + move.getRange().toIndex()) + .collect(Collectors.toCollection(HashSet::new)); + assertThat(distinctSpans).hasSizeGreaterThan(1); + } + + @Test + void drawnSpanNeverTouchesPinnedPrefix() { + var solutionMetaModel = TestdataPinnedUnassignedValuesListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataPinnedUnassignedValuesListValue.class); + + var values = new TestdataPinnedUnassignedValuesListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataPinnedUnassignedValuesListValue("v" + i); + } + var entity = new TestdataPinnedUnassignedValuesListEntity("A", values); + entity.setPlanningPinToIndex(3); + var solution = new TestdataPinnedUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListUnassignMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListUnassignMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getRange().fromIndex()).isGreaterThanOrEqualTo(3); + } + } + + @Test + void constructorRequiresAllowsUnassignedValues() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListUnassignMoveProvider<>(variableMetaModel)); + } + + @Test + void constructorRejectsInvalidSizes() { + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListUnassignMoveProvider<>(variableMetaModel, 0, 5)); + assertThatIllegalArgumentException() + .isThrownBy(() -> new SubListUnassignMoveProvider<>(variableMetaModel, 5, 2)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java new file mode 100644 index 00000000000..bb5ecabd229 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java @@ -0,0 +1,175 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.common.Lookup; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SubListUnassignMoveTest { + + @Nested + class Unassign { + + @Test + void unassignsSpan() { + var value1 = new TestdataAllowsUnassignedValuesListValue("A"); + var value2 = new TestdataAllowsUnassignedValuesListValue("B"); + var value3 = new TestdataAllowsUnassignedValuesListValue("C"); + var value4 = new TestdataAllowsUnassignedValuesListValue("D"); + var entity = new TestdataAllowsUnassignedValuesListEntity("Entity", value1, value2, value3, value4); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3, value4)); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var move = Moves.unassign(variableMetaModel, Range.of(entity, 1, 3)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(value1, value4); + } + + } + + @Nested + class TemporaryExecution { + + @Test + void executeTemporarilyThenUndo() { + var value1 = new TestdataAllowsUnassignedValuesListValue("A"); + var value2 = new TestdataAllowsUnassignedValuesListValue("B"); + var value3 = new TestdataAllowsUnassignedValuesListValue("C"); + var entity = new TestdataAllowsUnassignedValuesListEntity("Entity", value1, value2, value3); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2, value3)); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var move = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .executeTemporarily(move, view -> assertThat(entity.getValueList()).containsExactly(value3)); + + assertThat(entity.getValueList()).containsExactly(value1, value2, value3); + } + + } + + @Nested + class MoveProperties { + + @Test + void getPlanningEntitiesReturnsOwningEntity() { + var entity = + new TestdataAllowsUnassignedValuesListEntity("Entity", new TestdataAllowsUnassignedValuesListValue("A"), + new TestdataAllowsUnassignedValuesListValue("B")); + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var move = + (SubListUnassignMove) Moves + .unassign(variableMetaModel, Range.of(entity, 0, 1)); + + assertThat(move.getPlanningEntities()).containsExactly(entity); + } + + @Test + void getPlanningValuesReturnsUnassignedValues() { + var value1 = new TestdataAllowsUnassignedValuesListValue("A"); + var value2 = new TestdataAllowsUnassignedValuesListValue("B"); + var entity = new TestdataAllowsUnassignedValuesListEntity("Entity", value1, value2); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(value1, value2)); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var move = + (SubListUnassignMove) Moves + .unassign(variableMetaModel, Range.of(entity, 0, 2)); + + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(move.getPlanningValues()).containsExactly(value1, value2); + } + + @Test + void equalsAndHashCode() { + var entity = + new TestdataAllowsUnassignedValuesListEntity("Entity", new TestdataAllowsUnassignedValuesListValue("A"), + new TestdataAllowsUnassignedValuesListValue("B")); + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var move1 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 1)); + var move2 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 1)); + var move3 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isNotEqualTo(move3); + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + } + + @Nested + class Rebase { + + @Test + void rebaseCreatesNewMoveWithRebasedEntity() { + var entity = + new TestdataAllowsUnassignedValuesListEntity("Entity", new TestdataAllowsUnassignedValuesListValue("A")); + var rebasedEntity = + new TestdataAllowsUnassignedValuesListEntity("Entity", new TestdataAllowsUnassignedValuesListValue("A")); + + var solutionMetaModel = TestdataAllowsUnassignedValuesListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); + + var originalMove = + (SubListUnassignMove) Moves + .unassign(variableMetaModel, Range.of(entity, 0, 1)); + + var rebasedMove = originalMove.rebase(new Lookup() { + @Override + @SuppressWarnings("unchecked") + public T lookUpWorkingObject(T object) { + if (object == entity) { + return (T) rebasedEntity; + } + return object; + } + }); + + assertThat(rebasedMove.getRange(). entity()).isEqualTo(rebasedEntity); + } + + } + +} From 844d21593d4057be4dbc471c1ed6d5fe0cdabd04 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 15:02:29 +0200 Subject: [PATCH 03/16] Add 2-opt --- .../DefaultNeighborhoodProvider.java | 8 +- .../builtin/ListTailSwapMoveProvider.java | 172 +++++++++++++ .../core/preview/api/move/builtin/Moves.java | 25 ++ .../move/builtin/TwoOptListMoveProvider.java | 111 ++++++++ .../DefaultNeighborhoodProviderTest.java | 6 +- .../builtin/ListTailSwapMoveProviderTest.java | 138 ++++++++++ .../builtin/TwoOptListMoveProviderTest.java | 237 ++++++++++++++++++ .../neighborhoods.adoc | 2 +- 8 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java index 78ffc17bcb3..97537d6a465 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProvider.java @@ -8,8 +8,10 @@ import ai.timefold.solver.core.preview.api.move.builtin.ListAssignMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListChangeMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListSwapMoveProvider; +import ai.timefold.solver.core.preview.api.move.builtin.ListTailSwapMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListUnassignMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.SwapMoveProvider; +import ai.timefold.solver.core.preview.api.move.builtin.TwoOptListMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.UnassignMoveProvider; import ai.timefold.solver.core.preview.api.neighborhood.Neighborhood; import ai.timefold.solver.core.preview.api.neighborhood.NeighborhoodBuilder; @@ -30,12 +32,16 @@ public Neighborhood defineNeighborhood(NeighborhoodBuilder builder) { var hasBasicVariable = false; for (var variableMetaModel : entityMetaModel.genuineVariables()) { if (variableMetaModel instanceof PlanningListVariableMetaModel listVariableMetaModel) { - // TODO Implement 2-opt and 3-opt moves for list variables. // ListChangeMoveProvider's crossingNull=false: // ListAssignMoveProvider/ListUnassignMoveProvider below already cover those moves, // at a much higher rate than ListChangeMoveProvider's flag would. builder.add(new ListChangeMoveProvider<>(listVariableMetaModel, false)); builder.add(new ListSwapMoveProvider<>(listVariableMetaModel)); + // TwoOptListMoveProvider's crossingEntity=false: + // ListTailSwapMoveProvider below already covers the cross-entity tail swap, + // at a much higher rate than TwoOptListMoveProvider's flag would. + builder.add(new TwoOptListMoveProvider<>(listVariableMetaModel, false)); + builder.add(new ListTailSwapMoveProvider<>(listVariableMetaModel)); if (listVariableMetaModel.allowsUnassignedValues()) { builder.add(new ListAssignMoveProvider<>(listVariableMetaModel)); builder.add(new ListUnassignMoveProvider<>(listVariableMetaModel)); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java new file mode 100644 index 00000000000..f799ec000e7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java @@ -0,0 +1,172 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; +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.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Draws two assigned values on different entities and creates a move to swap their tails - + * the portion of each entity's {@link PlanningListVariable list variable} from the drawn position to the end - + * optionally in reverse element order. + *

    + * This is the cross-entity half of the classic 2-opt route-improving move, at full rate. + * {@link TwoOptListMoveProvider} makes this same move too, + * whenever its own {@code crossingEntity} is {@code true} - + * but there, only non-reversing, and only as one destination row among many, + * so it arrives rarely. + * This class exists to make it happen often, and to also emit the reversing variant. + * + * @see TwoOptListMoveProvider The same-entity reversal, and this same shape at a much lower rate. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class ListTailSwapMoveProvider implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final boolean selectReversingMoveToo; + + public ListTailSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, true); + } + + /** + * @param selectReversingMoveToo if {@code true}, also emits the reversing tail swap variant + */ + public ListTailSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel, + boolean selectReversingMoveToo) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.selectReversingMoveToo = selectReversingMoveToo; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var valueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new ListTailSwapMoveIterator<>(session, random, + variableMetaModel, valueDataset, selectReversingMoveToo)); + } + + /** + * Draws two assigned values on different entities and swaps their tails, producing a + * {@code SubListSwapMove}. Left = seed value, right = candidate value. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ + @NullMarked + private static final class ListTailSwapMoveIterator + implements Iterator>, RetiringBiWalk { + + private final PlanningListVariableMetaModel variableMetaModel; + private final boolean selectReversingMoveToo; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator leftValueIterator; + private final DefaultUniDatasetInstance valueInstance; + + private @Nullable Move nextMove = null; + private @Nullable PositionInList pendingLeftPosition = null; + + ListTailSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset valueDataset, boolean selectReversingMoveToo) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.selectReversingMoveToo = selectReversingMoveToo; + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + this.valueInstance = (DefaultUniDatasetInstance) session.getInstance(valueDataset); + this.leftValueIterator = valueInstance.retiringRandomIterator(random); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(leftValueIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Value_ leftValue) { + var leftPosition = (PositionInList) solutionView.getPositionOf(variableMetaModel, leftValue); + pendingLeftPosition = leftPosition; + var bailOutSize = valueInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(valueInstance.iterator(random), + candidateValue -> isValidTailSwap(leftPosition, + (PositionInList) solutionView.getPositionOf(variableMetaModel, candidateValue)), + bailOutSize); + } + + private boolean isValidTailSwap(PositionInList left, PositionInList right) { + Entity_ leftEntity = left.entity(); + Entity_ rightEntity = right.entity(); + if (leftEntity == rightEntity) { + // A same-entity "tail swap" is degenerate: the two tails would necessarily overlap. + return false; + } + if (variableMetaModel.isValueRangeOnSolution()) { + // We can move freely between entities, no per-entity value range to violate. + return true; + } + for (var index = left.index(); index < solutionView.countValues(variableMetaModel, leftEntity); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, leftEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, rightEntity, value)) { + return false; + } + } + for (var index = right.index(); index < solutionView.countValues(variableMetaModel, rightEntity); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, rightEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, leftEntity, value)) { + return false; + } + } + return true; + } + + @Override + public void accept(Value_ leftValue, Value_ rightValue) { + var leftPosition = Objects.requireNonNull(pendingLeftPosition); + pendingLeftPosition = null; + var rightPosition = (PositionInList) solutionView.getPositionOf(variableMetaModel, rightValue); + Entity_ leftEntity = leftPosition.entity(); + Entity_ rightEntity = rightPosition.entity(); + var leftSize = solutionView.countValues(variableMetaModel, leftEntity); + var rightSize = solutionView.countValues(variableMetaModel, rightEntity); + var reversing = selectReversingMoveToo && random.nextBoolean(); + nextMove = Moves.swap(variableMetaModel, Range.of(leftEntity, leftPosition.index(), leftSize), + Range.of(rightEntity, rightPosition.index(), rightSize), reversing); + } + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index 546b47d6d81..320fbf19a96 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -3,6 +3,7 @@ import java.util.Arrays; import java.util.List; +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.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; @@ -385,6 +386,30 @@ public static Move swap( return new SubListSwapMove<>(variableMetaModel, left, right, reversing); } + /** + * Creates a move that reverses a contiguous span of a list variable in place. + * This is the classic 2-opt route-improving move. + *

    + * The span is identified by a {@link Range}. + * + * @param variableMetaModel describes the list variable to be changed + * @param range the span to reverse; its length must be at least 2 + * @param the solution type + * @param the entity type + * @param the variable value type + * @return a move that, when executed, reverses the span in place + * @throws IllegalArgumentException if the range's length is less than 2 + */ + public static Move reverse( + PlanningListVariableMetaModel variableMetaModel, Range range) { + if (range.length() < 2) { + throw new IllegalArgumentException("The length (%d) of range (%s) must be at least 2." + .formatted(range.length(), range)); + } + return new SubListChangeMove<>(variableMetaModel, range, + ElementPosition.of(range.entity(), range.fromIndex()), true); + } + /** * Creates a move that unassigns a contiguous span of a list variable, * that is, removes every value of the span from the list, leaving it unassigned. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java new file mode 100644 index 00000000000..0c0d5bf04e1 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java @@ -0,0 +1,111 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +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.move.Move; +import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; +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.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsPredicate; +import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; + +import org.jspecify.annotations.NullMarked; + +/** + * For each entity's {@link PlanningListVariable list variable}, + * draws two assigned positions. + * When both are on the same entity, reverses the closed span between them in place - + * the classic 2-opt route-improving move. + *

    + * When {@code crossingEntity} is {@code true}, + * also allows a non-reversing tail swap between two different entities, + * as one destination candidate among many - so it arrives rarely. + * + * @see ListTailSwapMoveProvider The dedicated, full-rate counterpart for the cross-entity shape. + * + * @param the solution type + * @param the entity type + * @param the variable type + */ +@NullMarked +public final class TwoOptListMoveProvider implements MoveProvider { + + private final PlanningListVariableMetaModel variableMetaModel; + private final boolean crossingEntity; + + public TwoOptListMoveProvider(PlanningListVariableMetaModel variableMetaModel) { + this(variableMetaModel, true); + } + + /** + * @param crossingEntity if {@code true}, also allows a non-reversing tail swap between two + * different entities, as one destination candidate among many; + * see {@link ListTailSwapMoveProvider} for a dedicated, full-rate counterpart + */ + public TwoOptListMoveProvider(PlanningListVariableMetaModel variableMetaModel, + boolean crossingEntity) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.crossingEntity = crossingEntity; + } + + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var positionStream = moveStreamFactory.forEachAssignedValue(variableMetaModel) + .map((solutionView, value) -> (PositionInList) solutionView.getPositionOf(variableMetaModel, value)); + var predicate = (BiNeighborhoodsPredicate) this::isValidTwoOpt; + return moveStreamFactory.pick(positionStream) + .pick(positionStream, NeighborhoodsJoiners.filtering(predicate)) + .asMove(this::buildMove); + } + + private boolean isValidTwoOpt(SolutionView solutionView, PositionInList left, PositionInList right) { + if (Objects.equals(left, right)) { + return false; + } + Entity_ leftEntity = left.entity(); + Entity_ rightEntity = right.entity(); + if (leftEntity == rightEntity) { + return true; + } + if (!crossingEntity) { + return false; + } + if (variableMetaModel.isValueRangeOnSolution()) { + // We can move freely between entities, no per-entity value range to violate. + return true; + } + for (var index = left.index(); index < solutionView.countValues(variableMetaModel, leftEntity); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, leftEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, rightEntity, value)) { + return false; + } + } + for (var index = right.index(); index < solutionView.countValues(variableMetaModel, rightEntity); index++) { + var value = solutionView.getValueAtIndex(variableMetaModel, rightEntity, index); + if (!solutionView.isValueInRange(variableMetaModel, leftEntity, value)) { + return false; + } + } + return true; + } + + private Move buildMove(SolutionView solutionView, PositionInList left, PositionInList right) { + Entity_ leftEntity = left.entity(); + Entity_ rightEntity = right.entity(); + if (leftEntity == rightEntity) { + var fromIndex = Math.min(left.index(), right.index()); + var toIndex = Math.max(left.index(), right.index()) + 1; + return Moves.reverse(variableMetaModel, Range.of(leftEntity, fromIndex, toIndex)); + } + var leftSize = solutionView.countValues(variableMetaModel, leftEntity); + var rightSize = solutionView.countValues(variableMetaModel, rightEntity); + return Moves.swap(variableMetaModel, Range.of(leftEntity, left.index(), leftSize), + Range.of(rightEntity, right.index(), rightSize), false); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProviderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProviderTest.java index 54527530e67..e7915e23a9b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodProviderTest.java @@ -9,8 +9,10 @@ import ai.timefold.solver.core.preview.api.move.builtin.ListAssignMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListChangeMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListSwapMoveProvider; +import ai.timefold.solver.core.preview.api.move.builtin.ListTailSwapMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.ListUnassignMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.SwapMoveProvider; +import ai.timefold.solver.core.preview.api.move.builtin.TwoOptListMoveProvider; import ai.timefold.solver.core.preview.api.move.builtin.UnassignMoveProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import ai.timefold.solver.core.testdomain.list.TestdataListSolution; @@ -58,7 +60,8 @@ void listVariable() { .defineNeighborhood(builder); assertThat(neighborhood.getMoveProviderList()) .map(c -> (Class) c.getClass()) - .containsExactly(ListChangeMoveProvider.class, ListSwapMoveProvider.class); + .containsExactly(ListChangeMoveProvider.class, ListSwapMoveProvider.class, + TwoOptListMoveProvider.class, ListTailSwapMoveProvider.class); } @SuppressWarnings({ "rawtypes", "unchecked" }) @@ -72,6 +75,7 @@ void listVariableAllowsUnassignedValues() { assertThat(neighborhood.getMoveProviderList()) .map(c -> (Class) c.getClass()) .containsExactly(ListChangeMoveProvider.class, ListSwapMoveProvider.class, + TwoOptListMoveProvider.class, ListTailSwapMoveProvider.class, ListAssignMoveProvider.class, ListUnassignMoveProvider.class); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java new file mode 100644 index 00000000000..e9df4d48268 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java @@ -0,0 +1,138 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class ListTailSwapMoveProviderTest { + + @Test + void producesTailSwapsAcrossEntities() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var valuesA = new TestdataListValue[] { new TestdataListValue("a0"), new TestdataListValue("a1"), + new TestdataListValue("a2") }; + var entityA = new TestdataListEntity("A", valuesA); + var valuesB = new TestdataListValue[] { new TestdataListValue("b0"), new TestdataListValue("b1") }; + var entityB = new TestdataListEntity("B", valuesB); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + var allValues = new ArrayList(); + allValues.addAll(List.of(valuesA)); + allValues.addAll(List.of(valuesB)); + solution.setValueList(allValues); + + var context = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf( + Moves.swap(variableMetaModel, Range.of(entityA, 0, 3), Range.of(entityB, 0, 2), false), + Moves.swap(variableMetaModel, Range.of(entityA, 2, 3), Range.of(entityB, 1, 2), false)); + } + + @Test + void reversingAppearsByDefaultAndCanBeDisabled() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var valuesA = new TestdataListValue[] { new TestdataListValue("a0"), new TestdataListValue("a1"), + new TestdataListValue("a2") }; + var entityA = new TestdataListEntity("A", valuesA); + var valuesB = new TestdataListValue[] { new TestdataListValue("b0"), new TestdataListValue("b1"), + new TestdataListValue("b2") }; + var entityB = new TestdataListEntity("B", valuesB); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + var allValues = new ArrayList(); + allValues.addAll(List.of(valuesA)); + allValues.addAll(List.of(valuesB)); + solution.setValueList(allValues); + + var contextWithReversing = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel, true), solutionMetaModel) + .using(solution); + var movesWithReversing = contextWithReversing + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(movesWithReversing).anyMatch(SubListSwapMove::isReversing); + + var contextWithoutReversing = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel, false), solutionMetaModel) + .using(solution); + var movesWithoutReversing = contextWithoutReversing + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(movesWithoutReversing).isNotEmpty(); + assertThat(movesWithoutReversing).noneMatch(SubListSwapMove::isReversing); + } + + @Test + void sameEntityNeverProducesAMove() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[] { new TestdataListValue("v0"), new TestdataListValue("v1"), + new TestdataListValue("v2") }; + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + assertThat(context.getMovesAsStream()).isEmpty(); + } + + @Test + void valueRangeOnEntityRejectsInvalidSwap() { + var solutionMetaModel = TestdataListEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntityProvidingEntity.class) + .listVariable(); + + // e1's range is [v1, v2], e2's range is [v1, v3]; v2 and v3 are each exclusive to one entity. + var solution = TestdataListEntityProvidingSolution.generateSolution(); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var v2 = e1.getValueRange().get(1); + var v1 = e2.getValueRange().get(0); + var v3 = e2.getValueRange().get(1); + e1.getValueList().add(v2); + e2.getValueList().add(v1); + e2.getValueList().add(v3); + SolutionManager.updateShadowVariables(solution); + + // Swapping the whole tails would move v2 into e2 (out of range) and v1,v3 into e1 (v3 out of range). + var context = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesNoneOf( + Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), false), + Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), true), + Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), false), + Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), true)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java new file mode 100644 index 00000000000..ce4fd8349b5 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java @@ -0,0 +1,237 @@ +package ai.timefold.solver.core.preview.api.move.builtin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListValue; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +class TwoOptListMoveProviderTest { + + @Test + void sameEntityAlwaysProducesReversal() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var values = new TestdataListValue[10]; + for (var i = 0; i < 10; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getDestination().index()).isEqualTo(move.getSource().fromIndex()); + assertThat(move.isReversing()).isTrue(); + assertThat(move.getSource().length()).isGreaterThanOrEqualTo(2); + } + } + + @Test + void crossingEntityFalseNeverProducesTailSwap() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var solution = twoEntitySolution(); + + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel, false), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream().limit(500).toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(move -> move instanceof SubListSwapMove); + } + + @Test + void crossingEntityTrueProducesNonReversingTailSwapToo() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var solution = twoEntitySolution(); + + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel, true), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream() + .filter(SubListSwapMove.class::isInstance) + .map(SubListSwapMove.class::cast) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(SubListSwapMove::isReversing); + } + + private static TestdataListSolution twoEntitySolution() { + var valuesA = new TestdataListValue[5]; + for (var i = 0; i < 5; i++) { + valuesA[i] = new TestdataListValue("a" + i); + } + var entityA = new TestdataListEntity("A", valuesA); + var valuesB = new TestdataListValue[5]; + for (var i = 0; i < 5; i++) { + valuesB[i] = new TestdataListValue("b" + i); + } + var entityB = new TestdataListEntity("B", valuesB); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + var allValues = new ArrayList(); + allValues.addAll(List.of(valuesA)); + allValues.addAll(List.of(valuesB)); + solution.setValueList(allValues); + return solution; + } + + @Test + void pinnedPrefixNeverTouched() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + + var values = new TestdataPinnedWithIndexListValue[8]; + for (var i = 0; i < 8; i++) { + values[i] = new TestdataPinnedWithIndexListValue("v" + i); + } + var entity = new TestdataPinnedWithIndexListEntity("A", values); + entity.setPinIndex(3); + var solution = new TestdataPinnedWithIndexListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3); + } + } + + @Test + void fullyPinnedEntityNeverInvolved() { + var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataPinnedWithIndexListEntity.class) + .listVariable("valueList", TestdataPinnedWithIndexListValue.class); + + var pinnedValues = new TestdataPinnedWithIndexListValue[3]; + for (var i = 0; i < 3; i++) { + pinnedValues[i] = new TestdataPinnedWithIndexListValue("p" + i); + } + var pinnedEntity = new TestdataPinnedWithIndexListEntity("pinned", pinnedValues); + pinnedEntity.setPinned(true); + + var freeValues = new TestdataPinnedWithIndexListValue[5]; + for (var i = 0; i < 5; i++) { + freeValues[i] = new TestdataPinnedWithIndexListValue("f" + i); + } + var freeEntity = new TestdataPinnedWithIndexListEntity("free", freeValues); + + var solution = new TestdataPinnedWithIndexListSolution(); + solution.setEntityList(List.of(pinnedEntity, freeEntity)); + var allValues = new ArrayList(); + allValues.addAll(List.of(pinnedValues)); + allValues.addAll(List.of(freeValues)); + solution.setValueList(allValues); + + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + var moves = context.getMovesAsStream().limit(300).toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + if (move instanceof SubListChangeMove reversal) { + assertThat(reversal.getSource(). entity()).isNotEqualTo(pinnedEntity); + } else if (move instanceof SubListSwapMove swap) { + assertThat(swap.getLeftRange(). entity()).isNotEqualTo(pinnedEntity); + assertThat(swap.getRightRange(). entity()).isNotEqualTo(pinnedEntity); + } + } + } + + @Test + void valueRangeOnEntityRejectsTailSwap() { + var solutionMetaModel = TestdataListEntityProvidingSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntityProvidingEntity.class) + .listVariable(); + + // e1's range is [v1, v2], e2's range is [v1, v3]; v2 and v3 are each exclusive to one entity. + var solution = TestdataListEntityProvidingSolution.generateSolution(); + var e1 = solution.getEntityList().get(0); + var e2 = solution.getEntityList().get(1); + var v2 = e1.getValueRange().get(1); + var v1 = e2.getValueRange().get(0); + var v3 = e2.getValueRange().get(1); + e1.getValueList().add(v2); + e2.getValueList().add(v1); + e2.getValueList().add(v3); + SolutionManager.updateShadowVariables(solution); + + // Swapping the whole tails would move v2 into e2 (out of range) and v1,v3 into e1 (v3 out of range). + var context = NeighborhoodTester + .build(new TwoOptListMoveProvider<>(variableMetaModel, true), solutionMetaModel) + .using(solution); + context.producesNoneOf( + Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), false), + Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), false)); + } + + @Test + void executingReversalPinsTheIndexMath() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var v0 = new TestdataListValue("v0"); + var v1 = new TestdataListValue("v1"); + var v2 = new TestdataListValue("v2"); + var v3 = new TestdataListValue("v3"); + var v4 = new TestdataListValue("v4"); + var entity = new TestdataListEntity("A", v0, v1, v2, v3, v4); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(v0, v1, v2, v3, v4)); + + var move = Moves.reverse(variableMetaModel, Range.of(entity, 1, 4)); + MoveTester.build(solutionMetaModel) + .using(solution) + .execute(move); + + assertThat(entity.getValueList()).containsExactly(v0, v3, v2, v1, v4); + } + +} diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc index 54a7a5cae38..685f79854e4 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/neighborhoods.adoc @@ -906,5 +906,5 @@ configuring both together throws an exception at solver build time. All move providers included in the neighborhood have the same probability of being selected to generate a move. We are actively working on adding more advanced moves in the default neighborhood, -such as 2-opt, 3-opt and xref:optimization-algorithms/move-selector-reference.adoc#nearbySelection[nearby selection]. +such as xref:optimization-algorithms/move-selector-reference.adoc#nearbySelection[nearby selection]. The Neighborhoods API is still far from being a full replacement of Move Selectors. \ No newline at end of file From 5f36e47f79852e007e9a1cbb7adfc2de033c8a81 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 15:16:23 +0200 Subject: [PATCH 04/16] Copilot --- .../preview/api/move/builtin/MassListChangeMove.java | 9 ++++++++- .../preview/api/move/builtin/PillarSwapMove.java | 3 +-- .../stream/dataset/sample/DefaultRange.java | 12 +++++++++++- .../neighborhood/stream/dataset/sample/Sample.java | 2 ++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java index a200b5c8b10..40025b32448 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -110,6 +110,10 @@ public MassListChangeMove rebase(Lookup lookup) { @Override public SequencedCollection getPlanningEntities() { + // This misses the source entities; + // we accept that, as collecting them would have been far too expensive, + // and only happens during execute() - + // therefore the method would not provide the same result during the entire lifetime of the move. return destination == null ? List.of() : List.of(destination. entity()); } @@ -132,7 +136,10 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(variableMetaModel, sample, destination); + var hash = 31 + Objects.hashCode(variableMetaModel); + hash = hash * 31 + Objects.hashCode(sample); + hash = hash * 31 + Objects.hashCode(destination); + return hash; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java index f74b7ae4e3e..a4a51f106ce 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -23,8 +23,7 @@ * Requires to specify a (sub)set of variables to swap values of, * all of which must belong to the same entity class. *

    - * The two pillars must not share any members; - * the constructor throws {@link IllegalArgumentException} if they do. + * The two pillars must not share any members. * The constructor does not otherwise check that the pillars are safe to swap; see below. *

    * Every member of a pillar is assumed to hold the same value for each of the listed variables diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java index dd9337e209d..8cc96da542e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java @@ -50,7 +50,17 @@ public int compareTo(Range other) { if (result != 0) { return result; } - return Integer.compare(toIndex, other.toIndex()); + result = Integer.compare(toIndex, other.toIndex()); + if (result != 0) { + return result; + } + var otherEntity = other.entity(); + if (Objects.equals(entity, otherEntity)) { + return 0; + } + // Tie-breaker so compareTo() stays consistent with the identity-based equals()/hashCode(); + // otherwise ranges on different entities with the same indices would collapse in a sorted set/map. + return Integer.compare(System.identityHashCode(entity), System.identityHashCode(otherEntity)); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 2ff3ff63655..3cf416b20ca 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -25,6 +25,8 @@ * so it must read the same members every time. * * @param the type of the sample's members + * FIXME comparisons of samples in reverse order are a problem; + * what do we do about equality? */ @NullMarked public interface Sample From fcb5a6f03d476082bcf73cded8d63c2bfd14f2cc Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 17:05:28 +0200 Subject: [PATCH 05/16] Limit move size --- .../dataset/sample/SampleAssembler.java | 10 ++- .../builtin/ListTailSwapMoveProvider.java | 6 ++ .../move/builtin/MassAssignMoveProvider.java | 6 +- .../move/builtin/MassChangeMoveProvider.java | 6 +- .../builtin/MassListAssignMoveProvider.java | 5 ++ .../builtin/MassListChangeMoveProvider.java | 10 ++- .../builtin/MassListUnassignMoveProvider.java | 5 +- .../builtin/MassUnassignMoveProvider.java | 5 +- .../builtin/PillarChangeMoveProvider.java | 11 +-- .../move/builtin/PillarSwapMoveProvider.java | 6 +- .../builtin/PillarUnassignMoveProvider.java | 9 +- .../builtin/SubListChangeMoveProvider.java | 3 +- .../move/builtin/SubListSwapMoveProvider.java | 3 +- .../builtin/SubListUnassignMoveProvider.java | 2 +- .../builtin/SubPillarChangeMoveProvider.java | 13 ++- .../builtin/SubPillarSwapMoveProvider.java | 14 +-- .../SubPillarUnassignMoveProvider.java | 5 +- .../move/builtin/TwoOptListMoveProvider.java | 7 ++ .../stream/dataset/BiDatasetInstance.java | 2 +- .../stream/dataset/UniDatasetInstance.java | 2 +- .../stream/dataset/sample/DefaultSampler.java | 51 +++++++++++ .../stream/dataset/sample/Sampler.java | 19 ++-- .../stream/dataset/sample/Samplers.java | 53 +++++++++++ .../stream/dataset/sample/SubListSampler.java | 7 ++ .../bias/SamplingIteratorBiasIT.java | 6 +- .../dataset/sample/SamplingIteratorTest.java | 25 +++--- .../builtin/MassAssignMoveProviderTest.java | 8 +- .../builtin/MassChangeMoveProviderTest.java | 29 +++--- .../MassListAssignMoveProviderTest.java | 13 ++- .../MassListChangeMoveProviderTest.java | 23 +++-- .../MassListUnassignMoveProviderTest.java | 14 +-- .../builtin/MassUnassignMoveProviderTest.java | 12 +-- .../SubListChangeMoveProviderTest.java | 44 +++++++++ .../SubPillarChangeMoveProviderTest.java | 14 +-- .../SubPillarSwapMoveProviderTest.java | 73 ++++++++++++--- .../SubPillarUnassignMoveProviderTest.java | 8 +- .../stream/dataset/sample/SamplersTest.java | 89 +++++++++++++++++++ .../core/testutil/NeighborhoodTestUtils.java | 33 ------- 38 files changed, 484 insertions(+), 167 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java create mode 100644 core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index a951fc806c6..4266ec664eb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -7,6 +7,7 @@ import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.Supplier; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; @@ -26,7 +27,8 @@ public final class SampleAssembler { * * @return null if the source is empty */ - public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, Sampler sampler) { + public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, RandomGenerator random, + Sampler sampler) { if (!sourceIterator.hasNext()) { return null; } @@ -34,7 +36,7 @@ public final class SampleAssembler { var seed = sourceIterator.next(); var memberList = new ArrayList<@Nullable A>(); memberList.add(seed); - sampler.reset(seed); + sampler.reset(random, seed); while (sourceIterator.hasNext()) { var candidate = sourceIterator.next(); var decision = sampler.evaluate(memberList.size(), candidate); @@ -63,7 +65,7 @@ public final class SampleAssembler { * so retirement is local to one sample and the next sample starts from a full source */ public static Iterator> iterator(Supplier> sourceSupplier, - Sampler sampler) { + RandomGenerator random, Sampler sampler) { return new Iterator<>() { private @Nullable Sample pendingPillar = null; @@ -71,7 +73,7 @@ public static Iterator> iterator(Supplier> s @Override public boolean hasNext() { if (pendingPillar == null) { - pendingPillar = assemble(sourceSupplier.get(), sampler); + pendingPillar = assemble(sourceSupplier.get(), random, sampler); } return pendingPillar != null; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java index f799ec000e7..49b06a6c356 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java @@ -35,8 +35,14 @@ * but there, only non-reversing, and only as one destination row among many, * so it arrives rarely. * This class exists to make it happen often, and to also emit the reversing variant. + *

    + * The swapped tails are unbounded by design: + * a tail swap is defined by the tails, and bounding + * either one would produce a sub-list relocation instead, + * which {@code SubListChangeMoveProvider} already provides with a size cap. * * @see TwoOptListMoveProvider The same-entity reversal, and this same shape at a much lower rate. + * @see SubListChangeMoveProvider A bounded-size relocation instead of a whole tail. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java index 6696757513b..10252bcc790 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; @@ -31,7 +32,10 @@ * Samples of size less than 2 are excluded: {@code AssignMoveProvider} already covers them, * more cheaply. * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed. + * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed - + * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost + * linear in the data set size. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java index 18b8109ec2a..5fd96a93932 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; @@ -47,7 +48,10 @@ * Samples of size less than 2 are excluded: * {@code ChangeMoveProvider}/{@code AssignMoveProvider} already cover them, more cheaply. * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed. + * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed - + * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost + * linear in the data set size. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java index 74eac3b343b..1afd66b6ccd 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java @@ -10,6 +10,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; @@ -25,6 +26,10 @@ * otherwise the constructor throws {@link IllegalArgumentException}. *

    * Samples of size less than 2 are excluded: {@code ListAssignMoveProvider} already covers them, more cheaply. + * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost + * linear in the data set size. *

    * {@code MassListChangeMoveProvider} never produces this kind of move at all, * since its own source is currently assigned values only. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java index 5206bd4d610..da45a84cfd3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java @@ -10,6 +10,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; @@ -24,11 +25,14 @@ *

    * This provider never assigns: its source is currently-assigned values only. * Samples of size less than 2 are excluded: {@code ListChangeMoveProvider} already covers them, more cheaply. - * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all. + * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, + * s unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost + * linear in the data set size. *

    * A sample already sitting consecutively at the destination produces a move that changes nothing; - * this is accepted, in the same spirit as a mixed-value {@code MassChangeMoveProvider} sample that may leave some - * members unchanged. + * this is accepted, in the same spirit as a mixed-value {@code MassChangeMoveProvider} sample + * that may leave some members unchanged. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java index 5c5842cfd69..d446b9255b1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java @@ -14,11 +14,14 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; /** - * Draws {@link Sample}s, governed by a {@link Sampler}, + * Draws {@link Sample}s, governed by a {@link Sampler} - + * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move + * cost linear in the data set size - * out of the values currently assigned to any entity's {@link PlanningListVariable list variable}, * and creates a move to unassign every member at once. * Members need not share an entity or be adjacent; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java index d91305eea52..d35830bd300 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -13,11 +13,14 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; /** - * Draws {@link Sample}s, governed by a {@link Sampler}, + * Draws {@link Sample}s, governed by a {@link Sampler} - + * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move + * cost linear in the data set size - * out of the entities whose given basic planning variable is currently assigned a non-null value, of any value, * and creates a move to unassign every member at once * (set the basic planning variable to null). diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java index 92ad5280314..b3cf64a6091 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -38,16 +38,17 @@ * This provider never assigns: * a pillar's key is a value shared by its members, and unassigned is the absence of a value, not one. * Use {@code PillarUnassignMoveProvider} for unassign moves at a much higher rate; - * there is no pillar equivalent for assign, since it would require drawing a pillar keyed on "unassigned" - - * w either duplicate {@code MassAssignMoveProvider} (a subset) or, for the whole pillar, normally intersect to nothing - - * so use {@code MassAssignMoveProvider} or {@code AssignMoveProvider} instead. + * there is no pillar equivalent for assign, + * since it would require drawing a pillar keyed on "unassigned". *

    - * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@code SubPillarChangeMoveProvider}. + * Draws whole pillars only, unbounded by design: + * a pillar move is defined as moving every member of the pillar, + * so its cost is linear in the pillar's size with no cap. * * @see PillarUnassignMoveProvider Unassigning the whole pillar at a much higher rate. * @see MassAssignMoveProvider A sampler-chosen subset of the unassigned entities. * @see AssignMoveProvider Assigning a single entity at a time. - * @see SubPillarChangeMoveProvider A sampler-driven subset of the pillar. + * @see SubPillarChangeMoveProvider A sampler-driven, size-bounded subset of the pillar. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java index 7ac1c791274..8b0fe1de77a 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -39,9 +39,11 @@ * Size-1 pillars are legal on both sides of the swap, * since we need to be able to swap 1-sized pillar with an n-sized pillar. *

    - * Draws whole pillars only. For a sampler-driven subset of a pillar, see {@code SubPillarSwapMoveProvider}. + * Draws whole pillars only, unbounded by design: + * a pillar move is defined as moving every member of the pillar, + * so its cost is linear in the pillar's size with no cap. * - * @see SubPillarSwapMoveProvider A sampler-driven subset of the pillar. + * @see SubPillarSwapMoveProvider A sampler-driven, size-bounded subset of the pillar. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java index eecfe5ceff6..732ac37684b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java @@ -27,16 +27,15 @@ * is the size of the pillar members' value range), so it arrives rarely. * This class exists to make it happen often. *

    - * Draws whole pillars only. - * For a sampler-driven subset of a pillar, see {@code SubPillarUnassignMoveProvider}. - * For unassigning a single entity at a time, see {@code UnassignMoveProvider}. - * For assigning unassigned entities, see {@code MassAssignMoveProvider}. + * Draws whole pillars only, unbounded by design: + * a pillar move is defined as moving every member of the pillar, + * so its cost is linear in the pillar's size with no cap. *

    * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. * * @see PillarChangeMoveProvider Changing the whole pillar too, as one candidate among many. - * @see SubPillarUnassignMoveProvider A sampler-driven subset of the pillar. + * @see SubPillarUnassignMoveProvider A sampler-driven, size-bounded subset of the pillar. * @see UnassignMoveProvider Unassigning a single entity at a time. * @see MassAssignMoveProvider Assigning unassigned entities. * diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java index 86626778e88..c65ca743f31 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java @@ -64,7 +64,8 @@ public final class SubListChangeMoveProvider private final boolean crossingNull; public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel) { - this(variableMetaModel, 1, Integer.MAX_VALUE, true, variableMetaModel.allowsUnassignedValues()); + this(variableMetaModel, 1, SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE, true, + variableMetaModel.allowsUnassignedValues()); } public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java index ef5cb492599..74e88845dc4 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java @@ -52,7 +52,8 @@ public final class SubListSwapMoveProvider private final boolean selectReversingMoveToo; public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel) { - this(variableMetaModel, 1, Integer.MAX_VALUE, 1, Integer.MAX_VALUE, true); + this(variableMetaModel, 1, SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE, 1, + SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE, true); } public SubListSwapMoveProvider(PlanningListVariableMetaModel variableMetaModel, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java index 2a3d526d48f..8d56eeb323f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java @@ -49,7 +49,7 @@ public final class SubListUnassignMoveProvider private final int maximumSubListSize; public SubListUnassignMoveProvider(PlanningListVariableMetaModel variableMetaModel) { - this(variableMetaModel, 1, Integer.MAX_VALUE); + this(variableMetaModel, 1, SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE); } public SubListUnassignMoveProvider(PlanningListVariableMetaModel variableMetaModel, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java index 9e9bd636354..37d4f7ed31b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java @@ -23,13 +23,17 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** * For each subpillar of entities sharing a non-null value of the given variable, - * governed by a {@link Sampler}, + * governed by a {@link Sampler} - + * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, since an + * unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the + * pillar's size - * creates a move to change every member's value to a different value that is legal for every member. * The (sub)pillar is keyed on this one variable alone; * members may differ in every other variable. @@ -43,13 +47,6 @@ * This provider never assigns: * its key is a value shared by its members, * and unassigned is the absence of a value. - * Use {@code SubPillarUnassignMoveProvider} for unassign moves at a much higher rate; - * for assign, use {@code MassAssignMoveProvider} - * (which already draws sampler-chosen subsets of the unassigned entities) or {@code AssignMoveProvider}. - *

    - * For the whole pillar of every entity sharing the value at once, see {@code PillarChangeMoveProvider}. - * For a mixed-value sample with no shared key, which may include unassigned entities, see - * {@code MassChangeMoveProvider}. * * @see SubPillarUnassignMoveProvider Unassigning the whole subpillar at a much higher rate. * @see MassAssignMoveProvider A sampler-chosen subset of the unassigned entities. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 0097237e461..0a2ab79dd29 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -27,13 +27,16 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** - * Draws two subpillars, each governed by its own {@link Sampler}, + * Draws two subpillars, each governed by its own {@link Sampler} - + * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move + * cost linear in the pillars' combined size - * out of entities sharing the same combination of values across every variable given to the constructor, * and swaps that combination between the two subpillars, * provided at least one variable differs and every differing variable is legal on both sides; @@ -45,10 +48,11 @@ * For the whole pillar on both sides at once, see {@code PillarSwapMoveProvider}. *

    * There is no single-sampler overload: - * a {@link Sampler} may hold state across {@code decide} calls, - * and nothing in its contract promises that a sampler written for one side behaves safely - * if silently reused, unreset, for the other side. - * So the two sides always take separate instances. + * the two sides may want different policies (a tighter cap on one side, say), + * and passing two parameters says so explicitly. + * Sharing one instance between both sides is safe - + * see {@link Sampler}'s class documentation for why - + * but is rarely what a caller wants. * * @see PillarSwapMoveProvider The whole pillar on both sides at once. * diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java index f11c13a61ee..cc0719b1bcd 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -14,11 +14,14 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; /** - * Draws subpillars, governed by a {@link Sampler}, + * Draws subpillars, governed by a {@link Sampler} - + * see {@link Samplers} for ready-made ones, + * since an unbounded sampler makes this provider's move cost linear in the pillar's size - * of entities sharing a non-null value ("slice value") of the given variable * and creates a move to unassign every member at once (set the basic planning variable to null). * The (sub)pillar is keyed on this one variable alone; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java index 0c0d5bf04e1..f9b315d1e81 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java @@ -25,8 +25,15 @@ * When {@code crossingEntity} is {@code true}, * also allows a non-reversing tail swap between two different entities, * as one destination candidate among many - so it arrives rarely. + *

    + * The reversed span is unbounded by design: + * for a symmetric cost function a reversal changes only the two edges at its ends, + * so its score impact is independent of the span's length, + * unlike the {@code O(span)} notification cost of executing it. + * Capping the span would remove the classic 2-opt neighborhood for no scoring reason. * * @see ListTailSwapMoveProvider The dedicated, full-rate counterpart for the cross-entity shape. + * @see SubListChangeMoveProvider A bounded-size reversing relocation instead of an unbounded 2-opt. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java index 64697174248..03d5fdf193d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java @@ -73,7 +73,7 @@ default Iterator> samplingIterator(@Nullable A a, Sampler sampler, // the with-replacement draw wraps a filtering() join in a FilteringIterator that has a bail-out, // which reports "nothing left" while elements remain, // so a sample would end early and silently. - return SampleAssembler.iterator(() -> exhaustiveIterator(a, random), sampler); + return SampleAssembler.iterator(() -> exhaustiveIterator(a, random), random, sampler); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java index f1b936511a6..f985cf0d8df 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java @@ -86,7 +86,7 @@ default Iterator> samplingIterator(Sampler sampler, RandomGenerator // the with-replacement draw wraps a filtering() join in a FilteringIterator that has a bail-out, // which reports "nothing left" while elements remain, // so a sample would end early and silently. - return SampleAssembler.iterator(() -> exhaustiveIterator(random), sampler); + return SampleAssembler.iterator(() -> exhaustiveIterator(random), random, sampler); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java new file mode 100644 index 00000000000..d792a67b100 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java @@ -0,0 +1,51 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Stops a sample once it reaches a target size drawn uniformly from {@code [minimumSize, maximumSize]}, + * a fresh draw per sample. + */ +@NullMarked +final class DefaultSampler implements Sampler { + + private final int minimumSize; + private final int maximumSize; + private int targetSize; + + DefaultSampler(int minimumSize, int maximumSize) { + if (minimumSize < 1) { + throw new IllegalArgumentException("The minimumSize (%d) of a sampler must be at least 1." + .formatted(minimumSize)); + } + if (maximumSize < minimumSize) { + throw new IllegalArgumentException( + "The maximumSize (%d) of a sampler must be at least the minimumSize (%d)." + .formatted(maximumSize, minimumSize)); + } + this.minimumSize = minimumSize; + this.maximumSize = maximumSize; + this.targetSize = minimumSize; + } + + @Override + public void reset(RandomGenerator random, @Nullable A seed) { + targetSize = minimumSize == maximumSize ? minimumSize : random.nextInt(minimumSize, maximumSize + 1); + } + + @Override + public Decision evaluate(int sizeSoFar, @Nullable A candidate) { + if (targetSize <= 1) { + // sizeSoFar is already at least 1 (the seed) by the time evaluate is first called; + // ACCEPT_AND_STOP here would grow the sample to 2, not stop it at 1. + return Decision.STOP; + } + return sizeSoFar + 1 >= targetSize ? Decision.ACCEPT_AND_STOP : Decision.ACCEPT; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java index 9cf5c563fe9..5f2b15d3e83 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; import java.util.Iterator; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; @@ -9,9 +10,10 @@ /** * Decides which candidates join a {@link Sample}. + * Ready-made implementations are in {@link Samplers}. *

    * The first candidate drawn is always accepted and becomes the seed; - * {@link #reset(Object)} is called with it, + * {@link #reset(RandomGenerator, Object)} is called with it, * and {@link #evaluate(int, Object)} is first called for the second candidate, with {@code sizeSoFar == 1}. * A sample ends on {@link Decision#STOP}, {@link Decision#ACCEPT_AND_STOP}, * or when the source runs out. @@ -26,11 +28,14 @@ * one sample at a time. *

    * Move generation is single-threaded, + * and {@link #reset(RandomGenerator, Object)} runs before every sample + * and completes before {@link #evaluate(int, Object)} is called for that sample, * so a sampler may hold state across {@link #evaluate(int, Object)} calls - * and reset it in {@link #reset(Object)}. - * A sampler instance must not be shared between separate neighborhood providers, - * nor between the two sides of a swap: - * each side of a swap move provider always takes its own sampler instance. + * without it ever leaking into another sample. + * This makes a sampler instance safe to share between separate neighborhood providers, + * and between the two sides of a swap move provider, + * as long as sharing is what the caller wants + * (swap provider takes two separate sampler parameters so each side can use a different policy). * * @param the type of the sample's members */ @@ -41,9 +46,11 @@ public interface Sampler { * Called once per sample, after the seed is accepted and before the first * {@link #evaluate(int, Object)}. * + * @param random the solver's working random; safe to draw from to decide this sample's target + * size or any other per-sample state * @param seed the first candidate drawn, already a member */ - default void reset(@Nullable A seed) { + default void reset(RandomGenerator random, @Nullable A seed) { // Nothing to do by default. } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java new file mode 100644 index 00000000000..d92e26275bf --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java @@ -0,0 +1,53 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; + +import org.jspecify.annotations.NullMarked; + +/** + * Ready-made {@link Sampler} implementations. + */ +@NullMarked +public final class Samplers { + + /** + * @return a sampler that accepts every candidate offered, so a sample drains its whole source + * or slice + */ + public static Sampler all() { + return (sizeSoFar, candidate) -> Decision.ACCEPT; + } + + /** + * @param size the exact number of members every sample will have, + * or fewer if the source or slice runs dry first; at least 1 + * @return a sampler that stops a sample as soon as it reaches {@code size} members + */ + public static Sampler exactly(int size) { + return new DefaultSampler<>(size, size); + } + + /** + * @param maximumSize the largest number of members a sample may have; at least 1 + * @return a sampler that stops a sample once it reaches a size drawn uniformly from + * {@code [1, maximumSize]}, a fresh draw per sample + */ + public static Sampler upTo(int maximumSize) { + return new DefaultSampler<>(1, maximumSize); + } + + /** + * @param minimumSize the smallest number of members a sample may have; at least 1 + * @param maximumSize the largest number of members a sample may have; at least {@code minimumSize} + * @return a sampler that stops a sample once it reaches a size drawn uniformly from + * {@code [minimumSize, maximumSize]}, a fresh draw per sample + */ + public static Sampler between(int minimumSize, int maximumSize) { + return new DefaultSampler<>(minimumSize, maximumSize); + } + + private Samplers() { + // No external instances. + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java index c096a04a612..bc79d306d88 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -32,6 +32,13 @@ @NullMarked public final class SubListSampler { + /** + * The default {@code maximumSubListSize} of the built-in {@code SubList*MoveProvider} no-arg + * constructors: a sub-list relocation is blind to the score impact of the span it moves, so an + * unbounded span makes move cost linear in list size for no corresponding benefit. + */ + public static final int DEFAULT_MAXIMUM_SUB_LIST_SIZE = 10; + private final PlanningListVariableMetaModel variableMetaModel; private final int minimumSubListSize; private final TriangleElementFactory triangleElementFactory; diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java index 507d0a1b8b7..374d4a34a77 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.neighborhood.bias; import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import java.util.ArrayList; import java.util.EnumMap; @@ -12,6 +11,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -55,7 +55,7 @@ void uniSamplingIteratorSeedsEveryRowUniformly() { var root = new Random(0); BiasReport.tally("Uni samplingIterator, seed uniform over rows", trialCount, trial -> { var splitRandom = splitFrom(root); - var sample = instance.samplingIterator(sizeLimit(1), splitRandom).next(); + var sample = instance.samplingIterator(Samplers.exactly(1), splitRandom).next(); return sample.iterator().next().getCode(); }).expectUniform(valueList.stream().map(TestdataValue::getCode).toList()).assertWithinSigma(SIGMA_LIMIT); } @@ -94,7 +94,7 @@ void biSamplingIteratorMembersAreUniformAtEveryDrawPositionWithinASlice(int draw var root = new Random(0); BiasReport.tally("Bi samplingIterator, member uniform at draw #" + drawIndex, trialCount, trial -> { var splitRandom = splitFrom(root); - var sample = instance.samplingIterator(probe, sizeLimit(drawIndex), splitRandom).next(); + var sample = instance.samplingIterator(probe, Samplers.exactly(drawIndex), splitRandom).next(); var memberIterator = sample.iterator(); TestdataValue member = null; for (var i = 0; i < drawIndex; i++) { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 0b9b8f45b07..936320c5c12 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -1,23 +1,24 @@ package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.createSession; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimitFromTwo; import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; import java.util.List; +import java.util.random.RandomGenerator; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; import ai.timefold.solver.core.impl.solver.random.RandomSource; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; 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 SamplingIteratorTest { @@ -33,7 +34,7 @@ void samplingIteratorDrainsSourceWhenSamplerNeverStops() { var instance = session.getInstance(valueDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(Samplers.all(), random); // First sample should contain all values. assertThat(sampleIterator.hasNext()).isTrue(); @@ -61,7 +62,7 @@ void emptyDatasetHasNoNextSample() { var instance = session.getInstance(valueDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(Samplers.all(), random); assertThat(sampleIterator.hasNext()).isFalse(); } @@ -80,14 +81,14 @@ void samplerResetAndDecideAreCalledCorrectly() { var decideCalls = new ArrayList(); var recordingSampler = new Sampler() { @Override - public void reset(TestdataValue seed) { + public void reset(@NonNull RandomGenerator random, TestdataValue seed) { resetCalls.add(seed); } @Override public Decision evaluate(int sizeSoFar, TestdataValue candidate) { decideCalls.add(sizeSoFar); - return sizeLimitFromTwo(2).evaluate(sizeSoFar, candidate); + return Samplers. exactly(2).evaluate(sizeSoFar, candidate); } }; @@ -117,7 +118,7 @@ void sampleContainsNoDuplicates() { var instance = session.getInstance(valueDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(Samplers.all(), random); var firstSample = sampleIterator.next(); var seenElements = new ArrayList(); @@ -137,7 +138,7 @@ void sampleCanContainAndCheckForNullability() { var instance = session.getInstance(valueDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(Samplers.all(), random); var sample = sampleIterator.next(); @@ -165,7 +166,7 @@ void biSamplingIteratorEmptySliceHasNoNextSample() { var probe = new TestdataEntity("probe"); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(probe, Samplers.all(), random); assertThat(sampleIterator.hasNext()).isFalse(); } @@ -193,7 +194,7 @@ void biSamplingIteratorDrainsSliceWhenSamplerNeverStops() { var instance = session.getInstance(biDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(probe, Samplers.all(), random); // First sample should contain only the matching values. assertThat(sampleIterator.hasNext()).isTrue(); @@ -239,7 +240,7 @@ void biSamplingIteratorReachesAllMatchingMembersWithFilteredJoin() { var instance = session.getInstance(biDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(probe, Samplers.all(), random); // Sample should contain all 30 matching values despite the 70% rejection rate. assertThat(sampleIterator.hasNext()).isTrue(); @@ -271,7 +272,7 @@ void biSamplingIteratorDrainsCachedDatasetSliceWhenSamplerNeverStops() { var instance = session.getInstance(biDataset); var random = RandomSource.seeded(0L).moveIteratorUsage(); - var sampleIterator = instance.samplingIterator(probe, neverStoppingSampler(), random); + var sampleIterator = instance.samplingIterator(probe, Samplers.all(), random); // Cached dataset should also drain its slice correctly. assertThat(sampleIterator.hasNext()).isTrue(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java index 06f2d45519c..824354d30d3 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; @@ -8,6 +7,7 @@ import java.util.List; import java.util.stream.Collectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataValue; import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; @@ -38,7 +38,7 @@ void sampleMembersAreAlwaysASubsetOfTheUnassignedEntities() { solution.setEntityList(new ArrayList<>(allUnassigned)); var context = NeighborhoodTester - .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context @@ -74,7 +74,7 @@ void differentDrawsProduceDifferentSamples() { solution.setEntityList(new ArrayList<>(List.of(e0, e1, e2, e3, e4))); var context = NeighborhoodTester - .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var distinctMemberSets = context @@ -101,7 +101,7 @@ void pinnedUnassignedEntityNeverJoinsASample() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new MassAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java index a7e004fde5a..674f0af5798 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java @@ -1,12 +1,11 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.util.List; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -35,7 +34,8 @@ void mixedValueSampleYieldsOneMoveSettingEveryMemberToALegalValue() { var entity1 = solution.getEntityList().get(1); assertThat(entity0.getValue()).isNotEqualTo(entity1.getValue()); - var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (MassChangeMove) move) @@ -69,7 +69,7 @@ void assignSideDisabledExcludesUnassignedEntities() { // crossingNull=false: the source excludes unassigned entities entirely - not "isolating" the // assign side, but removing it, since false now governs the source too (see the class javadoc). var context = NeighborhoodTester - .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler(), false), solutionMetaModel) + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all(), false), solutionMetaModel) .using(solution); var moves = context @@ -99,7 +99,7 @@ void mixedSampleMayAssignOrUnassign() { // Default constructor: crossingNull is true, because this variable allows unassigned values. var context = NeighborhoodTester - .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all()), solutionMetaModel) .using(solution); var moves = context @@ -138,7 +138,8 @@ void crossingNullDefaultFalseWhenVariableDoesNotAllowUnassigned() { // Default constructor: crossingNull is false, because this variable does not allow unassigned // values - the constructor must not throw for the default, unlike an explicit true would. - var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (MassChangeMove) move) .limit(50) @@ -151,7 +152,7 @@ void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { var solutionMetaModel = TestdataSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); assertThatIllegalArgumentException() - .isThrownBy(() -> new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + .isThrownBy(() -> new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), true)); } @Test @@ -166,7 +167,8 @@ void homogeneousSampleNeverProducesANoOpMove() { entity.setValue(sharedValue); // Every entity shares one value -> every sample is homogeneous. } - var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (MassChangeMove) move) @@ -185,7 +187,8 @@ void sizeOneSampleProducesNoMove() { var solution = TestdataSolution.generateSolution(2, 5); - var context = NeighborhoodTester.build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + var context = NeighborhoodTester + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(1)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(10).toList(); @@ -209,12 +212,12 @@ void emptyIntersectionEndsTheIteratorInsteadOfHanging() { var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); solution.setEntityList(List.of(northEntity, southEntity)); - // neverStoppingSampler drains the whole 2-entity dataset every draw, so every sample is + // Samplers.all() drains the whole 2-entity dataset every draw, so every sample is // {north, south} and the intersection is always empty. crossingNull=false, explicitly: with // the default (true, since this variable allows unassigned values) a null destination is // still legal - see emptyIntersectionStillYieldsNullDestinationWhenCrossingNull below. var context = NeighborhoodTester - .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler(), false), solutionMetaModel) + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all(), false), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(10).toList(); @@ -242,7 +245,7 @@ void emptyIntersectionStillYieldsNullDestinationWhenCrossingNull() { // ordering: the non-null intersection is still empty, but a null destination (unassigning the // whole sample) is legal for both entities regardless, so it is offered instead of nothing. var context = NeighborhoodTester - .build(new MassChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all()), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -271,7 +274,7 @@ void pinnedEntitySharingAValueWithFreeEntitiesNeverJoinsASample() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new MassChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java index c738138704d..f3f8bf19b6e 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java @@ -1,12 +1,11 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.util.List; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataValue; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; @@ -36,7 +35,7 @@ void constructorRejectsNonUnassignedVariable() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); assertThatIllegalArgumentException() - .isThrownBy(() -> new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2))); + .isThrownBy(() -> new MassListAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2))); } @Test @@ -54,7 +53,7 @@ void sampleMembersAreAlwaysASubsetOfTheUnassignedValues() { solution.setValueList(List.of(assigned, unassigned1, unassigned2)); var context = NeighborhoodTester - .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -84,7 +83,7 @@ void destinationRespectsEveryMembersValueRange() { solution.setEntityList(List.of(entityA, entityB)); var context = NeighborhoodTester - .build(new MassListAssignMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .build(new MassListAssignMoveProvider<>(variableMetaModel, Samplers.all()), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -120,7 +119,7 @@ void destinationNeverFallsInThePinnedPrefix() { solution.setValueList(List.of(pinned1, pinned2, free1, unassigned1, unassigned2)); var context = NeighborhoodTester - .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -153,7 +152,7 @@ void fullyPinnedEntityIsNeverAssignedInto() { solution.setValueList(List.of(pinnedValues.get(0), unassigned1, unassigned2)); var context = NeighborhoodTester - .build(new MassListAssignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java index 9b0f09aebdd..326b783df02 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java @@ -1,7 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.neverStoppingSampler; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -10,6 +8,7 @@ import java.util.List; import java.util.stream.Collectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataValue; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; @@ -49,7 +48,7 @@ void sizeOneSampleProducesNoMove() { solution.setValueList(List.of(values)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(1)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(10).toList(); @@ -71,7 +70,7 @@ void sampleNeverIncludesAnUnassignedValue() { solution.setValueList(List.of(assigned1, assigned2, unassigned)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -102,7 +101,7 @@ void destinationRespectsEveryMembersValueRange() { solution.setEntityList(List.of(entityA, entityB)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, neverStoppingSampler()), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.all()), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -138,7 +137,7 @@ void crossingNullDefaultTrueAlsoUnassignsWholeSample() { solution.setValueList(List.of(values)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( move -> (MassListChangeMove) move) @@ -163,7 +162,7 @@ void crossingNullFalseNeverUnassignsSample() { solution.setValueList(List.of(values)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2), false), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), false), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( move -> (MassListChangeMove) move) @@ -179,7 +178,7 @@ void constructorRejectsExplicitCrossingNullOnVariableWithoutUnassignedValues() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); assertThatIllegalArgumentException() - .isThrownBy(() -> new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + .isThrownBy(() -> new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), true)); } @Test @@ -198,7 +197,7 @@ void differentDrawsProduceDifferentSamples() { solution.setValueList(List.of(values)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(3)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(3)), solutionMetaModel) .using(solution); var distinctSamples = context @@ -227,7 +226,7 @@ void pinnedValueNeverJoinsASample() { solution.setValueList(List.of(pinned1, pinned2, free1, free2)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -259,7 +258,7 @@ void destinationNeverFallsInThePinnedPrefix() { solution.setValueList(List.of(pinned1, pinned2, free1, free2)); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context @@ -303,7 +302,7 @@ void fullyPinnedEntityIsNeverDrawnFromOrInto() { solution.setValueList(allValues); var context = NeighborhoodTester - .build(new MassListChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java index 4ded43f0f83..a449b03894f 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.util.ArrayList; import java.util.List; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; import ai.timefold.solver.core.testdomain.list.TestdataListSolution; @@ -33,7 +33,7 @@ void constructorRejectsNonUnassignedVariable() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); assertThatIllegalArgumentException() - .isThrownBy(() -> new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2))); + .isThrownBy(() -> new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2))); } @Test @@ -52,7 +52,7 @@ void everyMoveHasANullDestination() { solution.setValueList(List.of(values)); var context = NeighborhoodTester - .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(3)), solutionMetaModel) + .build(new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(3)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -80,11 +80,11 @@ void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { solution.setEntityList(List.of(entity)); solution.setValueList(List.of(values)); - // sizeLimit(1) stops right at the seed, so every sample this sampler produces is size-1; + // Samplers.exactly(1) stops right at the seed, so every sample this sampler produces is size-1; // the provider must not treat that as a rejection, or the iterator would spin (or run dry) instead // of steadily producing moves. var context = NeighborhoodTester - .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + .build(new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(1)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(50).toList(); @@ -108,7 +108,7 @@ void pinnedValueNeverJoinsASample() { solution.setValueList(List.of(pinned1, pinned2, free1, free2)); var context = NeighborhoodTester - .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -147,7 +147,7 @@ void fullyPinnedEntityContributesNoMembers() { solution.setValueList(allValues); var context = NeighborhoodTester - .build(new MassListUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java index d485a899aad..7024b9efe7e 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java @@ -1,11 +1,11 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import java.util.List; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -26,7 +26,7 @@ void constructorRejectsNonUnassignedVariable() { var solutionMetaModel = TestdataSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); assertThatIllegalArgumentException() - .isThrownBy(() -> new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2))); + .isThrownBy(() -> new MassUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2))); } @Test @@ -45,7 +45,8 @@ void mixedValueSampleYieldsOneMoveNullingEveryMember() { solution.setEntityList(List.of(e0, e1, unassignedEntity)); var context = - NeighborhoodTester.build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + NeighborhoodTester + .build(new MassUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -71,7 +72,8 @@ void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 5); var context = - NeighborhoodTester.build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(1)), solutionMetaModel) + NeighborhoodTester + .build(new MassUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(1)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -100,7 +102,7 @@ void pinnedEntityNeverJoinsASample() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new MassUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new MassUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java index 3ead1ac8d88..6dc9de90d00 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.stream.Collectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.list.TestdataListEntity; import ai.timefold.solver.core.testdomain.list.TestdataListSolution; @@ -55,6 +56,49 @@ void drawnSpanLengthsRespectMinAndMax() { } } + @Test + void noArgConstructorNeverExceedsDefaultMaximumSubListSize() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + var listSize = SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE * 3; + var values = new TestdataListValue[listSize]; + for (var i = 0; i < listSize; i++) { + values[i] = new TestdataListValue("v" + i); + } + var entity = new TestdataListEntity("A", values); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entity)); + solution.setValueList(List.of(values)); + + var context = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + + var moves = context + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getSource().length()).isLessThanOrEqualTo(SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE); + } + + // The explicit constructor is untouched by the no-arg default: a larger maximum still applies. + var contextWithLargerMax = NeighborhoodTester + .build(new SubListChangeMoveProvider<>(variableMetaModel, 1, listSize), solutionMetaModel) + .using(solution); + var movesWithLargerMax = contextWithLargerMax + .getMovesAsStream( + move -> (SubListChangeMove) move) + .limit(300) + .toList(); + assertThat(movesWithLargerMax) + .anyMatch(move -> move.getSource().length() > SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE); + } + @Test void drawnSpanNeverTouchesPinnedPrefix() { var solutionMetaModel = TestdataPinnedWithIndexListSolution.buildSolutionDescriptor().getMetaModel(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java index 12ba1df1bde..dff22ef1135 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -8,6 +7,7 @@ import java.util.List; import java.util.stream.Collectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -36,7 +36,7 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (MassChangeMove) move) @@ -66,7 +66,7 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var distinctMemberSets = context @@ -95,7 +95,7 @@ void pinnedEntityExcludedFromSubpillar() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context @@ -122,7 +122,7 @@ void crossingNullDefaultTrueAlsoUnassignsSubpillar() { // Default constructor: crossingNull is true, because this variable allows unassigned values. var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context @@ -146,7 +146,7 @@ void crossingNullFalseNeverUnassignsSubpillar() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2), false), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), false), solutionMetaModel) .using(solution); var moves = context @@ -163,7 +163,7 @@ void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { var solutionMetaModel = TestdataSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); assertThatIllegalArgumentException() - .isThrownBy(() -> new SubPillarChangeMoveProvider<>(variableMetaModel, sizeLimit(2), true)); + .isThrownBy(() -> new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), true)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java index 159b1ada01a..2003dcb4833 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -10,6 +9,7 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -47,7 +47,8 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { var pillarB = new HashSet<>(entityList.subList(5, 10)); var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) @@ -79,7 +80,8 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + solutionMetaModel) .using(solution); var distinctMemberSets = context.getMovesAsStream(move -> (PillarSwapMove) move) @@ -89,6 +91,48 @@ void differentDrawsProduceDifferentSubpillars() { assertThat(distinctMemberSets).hasSizeGreaterThan(1); } + @Test + void sharedSamplerInstanceOnBothSidesMatchesTwoSeparateInstances() { + var solutionMetaModel = TestdataSolution.buildMetaModel(); + var entityMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class); + + var solution = TestdataSolution.generateSolution(2, 10); + var entityList = solution.getEntityList(); + var valueList = solution.getValueList(); + for (var i = 0; i < 5; i++) { + entityList.get(i).setValue(valueList.getFirst()); + } + for (var i = 5; i < 10; i++) { + entityList.get(i).setValue(valueList.get(1)); + } + + // Two separate, independently-stateful sampler instances - the ordinary way to call this + // constructor. + var separateContext = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.between(2, 4), Samplers.between(2, 4)), + solutionMetaModel) + .using(solution); + var movesWithSeparateSamplers = + separateContext.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(200) + .toList(); + + // One stateful sampler instance shared by both sides. Safe per Sampler's class documentation: + // reset() runs before every sample and a sample is assembled in full before it is returned, + // so sharing does not change which moves are produced - NeighborhoodTester always reseeds the + // working random the same way, so the two runs are directly comparable. + var sharedSampler = Samplers. between(2, 4); + var sharedContext = NeighborhoodTester + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sharedSampler, sharedSampler), solutionMetaModel) + .using(solution); + var movesWithSharedSampler = + sharedContext.getMovesAsStream(move -> (PillarSwapMove) move) + .limit(200) + .toList(); + + assertThat(movesWithSharedSampler).isEqualTo(movesWithSeparateSamplers); + } + @Test void bothSidesRespectTheirOwnRule() { var solutionMetaModel = TestdataSolution.buildMetaModel(); @@ -105,7 +149,8 @@ void bothSidesRespectTheirOwnRule() { } var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(1), sizeLimit(3)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(1), Samplers.exactly(3)), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) @@ -134,7 +179,8 @@ void pinnedEntityNeverParticipatesInSubpillarSwap() { solution.setEntityList(List.of(pinnedEntity, free1, free2, free3)); var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) @@ -159,7 +205,7 @@ void multivariateSwapUsesEveryListedVariable() { var otherValue0 = new TestdataOtherValue("o0"); // Pillar A and pillar B agree on primary and tertiary, differing only on secondary. Both - // groups have exactly 2 members, so a sizeLimit(2) sampler always draws the whole group, + // groups have exactly 2 members, so a Samplers.exactly(2) sampler always draws the whole group, // making the subpillar swap deterministic - same setup as // PillarSwapMoveProviderTest.atLeastOneDifferingVariableProducesMove. var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); @@ -176,7 +222,8 @@ void multivariateSwapUsesEveryListedVariable() { var pillarB = Sample.of(List.of(b1, b2)); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.exactly(2), Samplers.exactly(2)), + solutionMetaModel) .using(solution) .producesAllOf( Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), @@ -210,7 +257,7 @@ void excludedVariableIsIgnoredForKeyAndSwap() { solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); var moveList = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(allowedVariableMetaModelList, sizeLimit(2), sizeLimit(2)), + .build(new SubPillarSwapMoveProvider<>(allowedVariableMetaModelList, Samplers.exactly(2), Samplers.exactly(2)), solutionMetaModel) .using(solution) .getMovesAsStream() @@ -245,7 +292,8 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { solution.setEntityList(List.of(a1, b1)); var moveList = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, sizeLimit(1), sizeLimit(1)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.exactly(1), Samplers.exactly(1)), + solutionMetaModel) .using(solution) .getMovesAsStream() .limit(50) @@ -272,11 +320,12 @@ void singleVariableConstructorMatchesOneElementList() { var expectedMoveBtoA = Moves.pillarSwap(variableMetaModel, pillarB, pillarA); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModel, sizeLimit(2), sizeLimit(2)), solutionMetaModel) + .build(new SubPillarSwapMoveProvider<>(variableMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + solutionMetaModel) .using(solution) .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(List.of(variableMetaModel), sizeLimit(2), sizeLimit(2)), + .build(new SubPillarSwapMoveProvider<>(List.of(variableMetaModel), Samplers.exactly(2), Samplers.exactly(2)), solutionMetaModel) .using(solution) .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); @@ -285,7 +334,7 @@ void singleVariableConstructorMatchesOneElementList() { @Test void emptyListConstructorThrows() { assertThatThrownBy(() -> new SubPillarSwapMoveProvider(List.of(), - sizeLimit(2), sizeLimit(2))) + Samplers.exactly(2), Samplers.exactly(2))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("is empty"); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java index 417c95a3412..f007b053fb0 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -1,12 +1,12 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import static ai.timefold.solver.core.testutil.NeighborhoodTestUtils.sizeLimit; import static org.assertj.core.api.Assertions.assertThat; import java.util.HashSet; import java.util.List; import java.util.stream.Collectors; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.test.NeighborhoodTester; import ai.timefold.solver.core.testdomain.TestdataValue; import ai.timefold.solver.core.testdomain.pinned.unassignedvar.TestdataPinnedAllowsUnassignedEntity; @@ -33,7 +33,7 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { } var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -65,7 +65,7 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var distinctMemberSets = context.getMovesAsStream( @@ -91,7 +91,7 @@ void pinnedEntityNeverUnassigned() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, sizeLimit(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java new file mode 100644 index 00000000000..a8a338495b9 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java @@ -0,0 +1,89 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.HashSet; +import java.util.List; +import java.util.random.RandomGenerator; +import java.util.stream.IntStream; + +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; +import ai.timefold.solver.core.impl.solver.random.RandomSource; + +import org.junit.jupiter.api.Test; + +class SamplersTest { + + private static List sourceOf(int size) { + return IntStream.range(0, size).boxed().toList(); + } + + private static RandomGenerator seededRandom() { + return RandomSource.seeded(0L).moveIteratorUsage(); + } + + @Test + void exactlyYieldsExactSizeOnALargerSource() { + var sample = SampleAssembler.assemble(sourceOf(10).iterator(), seededRandom(), Samplers.exactly(4)); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(4); + } + + @Test + void exactlyYieldsSourceSizeWhenSourceIsSmaller() { + var sample = SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), Samplers. exactly(10)); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(3); + } + + @Test + void exactlyOneStopsAtTheSeed() { + var sample = SampleAssembler.assemble(sourceOf(10).iterator(), seededRandom(), Samplers. exactly(1)); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(1); + } + + @Test + void upToStaysWithinRangeAndVaries() { + var random = seededRandom(); + var sizes = new HashSet(); + var sampler = Samplers. upTo(8); + for (var i = 0; i < 50; i++) { + var sample = SampleAssembler.assemble(sourceOf(20).iterator(), random, sampler); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isBetween(1, 8); + sizes.add(sample.size()); + } + assertThat(sizes).hasSizeGreaterThan(1); + } + + @Test + void betweenStaysWithinRangeAndVaries() { + var random = seededRandom(); + var sizes = new HashSet(); + var sampler = Samplers. between(3, 7); + for (var i = 0; i < 50; i++) { + var sample = SampleAssembler.assemble(sourceOf(20).iterator(), random, sampler); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isBetween(3, 7); + sizes.add(sample.size()); + } + assertThat(sizes).hasSizeGreaterThan(1); + } + + @Test + void allDrainsTheSource() { + var sample = SampleAssembler.assemble(sourceOf(15).iterator(), seededRandom(), Samplers.all()); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(15); + } + + @Test + void constructorGuardsRejectInvalidSizes() { + assertThatIllegalArgumentException().isThrownBy(() -> Samplers.exactly(0)); + assertThatIllegalArgumentException().isThrownBy(() -> Samplers.upTo(0)); + assertThatIllegalArgumentException().isThrownBy(() -> Samplers.between(3, 2)); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java b/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java index 1699f74c73f..6e94c2ebd93 100644 --- a/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java +++ b/core/src/test/java/ai/timefold/solver/core/testutil/NeighborhoodTestUtils.java @@ -1,50 +1,17 @@ package ai.timefold.solver.core.testutil; -import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT; -import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT_AND_STOP; -import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.STOP; - import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultNeighborhoodSession; import ai.timefold.solver.core.impl.score.director.SessionContext; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; -import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import org.jspecify.annotations.NullMarked; @NullMarked public final class NeighborhoodTestUtils { - /** Accepts everything, so a sample drains its whole source. */ - public static Sampler neverStoppingSampler() { - return (sizeSoFar, candidate) -> ACCEPT; - } - - /** - * Stops the sample at exactly {@code limit} members ({@code limit <= 1} stops right after the - * seed, since the seed itself is never subject to {@code decide}). - */ - public static Sampler sizeLimit(int limit) { - if (limit <= 1) { - return (sizeSoFar, candidate) -> STOP; - } - return sizeLimitFromTwo(limit); - } - - /** - * As defined by {@link #sizeLimit(int)}, - * but without the {@code limit <= 1} guard. - * Requires {@code limit >= 2}; - * a smaller limit does not stop the sample where the caller expects. - */ - public static Sampler sizeLimitFromTwo(int limit) { - return (sizeSoFar, candidate) -> sizeSoFar + 1 >= limit - ? ACCEPT_AND_STOP - : ACCEPT; - } - /** * Builds and settles a {@code DatasetSession} directly * (bypassing a real {@code ScoreDirector} and solver), From 3d94b32546c0468c8ff9e30d9ceacb8cd9a525e9 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 18:31:53 +0200 Subject: [PATCH 06/16] Fix the samplers --- .../dataset/sample/SampleAssembler.java | 81 +++++-- .../move/builtin/MassAssignMoveProvider.java | 7 +- .../move/builtin/MassChangeMoveProvider.java | 7 +- .../builtin/MassDestinationMoveIterator.java | 14 +- .../builtin/MassListAssignMoveProvider.java | 5 +- .../builtin/MassListChangeMoveProvider.java | 5 +- .../MassListDestinationMoveIterator.java | 13 +- .../builtin/MassListUnassignMoveProvider.java | 12 +- .../builtin/MassUnassignMoveProvider.java | 15 +- .../builtin/SubListChangeMoveProvider.java | 3 +- .../move/builtin/SubListSwapMoveProvider.java | 5 +- .../builtin/SubListUnassignMoveProvider.java | 3 +- .../builtin/SubPillarChangeMoveProvider.java | 23 +- .../builtin/SubPillarSwapMoveProvider.java | 36 ++-- .../SubPillarUnassignMoveProvider.java | 40 ++-- .../stream/dataset/BiDatasetInstance.java | 26 +++ .../stream/dataset/UniDatasetInstance.java | 2 + .../stream/dataset/sample/DefaultRange.java | 4 +- .../stream/dataset/sample/DefaultSampler.java | 12 +- .../dataset/sample/DefaultSubListSampler.java | 51 +++++ .../stream/dataset/sample/PillarSampler.java | 61 ++++++ .../stream/dataset/sample/Range.java | 13 +- .../stream/dataset/sample/Sample.java | 2 - .../stream/dataset/sample/Sampler.java | 43 ++-- .../stream/dataset/sample/Samplers.java | 53 ++++- .../stream/dataset/sample/SubListSampler.java | 53 +---- .../bias/SubListSamplingBiasIT.java | 5 +- .../dataset/sample/SamplingIteratorTest.java | 78 ++++++- .../SubPillarChangeMoveProviderTest.java | 18 +- .../SubPillarSwapMoveProviderTest.java | 40 ++-- .../SubPillarUnassignMoveProviderTest.java | 9 +- .../stream/dataset/sample/SamplersTest.java | 202 +++++++++++++++++- 32 files changed, 731 insertions(+), 210 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java create mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 4266ec664eb..23b8348d122 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -1,6 +1,8 @@ package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; -import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.*; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT_AND_STOP; +import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.STOP; import java.util.ArrayList; import java.util.Iterator; @@ -9,6 +11,7 @@ import java.util.function.Supplier; import java.util.random.RandomGenerator; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; @@ -25,34 +28,63 @@ public final class SampleAssembler { * it retires every element it hands out, so a candidate is offered to a given sample exactly once, * and it has no bail-out, so it does not report "nothing left" while elements remain. * - * @return null if the source is empty + * @return null if the source is empty, if the sampler refuses the sample, or if the sample ends + * below the sampler's {@link Sampler#minimumSize()} */ public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, RandomGenerator random, Sampler sampler) { - if (!sourceIterator.hasNext()) { - return null; + sampler.reset(random); + return assemble(sourceIterator, sampler.minimumSize(), sampler, sampler); + } + + /** + * As defined by {@link #assemble(Iterator, RandomGenerator, Sampler)}, but for a {@link PillarSampler} + * drawn under a key. + */ + public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, RandomGenerator random, + @Nullable Key_ key, PillarSampler sampler) { + sampler.reset(random, key); + return assemble(sourceIterator, sampler.minimumSize(), sampler, sampler::evaluate); + } + + private static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, int minimumSize, + Object sampler, Sampler evaluator) { + if (minimumSize < 1) { + throw new IllegalArgumentException("The minimumSize (%d) of sampler (%s) must be at least 1." + .formatted(minimumSize, sampler)); } - // The first draw is always accepted and seeds the sample. - var seed = sourceIterator.next(); var memberList = new ArrayList<@Nullable A>(); - memberList.add(seed); - sampler.reset(random, seed); + var stoppedBySampler = false; while (sourceIterator.hasNext()) { var candidate = sourceIterator.next(); - var decision = sampler.evaluate(memberList.size(), candidate); + var decision = evaluator.evaluate(memberList.size(), candidate); if (decision == ACCEPT || decision == ACCEPT_AND_STOP) { memberList.add(candidate); } if (decision == STOP || decision == ACCEPT_AND_STOP) { + stoppedBySampler = true; break; } } - return Sample.of(memberList); + if (memberList.size() >= minimumSize) { + return Sample.of(memberList); + } + if (stoppedBySampler) { + // The sampler chose to stop itself, below its own declared floor: + // a contract bug, not an undersized world. + // A dry source without a sampler-initiated STOP falls through to the silent null below instead. + throw new IllegalStateException( + "The sampler (%s) stopped the sample at size (%d), below its own minimumSize (%d)." + .formatted(sampler, memberList.size(), minimumSize)); + } + // Source ran dry before reaching the minimum; not the sampler's fault. + return null; } /** * Samples with replacement: - * never ends unless the source is empty; + * never ends unless the source is empty or every draw is refused (a sampler refusal, or a sample + * below the sampler's {@link Sampler#minimumSize()}); * may return equal samples. * Each sample is assembled in full before it is returned, * so the sampler is never left mid-sample. @@ -66,16 +98,29 @@ public final class SampleAssembler { */ public static Iterator> iterator(Supplier> sourceSupplier, RandomGenerator random, Sampler sampler) { + return iterator(() -> assemble(sourceSupplier.get(), random, sampler)); + } + + /** + * As defined by {@link #iterator(Supplier, RandomGenerator, Sampler)}, but for a + * {@link PillarSampler} drawn under a key. + */ + public static Iterator> iterator(Supplier> sourceSupplier, + RandomGenerator random, @Nullable Key_ key, PillarSampler sampler) { + return iterator(() -> assemble(sourceSupplier.get(), random, key, sampler)); + } + + private static Iterator> iterator(Supplier<@Nullable Sample> sampleSupplier) { return new Iterator<>() { - private @Nullable Sample pendingPillar = null; + private @Nullable Sample pendingSample = null; @Override public boolean hasNext() { - if (pendingPillar == null) { - pendingPillar = assemble(sourceSupplier.get(), random, sampler); + if (pendingSample == null) { + pendingSample = sampleSupplier.get(); } - return pendingPillar != null; + return pendingSample != null; } @Override @@ -83,9 +128,9 @@ public Sample next() { if (!hasNext()) { throw new NoSuchElementException(); } - var pillar = Objects.requireNonNull(pendingPillar); - pendingPillar = null; - return pillar; + var sample = Objects.requireNonNull(pendingSample); + pendingSample = null; + return sample; } }; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java index 10252bcc790..2352e071881 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java @@ -31,8 +31,11 @@ *

    * Samples of size less than 2 are excluded: {@code AssignMoveProvider} already covers them, * more cheaply. - * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed - + * A {@link Sampler} whose very first {@code evaluate(0, ...)} call already returns {@code STOP} + * or {@code ACCEPT_AND_STOP} produces only size-1 samples, + * which this provider discards outright; + * use {@code MassUnassignMoveProvider}, + * or a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost * linear in the data set size. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java index 5fd96a93932..118839e9344 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java @@ -47,8 +47,11 @@ *

    * Samples of size less than 2 are excluded: * {@code ChangeMoveProvider}/{@code AssignMoveProvider} already cover them, more cheaply. - * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; - * use {@code MassUnassignMoveProvider} or a sampler that grows past the seed - + * A {@link Sampler} whose very first {@code evaluate(0, ...)} call already returns {@code STOP} + * or {@code ACCEPT_AND_STOP} produces only size-1 samples, + * which this provider discards outright; + * use {@code MassUnassignMoveProvider}, + * or a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost * linear in the data set size. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java index b603d2ff9f7..c53829f1ec9 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java @@ -26,8 +26,11 @@ *

    * A plain {@link Iterator}, not a {@link RetiringBiWalk}: * there is no left value to retire. - * {@code samplingIterator} never ends while {@code sourceDataset} is non-empty, - * so {@link #hasNext()} bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, + * {@code samplingIterator} may end early - + * the sampler can refuse a draw, or {@code sourceDataset} can be smaller than the sampler's minimum size - + * and {@link #hasNext()} then simply ends too, + * with no hang, since each call tries a fresh source. + * It otherwise bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, * matching the fixed-width-probe design of the pillar family. * This is not neutral to every model, but it costs nothing on the two that matter: * a solution-wide {@link ValueRange} is one deduplicated range, @@ -80,9 +83,10 @@ final class MassDestinationMoveIterator implements I @Override public boolean hasNext() { - // sampleIterator never ends while sourceDataset is non-empty - // (see UniDatasetInstance#samplingIterator's contract). - // A real Sampler can draw a different sample on every call, + // sampleIterator.hasNext() can return false early - a sampler refusal, or sourceDataset + // smaller than the sampler's minimum size - and the while condition below ends this call + // right there, with no hang: each call tries a fresh source, independent of the last. + // Otherwise, a real Sampler can draw a different sample on every call, // so failed draws are counted and this call gives up once they reach RetiringBiWalk.PROBE_ATTEMPT_COUNT. var failedSampleDraws = 0; while (nextMove == null && sampleIterator.hasNext() && failedSampleDraws < RetiringBiWalk.PROBE_ATTEMPT_COUNT) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java index 1afd66b6ccd..4355d04304d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java @@ -26,7 +26,10 @@ * otherwise the constructor throws {@link IllegalArgumentException}. *

    * Samples of size less than 2 are excluded: {@code ListAssignMoveProvider} already covers them, more cheaply. - * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * A {@link Sampler} whose very first {@code evaluate(0, ...)} call already returns {@code STOP} + * or {@code ACCEPT_AND_STOP} produces only size-1 samples, + * which this provider discards outright; + * a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 avoids that - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost * linear in the data set size. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java index da45a84cfd3..d8ff84c407f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java @@ -25,7 +25,10 @@ *

    * This provider never assigns: its source is currently-assigned values only. * Samples of size less than 2 are excluded: {@code ListChangeMoveProvider} already covers them, more cheaply. - * A {@link Sampler} that stops right at the seed (see its own contract) therefore yields no move here at all; + * A {@link Sampler} whose very first {@code evaluate(0, ...)} call already returns {@code STOP} + * or {@code ACCEPT_AND_STOP} produces only size-1 samples, + * which this provider discards outright; + * a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 avoids that - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, * s unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost * linear in the data set size. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java index e0ee8e92d08..7c7b8dcc82b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java @@ -28,8 +28,11 @@ * producing a {@code MassListChangeMove}. *

    * A plain {@link Iterator}, not a {@link RetiringBiWalk}: there is no left value to retire. - * {@code samplingIterator} never ends while {@code sourceDataset} is non-empty, - * so {@link #hasNext()} bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, + * {@code samplingIterator} may end early - + * the sampler can refuse a draw, or {@code sourceDataset} can be smaller than the sampler's minimum size - + * and {@link #hasNext()} then simply ends too, with no hang, + * since each call tries a fresh source. + * It otherwise bounds itself with a flat {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} failed-draw budget, * matching {@code MassDestinationMoveIterator}. * The destination search itself is a separate, pool-scaled budget, * matching {@code SubListChangeMoveProvider.SubListChangeMoveIterator.createRightIterator}. @@ -63,8 +66,10 @@ final class MassListDestinationMoveIterator implemen @Override public boolean hasNext() { - // sampleIterator never ends while sourceDataset is non-empty (see UniDatasetInstance#samplingIterator's - // contract), so failed draws are counted and this call gives up once they reach + // sampleIterator.hasNext() can return false early - a sampler refusal, or sourceDataset + // smaller than the sampler's minimum size - and the while condition below ends this call + // right there, with no hang: each call tries a fresh source, independent of the last. + // Otherwise, failed draws are counted and this call gives up once they reach // RetiringBiWalk.PROBE_ATTEMPT_COUNT. var failedSampleDraws = 0; while (nextMove == null && sampleIterator.hasNext() && failedSampleDraws < RetiringBiWalk.PROBE_ATTEMPT_COUNT) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java index d446b9255b1..221c7fac645 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java @@ -77,11 +77,13 @@ public MoveStream build(MoveStreamFactory moveStreamFactor *

    * Unlike {@link MassListAssignMoveProvider}/{@link MassListChangeMoveProvider}, * size-1 samples are not skipped here, and deliberately so: - * skipping would give this iterator its first way to reject a draw, - * but {@code samplingIterator} never ends while the source dataset is non-empty, - * and a size-1 sample can be all a given {@link Sampler} ever produces. - * Emitting the size-1 sample costs nothing and removes the problem at its root, - * since unassigning one value is no more expensive than unassigning several. + * unassigning one value is no more expensive than unassigning several, + * so there is nothing to gain by discarding it. + * A {@link Sampler} whose {@link Sampler#minimumSize() minimumSize} is greater than 1 can still + * refuse a draw on a dataset smaller than that minimum - + * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, + * ending this iterator without spinning, + * since each call is independent and tries a fresh source. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java index d35830bd300..49ffe35953b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -79,14 +79,13 @@ public MoveStream build(MoveStreamFactory moveStreamFactor *

    * Unlike {@link MassAssignMoveProvider}/{@link MassChangeMoveProvider}, * size-1 samples are not skipped here, and deliberately so: - * skipping would give this iterator its first way to reject a draw, - * but {@code samplingIterator} never ends while the source dataset is non-empty, - * and a size-1 sample can be all a given {@link Sampler} ever produces. - * Any guard against the resulting hang costs something - - * a source-size early-out still spins against a {@link Sampler} that stops after the seed, - * and a failed-draw counter is machinery this provider does not otherwise need. - * Emitting the size-1 sample costs nothing and removes the problem at its root, - * since unassigning one entity is no more expensive than unassigning several. + * unassigning one entity is no more expensive than unassigning several, + * so there is nothing to gain by discarding it. + * A {@link Sampler} whose {@link Sampler#minimumSize() minimumSize} is greater than 1 can still + * refuse a draw on a dataset smaller than that minimum - + * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, + * ending this iterator without spinning, + * since each call is independent and tries a fresh source. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java index c65ca743f31..173ab8b7073 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java @@ -24,6 +24,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; import org.jspecify.annotations.NullMarked; @@ -157,7 +158,7 @@ private static final class SubListChangeMoveIterator var sourceInstance = (DefaultUniDatasetInstance) session.getInstance(sourceDataset); this.sliceValueIterator = sourceInstance.retiringRandomIterator(random); this.destinationInstance = session.getInstance(destinationDataset); - this.sampler = new SubListSampler<>(variableMetaModel, minimumSubListSize, maximumSubListSize, random); + this.sampler = Samplers.subList(variableMetaModel, minimumSubListSize, maximumSubListSize, random); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java index 74e88845dc4..23cf4259927 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java @@ -20,6 +20,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; import org.jspecify.annotations.NullMarked; @@ -132,9 +133,9 @@ private static final class SubListSwapMoveIterator this.solutionView = session.getSolutionView(); this.valueInstance = (DefaultUniDatasetInstance) session.getInstance(valueDataset); this.leftValueIterator = valueInstance.retiringRandomIterator(random); - this.leftSampler = new SubListSampler<>(variableMetaModel, leftMinimumSubListSize, leftMaximumSubListSize, random); + this.leftSampler = Samplers.subList(variableMetaModel, leftMinimumSubListSize, leftMaximumSubListSize, random); this.rightSampler = - new SubListSampler<>(variableMetaModel, rightMinimumSubListSize, rightMaximumSubListSize, random); + Samplers.subList(variableMetaModel, rightMinimumSubListSize, rightMaximumSubListSize, random); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java index 8d56eeb323f..a8572548465 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java @@ -16,6 +16,7 @@ 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.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; import org.jspecify.annotations.NullMarked; @@ -103,7 +104,7 @@ private static final class SubListUnassignMoveIterator) session.getInstance(valueDataset); this.valueIterator = valueInstance.retiringRandomIterator(random); - this.sampler = new SubListSampler<>(variableMetaModel, minimumSubListSize, maximumSubListSize, random); + this.sampler = Samplers.subList(variableMetaModel, minimumSubListSize, maximumSubListSize, random); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java index 37d4f7ed31b..93fecc083ec 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java @@ -21,19 +21,18 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; -import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; -import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** * For each subpillar of entities sharing a non-null value of the given variable, - * governed by a {@link Sampler} - - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, since an - * unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the - * pillar's size - + * governed by a {@link PillarSampler} - + * {@code Samplers.pillar(Samplers.between(2, n))} is the recommended choice, + * since an unbounded {@code Samplers.pillar(Samplers.all())} makes this provider's move cost + * linear in the pillar's size - * creates a move to change every member's value to a different value that is legal for every member. * The (sub)pillar is keyed on this one variable alone; * members may differ in every other variable. @@ -63,11 +62,11 @@ public final class SubPillarChangeMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; - private final Sampler sampler; + private final PillarSampler sampler; private final boolean crossingNull; public SubPillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, - Sampler sampler) { + PillarSampler sampler) { this(variableMetaModel, sampler, variableMetaModel.allowsUnassigned()); } @@ -77,7 +76,7 @@ public SubPillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, - Sampler sampler, boolean crossingNull) { + PillarSampler sampler, boolean crossingNull) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.sampler = Objects.requireNonNull(sampler); if (crossingNull && !variableMetaModel.allowsUnassigned()) { @@ -105,7 +104,7 @@ public MoveStream build(MoveStreamFactory moveStreamFactor *

    * Unlike the whole-pillar variant, * a fresh subpillar is assembled on every {@link #createRightIterator} call, never cached across probes: - * a real {@link Sampler} can legitimately draw a different subpillar for the same slice value on each attempt, + * a real {@link PillarSampler} can legitimately draw a different subpillar for the same slice value on each attempt, * and caching the first one would turn {@link RetiringBiWalk}'s remaining probes into deterministic no-ops. *

    * The destination is drawn from the (sub)pillar members' own {@link ValueRange}s @@ -122,7 +121,7 @@ private static final class SubPillarChangeMoveIterator>, RetiringBiWalk { private final PlanningVariableMetaModel variableMetaModel; - private final Sampler sampler; + private final PillarSampler sampler; private final boolean crossingNull; private final RandomGenerator random; private final SolutionView solutionView; @@ -136,7 +135,7 @@ public SubPillarChangeMoveIterator(MoveIteratorSession session, Rando PlanningVariableMetaModel variableMetaModel, UniDataset distinctValueDataset, BiDataset pillarSourceDataset, - Sampler sampler, boolean crossingNull) { + PillarSampler sampler, boolean crossingNull) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.sampler = Objects.requireNonNull(sampler); this.crossingNull = crossingNull; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 0a2ab79dd29..4ab3711fa3d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -25,6 +25,7 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; @@ -34,9 +35,9 @@ import org.jspecify.annotations.Nullable; /** - * Draws two subpillars, each governed by its own {@link Sampler} - - * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move - * cost linear in the pillars' combined size - + * Draws two subpillars, each governed by its own {@link PillarSampler} - + * see {@link Samplers#pillar(Sampler) Samplers.pillar} to lift a size-only policy, + * since an unbounded sampler makes this provider's move cost linear in the pillars' combined size - * out of entities sharing the same combination of values across every variable given to the constructor, * and swaps that combination between the two subpillars, * provided at least one variable differs and every differing variable is legal on both sides; @@ -51,7 +52,7 @@ * the two sides may want different policies (a tighter cap on one side, say), * and passing two parameters says so explicitly. * Sharing one instance between both sides is safe - - * see {@link Sampler}'s class documentation for why - + * see {@link PillarSampler}'s class documentation for why - * but is rarely what a caller wants. * * @see PillarSwapMoveProvider The whole pillar on both sides at once. @@ -65,23 +66,24 @@ public final class SubPillarSwapMoveProvider private final GenuineEntityMetaModel entityMetaModel; private final List> variableMetaModelList; - private final Sampler leftSampler; - private final Sampler rightSampler; + private final PillarSampler, Entity_> leftSampler; + private final PillarSampler, Entity_> rightSampler; /** - * As defined by {@link #SubPillarSwapMoveProvider(List, Sampler, Sampler)}, but for every basic - * planning variable of {@code entityMetaModel}. + * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, but for + * every basic planning variable of {@code entityMetaModel}. */ - public SubPillarSwapMoveProvider(GenuineEntityMetaModel entityMetaModel, Sampler leftSampler, - Sampler rightSampler) { + public SubPillarSwapMoveProvider(GenuineEntityMetaModel entityMetaModel, + PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { this(MoveProviderUtil.basicVariablesOf(entityMetaModel), leftSampler, rightSampler); } /** - * As defined by {@link #SubPillarSwapMoveProvider(List, Sampler, Sampler)}, but for a single variable. + * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, but for a + * single variable. */ public SubPillarSwapMoveProvider(PlanningVariableMetaModel variableMetaModel, - Sampler leftSampler, Sampler rightSampler) { + PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { this(List.of(variableMetaModel), leftSampler, rightSampler); } @@ -95,7 +97,7 @@ public SubPillarSwapMoveProvider(PlanningVariableMetaModel> variableMetaModelList, - Sampler leftSampler, Sampler rightSampler) { + PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { this.variableMetaModelList = MoveProviderUtil.normalize(variableMetaModelList); this.entityMetaModel = variableMetaModelList.getFirst().entity(); this.leftSampler = Objects.requireNonNull(leftSampler); @@ -131,7 +133,7 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * Unlike the whole-pillar variant, * a fresh left subpillar is assembled on every {@link #createRightIterator} call, * never cached across probes: - * a real {@link Sampler} can legitimately draw a different subpillar for the same left key on each attempt + * a real {@link PillarSampler} can legitimately draw a different subpillar for the same left key on each attempt * (and a different subpillar can have different legal ranges, since fewer members mean fewer constraints), * so caching the first one would turn {@link RetiringBiWalk}'s remaining probes into * deterministic no-ops for the left side. @@ -152,8 +154,8 @@ private static final class SubPillarSwapMoveIterator implements Iterator>, RetiringBiWalk, List> { private final List> variableMetaModelList; - private final Sampler leftSampler; - private final Sampler rightSampler; + private final PillarSampler, Entity_> leftSampler; + private final PillarSampler, Entity_> rightSampler; private final RandomGenerator random; private final SolutionView solutionView; private final RetiringRandomIterator> leftKeyIterator; @@ -168,7 +170,7 @@ public SubPillarSwapMoveIterator(MoveIteratorSession session, RandomG List> variableMetaModelList, UniDataset> distinctKeys, BiDataset, Entity_> pillarSourceDataset, - Sampler leftSampler, Sampler rightSampler) { + PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); this.leftSampler = Objects.requireNonNull(leftSampler); this.rightSampler = Objects.requireNonNull(rightSampler); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java index cc0719b1bcd..45fa5e10ae4 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.preview.api.move.builtin; import java.util.Iterator; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.random.RandomGenerator; @@ -13,14 +14,17 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** - * Draws subpillars, governed by a {@link Sampler} - - * see {@link Samplers} for ready-made ones, + * Draws subpillars, governed by a {@link PillarSampler} - + * see {@link Samplers#pillar(Sampler) Samplers.pillar} to lift a size-only policy, * since an unbounded sampler makes this provider's move cost linear in the pillar's size - * of entities sharing a non-null value ("slice value") of the given variable * and creates a move to unassign every member at once (set the basic planning variable to null). @@ -52,10 +56,10 @@ public final class SubPillarUnassignMoveProvider implements MoveProvider { private final PlanningVariableMetaModel variableMetaModel; - private final Sampler sampler; + private final PillarSampler sampler; public SubPillarUnassignMoveProvider(PlanningVariableMetaModel variableMetaModel, - Sampler sampler) { + PillarSampler sampler) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); if (!variableMetaModel.allowsUnassigned()) { throw new IllegalArgumentException( @@ -76,9 +80,9 @@ public MoveStream build(MoveStreamFactory moveStreamFactor /** * Draws subpillars sharing an assigned value ("slice value") and unassigns every member, * producing a {@code MassChangeMove} with a null destination. - * The destination is fixed at null, so nothing can ever be rejected: - * every drawn subpillar yields a valid move, - * with no probing and no left value to retire. + * The destination is fixed at null, so a drawn subpillar's own move never gets rejected - + * but the subpillar draw itself can be, if the sampler refuses or the slice is smaller than the + * sampler's minimum size, and then this iterator simply ends. * * @param the solution type * @param the entity type @@ -91,14 +95,16 @@ private static final class SubPillarUnassignMoveIterator sliceValueIterator; private final BiDatasetInstance pillarSourceInstance; private final PlanningVariableMetaModel variableMetaModel; - private final Sampler sampler; + private final PillarSampler sampler; private final RandomGenerator random; + private @Nullable Sample pendingSample = null; + public SubPillarUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, PlanningVariableMetaModel variableMetaModel, UniDataset distinctValueDataset, BiDataset pillarSourceDataset, - Sampler sampler) { + PillarSampler sampler) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.sampler = Objects.requireNonNull(sampler); this.random = Objects.requireNonNull(random); @@ -114,16 +120,20 @@ public SubPillarUnassignMoveIterator(MoveIteratorSession session, Ran @Override public boolean hasNext() { - return sliceValueIterator.hasNext(); + if (pendingSample == null && sliceValueIterator.hasNext()) { + var samples = pillarSourceInstance.samplingIterator(sliceValueIterator.next(), sampler, random); + pendingSample = samples.hasNext() ? samples.next() : null; + } + return pendingSample != null; } @Override public Move next() { - var sliceValue = sliceValueIterator.next(); - // Never ends while the slice is not empty, - // and a just-drawn slice value always has at least one member, - // so this draw always succeeds. - var pillar = pillarSourceInstance.samplingIterator(sliceValue, sampler, random).next(); + if (!hasNext()) { + throw new NoSuchElementException(); + } + var pillar = Objects.requireNonNull(pendingSample); + pendingSample = null; return Moves.massChange(variableMetaModel, pillar, null); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java index 03d5fdf193d..414abbf5581 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java @@ -4,8 +4,10 @@ import java.util.random.RandomGenerator; import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -62,6 +64,12 @@ public interface BiDatasetInstance { * and O(1) to create the draw. * On a comparison- or range-indexed slice, add O(b), b being the number of matching buckets, * because the underlying iterator walks every bucket up front. + *

    + * There is a second overload of this method taking a {@link PillarSampler} instead, + * which sees the left value as a key. + * The two sampler types are unrelated, + * so a bare lambda argument does not compile as ambiguous; + * pass one built by {@link Samplers}, such as {@link Samplers#all()} or {@link Samplers#pillar(Sampler)}. * * @param a the slice selector; may be null, as null rows are legal * @param sampler decides which candidates join each sample @@ -76,4 +84,22 @@ default Iterator> samplingIterator(@Nullable A a, Sampler sampler, return SampleAssembler.iterator(() -> exhaustiveIterator(a, random), random, sampler); } + /** + * As defined by {@link #samplingIterator(Object, Sampler, RandomGenerator)}, but for a + * {@link PillarSampler}, which sees the left value {@code a} as the key the sample is drawn under - + * still a slice selector, not a key the resulting {@link Sample} itself carries. + *

    + * The two sampler types are unrelated, so a bare lambda argument does not compile as ambiguous; + * pass one built by {@link Samplers}, such as {@link Samplers#pillar(Sampler)}. + * + * @param a the slice selector; may be null, as null rows are legal + * @param sampler decides which candidates join each sample + * @param random never null + * @return never null + */ + default Iterator> samplingIterator(@Nullable A a, PillarSampler sampler, RandomGenerator random) { + // Must be exhaustiveIterator, never iterator: see the other overload's comment. + return SampleAssembler.iterator(() -> exhaustiveIterator(a, random), random, a, sampler); + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java index f985cf0d8df..295aa550013 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/UniDatasetInstance.java @@ -4,6 +4,7 @@ import java.util.random.RandomGenerator; import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; @@ -70,6 +71,7 @@ public interface UniDatasetInstance { *

    * Each sample of k members costs about k draws plus whatever the sampler rejects; * the framework never rejects a candidate itself. + * A Uni dataset has no key, so this method has no {@link PillarSampler} form. * Prefer {@link BiDatasetInstance#samplingIterator(Object, Sampler, RandomGenerator)} * when the grouping is known at build time, so that the index does the filtering; * use this form when membership can only be decided at run time. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java index 8cc96da542e..310717aa55e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java @@ -24,10 +24,10 @@ record DefaultRange(Object entity, int fromIndex, int toIndex) @Override public boolean equals(@Nullable Object o) { - if (!(o instanceof DefaultRange that)) { + if (!(o instanceof DefaultRange(var entity1, var index, var toIndex1))) { return false; } - return fromIndex == that.fromIndex && toIndex == that.toIndex && entity == that.entity; + return fromIndex == index && toIndex == toIndex1 && entity == entity1; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java index d792a67b100..968e35780dc 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSampler.java @@ -34,17 +34,17 @@ final class DefaultSampler implements Sampler { } @Override - public void reset(RandomGenerator random, @Nullable A seed) { + public void reset(RandomGenerator random) { targetSize = minimumSize == maximumSize ? minimumSize : random.nextInt(minimumSize, maximumSize + 1); } + @Override + public int minimumSize() { + return minimumSize; + } + @Override public Decision evaluate(int sizeSoFar, @Nullable A candidate) { - if (targetSize <= 1) { - // sizeSoFar is already at least 1 (the seed) by the time evaluate is first called; - // ACCEPT_AND_STOP here would grow the sample to 2, not stop it at 1. - return Decision.STOP; - } return sizeSoFar + 1 >= targetSize ? Decision.ACCEPT_AND_STOP : Decision.ACCEPT; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java new file mode 100644 index 00000000000..2cb47ce75b0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java @@ -0,0 +1,51 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.impl.util.TriangleElementFactory; +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.move.SolutionView; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +final class DefaultSubListSampler implements SubListSampler { + + private final PlanningListVariableMetaModel variableMetaModel; + private final int minimumSubListSize; + private final TriangleElementFactory triangleElementFactory; + + DefaultSubListSampler(PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize, RandomGenerator random) { + this.variableMetaModel = variableMetaModel; + this.minimumSubListSize = minimumSubListSize; + this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, random); + } + + @Override + @Nullable + public Range byValue(SolutionView solutionView, Value_ seedValue) { + var position = solutionView.getPositionOf(variableMetaModel, seedValue); + if (!(position instanceof PositionInList assigned)) { + throw new IllegalArgumentException("The seedValue (%s) is not assigned.".formatted(seedValue)); + } + return byEntity(solutionView, assigned.entity()); + } + + @Override + @Nullable + public Range byEntity(SolutionView solutionView, Entity_ entity) { + var firstUnpinned = solutionView.getFirstUnpinnedIndex(variableMetaModel, entity); + var listSize = solutionView.countValues(variableMetaModel, entity) - firstUnpinned; + if (listSize < minimumSubListSize) { + return null; + } + var element = triangleElementFactory.nextElement(listSize); + var length = listSize - element.level() + 1; + var fromIndex = element.indexOnLevel() - 1 + firstUnpinned; + return Range.of(entity, fromIndex, fromIndex + length); + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java new file mode 100644 index 00000000000..2222d7e97af --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java @@ -0,0 +1,61 @@ +package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; + +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.BiDatasetInstance; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Decides which candidates join a {@link Sample} drawn under a key, + * such as a slice of a {@link BiDatasetInstance}. + * Unrelated to {@link Sampler}: + * a plain, key-blind size policy is lifted into one via {@link Samplers#pillar(Sampler)}. + *

    + * Otherwise follows {@link Sampler}'s contract exactly: + * {@link #reset(RandomGenerator, Object)} runs once per sample before the first {@link #evaluate(int, Object)} call, + * which is then called for every candidate offered starting at {@code sizeSoFar == 0}; + * a sample below {@link #minimumSize()} is discarded, + * and the sampler stopping itself below its own {@link #minimumSize()} is a contract violation. + * No built-in sampler reads the key today - + * the split exists to keep this family apart from {@link Sampler} in the type system, + * not because a key-aware policy exists yet. + * Move generation is single-threaded; + * see {@link Sampler} for what that means for sampler state. + * + * @param the type of the key the sample was drawn under, such as the slice selector + * @param the type of the sample's members + */ +@NullMarked +public interface PillarSampler { + + /** + * Called once per sample, before the first {@link #evaluate(int, Object)} call. + * + * @param random the solver's working random; safe to draw from to decide this sample's target + * size or any other per-sample state + * @param key the slice selector the sample is being drawn under; may be null, as a null slice + * selector is legal + */ + default void reset(RandomGenerator random, @Nullable Key_ key) { + // Nothing to do by default. + } + + /** + * @return the smallest number of members a sample may have; at least 1. A sample with fewer + * members, however it ended, is discarded. + */ + default int minimumSize() { + return 1; + } + + /** + * @param sizeSoFar the number of members already accepted; 0 for the first candidate offered + * @param candidate the candidate offered, drawn at most once per sample + * @return what to do with the candidate + */ + Decision evaluate(int sizeSoFar, @Nullable A candidate); + +} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java index 35b7453e3a1..b3c15d27f91 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java @@ -5,21 +5,16 @@ import org.jspecify.annotations.NullMarked; /** - * A contiguous span of positions in a list variable, drawn together to be moved as one. + * A contiguous span of positions in a list variable, + * drawn together to be moved as one. * Unlike {@link Sample}, which is an unordered member set with no key, - * a {@code Range} is positionally identified: two ranges are equal only when they point - * at the same entity and the same {@code [fromIndex, toIndex)} span. + * a {@code Range} is positionally identified: + * two ranges are equal only when they point at the same entity and the same {@code [fromIndex, toIndex)} span. *

    * A range is immutable and safe to hold in a move, * but it says nothing about the solution, * so every value a move needs must be read from the live solution. * Instances are produced by {@link SubListSampler}. - *

    - * This package and all of its subpackages are part of the Neighborhoods API, - * which is under development and is only offered as a preview feature. - * There are no guarantees for backward compatibility; - * any class, method, or field may change or be removed without prior notice, - * although we will strive to avoid this as much as possible. */ @NullMarked public sealed interface Range extends Comparable diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 3cf416b20ca..2ff3ff63655 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -25,8 +25,6 @@ * so it must read the same members every time. * * @param the type of the sample's members - * FIXME comparisons of samples in reverse order are a problem; - * what do we do about equality? */ @NullMarked public interface Sample diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java index 5f2b15d3e83..6e8aefc318f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java @@ -12,30 +12,29 @@ * Decides which candidates join a {@link Sample}. * Ready-made implementations are in {@link Samplers}. *

    - * The first candidate drawn is always accepted and becomes the seed; - * {@link #reset(RandomGenerator, Object)} is called with it, - * and {@link #evaluate(int, Object)} is first called for the second candidate, with {@code sizeSoFar == 1}. - * A sample ends on {@link Decision#STOP}, {@link Decision#ACCEPT_AND_STOP}, - * or when the source runs out. + * {@link #reset(RandomGenerator)} runs once per sample, + * before the first {@link #evaluate(int, Object)} call, + * and {@link #evaluate(int, Object)} is then called for every candidate offered, + * starting with {@code sizeSoFar == 0}. + * A sample ends on {@link Decision#STOP}, {@link Decision#ACCEPT_AND_STOP}, or when the source runs out. * A sampler which never stops yields the whole dataset or the whole slice. - * A sampler that stops right at the seed - - * deciding {@link Decision#STOP} or {@link Decision#ACCEPT_AND_STOP} on the very first {@link #evaluate(int, Object)} call - - * yields only size-1 samples; - * move providers built on top of a {@code Sampler} may reject those. + *

    + * A sample with fewer members than {@link #minimumSize()} is discarded entirely - + * as if the source had been empty. + * If the sampler itself stops the sample (via {@link Decision#STOP} or {@link Decision#ACCEPT_AND_STOP}) + * while still below its own {@link #minimumSize()}, that is treated as a contract violation, + * not as an undersized world, and fails fast. *

    * A sample is assembled in full before it is returned, * so one sampler instance may serve several {@link Iterator}s in sequence, * one sample at a time. *

    * Move generation is single-threaded, - * and {@link #reset(RandomGenerator, Object)} runs before every sample + * and {@link #reset(RandomGenerator)} runs before every sample * and completes before {@link #evaluate(int, Object)} is called for that sample, * so a sampler may hold state across {@link #evaluate(int, Object)} calls * without it ever leaking into another sample. - * This makes a sampler instance safe to share between separate neighborhood providers, - * and between the two sides of a swap move provider, - * as long as sharing is what the caller wants - * (swap provider takes two separate sampler parameters so each side can use a different policy). + * This makes a sampler instance safe to share between separate neighborhood providers. * * @param the type of the sample's members */ @@ -43,19 +42,25 @@ public interface Sampler { /** - * Called once per sample, after the seed is accepted and before the first - * {@link #evaluate(int, Object)}. + * Called once per sample, before the first {@link #evaluate(int, Object)} call. * * @param random the solver's working random; safe to draw from to decide this sample's target * size or any other per-sample state - * @param seed the first candidate drawn, already a member */ - default void reset(RandomGenerator random, @Nullable A seed) { + default void reset(RandomGenerator random) { // Nothing to do by default. } /** - * @param sizeSoFar the number of members already accepted; at least 1 + * @return the smallest number of members a sample may have; at least 1. A sample with fewer + * members, however it ended, is discarded. + */ + default int minimumSize() { + return 1; + } + + /** + * @param sizeSoFar the number of members already accepted; 0 for the first candidate offered * @param candidate the candidate offered, drawn at most once per sample * @return what to do with the candidate */ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java index d92e26275bf..d8258edf8b3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java @@ -1,11 +1,16 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; +import java.util.Objects; +import java.util.random.RandomGenerator; + +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** - * Ready-made {@link Sampler} implementations. + * Ready-made {@link Sampler}, {@link PillarSampler} and {@link SubListSampler} implementations. */ @NullMarked public final class Samplers { @@ -37,6 +42,10 @@ public static Sampler upTo(int maximumSize) { } /** + * A slice smaller than {@code minimumSize} yields no sample at all, and still costs a full drain + * of that slice to find out - keep {@code minimumSize} near the smallest group actually worth + * moving. + * * @param minimumSize the smallest number of members a sample may have; at least 1 * @param maximumSize the largest number of members a sample may have; at least {@code minimumSize} * @return a sampler that stops a sample once it reaches a size drawn uniformly from @@ -46,6 +55,48 @@ public static Sampler between(int minimumSize, int maximumSize) { return new DefaultSampler<>(minimumSize, maximumSize); } + /** + * Lifts a key-blind {@link Sampler} into a {@link PillarSampler}, ignoring whatever key it is + * drawn under. A key-aware policy implements {@link PillarSampler} directly instead. + * + * @param sampler never null + * @return never null + */ + public static PillarSampler pillar(Sampler sampler) { + Objects.requireNonNull(sampler); + return new PillarSampler<>() { + + @Override + public void reset(RandomGenerator random, @Nullable Key_ key) { + sampler.reset(random); + } + + @Override + public int minimumSize() { + return sampler.minimumSize(); + } + + @Override + public Decision evaluate(int sizeSoFar, @Nullable A candidate) { + return sampler.evaluate(sizeSoFar, candidate); + } + + }; + } + + /** + * The only way to build a {@link SubListSampler}. + * The caller must already hold the working random, so in practice this is called by a move + * provider, not directly by user code. + * + * @return never null + */ + public static SubListSampler subList( + PlanningListVariableMetaModel variableMetaModel, + int minimumSubListSize, int maximumSubListSize, RandomGenerator random) { + return new DefaultSubListSampler<>(variableMetaModel, minimumSubListSize, maximumSubListSize, random); + } + private Samplers() { // No external instances. } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java index bc79d306d88..4351f4e6a97 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -1,10 +1,6 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; -import java.util.random.RandomGenerator; - -import ai.timefold.solver.core.impl.util.TriangleElementFactory; 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.move.SolutionView; import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; @@ -12,12 +8,12 @@ import org.jspecify.annotations.Nullable; /** - * Draws a contiguous span ("sub-list") of a list variable, - * seeded by an assigned value. - * the seed value only picks the entity, + * Draws a contiguous span ("sub-list") of a list variable, seeded by an assigned value. + * Built only via {@link Samplers#subList(PlanningListVariableMetaModel, int, int, java.util.random.RandomGenerator) + * Samplers.subList}. + * The seed value only picks the entity, * and a fresh {@code (fromIndex, length)} is then drawn uniformly - * over every admissible sub-list of the entity's unpinned window, - * via {@link TriangleElementFactory}. + * over every admissible sub-list of the entity's unpinned window. *

    * Caveat: a fully {@code @PlanningPin}-immovable entity reports * {@link SolutionView#getFirstUnpinnedIndex} as {@code 0} even though its whole list is pinned. @@ -30,25 +26,14 @@ * @param the variable type */ @NullMarked -public final class SubListSampler { +public interface SubListSampler { /** * The default {@code maximumSubListSize} of the built-in {@code SubList*MoveProvider} no-arg - * constructors: a sub-list relocation is blind to the score impact of the span it moves, so an - * unbounded span makes move cost linear in list size for no corresponding benefit. + * constructors: a sub-list relocation is blind to the score impact of the span it moves, + * so an unbounded span makes move cost linear in list size for no corresponding benefit. */ - public static final int DEFAULT_MAXIMUM_SUB_LIST_SIZE = 10; - - private final PlanningListVariableMetaModel variableMetaModel; - private final int minimumSubListSize; - private final TriangleElementFactory triangleElementFactory; - - public SubListSampler(PlanningListVariableMetaModel variableMetaModel, int minimumSubListSize, - int maximumSubListSize, RandomGenerator random) { - this.variableMetaModel = variableMetaModel; - this.minimumSubListSize = minimumSubListSize; - this.triangleElementFactory = new TriangleElementFactory(minimumSubListSize, maximumSubListSize, random); - } + int DEFAULT_MAXIMUM_SUB_LIST_SIZE = 10; /** * @param seedValue an assigned value; only used to pick the entity, never anchored to its own position @@ -56,29 +41,13 @@ public SubListSampler(PlanningListVariableMetaModel * is smaller than the minimum sub-list size */ @Nullable - public Range byValue(SolutionView solutionView, Value_ seedValue) { - var position = solutionView.getPositionOf(variableMetaModel, seedValue); - if (!(position instanceof PositionInList assigned)) { - throw new IllegalArgumentException("The seedValue (%s) is not assigned.".formatted(seedValue)); - } - return byEntity(solutionView, assigned.entity()); - } + Range byValue(SolutionView solutionView, Value_ seedValue); /** * @return a freshly drawn span over the entity's whole unpinned window, * or {@code null} if that window is smaller than the minimum sub-list size */ @Nullable - public Range byEntity(SolutionView solutionView, Entity_ entity) { - var firstUnpinned = solutionView.getFirstUnpinnedIndex(variableMetaModel, entity); - var listSize = solutionView.countValues(variableMetaModel, entity) - firstUnpinned; - if (listSize < minimumSubListSize) { - return null; - } - var element = triangleElementFactory.nextElement(listSize); - var length = listSize - element.level() + 1; - var fromIndex = element.indexOnLevel() - 1 + firstUnpinned; - return Range.of(entity, fromIndex, fromIndex + length); - } + Range byEntity(SolutionView solutionView, Entity_ entity); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java index fa9b476935c..d14db2d508c 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java @@ -12,6 +12,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.move.MoveDirector; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.SubListSampler; import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEasyScoreCalculator; import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; @@ -60,7 +61,7 @@ void drawnSpanIsUniformOverEveryAdmissiblePair() { scoreDirector.setWorkingSolution(solution); var solutionView = new MoveDirector<>(scoreDirector); - var sampler = new SubListSampler<>(variableMetaModel, 2, 5, new Random(0)); + var sampler = Samplers.subList(variableMetaModel, 2, 5, new Random(0)); var seedValue = values[0]; var expectedPairSet = new HashSet(); @@ -113,7 +114,7 @@ void drawnEntityIsWeightedByUnpinnedSize() { scoreDirector.setWorkingSolution(solution); var solutionView = new MoveDirector<>(scoreDirector); - var sampler = new SubListSampler<>(variableMetaModel, 1, Integer.MAX_VALUE, new Random(0)); + var sampler = Samplers.subList(variableMetaModel, 1, Integer.MAX_VALUE, new Random(0)); var seedPicker = new Random(1); var weightByEntity = new HashMap(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 936320c5c12..3e92704b328 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -10,6 +10,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.neighborhood.stream.DefaultMoveStreamFactory; import ai.timefold.solver.core.impl.solver.random.RandomSource; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.PillarSampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; @@ -77,12 +78,12 @@ void samplerResetAndDecideAreCalledCorrectly() { var session = createSession(moveStreamFactory, solution); var instance = session.getInstance(valueDataset); - var resetCalls = new ArrayList(); + var resetCallCount = new int[1]; var decideCalls = new ArrayList(); var recordingSampler = new Sampler() { @Override - public void reset(@NonNull RandomGenerator random, TestdataValue seed) { - resetCalls.add(seed); + public void reset(@NonNull RandomGenerator random) { + resetCallCount[0]++; } @Override @@ -100,11 +101,12 @@ public Decision evaluate(int sizeSoFar, TestdataValue candidate) { var firstSample = sampleIterator.next(); // Reset should have been called exactly once. - assertThat(resetCalls).hasSize(1); - // decide should have been called for the remaining elements. + assertThat(resetCallCount[0]).isEqualTo(1); + // decide should have been called for every candidate offered, starting with the first. assertThat(decideCalls).isNotEmpty(); - // First decide call should have sizeSoFar == 1. - assertThat(decideCalls.get(0)).isEqualTo(1); + // First decide call should have sizeSoFar == 0: the first candidate is no longer an + // unconditionally accepted seed. + assertThat(decideCalls.get(0)).isEqualTo(0); } @Test @@ -281,4 +283,66 @@ void biSamplingIteratorDrainsCachedDatasetSliceWhenSamplerNeverStops() { assertThat(sample).contains(probeValue1, probeValue2); } + @Test + void biSamplingIteratorPillarSamplerReceivesTheSliceSelectorAsKey() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + var biDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var probe = new TestdataEntity("probe"); + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + solution.setValueList(List.of(new TestdataValue("probe-1"))); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var recordedKeys = new ArrayList(); + var recordingSampler = new PillarSampler() { + @Override + public void reset(@NonNull RandomGenerator random, TestdataEntity key) { + recordedKeys.add(key); + } + + @Override + public Decision evaluate(int sizeSoFar, TestdataValue candidate) { + return Decision.ACCEPT; + } + }; + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, recordingSampler, random); + + assertThat(sampleIterator.hasNext()).isTrue(); + sampleIterator.next(); + assertThat(recordedKeys).containsExactly(probe); + } + + @Test + void biSamplingIteratorEndsWhenSamplerRefusesBelowMinimumSize() { + var moveStreamFactory = + new DefaultMoveStreamFactory<>(TestdataSolution.buildSolutionDescriptor(), EnvironmentMode.PHASE_ASSERT); + var entityStream = moveStreamFactory.forEach(TestdataEntity.class, false); + var valueStream = moveStreamFactory.forEach(TestdataValue.class, false); + var joiner = NeighborhoodsJoiners. equal(TestdataEntity::getCode, + value -> value.getCode().split("-")[0]); + var biDataset = entityStream.asCachedDataset().join(valueStream, joiner); + + var probe = new TestdataEntity("probe"); + var solution = new TestdataSolution("solution"); + solution.setEntityList(List.of(probe)); + // Only 2 matching values, but the sampler below demands at least 3. + solution.setValueList(List.of(new TestdataValue("probe-1"), new TestdataValue("probe-2"))); + var session = createSession(moveStreamFactory, solution); + var instance = session.getInstance(biDataset); + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + var sampleIterator = instance.samplingIterator(probe, Samplers.pillar(Samplers.exactly(3)), random); + + assertThat(sampleIterator.hasNext()).isFalse(); + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java index dff22ef1135..d083e5c1ec4 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java @@ -36,7 +36,8 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream(move -> (MassChangeMove) move) @@ -66,7 +67,8 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var distinctMemberSets = context @@ -95,7 +97,8 @@ void pinnedEntityExcludedFromSubpillar() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var moves = context @@ -122,7 +125,8 @@ void crossingNullDefaultTrueAlsoUnassignsSubpillar() { // Default constructor: crossingNull is true, because this variable allows unassigned values. var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var moves = context @@ -146,7 +150,8 @@ void crossingNullFalseNeverUnassignsSubpillar() { } var context = NeighborhoodTester - .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), false), solutionMetaModel) + .build(new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2)), false), + solutionMetaModel) .using(solution); var moves = context @@ -163,7 +168,8 @@ void constructorRejectsExplicitCrossingNullOnNonUnassignedVariable() { var solutionMetaModel = TestdataSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class).basicVariable(); assertThatIllegalArgumentException() - .isThrownBy(() -> new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2), true)); + .isThrownBy( + () -> new SubPillarChangeMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2)), true)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java index 2003dcb4833..59723a261d4 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java @@ -47,7 +47,8 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { var pillarB = new HashSet<>(entityList.subList(5, 10)); var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution); @@ -80,7 +81,8 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution); @@ -109,7 +111,8 @@ void sharedSamplerInstanceOnBothSidesMatchesTwoSeparateInstances() { // Two separate, independently-stateful sampler instances - the ordinary way to call this // constructor. var separateContext = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.between(2, 4), Samplers.between(2, 4)), + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.between(2, 4)), + Samplers.pillar(Samplers.between(2, 4))), solutionMetaModel) .using(solution); var movesWithSeparateSamplers = @@ -121,7 +124,7 @@ void sharedSamplerInstanceOnBothSidesMatchesTwoSeparateInstances() { // reset() runs before every sample and a sample is assembled in full before it is returned, // so sharing does not change which moves are produced - NeighborhoodTester always reseeds the // working random the same way, so the two runs are directly comparable. - var sharedSampler = Samplers. between(2, 4); + var sharedSampler = Samplers., TestdataEntity> pillar(Samplers.between(2, 4)); var sharedContext = NeighborhoodTester .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sharedSampler, sharedSampler), solutionMetaModel) .using(solution); @@ -149,7 +152,8 @@ void bothSidesRespectTheirOwnRule() { } var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(1), Samplers.exactly(3)), + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.exactly(1)), + Samplers.pillar(Samplers.exactly(3))), solutionMetaModel) .using(solution); @@ -170,16 +174,21 @@ void pinnedEntityNeverParticipatesInSubpillarSwap() { var v0 = new TestdataValue("v0"); var v1 = new TestdataValue("v1"); var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); + // Two free members on v0, not one: pinnedEntity is excluded from the entity source (as + // asserted below), so the v0 group's own minimum size for Samplers.exactly(2) must be met by + // free entities alone. var free1 = new TestdataPinnedEntity("free1", v0, false); + var free1b = new TestdataPinnedEntity("free1b", v0, false); var free2 = new TestdataPinnedEntity("free2", v1, false); var free3 = new TestdataPinnedEntity("free3", v1, false); var solution = new TestdataPinnedSolution("s"); solution.setValueList(List.of(v0, v1)); - solution.setEntityList(List.of(pinnedEntity, free1, free2, free3)); + solution.setEntityList(List.of(pinnedEntity, free1, free1b, free2, free3)); var context = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution); @@ -222,7 +231,8 @@ void multivariateSwapUsesEveryListedVariable() { var pillarB = Sample.of(List.of(b1, b2)); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution) .producesAllOf( @@ -257,7 +267,8 @@ void excludedVariableIsIgnoredForKeyAndSwap() { solution.setMultiVarEntityList(List.of(a1, a2, b1, b2)); var moveList = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(allowedVariableMetaModelList, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(allowedVariableMetaModelList, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution) .getMovesAsStream() @@ -292,7 +303,8 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { solution.setEntityList(List.of(a1, b1)); var moveList = NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.exactly(1), Samplers.exactly(1)), + .build(new SubPillarSwapMoveProvider<>(variableMetaModelList, Samplers.pillar(Samplers.exactly(1)), + Samplers.pillar(Samplers.exactly(1))), solutionMetaModel) .using(solution) .getMovesAsStream() @@ -320,12 +332,14 @@ void singleVariableConstructorMatchesOneElementList() { var expectedMoveBtoA = Moves.pillarSwap(variableMetaModel, pillarB, pillarA); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(variableMetaModel, Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution) .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); NeighborhoodTester - .build(new SubPillarSwapMoveProvider<>(List.of(variableMetaModel), Samplers.exactly(2), Samplers.exactly(2)), + .build(new SubPillarSwapMoveProvider<>(List.of(variableMetaModel), Samplers.pillar(Samplers.exactly(2)), + Samplers.pillar(Samplers.exactly(2))), solutionMetaModel) .using(solution) .producesAllOf(expectedMoveAtoB, expectedMoveBtoA); @@ -334,7 +348,7 @@ void singleVariableConstructorMatchesOneElementList() { @Test void emptyListConstructorThrows() { assertThatThrownBy(() -> new SubPillarSwapMoveProvider(List.of(), - Samplers.exactly(2), Samplers.exactly(2))) + Samplers.pillar(Samplers.exactly(2)), Samplers.pillar(Samplers.exactly(2)))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("is empty"); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java index f007b053fb0..f35bc9b4f1f 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -33,7 +33,8 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { } var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( @@ -65,7 +66,8 @@ void differentDrawsProduceDifferentSubpillars() { } var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var distinctMemberSets = context.getMovesAsStream( @@ -91,7 +93,8 @@ void pinnedEntityNeverUnassigned() { solution.setEntityList(List.of(pinnedEntity, free1, free2)); var context = NeighborhoodTester - .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) .using(solution); var moves = context.getMovesAsStream( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java index a8a338495b9..499da846557 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java @@ -2,15 +2,20 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import java.util.ArrayList; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.random.RandomGenerator; import java.util.stream.IntStream; import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.SampleAssembler; import ai.timefold.solver.core.impl.solver.random.RandomSource; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision; +import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.Test; class SamplersTest { @@ -31,19 +36,57 @@ void exactlyYieldsExactSizeOnALargerSource() { } @Test - void exactlyYieldsSourceSizeWhenSourceIsSmaller() { + void exactlyReturnsNullWhenSourceIsSmallerThanMinimum() { var sample = SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), Samplers. exactly(10)); - assertThat(sample).isNotNull(); - assertThat(sample.size()).isEqualTo(3); + assertThat(sample).isNull(); + } + + @Test + void betweenReturnsNullWhenSourceIsSmallerThanMinimum() { + var sample = SampleAssembler.assemble(sourceOf(2).iterator(), seededRandom(), Samplers. between(3, 7)); + assertThat(sample).isNull(); + } + + @Test + void upToAndAllHaveNoFloorAndStayProductiveOnAShortSource() { + var upToSample = SampleAssembler.assemble(sourceOf(1).iterator(), seededRandom(), Samplers. upTo(8)); + assertThat(upToSample).isNotNull(); + assertThat(upToSample.size()).isEqualTo(1); + + var allSample = SampleAssembler.assemble(sourceOf(1).iterator(), seededRandom(), Samplers. all()); + assertThat(allSample).isNotNull(); + assertThat(allSample.size()).isEqualTo(1); } @Test - void exactlyOneStopsAtTheSeed() { + void exactlyOneStopsAtTheFirstCandidate() { var sample = SampleAssembler.assemble(sourceOf(10).iterator(), seededRandom(), Samplers. exactly(1)); assertThat(sample).isNotNull(); assertThat(sample.size()).isEqualTo(1); } + @Test + void exactlyOneConsumesOnlyOneElementFromTheSource() { + var delegate = sourceOf(10).iterator(); + var drawCount = new int[1]; + var countingIterator = new Iterator() { + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public Integer next() { + drawCount[0]++; + return delegate.next(); + } + }; + var sample = SampleAssembler.assemble(countingIterator, seededRandom(), Samplers. exactly(1)); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(1); + assertThat(drawCount[0]).isEqualTo(1); + } + @Test void upToStaysWithinRangeAndVaries() { var random = seededRandom(); @@ -72,6 +115,18 @@ void betweenStaysWithinRangeAndVaries() { assertThat(sizes).hasSizeGreaterThan(1); } + @Test + void betweenMinimumSizeIsTheDeclaredFloorNotTheDrawnTarget() { + var random = seededRandom(); + var sampler = Samplers. between(2, 5); + for (var i = 0; i < 50; i++) { + var sample = SampleAssembler.assemble(sourceOf(20).iterator(), random, sampler); + assertThat(sample).isNotNull(); + // minimumSize() must stay 2 regardless of which target size reset() happened to draw. + assertThat(sampler.minimumSize()).isEqualTo(2); + } + } + @Test void allDrainsTheSource() { var sample = SampleAssembler.assemble(sourceOf(15).iterator(), seededRandom(), Samplers.all()); @@ -86,4 +141,143 @@ void constructorGuardsRejectInvalidSizes() { assertThatIllegalArgumentException().isThrownBy(() -> Samplers.between(3, 2)); } + @Test + void evaluateSeesTheFirstCandidateAtSizeZero() { + var recordedSizes = new ArrayList(); + Sampler sampler = (sizeSoFar, candidate) -> { + recordedSizes.add(sizeSoFar); + return Decision.ACCEPT_AND_STOP; + }; + var sample = SampleAssembler.assemble(sourceOf(5).iterator(), seededRandom(), sampler); + assertThat(sample).isNotNull(); + assertThat(recordedSizes).containsExactly(0); + assertThat(sample.size()).isEqualTo(1); + } + + @Test + void rejectAtSizeZeroKeepsDrawingForAFirstMember() { + var callCount = new int[1]; + Sampler sampler = (sizeSoFar, candidate) -> { + callCount[0]++; + return callCount[0] < 3 ? Decision.REJECT : Decision.ACCEPT_AND_STOP; + }; + // sourceOf(5) offers 0, 1, 2, 3, 4 in order: the first two candidates are rejected, the third + // (value 2) is accepted and stops the sample. + var sample = SampleAssembler.assemble(sourceOf(5).iterator(), seededRandom(), sampler); + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(1); + assertThat(sample.first()).isEqualTo(2); + } + + @Test + void resetRunsBeforeTheFirstEvaluateCall() { + var callOrder = new ArrayList(); + var sampler = new Sampler() { + @Override + public void reset(@NonNull RandomGenerator random) { + callOrder.add("reset"); + } + + @Override + public Decision evaluate(int sizeSoFar, Integer candidate) { + callOrder.add("evaluate"); + return Decision.ACCEPT_AND_STOP; + } + }; + SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), sampler); + assertThat(callOrder).containsExactly("reset", "evaluate"); + } + + @Test + void minimumSizeBelowOneThrows() { + var sampler = new Sampler() { + @Override + public int minimumSize() { + return 0; + } + + @Override + public Decision evaluate(int sizeSoFar, Integer candidate) { + return Decision.ACCEPT_AND_STOP; + } + }; + assertThatIllegalArgumentException() + .isThrownBy(() -> SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), sampler)) + .withMessageContaining("minimumSize"); + } + + @Test + void samplerStoppingBelowItsOwnMinimumSizeThrows() { + var sampler = new Sampler() { + @Override + public int minimumSize() { + return 5; + } + + @Override + public Decision evaluate(int sizeSoFar, Integer candidate) { + return sizeSoFar == 0 ? Decision.ACCEPT_AND_STOP : Decision.ACCEPT; + } + }; + assertThatIllegalStateException() + .isThrownBy(() -> SampleAssembler.assemble(sourceOf(10).iterator(), seededRandom(), sampler)) + .withMessageContaining("minimumSize"); + } + + @Test + void dryStopWithoutSamplerStopReturnsNullNotAnException() { + var sampler = new Sampler() { + @Override + public int minimumSize() { + return 5; + } + + @Override + public Decision evaluate(int sizeSoFar, Integer candidate) { + return Decision.ACCEPT; + } + }; + // Only 2 candidates ever exist: the source runs dry before the sampler ever gets a chance to + // stop, so this is an undersized world, not a contract violation. + var sample = SampleAssembler.assemble(sourceOf(2).iterator(), seededRandom(), sampler); + assertThat(sample).isNull(); + } + + @Test + void pillarForwardsResetToTheWrappedSampler() { + var resetCallCount = new int[1]; + var sampler = new Sampler() { + @Override + public void reset(@NonNull RandomGenerator random) { + resetCallCount[0]++; + } + + @Override + public Decision evaluate(int sizeSoFar, Integer candidate) { + return Decision.ACCEPT_AND_STOP; + } + }; + var pillarSampler = Samplers. pillar(sampler); + SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), "key", pillarSampler); + assertThat(resetCallCount[0]).isEqualTo(1); + } + + @Test + void pillarForwardsEvaluateDecisionsUnchanged() { + for (var decision : Decision.values()) { + Sampler sampler = (sizeSoFar, candidate) -> decision; + var pillarSampler = Samplers. pillar(sampler); + assertThat(pillarSampler.evaluate(0, 1)).isEqualTo(decision); + } + } + + @Test + void pillarForwardsMinimumSizeAndEnforcesItThroughTheKeyedAssemble() { + var pillarSampler = Samplers. pillar(Samplers.exactly(4)); + assertThat(pillarSampler.minimumSize()).isEqualTo(4); + + var sample = SampleAssembler.assemble(sourceOf(3).iterator(), seededRandom(), "key", pillarSampler); + assertThat(sample).isNull(); + } + } From f403ad1c2096df89b5cc3e680b4a6f78e8624f4e Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 19:52:24 +0200 Subject: [PATCH 07/16] Claude review --- .../solver/core/impl/move/MoveDirector.java | 20 ++++++- .../dataset/sample/SampleAssembler.java | 17 +++--- .../core/preview/api/move/SolutionView.java | 2 + .../SubPillarUnassignMoveProvider.java | 59 ++++++++++++------- .../stream/dataset/sample/DefaultRange.java | 19 ------ .../stream/dataset/sample/Range.java | 2 +- .../stream/dataset/sample/Sampler.java | 4 +- .../core/impl/move/MoveDirectorTest.java | 44 ++++++++++++++ .../dataset/sample/SamplingIteratorTest.java | 27 +++++++++ .../SubPillarUnassignMoveProviderTest.java | 41 +++++++++++++ 10 files changed, 185 insertions(+), 50 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index 30f43a16348..b0a9f234358 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -391,6 +391,15 @@ private static void requireNonEmptySpan(int fromIndex, int toIndex) { } } + private static void requireValidDestinationIndex(int destinationIndex, int maximumDestinationIndex, + Entity_ entity) { + if (destinationIndex < 0 || destinationIndex > maximumDestinationIndex) { + throw new IllegalArgumentException( + "The destinationIndex (%d) of entity (%s) must be between 0 and %d." + .formatted(destinationIndex, entity, maximumDestinationIndex)); + } + } + @SuppressWarnings("unchecked") @Override public final List moveValuesInList( @@ -400,6 +409,7 @@ public final List moveValuesInList( var variableDescriptor = extractVariableDescriptor(variableMetaModel); var list = variableDescriptor.getValue(entity); var length = toIndex - fromIndex; + requireValidDestinationIndex(destinationIndex, list.size() - length, entity); var planningValues = CollectionUtils.copy(list.subList(fromIndex, toIndex), reversing); var bracketFromIndex = Math.min(fromIndex, destinationIndex); @@ -429,6 +439,8 @@ public final List moveValuesBetweenLists( var variableDescriptor = extractVariableDescriptor(variableMetaModel); var sourceList = variableDescriptor.getValue(sourceEntity); var length = sourceToIndex - sourceFromIndex; + requireValidDestinationIndex(destinationIndex, variableDescriptor.getValue(destinationEntity).size(), + destinationEntity); var planningValues = CollectionUtils.copy(sourceList.subList(sourceFromIndex, sourceToIndex), reversing); externalScoreDirector.beforeListVariableChanged(variableDescriptor, sourceEntity, sourceFromIndex, sourceToIndex); @@ -459,7 +471,6 @@ public void swapValuesInList( var variableDescriptor = extractVariableDescriptor(variableMetaModel); var list = variableDescriptor.getValue(entity); var leftLength = leftToIndex - leftFromIndex; - var rightLength = rightToIndex - rightFromIndex; var leftPlanningValues = CollectionUtils.copy(list.subList(leftFromIndex, leftToIndex), reversing); var rightPlanningValues = CollectionUtils.copy(list.subList(rightFromIndex, rightToIndex), reversing); var leftDestinationIndex = rightToIndex - leftLength; @@ -542,8 +553,13 @@ public final List unassignValues( if (valueRangeDescriptor.canExtractValueRangeFromSolution()) { return backingScoreDirector.getValueRangeManager().getFromSolution(valueRangeDescriptor); } else { + if (entity == null) { + throw new IllegalArgumentException( + "The entity (null) cannot be null when the value range (%s) is defined on the entity." + .formatted(valueRangeDescriptor)); + } return backingScoreDirector.getValueRangeManager() - .getFromEntity(valueRangeDescriptor, Objects.requireNonNull(entity)); + .getFromEntity(valueRangeDescriptor, entity); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 23b8348d122..9e5d6057d8f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -4,8 +4,8 @@ import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.ACCEPT_AND_STOP; import static ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample.Decision.STOP; -import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.Supplier; @@ -53,21 +53,24 @@ public final class SampleAssembler { throw new IllegalArgumentException("The minimumSize (%d) of sampler (%s) must be at least 1." .formatted(minimumSize, sampler)); } - var memberList = new ArrayList<@Nullable A>(); + // A set, not a list: sizeSoFar passed to evaluate() and the minimumSize check below + // must both see the distinct member count, since Sample.of() deduplicates anyway - + // otherwise an accepted duplicate would consume a slot the sampler believes it filled. + var memberSet = new LinkedHashSet<@Nullable A>(); var stoppedBySampler = false; while (sourceIterator.hasNext()) { var candidate = sourceIterator.next(); - var decision = evaluator.evaluate(memberList.size(), candidate); + var decision = evaluator.evaluate(memberSet.size(), candidate); if (decision == ACCEPT || decision == ACCEPT_AND_STOP) { - memberList.add(candidate); + memberSet.add(candidate); } if (decision == STOP || decision == ACCEPT_AND_STOP) { stoppedBySampler = true; break; } } - if (memberList.size() >= minimumSize) { - return Sample.of(memberList); + if (memberSet.size() >= minimumSize) { + return Sample.of(memberSet); } if (stoppedBySampler) { // The sampler chose to stop itself, below its own declared floor: @@ -75,7 +78,7 @@ public final class SampleAssembler { // A dry source without a sampler-initiated STOP falls through to the silent null below instead. throw new IllegalStateException( "The sampler (%s) stopped the sample at size (%d), below its own minimumSize (%d)." - .formatted(sampler, memberList.size(), minimumSize)); + .formatted(sampler, memberSet.size(), minimumSize)); } // Source ran dry before reaching the minimum; not the sampler's fault. return null; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index 2fa05d2c3bd..cfc761511d0 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -193,6 +193,8 @@ default boolean isValueInRange(GenuineVariableMetaModel generic type of the entity that the variable is defined on * @param generic type of the value that the variable can take * @return the value range of the variable + * @throws IllegalArgumentException if {@code entity} is null and the value range is defined on the entity, + * not on {@link PlanningSolution} */ ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java index 45fa5e10ae4..ecafc6c1ab0 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -1,10 +1,15 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import java.util.Collections; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; import ai.timefold.solver.core.preview.api.move.Move; import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; @@ -81,8 +86,14 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * Draws subpillars sharing an assigned value ("slice value") and unassigns every member, * producing a {@code MassChangeMove} with a null destination. * The destination is fixed at null, so a drawn subpillar's own move never gets rejected - - * but the subpillar draw itself can be, if the sampler refuses or the slice is smaller than the - * sampler's minimum size, and then this iterator simply ends. + * but the subpillar draw itself can be, + * if the sampler refuses or the slice is smaller than the sampler's minimum size. + * A slice value that keeps failing that draw is retired by {@link RetiringBiWalk} + * after {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} attempts, + * the same as every other {@code Sub*} provider in this package - + * one bad slice value must not end the whole iterator, since {@link PillarSampler} is + * contractually forbidden from stopping below its own {@link PillarSampler#minimumSize()}, + * so a failed draw is proof about that one slice, not about the rest of the domain. * * @param the solution type * @param the entity type @@ -90,15 +101,15 @@ public MoveStream build(MoveStreamFactory moveStreamFactor */ @NullMarked private static final class SubPillarUnassignMoveIterator - implements Iterator> { + implements Iterator>, RetiringBiWalk> { - private final Iterator sliceValueIterator; + private final RetiringRandomIterator sliceValueIterator; private final BiDatasetInstance pillarSourceInstance; private final PlanningVariableMetaModel variableMetaModel; private final PillarSampler sampler; private final RandomGenerator random; - private @Nullable Sample pendingSample = null; + private @Nullable Move nextMove = null; public SubPillarUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, PlanningVariableMetaModel variableMetaModel, @@ -108,23 +119,15 @@ public SubPillarUnassignMoveIterator(MoveIteratorSession session, Ran this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.sampler = Objects.requireNonNull(sampler); this.random = Objects.requireNonNull(random); - var distinctValueInstance = session.getInstance(distinctValueDataset); - // Plain sampling-with-replacement, not exhaustiveIterator: - // nothing needs retiring at this outer level, - // since a destination of null can never be rejected. - // The subpillar's own members are still drawn without replacement inside samplingIterator below, - // per the sampler. - this.sliceValueIterator = distinctValueInstance.iterator(random); + var distinctValueInstance = + (DefaultUniDatasetInstance) session.getInstance(distinctValueDataset); + this.sliceValueIterator = distinctValueInstance.retiringRandomIterator(random); this.pillarSourceInstance = session.getInstance(pillarSourceDataset); } @Override public boolean hasNext() { - if (pendingSample == null && sliceValueIterator.hasNext()) { - var samples = pillarSourceInstance.samplingIterator(sliceValueIterator.next(), sampler, random); - pendingSample = samples.hasNext() ? samples.next() : null; - } - return pendingSample != null; + return nextMove != null || RetiringBiWalk.advance(sliceValueIterator, this); } @Override @@ -132,9 +135,25 @@ public Move next() { if (!hasNext()) { throw new NoSuchElementException(); } - var pillar = Objects.requireNonNull(pendingSample); - pendingSample = null; - return Moves.massChange(variableMetaModel, pillar, null); + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator> createRightIterator(Value_ sliceValue) { + var samples = pillarSourceInstance.samplingIterator(sliceValue, sampler, random); + if (!samples.hasNext()) { + // Refused by the sampler, or the slice is smaller than its minimumSize; try again, + // and retire sliceValue for good once every attempt has failed. + return Collections.emptyIterator(); + } + return List.of(samples.next()).iterator(); + } + + @Override + public void accept(Value_ sliceValue, Sample pillar) { + nextMove = Moves.massChange(variableMetaModel, pillar, null); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java index 310717aa55e..0552501f711 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java @@ -44,23 +44,4 @@ public String toString() { return entity + "[" + fromIndex + ".." + (toIndex - 1) + "]"; } - @Override - public int compareTo(Range other) { - var result = Integer.compare(fromIndex, other.fromIndex()); - if (result != 0) { - return result; - } - result = Integer.compare(toIndex, other.toIndex()); - if (result != 0) { - return result; - } - var otherEntity = other.entity(); - if (Objects.equals(entity, otherEntity)) { - return 0; - } - // Tie-breaker so compareTo() stays consistent with the identity-based equals()/hashCode(); - // otherwise ranges on different entities with the same indices would collapse in a sorted set/map. - return Integer.compare(System.identityHashCode(entity), System.identityHashCode(otherEntity)); - } - } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java index b3c15d27f91..a3a2b77e632 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java @@ -17,7 +17,7 @@ * Instances are produced by {@link SubListSampler}. */ @NullMarked -public sealed interface Range extends Comparable +public sealed interface Range permits DefaultRange { /** diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java index 6e8aefc318f..068649b3767 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java @@ -60,7 +60,9 @@ default int minimumSize() { } /** - * @param sizeSoFar the number of members already accepted; 0 for the first candidate offered + * @param sizeSoFar the number of distinct members already accepted; 0 for the first candidate offered. + * A duplicate acceptance (the source offers a candidate equal to one already accepted) + * does not advance this count, since the assembled sample deduplicates. * @param candidate the candidate offered, drawn at most once per sample * @return what to do with the candidate */ diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index c99eaf54eea..0e4d089a459 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.LinkedHashSet; @@ -1372,6 +1373,27 @@ void moveValuesInListThrowsOnEmptySpan() { .hasMessageContaining("fromIndex (1)"); } + @Test + void moveValuesInListThrowsOnDestinationIndexOutOfBounds() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var value1 = new TestdataListValue("value1"); + var value2 = new TestdataListValue("value2"); + var value3 = new TestdataListValue("value3"); + var entity = new TestdataListEntity("A", value1, value2, value3); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + // The span [0, 1) has length 1; after its removal the list has 2 elements, + // so the only valid destinationIndex values are 0, 1 and 2. + assertThatThrownBy(() -> moveDirector.moveValuesInList(variableMetaModel, entity, 0, 1, 3, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destinationIndex (3)"); + assertThat(entity.getValueList()).containsExactly(value1, value2, value3); + verifyNoInteractions(mockScoreDirector); + } + @Test void moveValuesBetweenLists() { var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); @@ -1456,6 +1478,28 @@ void moveValuesBetweenListsThrowsOnSameEntity() { .hasMessageContaining("must be different"); } + @Test + void moveValuesBetweenListsThrowsOnDestinationIndexOutOfBounds() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var valueA1 = new TestdataListValue("valueA1"); + var entityA = new TestdataListEntity("A", valueA1); + var valueB1 = new TestdataListValue("valueB1"); + var entityB = new TestdataListEntity("B", valueB1); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + // entityB's list has 1 element, so the only valid destinationIndex values are 0 and 1. + assertThatThrownBy( + () -> moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 0, 1, entityB, 2, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("destinationIndex (2)"); + assertThat(entityA.getValueList()).containsExactly(valueA1); + assertThat(entityB.getValueList()).containsExactly(valueB1); + verifyNoInteractions(mockScoreDirector); + } + @Test void moveValuesBetweenListsThrowsOnEmptySpan() { var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 3e92704b328..1436bf7dac1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -148,6 +148,33 @@ void sampleCanContainAndCheckForNullability() { assertThat(sample.contains(null)).isFalse(); } + @Test + void samplerMinimumSizeIsCheckedAfterDeduplication() { + record Candidate(String code) { + } + // Two distinct instances, equal by content: the assembler must count the sample's + // distinct members, not the raw candidate count, when it checks minimumSize. + var source = List.of(new Candidate("only"), new Candidate("only")).iterator(); + + var sampler = new Sampler() { + @Override + public int minimumSize() { + return 2; + } + + @Override + public Decision evaluate(int sizeSoFar, Candidate candidate) { + return Decision.ACCEPT; // Never stops itself; only the drained source ends the sample. + } + }; + + var random = RandomSource.seeded(0L).moveIteratorUsage(); + // Both candidates are equal, so only 1 distinct member is ever accepted - below the + // sampler's minimumSize of 2. Must be discarded entirely (null), not returned as an + // undersized Sample of size 1. + assertThat(SampleAssembler.assemble(source, random, sampler)).isNull(); + } + // ===== Bi tests ===== @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java index f35bc9b4f1f..1e20ca9a598 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.stream.Collectors; @@ -78,6 +79,46 @@ void differentDrawsProduceDifferentSubpillars() { assertThat(distinctMemberSets).hasSizeGreaterThan(1); } + @Test + void survivesSlicesSmallerThanTheSamplersMinimumSize() { + var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + + // One value shared by 2 entities (a legal pillar for a minimumSize-2 sampler), + // and 20 values each held by exactly 1 entity (a slice too small to ever succeed). + // Skewed heavily toward the too-small slices, so a provider that gives up on its first + // bad draw - instead of retrying another slice value - reliably produces zero moves here. + var goodValue = new TestdataValue("good"); + List valueList = new ArrayList<>(); + valueList.add(goodValue); + List entityList = new ArrayList<>(); + entityList.add(new TestdataAllowsUnassignedEntity("good-0", goodValue)); + entityList.add(new TestdataAllowsUnassignedEntity("good-1", goodValue)); + for (var i = 0; i < 20; i++) { + var singletonValue = new TestdataValue("v" + i); + valueList.add(singletonValue); + entityList.add(new TestdataAllowsUnassignedEntity("singleton-" + i, singletonValue)); + } + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(valueList); + solution.setEntityList(entityList); + + var context = NeighborhoodTester + .build(new SubPillarUnassignMoveProvider<>(variableMetaModel, Samplers.pillar(Samplers.exactly(2))), + solutionMetaModel) + .using(solution); + + var moves = context.getMovesAsStream( + move -> (MassChangeMove) move) + .limit(50) + .toList(); + assertThat(moves).isNotEmpty(); + for (var move : moves) { + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityList.get(0), entityList.get(1)); + assertThat(move.getPlanningValues().getFirst()).isNull(); + } + } + @Test void pinnedEntityNeverUnassigned() { var solutionMetaModel = TestdataPinnedAllowsUnassignedSolution.buildMetaModel(); From c48e2ec0ad2f852576208853c15ad0d64719a8b0 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 21:01:05 +0200 Subject: [PATCH 08/16] Fix the first() mess --- .../core/preview/api/move/builtin/MoveProviderUtil.java | 8 ++++++-- .../api/move/builtin/PillarChangeMoveProvider.java | 2 +- .../core/preview/api/move/builtin/PillarSwapMove.java | 4 ++-- .../neighborhood/stream/dataset/sample/DefaultSample.java | 3 ++- .../api/neighborhood/stream/dataset/sample/Sample.java | 8 ++++++-- .../neighborhood/stream/dataset/sample/SamplersTest.java | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java index 035a58a15b2..67be2782f17 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java @@ -212,6 +212,9 @@ public static boolean anyAssigned(Sample s * @return {@code true} if the swap changes at least one variable, * and every changed variable is legal on both sides; * {@code false} if any variable's swap falls out of range for either side. + * {@code leftPillar} and {@code rightPillar} must both be homogeneous per every variable in + * {@code variableMetaModelList}: each pillar's {@link Sample#representative()} is read once + * per variable and stands in for every one of its members. */ public static boolean isValidSwap(SolutionView solutionView, List> variableMetaModelList, @@ -219,8 +222,9 @@ public static boolean isValidSwap(SolutionView s var change = false; for (var i = 0; i < variableMetaModelList.size(); i++) { var variableMetaModel = variableMetaModelList.get(i); - var oldLeftValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(leftPillar.first())); - var oldRightValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(rightPillar.first())); + var oldLeftValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(leftPillar.representative())); + var oldRightValue = + solutionView.getValue(variableMetaModel, Objects.requireNonNull(rightPillar.representative())); if (Objects.equals(oldLeftValue, oldRightValue)) { continue; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java index b3cf64a6091..52c82cc665b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -182,7 +182,7 @@ public Iterator createRightIterator(Sample pillar) { } // The pillar is homogeneous by construction (one cached row per assigned value); // recover the slice value from any one member to exclude it as a no-op destination. - var sliceValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(pillar.first())); + var sliceValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(pillar.representative())); var destination = ranges.findDestination(random, sliceValue); if (destination == null) { cachedPillarProvenEmpty = true; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java index a4a51f106ce..1b34f6c6d5e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -114,8 +114,8 @@ public void execute(MutableSolutionView solutionView) { if (valueList != null) { return valueList; } - var leftHead = Objects.requireNonNull(leftPillar.first()); - var rightHead = Objects.requireNonNull(rightPillar.first()); + var leftHead = Objects.requireNonNull(leftPillar.representative()); + var rightHead = Objects.requireNonNull(rightPillar.representative()); valueList = MoveProviderUtil.cachedValuesOf(leftHead, rightHead, variableMetaModelList); return valueList; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java index 2a7715ebf80..4c5281ea0c3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java @@ -33,7 +33,8 @@ public boolean contains(@Nullable A element) { } @Override - public @Nullable A first() { + public @Nullable A representative() { + // A LinkedHashSet is what makes "the same member on every call" true. return memberSet.getFirst(); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 2ff3ff63655..c18f44663ac 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -42,10 +42,14 @@ public interface Sample boolean contains(@Nullable A element); /** - * @return the first member; never null + * @return a representative member of the sample - which one is unspecified, beyond that a given + * instance returns the same one on every call. + * May be null, as null members are legal. + * Useful for reading a value every member is known to share, + * such as a homogeneous pillar's current variable value. */ @Nullable - A first(); + A representative(); /** * Creates a sample from a collection, copying it and removing duplicates, diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java index 499da846557..0d2ceced338 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java @@ -166,7 +166,7 @@ void rejectAtSizeZeroKeepsDrawingForAFirstMember() { var sample = SampleAssembler.assemble(sourceOf(5).iterator(), seededRandom(), sampler); assertThat(sample).isNotNull(); assertThat(sample.size()).isEqualTo(1); - assertThat(sample.first()).isEqualTo(2); + assertThat(sample.representative()).isEqualTo(2); } @Test From bb8f7fc5f7634011722df12130681e58774447b1 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Sun, 23 Aug 2026 21:22:07 +0200 Subject: [PATCH 09/16] Semantic line breaks --- .../common/index/RetiringRandomIterator.java | 5 +- .../DefaultPlanningListVariableMetaModel.java | 3 +- .../DefaultPlanningVariableMetaModel.java | 4 +- .../DefaultShadowVariableMetaModel.java | 3 +- .../descriptor/SolutionDescriptor.java | 7 ++- .../solver/core/impl/move/MoveDirector.java | 4 +- .../DefaultNeighborhoodTestContext.java | 3 +- .../dataset/JustInTimeBiDatasetInstance.java | 7 ++- .../dataset/sample/SampleAssembler.java | 28 +++++----- .../common/AbstractLeftDatasetInstance.java | 4 +- .../impl/util/TriangleElementFactory.java | 4 +- .../core/impl/util/TriangularNumbers.java | 5 +- .../api/domain/metamodel/ElementPosition.java | 3 +- .../metamodel/ShadowEntityMetaModel.java | 3 +- .../metamodel/ShadowVariableMetaModel.java | 3 +- .../domain/metamodel/VariableMetaModel.java | 3 +- .../solver/core/preview/api/move/Move.java | 29 ++++++----- .../preview/api/move/MutableSolutionView.java | 24 ++++----- .../core/preview/api/move/SolutionView.java | 15 +++--- .../move/builtin/ListChangeMoveProvider.java | 4 +- .../builtin/ListTailSwapMoveProvider.java | 9 ++-- .../builtin/ListUnassignMoveProvider.java | 4 +- .../move/builtin/MassAssignMoveProvider.java | 6 +-- .../move/builtin/MassChangeMoveProvider.java | 3 +- .../builtin/MassDestinationMoveIterator.java | 11 ++-- .../builtin/MassListAssignMoveProvider.java | 6 +-- .../api/move/builtin/MassListChangeMove.java | 13 ++--- .../builtin/MassListChangeMoveProvider.java | 7 ++- .../MassListDestinationMoveIterator.java | 8 +-- .../builtin/MassListUnassignMoveProvider.java | 12 ++--- .../builtin/MassUnassignMoveProvider.java | 8 +-- .../api/move/builtin/MoveProviderUtil.java | 52 +++++++++---------- .../core/preview/api/move/builtin/Moves.java | 31 +++++------ .../builtin/PillarChangeMoveProvider.java | 4 +- .../api/move/builtin/PillarSwapMove.java | 16 +++--- .../move/builtin/PillarSwapMoveProvider.java | 5 +- .../builtin/PillarUnassignMoveProvider.java | 5 +- .../api/move/builtin/SampleValueRanges.java | 14 ++--- .../builtin/SubListChangeMoveProvider.java | 11 ++-- .../move/builtin/SubListSwapMoveProvider.java | 4 +- .../api/move/builtin/SubListUnassignMove.java | 5 +- .../builtin/SubListUnassignMoveProvider.java | 4 +- .../builtin/SubPillarChangeMoveProvider.java | 7 ++- .../builtin/SubPillarSwapMoveProvider.java | 12 ++--- .../SubPillarUnassignMoveProvider.java | 4 +- .../preview/api/move/builtin/SwapMove.java | 5 +- .../move/builtin/TwoOptListMoveProvider.java | 4 +- .../stream/MoveStreamFactory.java | 18 ++++--- .../stream/dataset/BiDatasetInstance.java | 4 +- .../stream/dataset/sample/PillarSampler.java | 12 ++--- .../stream/dataset/sample/Sample.java | 4 +- .../stream/dataset/sample/Sampler.java | 12 ++--- .../stream/dataset/sample/Samplers.java | 26 +++++----- .../stream/dataset/sample/SubListSampler.java | 20 +++---- .../stream/dataset/sample/package-info.java | 10 ++-- .../enumerating/BiEnumeratingStream.java | 17 +++--- .../enumerating/UniEnumeratingStream.java | 31 +++++------ .../RandomSubListSwapMoveSelectorTest.java | 5 +- .../core/impl/move/MoveDirectorTest.java | 9 ++-- .../neighborhood/bias/AbstractBiasIT.java | 12 ++--- .../impl/neighborhood/bias/BiasReport.java | 44 ++++++++-------- .../bias/SamplingIteratorBiasIT.java | 21 ++++---- .../bias/SubListSamplingBiasIT.java | 23 ++++---- .../dataset/sample/SamplingIteratorTest.java | 14 ++--- .../move/builtin/ChangeMoveProviderTest.java | 8 +-- .../api/move/builtin/ChangeMoveTest.java | 4 +- .../builtin/ListSwapMoveProviderTest.java | 10 ++-- .../builtin/MassChangeMoveProviderTest.java | 27 +++++----- .../MassListChangeMoveProviderTest.java | 4 +- .../move/builtin/MassListChangeMoveTest.java | 16 +++--- .../MassListUnassignMoveProviderTest.java | 4 +- .../builtin/MassUnassignMoveProviderTest.java | 4 +- .../builtin/PillarChangeMoveProviderTest.java | 25 ++++----- .../builtin/PillarSwapMoveProviderTest.java | 50 ++++++++++-------- .../api/move/builtin/PillarSwapMoveTest.java | 9 ++-- .../PillarUnassignMoveProviderTest.java | 4 +- .../move/builtin/SampleValueRangesBiasIT.java | 21 ++++---- .../move/builtin/SampleValueRangesTest.java | 14 ++--- .../builtin/SubListSwapMoveProviderTest.java | 7 +-- .../SubPillarChangeMoveProviderTest.java | 5 +- .../SubPillarSwapMoveProviderTest.java | 29 ++++++----- .../SubPillarUnassignMoveProviderTest.java | 6 ++- .../move/builtin/SwapMoveProviderTest.java | 17 +++--- .../api/move/builtin/SwapMoveTest.java | 6 +-- .../stream/dataset/sample/SamplersTest.java | 8 +-- .../stream/enumerating/PillarDatasetTest.java | 10 ++-- .../migration/preview/package-info.java | 4 +- 87 files changed, 508 insertions(+), 497 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java index 98f977c9979..911c4bab808 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java @@ -38,9 +38,8 @@ public sealed interface RetiringRandomIterator } /** - * Adapts an iterator of one type to another, without changing which element retirement - * targets: {@link #retire()} on the result still retires whatever the delegate itself last - * handed out. + * Adapts an iterator of one type to another, without changing which element retirement targets: + * {@link #retire()} on the result still retires whatever the delegate itself last handed out. */ static RetiringRandomIterator mapping( RetiringRandomIterator delegate, Function mapper) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java index a55be748ea3..0153848b7a4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningListVariableMetaModel.java @@ -43,8 +43,7 @@ public boolean isValueRangeOnSolution() { @Override public boolean equals(Object o) { // Do not use entity in equality checks; - // If an entity is subclassed, that subclass will have it - // own distinct VariableMetaModel + // If an entity is subclassed, that subclass will have it own distinct VariableMetaModel if (o instanceof DefaultPlanningListVariableMetaModel that) { return Objects.equals(variableDescriptor, that.variableDescriptor); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java index e4994f3b9ac..97e51acb693 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultPlanningVariableMetaModel.java @@ -48,8 +48,8 @@ public boolean isValueRangeOnSolution() { @Override public boolean equals(Object o) { // Do not use entity in equality checks; - // If an entity is subclassed, that subclass will have it - // own distinct VariableMetaModel + // If an entity is subclassed, + // that subclass will have it own distinct VariableMetaModel if (o instanceof DefaultPlanningVariableMetaModel that) { return Objects.equals(variableDescriptor, that.variableDescriptor); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java index 8602957267c..d10e0df99b9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/DefaultShadowVariableMetaModel.java @@ -38,8 +38,7 @@ public ShadowVariableDescriptor variableDescriptor() { @Override public boolean equals(Object o) { // Do not use entity in equality checks; - // If an entity is subclassed, that subclass will have it - // own distinct VariableMetaModel + // If an entity is subclassed, that subclass will have it own distinct VariableMetaModel if (o instanceof DefaultShadowVariableMetaModel that) { return Objects.equals(variableDescriptor, that.variableDescriptor); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java index aa0024f3be3..91c34eaf09b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java @@ -74,8 +74,7 @@ import org.slf4j.LoggerFactory; /** - * @param the solution type, the class with the {@link PlanningSolution} - * annotation + * @param the solution type, the class with the {@link PlanningSolution} annotation */ @NullMarked public final class SolutionDescriptor { @@ -126,8 +125,8 @@ public static SolutionDescriptor buildSolutionDescriptor( solutionDescriptor.processUnannotatedFieldsAndMethods(descriptorPolicy); solutionDescriptor.processAnnotations(descriptorPolicy); - // Before iterating over the entity classes, we need to read the inheritance chain, - // add all parent and child classes, and sort them. + // Before iterating over the entity classes, we need to read the inheritance chain, add all parent and child classes, + // and sort them. var updatedEntityClassList = new ArrayList<>(entityClassList); for (var entityClass : entityClassList) { var inheritedEntityClasses = extractInheritedClasses(entityClass); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index b0a9f234358..ac706402f8a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -311,8 +311,8 @@ public Value_ replaceValue( * so it only wins when that span is short relative to what {@code removeAdd} would have to copy. * *

    - * The threshold constant 8 was determined empirically by benchmarking on HotSpot - * with a microbenchmark that performed moves of varying distances and positions within lists of varying sizes. + * The threshold constant 8 was determined empirically by benchmarking on HotSpot with a microbenchmark + * that performed moves of varying distances and positions within lists of varying sizes. * * @param list the list to mutate; assumes {@link ArrayList} * @param from index of the element to move diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java index edf199150de..60b452b77e6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/DefaultNeighborhoodTestContext.java @@ -87,8 +87,7 @@ public void producesNoneOf(Move... unexpectedMoves) { * Repeatedly draws moves from {@link NeighborhoodsBasedMoveRepository#iterator(RandomGenerator)} * (restarting whenever it exhausts) * until either {@code stopCondition} returns true, - * the iteration limit is - * reached, + * the iteration limit is reached, * or an entire pass draws nothing. *

    * Every pass shares the same {@link RandomGenerator} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java index 2294c9a3d5c..425df69a7d2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/JustInTimeBiDatasetInstance.java @@ -23,8 +23,8 @@ /** * A {@link BiDatasetInstance} produced by {@code UniDataset.join}: - * the join is not materialized in Bavet but computed just in time inside this instance, - * out of a left and a right {@code UniDataset}. + * the join is not materialized in Bavet + * but computed just in time inside this instance, out of a left and a right {@code UniDataset}. */ @NullMarked public final class JustInTimeBiDatasetInstance implements BiDatasetInstance { @@ -207,8 +207,7 @@ public Iterator> createRightIterator(UniTuple leftTuple) { if (filter == null) { return rightTupleIterator; } - // RetiringBiWalk.advance() retries this call - // up to PROBE_ATTEMPT_COUNT times before retiring the left, + // RetiringBiWalk.advance() retries this call up to PROBE_ATTEMPT_COUNT times before retiring the left, // since a single bail-out is a false negative, not proof of emptiness. var bailOutSize = rightDatasetInstance.size(compositeKey) * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; return new FilteringIterator<>(rightTupleIterator, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 9e5d6057d8f..7ea69c9e914 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -25,11 +25,14 @@ public final class SampleAssembler { * Assembles one sample, eagerly. *

    * The source iterator must be an exhaustive (without-replacement) iterator: - * it retires every element it hands out, so a candidate is offered to a given sample exactly once, - * and it has no bail-out, so it does not report "nothing left" while elements remain. + * it retires every element it hands out, + * so a candidate is offered to a given sample exactly once, + * and it has no bail-out, + * so it does not report "nothing left" while elements remain. * - * @return null if the source is empty, if the sampler refuses the sample, or if the sample ends - * below the sampler's {@link Sampler#minimumSize()} + * @return null if the source is empty, + * if the sampler refuses the sample, + * or if the sample ends below the sampler's {@link Sampler#minimumSize()} */ public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, RandomGenerator random, Sampler sampler) { @@ -38,8 +41,8 @@ public final class SampleAssembler { } /** - * As defined by {@link #assemble(Iterator, RandomGenerator, Sampler)}, but for a {@link PillarSampler} - * drawn under a key. + * As defined by {@link #assemble(Iterator, RandomGenerator, Sampler)}, + * but for a {@link PillarSampler} drawn under a key. */ public static @Nullable Sample assemble(Iterator<@Nullable A> sourceIterator, RandomGenerator random, @Nullable Key_ key, PillarSampler sampler) { @@ -53,8 +56,9 @@ public final class SampleAssembler { throw new IllegalArgumentException("The minimumSize (%d) of sampler (%s) must be at least 1." .formatted(minimumSize, sampler)); } - // A set, not a list: sizeSoFar passed to evaluate() and the minimumSize check below - // must both see the distinct member count, since Sample.of() deduplicates anyway - + // A set, not a list: + // sizeSoFar passed to evaluate() and the minimumSize check below must both see the distinct member count, + // since Sample.of() deduplicates anyway - // otherwise an accepted duplicate would consume a slot the sampler believes it filled. var memberSet = new LinkedHashSet<@Nullable A>(); var stoppedBySampler = false; @@ -86,8 +90,8 @@ public final class SampleAssembler { /** * Samples with replacement: - * never ends unless the source is empty or every draw is refused (a sampler refusal, or a sample - * below the sampler's {@link Sampler#minimumSize()}); + * never ends unless the source is empty or every draw is refused + * (a sampler refusal, or a sample below the sampler's {@link Sampler#minimumSize()}); * may return equal samples. * Each sample is assembled in full before it is returned, * so the sampler is never left mid-sample. @@ -105,8 +109,8 @@ public static Iterator> iterator(Supplier> s } /** - * As defined by {@link #iterator(Supplier, RandomGenerator, Sampler)}, but for a - * {@link PillarSampler} drawn under a key. + * As defined by {@link #iterator(Supplier, RandomGenerator, Sampler)}, + * but for a {@link PillarSampler} drawn under a key. */ public static Iterator> iterator(Supplier> sourceSupplier, RandomGenerator random, @Nullable Key_ key, PillarSampler sampler) { 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 8e6d520ab78..4eb24d6191a 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 @@ -56,8 +56,8 @@ public void retract(Tuple_ tuple) { /** * Not part of {@link UniDatasetInstance}: only satisfies {@link Iterable}, - * for callers (such as {@code JustInTimeBiDatasetInstance#size()}) that need a plain, - * non-random walk internally. + * for callers (such as {@code JustInTimeBiDatasetInstance#size()}) + * that need a plain, non-random walk internally. */ @Override public Iterator iterator() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java index 0861a58ef65..89df3f276f4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java @@ -22,8 +22,8 @@ public TriangleElementFactory(int minimumSubListSize, int maximumSubListSize, Ra } /** - * Validates a {@code (minimumSubListSize, maximumSubListSize)} pair without needing a - * {@link RandomGenerator}, so that a caller which only creates a {@link TriangleElementFactory} later + * Validates a {@code (minimumSubListSize, maximumSubListSize)} pair without needing a {@link RandomGenerator}, + * so that a caller which only creates a {@link TriangleElementFactory} later * (once a {@link RandomGenerator} becomes available) can still fail fast at construction time. * * @throws IllegalArgumentException if {@code minimumSubListSize > maximumSubListSize}, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java index 5aade30fb4b..d291a51231f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangularNumbers.java @@ -10,8 +10,9 @@ public final class TriangularNumbers { /** * Calculate nth triangular number. * This is used to calculate the number of subLists for a given list variable of size n. - * To be able to use {@code int} arithmetic to calculate the triangular number, n must be less than or equal to - * {@link #HIGHEST_SAFE_N}. If the n is higher, the method throws an exception. + * To be able to use {@code int} arithmetic to calculate the triangular number, + * n must be less than or equal to {@link #HIGHEST_SAFE_N}. + * If the n is higher, the method throws an exception. * * @param n size of the triangle (the length of its side) * @return nth triangular number diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java index 14426d9ee61..baae1dddaa5 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ElementPosition.java @@ -29,8 +29,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. */ @NullMarked diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java index b8a0cc58346..a0698034f50 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowEntityMetaModel.java @@ -16,8 +16,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @param The solution type. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java index 3d0f03ed0d3..0f0ec7f7729 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/ShadowVariableMetaModel.java @@ -19,8 +19,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @param the solution type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java index 68d7210b564..0bc851cec00 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/VariableMetaModel.java @@ -17,8 +17,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @param diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java index 861b53554ed..160f34f4b44 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/Move.java @@ -16,18 +16,18 @@ import org.jspecify.annotations.Nullable; /** - * A Move represents a change of 1 or more {@link PlanningVariable}s of 1 or more {@link PlanningEntity}s - * in the working {@link PlanningSolution}. + * A Move represents a change of 1 or more {@link PlanningVariable}s of 1 or more {@link PlanningEntity}s in the working + * {@link PlanningSolution}. *

    * Usually the move holds a direct reference to each {@link PlanningEntity} of the {@link PlanningSolution} * which it will change when {@link #execute(MutableSolutionView)} is called. * It is recommended for the moves to not touch shadow variables, * the solver will update shadow variables after move execution is complete. - * If the move has to touch shadow variables, it is responsible for updating them - * consistently across the entire dependency graph. + * If the move has to touch shadow variables, + * it is responsible for updating them consistently across the entire dependency graph. *

    - * A move must implement {@link Object#equals(Object)} and {@link Object#hashCode()} - * in a way which can distinguish individual move instances from each other + * A move must implement {@link Object#equals(Object)} and {@link Object#hashCode()} in a way + * which can distinguish individual move instances from each other * and only marks moves as equal if they result in the exact same solution. * This is used by several features, such as tabu search and {@link NeighborhoodTester}. *

    @@ -55,19 +55,20 @@ public interface Move { void execute(MutableSolutionView solutionView); /** - * Rebases a move from an origin working solution - * to another destination working solution which is usually on another {@link Thread}. + * Rebases a move from an origin working solution to another destination working solution + * which is usually on another {@link Thread}. * It is necessary for multithreaded solving to function. *

    - * The new move returned by this method translates the entities and problem facts - * to the destination {@link PlanningSolution} of the destination. - * That destination {@link PlanningSolution} is a deep planning clone (or an even deeper clone) - * of the origin {@link PlanningSolution} that this move has been generated from. + * The new move returned by this method translates the entities and problem facts to the destination + * {@link PlanningSolution} of the destination. + * That destination {@link PlanningSolution} is a deep planning clone (or an even deeper clone) of the origin + * {@link PlanningSolution} + * that this move has been generated from. *

    * That new move does the exact same change as this move, * resulting in the same {@link PlanningSolution} state, - * presuming that destination {@link PlanningSolution} was in the same state - * as the original {@link PlanningSolution} to begin with. + * presuming that destination {@link PlanningSolution} was in the same state as the original {@link PlanningSolution} to + * begin with. *

    * An implementation of this method typically iterates through every entity and fact instance in this move, * translates each one to the destination with {@link Lookup#lookUpWorkingObject(Object)} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java index dc9a4543e5f..dbc3ca9e5e1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java @@ -25,8 +25,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @param @@ -185,8 +184,7 @@ Value_ moveValueBetweenLists(PlanningListVariableMetaModel Value_ moveValueBetweenLists( * All values at or after the index are shifted to the right. * @return the value that was moved * @throws IllegalArgumentException if sourceIndex == destinationIndex - * @see #replaceValue(PlanningListVariableMetaModel, Object, int, int) Similar operation that replaces the value at - * the destination index instead. + * @see #replaceValue(PlanningListVariableMetaModel, Object, int, int) Similar operation that replaces the value at the + * destination index instead. * @see #shiftValue(PlanningListVariableMetaModel, Object, int, int) Equivalent operation using offset calculation instead * of index arithmetics. */ @@ -278,8 +276,7 @@ default Value_ replaceValue(PlanningListVariableMetaModel List moveValuesInList( int fromIndex, int toIndex, int destinationIndex, boolean reversing); /** - * Moves a contiguous span of values from one entity's {@link PlanningListVariable planning list variable} - * to another. + * Moves a contiguous span of values from one entity's {@link PlanningListVariable planning list variable} to another. * * @param variableMetaModel Describes the variable to be changed. * @param sourceEntity The entity from which the span will be removed. @@ -366,8 +362,8 @@ List moveValuesInList( * inserted; all values at or after the index are shifted to the right. * @param reversing if {@code true}, the span is inserted in reverse element order * @return the moved values, in the order they were inserted - * @throws IllegalArgumentException if {@code sourceEntity == destinationEntity} or {@code sourceToIndex <= - * sourceFromIndex} + * @throws IllegalArgumentException if {@code sourceEntity == destinationEntity} or + * {@code sourceToIndex <= sourceFromIndex} */ List moveValuesBetweenLists( PlanningListVariableMetaModel variableMetaModel, Entity_ sourceEntity, @@ -375,8 +371,8 @@ List moveValuesBetweenLists( boolean reversing); /** - * Swaps two contiguous, non-overlapping spans of values within one entity's - * {@link PlanningListVariable planning list variable}. + * Swaps two contiguous, non-overlapping spans of values within one entity's {@link PlanningListVariable planning list + * variable}. * * @param variableMetaModel Describes the variable to be changed. * @param entity The entity whose variable values are to be swapped. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index cfc761511d0..8479d3b60b4 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -27,8 +27,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @param @@ -93,8 +92,8 @@ ElementPosition getPositionOf( /** * Reads the index of the first element of a {@link PlanningListVariable list planning variable} * that is not pinned. - * The pinned portion of a list variable, if any, is always a prefix: every index below the - * returned value is pinned, every index at or above it is not. + * The pinned portion of a list variable, if any, is always a prefix: + * every index below the returned value is pinned, every index at or above it is not. *

    * Caveat: for an entity that is entirely immovable due to {@link PlanningPin}, * this method returns {@code 0} even though every element of its list is pinned; @@ -182,8 +181,8 @@ default boolean isValueInRange(GenuineVariableMetaModel - * The returned range may include {@code null} if the variable - * {@link PlanningVariable#allowsUnassigned() allows unassigned} values; + * The returned range may include {@code null} + * if the variable {@link PlanningVariable#allowsUnassigned() allows unassigned} values; * use {@link ValueRange#contains(Object)} to check {@code null} the same way as any other value. * * @param variableMetaModel variable in question @@ -193,8 +192,8 @@ default boolean isValueInRange(GenuineVariableMetaModel generic type of the entity that the variable is defined on * @param generic type of the value that the variable can take * @return the value range of the variable - * @throws IllegalArgumentException if {@code entity} is null and the value range is defined on the entity, - * not on {@link PlanningSolution} + * @throws IllegalArgumentException if {@code entity} is null + * and the value range is defined on the entity, not on {@link PlanningSolution} */ ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity); 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 05ab1fd8ef8..704bb6bea19 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 @@ -23,8 +23,8 @@ *

    * This does not remove the need for {@code ListAssignMoveProvider} and {@code ListUnassignMoveProvider}: * here, a null-crossing move is one candidate among many, so it arrives rarely. - * A configuration that wants such moves often should add {@code ListAssignMoveProvider}/{@code ListUnassignMoveProvider} - * in addition to turning this flag off to avoid further oversampling. + * A configuration that wants such moves often should add {@code ListAssignMoveProvider}/{@code ListUnassignMoveProvider} in + * addition to turning this flag off to avoid further oversampling. * *

    * To reassign a value, creates: diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java index 49b06a6c356..584c6ebceb6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java @@ -37,8 +37,8 @@ * This class exists to make it happen often, and to also emit the reversing variant. *

    * The swapped tails are unbounded by design: - * a tail swap is defined by the tails, and bounding - * either one would produce a sub-list relocation instead, + * a tail swap is defined by the tails, + * and bounding either one would produce a sub-list relocation instead, * which {@code SubListChangeMoveProvider} already provides with a size cap. * * @see TwoOptListMoveProvider The same-entity reversal, and this same shape at a much lower rate. @@ -75,8 +75,9 @@ public MoveStream build(MoveStreamFactory moveStreamFactor } /** - * Draws two assigned values on different entities and swaps their tails, producing a - * {@code SubListSwapMove}. Left = seed value, right = candidate value. + * Draws two assigned values on different entities and swaps their tails, + * producing a {@code SubListSwapMove}. + * Left = seed value, right = candidate value. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java index 1e4f5113e96..28906f0eb5d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMoveProvider.java @@ -13,8 +13,8 @@ * For each value currently assigned to any entity's list variable, * creates a move to unassign it (remove it from the list). *

    - * This class is part of the Neighborhoods API, which is under development and is only offered as a preview - * feature. + * This class is part of the Neighborhoods API, + * which is under development and is only offered as a preview feature. * There are no guarantees for backward compatibility; * any class, method, or field may change or be removed without prior notice, * although we will strive to avoid this as much as possible. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java index 2352e071881..2f06e7c751a 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProvider.java @@ -29,16 +29,14 @@ * Requires that the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned}; * otherwise the constructor throws {@link IllegalArgumentException}. *

    - * Samples of size less than 2 are excluded: {@code AssignMoveProvider} already covers them, - * more cheaply. + * Samples of size less than 2 are excluded: {@code AssignMoveProvider} already covers them, more cheaply. * A {@link Sampler} whose very first {@code evaluate(0, ...)} call already returns {@code STOP} * or {@code ACCEPT_AND_STOP} produces only size-1 samples, * which this provider discards outright; * use {@code MassUnassignMoveProvider}, * or a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, - * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost - * linear in the data set size. + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java index 118839e9344..db481104560 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProvider.java @@ -53,8 +53,7 @@ * use {@code MassUnassignMoveProvider}, * or a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, - * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost - * linear in the data set size. + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java index c53829f1ec9..3dfd6de5395 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassDestinationMoveIterator.java @@ -39,8 +39,8 @@ * so the first draw succeeds there as well. * Only disjoint entity-dependent ranges fail systematically, * and there the legal samples are so rare - * (about {@code r^(1-k)} for r regions and samples of size k) that - * a pool-scaled budget would pay for {@code n * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER} intersections every step + * (about {@code r^(1-k)} for r regions and samples of size k) + * that a pool-scaled budget would pay for {@code n * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER} intersections every step * to recover a fraction of them. * That configuration wants {@link SubPillarChangeMoveProvider} or a region-aware {@link Sampler} instead. * @@ -83,9 +83,10 @@ final class MassDestinationMoveIterator implements I @Override public boolean hasNext() { - // sampleIterator.hasNext() can return false early - a sampler refusal, or sourceDataset - // smaller than the sampler's minimum size - and the while condition below ends this call - // right there, with no hang: each call tries a fresh source, independent of the last. + // sampleIterator.hasNext() can return false early - + // a sampler refusal, or sourceDataset smaller than the sampler's minimum size - + // and the while condition below ends this call right there, with no hang: + // each call tries a fresh source, independent of the last. // Otherwise, a real Sampler can draw a different sample on every call, // so failed draws are counted and this call gives up once they reach RetiringBiWalk.PROBE_ATTEMPT_COUNT. var failedSampleDraws = 0; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java index 4355d04304d..3f45c62f1f2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProvider.java @@ -21,8 +21,7 @@ * at one destination position legal for every member. * Members need not share anything beyond currently being unassigned. *

    - * Requires that the variable - * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; + * Requires that the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; * otherwise the constructor throws {@link IllegalArgumentException}. *

    * Samples of size less than 2 are excluded: {@code ListAssignMoveProvider} already covers them, more cheaply. @@ -31,8 +30,7 @@ * which this provider discards outright; * a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 avoids that - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, - * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost - * linear in the data set size. + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. *

    * {@code MassListChangeMoveProvider} never produces this kind of move at all, * since its own source is currently assigned values only. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java index 40025b32448..d894cefb44e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -66,12 +66,13 @@ public List> variableM @Override public void execute(MutableSolutionView solutionView) { // Two passes, deliberately: pass 1 reads every member's ORIGINAL position before anything is removed. - // Reading positions inside a single mutate-as-you-go loop is WRONG - a member sitting at or after - // destination.index() can have its live index shift below destination.index() once earlier same-entity - // members are removed, double-counting it as "before" and corrupting the adjusted index. Worked - // counterexample: entity [a,b,c,d,e], destination index 2 (before c), sample {a,b,c} (c sits AT the - // destination, not before it) - a single-pass implementation gives removedBeforeDestination=3 and an - // adjusted index of -1. + // Reading positions inside a single mutate-as-you-go loop is WRONG - + // a member sitting at or after destination.index() can have its live index shift below destination.index() + // once earlier same-entity members are removed, + // double-counting it as "before" and corrupting the adjusted index. + // Worked counterexample: + // entity [a,b,c,d,e], destination index 2 (before c), sample {a,b,c} (c sits AT the destination, not before it) - + // a single-pass implementation gives removedBeforeDestination=3 and an adjusted index of -1. var valueList = new ArrayList(sample.size()); var removedBeforeDestination = 0; for (var member : sample) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java index d8ff84c407f..639b567714e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java @@ -30,8 +30,7 @@ * which this provider discards outright; * a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 avoids that - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, - * s unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost - * linear in the data set size. + * s unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. *

    * A sample already sitting consecutively at the destination produces a move that changes nothing; * this is accepted, in the same spirit as a mixed-value {@code MassChangeMoveProvider} sample @@ -60,8 +59,8 @@ public MassListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java index 7c7b8dcc82b..cf5e36e297a 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListDestinationMoveIterator.java @@ -69,14 +69,14 @@ public boolean hasNext() { // sampleIterator.hasNext() can return false early - a sampler refusal, or sourceDataset // smaller than the sampler's minimum size - and the while condition below ends this call // right there, with no hang: each call tries a fresh source, independent of the last. - // Otherwise, failed draws are counted and this call gives up once they reach - // RetiringBiWalk.PROBE_ATTEMPT_COUNT. + // Otherwise, failed draws are counted + // and this call gives up once they reach RetiringBiWalk.PROBE_ATTEMPT_COUNT. var failedSampleDraws = 0; while (nextMove == null && sampleIterator.hasNext() && failedSampleDraws < RetiringBiWalk.PROBE_ATTEMPT_COUNT) { var sample = sampleIterator.next(); if (sample.size() < 2) { - // Size-1 samples are excluded: ListChangeMoveProvider/ListAssignMoveProvider already cover them, - // more cheaply than a full destination search would here. + // Size-1 samples are excluded: + // ListChangeMoveProvider/ListAssignMoveProvider already cover them, more cheaply than a full destination search would here. failedSampleDraws++; continue; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java index 221c7fac645..f29cd7f94be 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java @@ -20,15 +20,14 @@ /** * Draws {@link Sample}s, governed by a {@link Sampler} - - * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move - * cost linear in the data set size - + * see {@link Samplers} for ready-made ones, + * since an unbounded sampler makes this provider's move cost linear in the data set size - * out of the values currently assigned to any entity's {@link PlanningListVariable list variable}, * and creates a move to unassign every member at once. * Members need not share an entity or be adjacent; * unlike {@code SubListUnassignMoveProvider}, this draws a scattered sample, not a contiguous span. *

    - * Requires that the variable - * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; + * Requires that the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}; * otherwise the constructor throws {@link IllegalArgumentException}. *

    * {@code MassListChangeMoveProvider} makes this same kind of move too, @@ -79,8 +78,9 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * size-1 samples are not skipped here, and deliberately so: * unassigning one value is no more expensive than unassigning several, * so there is nothing to gain by discarding it. - * A {@link Sampler} whose {@link Sampler#minimumSize() minimumSize} is greater than 1 can still - * refuse a draw on a dataset smaller than that minimum - + * A {@link Sampler} + * whose {@link Sampler#minimumSize() minimumSize} is greater than 1 can still refuse a draw on a dataset smaller than that + * minimum - * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, * ending this iterator without spinning, * since each call is independent and tries a fresh source. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java index 49ffe35953b..39937ed1e7d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -19,8 +19,8 @@ /** * Draws {@link Sample}s, governed by a {@link Sampler} - - * see {@link Samplers} for ready-made ones, since an unbounded sampler makes this provider's move - * cost linear in the data set size - + * see {@link Samplers} for ready-made ones, + * since an unbounded sampler makes this provider's move cost linear in the data set size - * out of the entities whose given basic planning variable is currently assigned a non-null value, of any value, * and creates a move to unassign every member at once * (set the basic planning variable to null). @@ -81,8 +81,8 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * size-1 samples are not skipped here, and deliberately so: * unassigning one entity is no more expensive than unassigning several, * so there is nothing to gain by discarding it. - * A {@link Sampler} whose {@link Sampler#minimumSize() minimumSize} is greater than 1 can still - * refuse a draw on a dataset smaller than that minimum - + * A {@link Sampler} whose {@link Sampler#minimumSize() minimumSize} is greater than 1 + * can still refuse a draw on a dataset smaller than that minimum - * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, * ending this iterator without spinning, * since each call is independent and tries a fresh source. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java index 67be2782f17..2106831de63 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java @@ -25,8 +25,7 @@ final class MoveProviderUtil { /** - * Every basic planning variable of the entity class, - * in natural order, + * Every basic planning variable of the entity class, in natural order, * as defined by {@link PlanningVariableMetaModel#compareTo(Object)}. * * @param entityMetaModel @@ -49,8 +48,7 @@ final class MoveProviderUtil { } /** - * Return these variables in their natural order, - * as defined by {@link PlanningVariableMetaModel#compareTo(Object)}. + * Return these variables in their natural order, as defined by {@link PlanningVariableMetaModel#compareTo(Object)}. * * * @param variableMetaModelList Arbitrary list of variables. @@ -103,8 +101,7 @@ public static List compositeKeyOf(Entity_ entity, * One cached row per distinct assigned value, * each row a whole {@link Sample} of that value's members. * Built once per settle per changed group, never re-assembled per draw - - * the drawing move providers read a row directly - * instead of running {@code SampleAssembler} over an index every time. + * the drawing move providers read a row directly instead of running {@code SampleAssembler} over an index every time. */ public static UniDataset> assignedPillars( MoveStreamFactory moveStreamFactory, @@ -143,9 +140,10 @@ public static BiDataset /** * Every entity of the class, assigned or not. * Unlike {@link #assignedEntities}, this deliberately admits unassigned entities: - * a {@code Mass*} sample drawn from it may contain them, and the move built from that sample - * assigns them as a side effect, crossing null upward. Used by {@link MassChangeMoveProvider} - * only when it is crossing null; otherwise it uses {@link #assignedEntityDataset} instead. + * a {@code Mass*} sample drawn from it may contain them, + * and the move built from that sample assigns them as a side effect, crossing null upward. + * Used by {@link MassChangeMoveProvider} only when it is crossing null; + * otherwise it uses {@link #assignedEntityDataset} instead. */ public static UniDataset allEntities( MoveStreamFactory moveStreamFactory, @@ -154,9 +152,8 @@ public static UniDataset allEnt } /** - * Every entity currently assigned a non-null value, with no grouping - unlike - * {@link #assignedPillars}/{@link #entitiesByAssignedValue}, members of one drawn sample need not - * share a value. + * Every entity currently assigned a non-null value, with no grouping - + * unlike {@link #assignedPillars}/{@link #entitiesByAssignedValue}, members of one drawn sample need not share a value. */ public static UniDataset assignedEntityDataset( MoveStreamFactory moveStreamFactory, @@ -165,11 +162,12 @@ public static UniDataset assign } /** - * @return the value every member of {@code sample} currently holds, or {@code null} if any two - * members disagree, or if {@code sample} is entirely unassigned. - * Either answer is the correct {@code excludedValue} for - * {@link SampleValueRanges#findDestination}: {@code null} is never itself a candidate - * destination, so excluding "no shared value" excludes nothing. + * @return the value every member of {@code sample} currently holds, + * or {@code null} if any two members disagree, + * or if {@code sample} is entirely unassigned. + * Either answer is the correct {@code excludedValue} for {@link SampleValueRanges#findDestination}: + * {@code null} is never itself a candidate destination, + * so excluding "no shared value" excludes nothing. */ public static @Nullable Value_ sharedValueOf(Sample sample, PlanningVariableMetaModel variableMetaModel, SolutionView solutionView) { @@ -189,8 +187,8 @@ public static UniDataset assign /** * @return {@code true} if at least one member of {@code sample} currently holds a non-null value; - * short-circuits on the first one. Used to keep a null destination from being offered for - * a sample that is already entirely unassigned, which would otherwise be a no-op move. + * short-circuits on the first one. Used to keep a null destination from being offered for a sample + * that is already entirely unassigned, which would otherwise be a no-op move. */ public static boolean anyAssigned(Sample sample, PlanningVariableMetaModel variableMetaModel, SolutionView solutionView) { @@ -213,8 +211,9 @@ public static boolean anyAssigned(Sample s * and every changed variable is legal on both sides; * {@code false} if any variable's swap falls out of range for either side. * {@code leftPillar} and {@code rightPillar} must both be homogeneous per every variable in - * {@code variableMetaModelList}: each pillar's {@link Sample#representative()} is read once - * per variable and stands in for every one of its members. + * {@code variableMetaModelList}: + * each pillar's {@link Sample#representative()} is read once per variable + * and stands in for every one of its members. */ public static boolean isValidSwap(SolutionView solutionView, List> variableMetaModelList, @@ -228,10 +227,10 @@ public static boolean isValidSwap(SolutionView s if (Objects.equals(oldLeftValue, oldRightValue)) { continue; } - // Unlike leftRangesPerVariable, the right side is rebuilt from scratch on every call: the - // left pillar is stable for a whole settle-to-settle window and worth caching across - // candidates, but each rightPillar here is a fresh draw, so a cache on it would almost - // never hit and would cost more than it saves. + // Unlike leftRangesPerVariable, the right side is rebuilt from scratch on every call: + // the left pillar is stable for a whole settle-to-settle window and worth caching across candidates, + // but each rightPillar here is a fresh draw, + // so a cache on it would almost never hit and would cost more than it saves. var rightRanges = SampleValueRanges.of(rightPillar, variableMetaModel, solutionView); if (leftRangesPerVariable.get(i).containsInEvery(oldRightValue) && rightRanges.containsInEvery(oldLeftValue)) { change = true; @@ -263,8 +262,7 @@ public static boolean isValidSwap(SolutionView s } /** - * Appends every other value of {@code cachedValues} - * (as produced by {@link #cachedValuesOf}) + * Appends every other value of {@code cachedValues} (as produced by {@link #cachedValuesOf}) * to {@code s}, comma-separated: * the left value of each pair if {@code left}, the right value otherwise. */ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index 320fbf19a96..6da7eff416e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -26,8 +26,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * * @see MutableSolutionView The view used by moves to perform mutating operations. @@ -93,8 +92,8 @@ public static Move change( /** * Creates a move that swaps the value of a single planning variable between two entities. *

    - * Both entities must be different instances. After execution, the left entity will have - * the value that the right entity had, and vice versa. + * Both entities must be different instances. + * After execution, the left entity will have the value that the right entity had, and vice versa. *

    * Only provide entities whose values can be swapped; * for example, if one of the values is not in the value range of the other entity's variable, @@ -119,8 +118,9 @@ public static Move swap( /** * Creates a move that swaps the values of multiple planning variables between two entities. *

    - * Both entities must be different instances. For each variable in the list, after execution, - * the left entity will have the value that the right entity had, and vice versa. + * Both entities must be different instances. + * For each variable in the list, after execution, the left entity will have the value that the right entity had, and vice + * versa. *

    * Only provide entities whose values can be swapped; * for example, if one of the values is not in the value range of the other entity's variable, @@ -177,15 +177,15 @@ public static Move pillarSwap( } /** - * Creates a move that swaps the values of one or more planning variables between the members of two - * {@link Sample}s. + * Creates a move that swaps the values of one or more planning variables between the members of two {@link Sample}s. *

    * This is the pillar equivalent of {@link #swap(List, Object, Object)}. * The two pillars must not share any members; violating this throws {@link IllegalArgumentException}. - * The caller MUST only pass homogeneous pillars (every member of a pillar holding the same value - * for each listed variable) and pillars whose values can be swapped; + * The caller MUST only pass homogeneous pillars (every member of a pillar holding the same value for each listed variable) + * and pillars whose values can be swapped; * for example, if one of the values is not in the value range of a member of the other pillar, - * swapping would lead to an invalid solution. Neither condition is re-checked by the move; + * swapping would lead to an invalid solution. + * Neither condition is re-checked by the move; * see {@link PillarSwapMove} for what happens when a caller violates them. * * @param variableMetaModelList the list of planning variables to swap; must not be empty. @@ -215,8 +215,8 @@ public static Move pillarSwap( * Creates a move that assigns a value to a list variable at a specified position. *

    * The value must not already be assigned to any list variable. - * This move inserts the value at the given position, shifting all existing values - * at or after that position to the right. + * This move inserts the value at the given position, + * shifting all existing values at or after that position to the right. * * @param variableMetaModel describes the list variable to be changed * @param value the value to be assigned; must not already be assigned to a list variable @@ -277,8 +277,9 @@ public static Move unassign( * The element at the source position is removed and inserted at the destination position. * Both positions may be in the same entity or in different entities. *

    - * If the source and destination are within the same entity, the element is first removed - * from the source position (shifting later elements left), then inserted at the destination position. + * If the source and destination are within the same entity, + * the element is first removed from the source position (shifting later elements left), then inserted at the destination + * position. * * @param variableMetaModel describes the list variable to be changed * @param source the source position from which to move the element diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java index 52c82cc665b..1eb3f5a2dbb 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -118,8 +118,8 @@ private static final class PillarChangeMoveIterator private @Nullable Sample cachedPillar = null; /** * Computed once per {@link #cachedPillar}, not once per probe: - * {@code createRightIterator} can be called up to {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} times - * for the same left pillar - + * {@code createRightIterator} can be called up to {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} times for the same left + * pillar - * an equal pillar every time, since a pillar row is stable for the whole settle-to-settle window - * and the ranges (and, once found, the proven-empty verdict below) do not change between those calls. *

    diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java index 1b34f6c6d5e..5732f4f560d 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -38,11 +38,12 @@ * for example, if one of the values is not in the value range of a member of the other pillar, * swapping would lead to an invalid solution. * This move does not re-check that at execution time, matching {@link Moves#pillarSwap}; - * if the pair is invalid, the move writes the out-of-range value anyway and the solution becomes invalid, - * with no exception. The built-in providers never propose such a pair. + * if the pair is invalid, + * the move writes the out-of-range value anyway and the solution becomes invalid, with no exception. + * The built-in providers never propose such a pair. *

    - * Similarly, a pair whose every listed variable already matches between the two pillars - * is accepted by the constructor and now performs writes that produce no net change; + * Similarly, a pair whose every listed variable already matches between the two pillars is accepted by the constructor and now + * performs writes that produce no net change; * the built-in providers never propose such a pair either, * since they key pillars on the composite of all listed variables and only pair distinct keys. *

    @@ -63,8 +64,7 @@ public final class PillarSwapMove extends AbstractMove rightPillar; /** - * Cache of the values of the pillars' representative members at the time - * of the first call of {@link #getCachedValues()}. + * Cache of the values of the pillars' representative members at the time of the first call of {@link #getCachedValues()}. * Ideally, the method would first be called before the values are changed by the move, * so that the {@link #toString()} method shows the original values. *

    @@ -72,8 +72,8 @@ public final class PillarSwapMove extends AbstractMove - * Relies on pillar homogeneity: every member of a pillar is assumed to share the same value - * of a given variable, so reading the head of the pillar is enough. + * Relies on pillar homogeneity: every member of a pillar is assumed to share the same value of a given variable, + * so reading the head of the pillar is enough. */ private @Nullable List<@Nullable Object> valueList; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java index 8b0fe1de77a..fe1b56aaa5e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -34,8 +34,9 @@ * and swaps that combination between the two pillars, * provided at least one variable differs and every differing variable is legal on both sides; * if any differing variable is out of range, the pair is skipped entirely. - * A pillar's composite key is a list with one value per variable, in {@link PlanningEntityMetaModel#variables()} - * declaration order, regardless of the order the constructor was given. + * A pillar's composite key is a list with one value per variable, in {@link PlanningEntityMetaModel#variables()} declaration + * order, + * regardless of the order the constructor was given. * Size-1 pillars are legal on both sides of the swap, * since we need to be able to swap 1-sized pillar with an n-sized pillar. *

    diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java index 732ac37684b..250618d8100 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java @@ -23,8 +23,9 @@ *

    * {@code PillarChangeMoveProvider} makes this same move too, * whenever its own {@code crossingNull} is {@code true} - - * but there, only with probability {@code 1/(s+1)} per drawn pillar (where {@code s} - * is the size of the pillar members' value range), so it arrives rarely. + * but there, only with probability {@code 1/(s+1)} per drawn pillar + * (where {@code s} is the size of the pillar members' value range), + * so it arrives rarely. * This class exists to make it happen often. *

    * Draws whole pillars only, unbounded by design: diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java index b83b0bc3252..32036fc6ea0 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java @@ -27,9 +27,9 @@ * usually the set holds a single, shared range instance, * since {@code ValueRangeState} already deduplicates equal ranges to one cached instance. *

    - * Two instances are {@link #equals equal} when they hold the same distinct ranges, - * regardless of order - used to remember a proven-empty verdict across redraws of the same sample - * under a deterministic {@link Sampler}. + * Two instances are {@link #equals equal} + * when they hold the same distinct ranges, regardless of order - + * used to remember a proven-empty verdict across redraws of the same sample under a deterministic {@link Sampler}. * * @param the variable's value type */ @@ -40,8 +40,8 @@ record SampleValueRanges(Set> distinctRangeSet, Value /** * @return the sample members' distinct {@link ValueRange}s for {@code variableMetaModel}, - * with any {@link NullAllowingValueRange} wrapper removed so - * {@code null} is never a candidate destination out of {@link #findDestination}/ {@link #pickExactly} - + * with any {@link NullAllowingValueRange} wrapper removed + * so {@code null} is never a candidate destination out of {@link #findDestination}/ {@link #pickExactly} - * it would collide with their "not found" signal. * A caller that wants a null destination decides on it separately with {@link #rollNull}, * before calling either method. @@ -57,8 +57,8 @@ public static SampleValueRanges of(Sample SampleValueRanges of(Set> distinctRangeSet) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java index 173ab8b7073..18c20a0fe63 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java @@ -76,8 +76,9 @@ public SubListChangeMoveProvider(PlanningListVariableMetaModel variableMetaModel, int minimumSubListSize, int maximumSubListSize, boolean selectReversingMoveToo, boolean crossingNull) { @@ -119,9 +120,9 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * or, for an unassigned destination, a {@code SubListUnassignMove}. * Left = seed value, right = destination position. *

    - * A fresh span is drawn on every {@link #createRightIterator} call, never cached - * across probes: caching the first draw would turn {@link RetiringBiWalk}'s remaining probes into - * deterministic no-ops, the same reasoning {@link SubPillarChangeMoveProvider} documents. + * A fresh span is drawn on every {@link #createRightIterator} call, never cached across probes: + * caching the first draw would turn {@link RetiringBiWalk}'s remaining probes into deterministic no-ops, + * the same reasoning {@link SubPillarChangeMoveProvider} documents. *

    * Known ceiling: the left pool is seed values, but a value only picks its entity, * so deadness is per-entity while retirement is per-value. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java index 23cf4259927..d1ad446c85e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java @@ -34,8 +34,8 @@ * possibly in reverse element order. *

    * There is no {@code crossingNull} flag: - * a swap of two spans cannot cross null by construction, since - * both spans are drawn from assigned runs. + * a swap of two spans cannot cross null by construction, + * since both spans are drawn from assigned runs. * * @param the solution type * @param the entity type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java index dbadc624a33..14c565100d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java @@ -18,8 +18,9 @@ import org.jspecify.annotations.Nullable; /** - * Unassigns a contiguous span of a {@link PlanningListVariable list variable}, that is, removes every value of - * the span from the list, leaving it unassigned. The span is identified by a {@link Range}. + * Unassigns a contiguous span of a {@link PlanningListVariable list variable}, + * that is, removes every value of the span from the list, leaving it unassigned. + * The span is identified by a {@link Range}. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the entity type, the class with the {@link PlanningEntity} annotation diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java index a8572548465..c8a3cb72c7e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProvider.java @@ -78,8 +78,8 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * producing a {@code SubListUnassignMove}. * The destination is fixed at null, so nothing can ever be rejected: * no {@code RetiringBiWalk} is needed, - * and a failed draw is proof that the seed's entity has fewer unpinned values - * than the minimum sub-list size, not a bail-out false negative - + * and a failed draw is proof that the seed's entity has fewer unpinned values than the minimum sub-list size, + * not a bail-out false negative - * so the seed is retired immediately rather than probed N times. * * @param the solution type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java index 93fecc083ec..9dfd948619e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProvider.java @@ -31,8 +31,7 @@ * For each subpillar of entities sharing a non-null value of the given variable, * governed by a {@link PillarSampler} - * {@code Samplers.pillar(Samplers.between(2, n))} is the recommended choice, - * since an unbounded {@code Samplers.pillar(Samplers.all())} makes this provider's move cost - * linear in the pillar's size - + * since an unbounded {@code Samplers.pillar(Samplers.all())} makes this provider's move cost linear in the pillar's size - * creates a move to change every member's value to a different value that is legal for every member. * The (sub)pillar is keyed on this one variable alone; * members may differ in every other variable. @@ -98,8 +97,8 @@ public MoveStream build(MoveStreamFactory moveStreamFactor } /** - * Draws subpillars sharing an assigned value ("slice value") and pairs each with a destination - * value, producing a {@code MassChangeMove}. + * Draws subpillars sharing an assigned value ("slice value") + * and pairs each with a destination value, producing a {@code MassChangeMove}. * Left = slice value, right = destination value. *

    * Unlike the whole-pillar variant, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 4ab3711fa3d..12c055bdeea 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -70,8 +70,8 @@ public final class SubPillarSwapMoveProvider private final PillarSampler, Entity_> rightSampler; /** - * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, but for - * every basic planning variable of {@code entityMetaModel}. + * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, + * but for every basic planning variable of {@code entityMetaModel}. */ public SubPillarSwapMoveProvider(GenuineEntityMetaModel entityMetaModel, PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { @@ -79,8 +79,8 @@ public SubPillarSwapMoveProvider(GenuineEntityMetaModel enti } /** - * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, but for a - * single variable. + * As defined by {@link #SubPillarSwapMoveProvider(List, PillarSampler, PillarSampler)}, + * but for a single variable. */ public SubPillarSwapMoveProvider(PlanningVariableMetaModel variableMetaModel, PillarSampler, Entity_> leftSampler, PillarSampler, Entity_> rightSampler) { @@ -135,8 +135,8 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * never cached across probes: * a real {@link PillarSampler} can legitimately draw a different subpillar for the same left key on each attempt * (and a different subpillar can have different legal ranges, since fewer members mean fewer constraints), - * so caching the first one would turn {@link RetiringBiWalk}'s remaining probes into - * deterministic no-ops for the left side. + * so caching the first one would turn {@link RetiringBiWalk}'s remaining probes into deterministic no-ops for the left + * side. * The right side is drawn fresh per candidate probed, * same as the whole-pillar variant. *

    diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java index ecafc6c1ab0..53e430356ca 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProvider.java @@ -91,8 +91,8 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * A slice value that keeps failing that draw is retired by {@link RetiringBiWalk} * after {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} attempts, * the same as every other {@code Sub*} provider in this package - - * one bad slice value must not end the whole iterator, since {@link PillarSampler} is - * contractually forbidden from stopping below its own {@link PillarSampler#minimumSize()}, + * one bad slice value must not end the whole iterator, + * since {@link PillarSampler} is contractually forbidden from stopping below its own {@link PillarSampler#minimumSize()}, * so a failed draw is proof about that one slice, not about the rest of the domain. * * @param the solution type diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java index cb6e1008b70..da978a3b0d0 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java @@ -30,8 +30,9 @@ * with no exception. * The built-in {@code SwapMoveProvider} never proposes such a pair. *

    - * Similarly, a move over two entities that already hold equal values on every listed variable now performs - * writes that produce no net change; the built-in {@code SwapMoveProvider} never proposes such a move either. + * Similarly, a move over two entities that already hold equal values on every listed variable now performs writes that produce + * no net change; + * the built-in {@code SwapMoveProvider} never proposes such a move either. *

    * The caller is responsible for ordering the given variables consistently; * this constructor does not reorder them. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java index f9b315d1e81..e2e34eadfcd 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java @@ -50,8 +50,8 @@ public TwoOptListMoveProvider(PlanningListVariableMetaModel variableMetaModel, 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 d7e0da6b720..41b39fff5ec 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 @@ -45,8 +45,7 @@ public interface MoveStreamFactory { * @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. - * @see #forEachUnfiltered(Class, boolean) Specialized method exists to automatically include pinned entities as - * well. + * @see #forEachUnfiltered(Class, boolean) Specialized method exists to automatically include pinned entities as well. */ UniEnumeratingStream forEach(Class sourceClass, boolean includeNull); @@ -63,8 +62,9 @@ public interface MoveStreamFactory { /** * 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. + * 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 @@ -77,8 +77,9 @@ public interface MoveStreamFactory { /** * 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. + * 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 @@ -126,8 +127,9 @@ public interface MoveStreamFactory { UniPickingStream pick(UniEnumeratingStream enumeratingStream); /** - * Terminal operation for datasets {@link UniEnumeratingStream#asCachedDataset() cached} from streams - * started on this factory, parallel to {@link UniPickingStream#asMove}. + * Terminal operation for datasets {@link UniEnumeratingStream#asCachedDataset() cached} from streams started on this + * factory, + * parallel to {@link UniPickingStream#asMove}. * The move order of the given provider's iterator is never part of the API contract. */ MoveStream buildMoveStream(MoveIteratorProvider iteratorProvider); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java index 414abbf5581..b7af4be9437 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/BiDatasetInstance.java @@ -85,8 +85,8 @@ default Iterator> samplingIterator(@Nullable A a, Sampler sampler, } /** - * As defined by {@link #samplingIterator(Object, Sampler, RandomGenerator)}, but for a - * {@link PillarSampler}, which sees the left value {@code a} as the key the sample is drawn under - + * As defined by {@link #samplingIterator(Object, Sampler, RandomGenerator)}, + * but for a {@link PillarSampler}, which sees the left value {@code a} as the key the sample is drawn under - * still a slice selector, not a key the resulting {@link Sample} itself carries. *

    * The two sampler types are unrelated, so a bare lambda argument does not compile as ambiguous; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java index 2222d7e97af..b5edad227f2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/PillarSampler.java @@ -34,18 +34,18 @@ public interface PillarSampler { /** * Called once per sample, before the first {@link #evaluate(int, Object)} call. * - * @param random the solver's working random; safe to draw from to decide this sample's target - * size or any other per-sample state - * @param key the slice selector the sample is being drawn under; may be null, as a null slice - * selector is legal + * @param random the solver's working random; safe to draw from to decide this sample's target size + * or any other per-sample state + * @param key the slice selector the sample is being drawn under; may be null, + * as a null slice selector is legal */ default void reset(RandomGenerator random, @Nullable Key_ key) { // Nothing to do by default. } /** - * @return the smallest number of members a sample may have; at least 1. A sample with fewer - * members, however it ended, is discarded. + * @return the smallest number of members a sample may have; at least 1. + * A sample with fewer members, however it ended, is discarded. */ default int minimumSize() { return 1; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index c18f44663ac..59836568f13 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -42,8 +42,8 @@ public interface Sample boolean contains(@Nullable A element); /** - * @return a representative member of the sample - which one is unspecified, beyond that a given - * instance returns the same one on every call. + * @return a representative member of the sample - which one is unspecified, + * beyond that a given instance returns the same one on every call. * May be null, as null members are legal. * Useful for reading a value every member is known to share, * such as a homogeneous pillar's current variable value. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java index 068649b3767..66f198543c5 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sampler.java @@ -44,16 +44,16 @@ public interface Sampler { /** * Called once per sample, before the first {@link #evaluate(int, Object)} call. * - * @param random the solver's working random; safe to draw from to decide this sample's target - * size or any other per-sample state + * @param random the solver's working random; safe to draw from to decide this sample's target size + * or any other per-sample state */ default void reset(RandomGenerator random) { // Nothing to do by default. } /** - * @return the smallest number of members a sample may have; at least 1. A sample with fewer - * members, however it ended, is discarded. + * @return the smallest number of members a sample may have; at least 1. + * A sample with fewer members, however it ended, is discarded. */ default int minimumSize() { return 1; @@ -61,8 +61,8 @@ default int minimumSize() { /** * @param sizeSoFar the number of distinct members already accepted; 0 for the first candidate offered. - * A duplicate acceptance (the source offers a candidate equal to one already accepted) - * does not advance this count, since the assembled sample deduplicates. + * A duplicate acceptance (the source offers a candidate equal to one already accepted) does not advance this count, + * since the assembled sample deduplicates. * @param candidate the candidate offered, drawn at most once per sample * @return what to do with the candidate */ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java index d8258edf8b3..58f45a0d4c3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Samplers.java @@ -16,8 +16,8 @@ public final class Samplers { /** - * @return a sampler that accepts every candidate offered, so a sample drains its whole source - * or slice + * @return a sampler that accepts every candidate offered, + * so a sample drains its whole source or slice */ public static Sampler all() { return (sizeSoFar, candidate) -> Decision.ACCEPT; @@ -34,30 +34,30 @@ public static Sampler exactly(int size) { /** * @param maximumSize the largest number of members a sample may have; at least 1 - * @return a sampler that stops a sample once it reaches a size drawn uniformly from - * {@code [1, maximumSize]}, a fresh draw per sample + * @return a sampler that stops a sample + * once it reaches a size drawn uniformly from {@code [1, maximumSize]}, a fresh draw per sample */ public static Sampler upTo(int maximumSize) { return new DefaultSampler<>(1, maximumSize); } /** - * A slice smaller than {@code minimumSize} yields no sample at all, and still costs a full drain - * of that slice to find out - keep {@code minimumSize} near the smallest group actually worth - * moving. + * A slice smaller than {@code minimumSize} yields no sample at all, + * and still costs a full drain of that slice to find out - + * keep {@code minimumSize} near the smallest group actually worth moving. * * @param minimumSize the smallest number of members a sample may have; at least 1 * @param maximumSize the largest number of members a sample may have; at least {@code minimumSize} - * @return a sampler that stops a sample once it reaches a size drawn uniformly from - * {@code [minimumSize, maximumSize]}, a fresh draw per sample + * @return a sampler that stops a sample + * once it reaches a size drawn uniformly from {@code [minimumSize, maximumSize]}, a fresh draw per sample */ public static Sampler between(int minimumSize, int maximumSize) { return new DefaultSampler<>(minimumSize, maximumSize); } /** - * Lifts a key-blind {@link Sampler} into a {@link PillarSampler}, ignoring whatever key it is - * drawn under. A key-aware policy implements {@link PillarSampler} directly instead. + * Lifts a key-blind {@link Sampler} into a {@link PillarSampler}, ignoring whatever key it is drawn under. + * A key-aware policy implements {@link PillarSampler} directly instead. * * @param sampler never null * @return never null @@ -86,8 +86,8 @@ public Decision evaluate(int sizeSoFar, @Nullable A candidate) { /** * The only way to build a {@link SubListSampler}. - * The caller must already hold the working random, so in practice this is called by a move - * provider, not directly by user code. + * The caller must already hold the working random, + * so in practice this is called by a move provider, not directly by user code. * * @return never null */ diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java index 4351f4e6a97..cdc2ff00595 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -12,12 +12,14 @@ * Built only via {@link Samplers#subList(PlanningListVariableMetaModel, int, int, java.util.random.RandomGenerator) * Samplers.subList}. * The seed value only picks the entity, - * and a fresh {@code (fromIndex, length)} is then drawn uniformly - * over every admissible sub-list of the entity's unpinned window. + * and a fresh {@code (fromIndex, length)} is then drawn uniformly over every admissible sub-list of the entity's unpinned + * window. *

    - * Caveat: a fully {@code @PlanningPin}-immovable entity reports - * {@link SolutionView#getFirstUnpinnedIndex} as {@code 0} even though its whole list is pinned. - * This drawer is only safe when every seed value comes from an enumeration + * Caveat: a fully {@code @PlanningPin}-immovable entity reports {@link SolutionView#getFirstUnpinnedIndex} as + * {@code 0} + * even though its whole list is pinned. + * This drawer is only safe + * when every seed value comes from an enumeration * that already excludes immovable entities, * such as {@link MoveStreamFactory#forEachAssignedValue}. * @@ -29,16 +31,16 @@ public interface SubListSampler { /** - * The default {@code maximumSubListSize} of the built-in {@code SubList*MoveProvider} no-arg - * constructors: a sub-list relocation is blind to the score impact of the span it moves, + * The default {@code maximumSubListSize} of the built-in {@code SubList*MoveProvider} no-arg constructors: + * a sub-list relocation is blind to the score impact of the span it moves, * so an unbounded span makes move cost linear in list size for no corresponding benefit. */ int DEFAULT_MAXIMUM_SUB_LIST_SIZE = 10; /** * @param seedValue an assigned value; only used to pick the entity, never anchored to its own position - * @return a freshly drawn span over the entity's whole unpinned window, or {@code null} if that window - * is smaller than the minimum sub-list size + * @return a freshly drawn span over the entity's whole unpinned window, or {@code null} + * if that window is smaller than the minimum sub-list size */ @Nullable Range byValue(SolutionView solutionView, Value_ seedValue); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java index 0a18ce023f5..fbc02072e04 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/package-info.java @@ -1,7 +1,8 @@ /** - * This package contains classes and interfaces that are used to enumerate, sample and combine - * facts and entities of a {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} - * into {@link ai.timefold.solver.core.preview.api.move.Move}s. + * This package contains classes and interfaces + * that are used to enumerate, sample and combine facts and entities of a + * {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} into + * {@link ai.timefold.solver.core.preview.api.move.Move}s. * A drawn set of members with no key is a * {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample}, * produced by {@link ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler}; @@ -17,8 +18,7 @@ *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. - * Please direct your feedback to - * Timefold Solver GitHub + * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. */ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java index 9bee7e84ed2..06f52448307 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java @@ -38,22 +38,19 @@ BiEnumeratingStream map( BiNeighborhoodsMapper mappingB); /** - * As defined by {@link UniEnumeratingStream#groupBy(UniNeighborhoodsMapper)}, - * only for {@link BiEnumeratingStream} sources. + * As defined by {@link UniEnumeratingStream#groupBy(UniNeighborhoodsMapper)}, only for {@link BiEnumeratingStream} sources. */ UniEnumeratingStream groupBy(BiNeighborhoodsMapper key); /** - * As defined by - * {@link UniEnumeratingStream#groupBy(UniNeighborhoodsCollector)}, - * only for {@link BiEnumeratingStream} sources. + * As defined by {@link UniEnumeratingStream#groupBy(UniNeighborhoodsCollector)}, only for {@link BiEnumeratingStream} + * sources. */ UniEnumeratingStream groupBy(BiNeighborhoodsCollector collector); /** - * As defined by - * {@link UniEnumeratingStream#groupBy(UniNeighborhoodsMapper, UniNeighborhoodsCollector)}, - * only for {@link BiEnumeratingStream} sources. + * As defined by {@link UniEnumeratingStream#groupBy(UniNeighborhoodsMapper, UniNeighborhoodsCollector)}, only for + * {@link BiEnumeratingStream} sources. */ BiEnumeratingStream groupBy( BiNeighborhoodsMapper key, @@ -69,8 +66,8 @@ BiEnumeratingStream groupBy( * kept up to date in memory as the working solution changes. * Use this to consume the dataset from a custom {@link MoveIteratorProvider}, * as opposed to being consumed by {@link MoveStreamFactory#pick(UniEnumeratingStream)} - * Resolve the returned handle against a {@link MoveIteratorSession} - * inside {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. + * Resolve the returned handle against a {@link MoveIteratorSession} inside + * {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. *

    * Repeated calls on the same stream return an equal handle, and the rows are materialized only once. * diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java index 95245e98b47..cb9625d71fe 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java @@ -71,8 +71,8 @@ default BiEnumeratingStream join(UniEnumeratingStream * Important: Joining is faster and more scalable than a {@link BiEnumeratingStream#filter(BiNeighborhoodsPredicate) * filter}, @@ -194,9 +194,8 @@ default UniEnumeratingStream ifExists(UniEnumeratingStream the type of the second matched fact * @return a stream that matches every A where B exists for which the {@link BiNeighborhoodsJoiner}s are true @@ -250,9 +249,8 @@ default UniEnumeratingStream ifExists(Class otherClass, BiN } /** - * Create a new {@link UniEnumeratingStream} for every A where B exists for which all {@link BiNeighborhoodsJoiner}s are - * true - * (for the properties they extract from both facts). + * Create a new {@link UniEnumeratingStream} for every A where B exists + * for which all {@link BiNeighborhoodsJoiner}s are true (for the properties they extract from both facts). * * @param the type of the second matched fact * @return a stream that matches every A where B exists for which the {@link BiNeighborhoodsJoiner}s are true @@ -308,9 +306,8 @@ default UniEnumeratingStream ifNotExists(UniEnumeratingStream< } /** - * Create a new {@link UniEnumeratingStream} for every A where B does not exist for which the {@link BiNeighborhoodsJoiner}s - * are true - * (for the properties they extract from both facts). + * Create a new {@link UniEnumeratingStream} for every A where B does not exist + * for which the {@link BiNeighborhoodsJoiner}s are true (for the properties they extract from both facts). * * @param the type of the second matched fact * @return a stream that matches every A where B does not exist for which the {@link BiNeighborhoodsJoiner}s are true @@ -363,9 +360,8 @@ default UniEnumeratingStream ifNotExists(Class otherClass, } /** - * Create a new {@link UniEnumeratingStream} for every A where B does not exist for which the {@link BiNeighborhoodsJoiner}s - * are true - * (for the properties they extract from both facts). + * Create a new {@link UniEnumeratingStream} for every A where B does not exist + * for which the {@link BiNeighborhoodsJoiner}s are true (for the properties they extract from both facts). * * @param the type of the second matched fact * @return a stream that matches every A where B does not exist for which the {@link BiNeighborhoodsJoiner}s are true @@ -404,8 +400,7 @@ default UniEnumeratingStream ifNotExists(Class otherClass, *

    * Simple example: assuming a enumerating stream of tuples of {@code Person}s * {@code [Ann(age = 20), Beth(age = 25), Cathy(age = 30)]}, - * calling {@code map(Person::getAge)} on such stream will produce a stream of {@link Integer}s - * {@code [20, 25, 30]}, + * calling {@code map(Person::getAge)} on such stream will produce a stream of {@link Integer}s {@code [20, 25, 30]}, * *

    * Example with a non-bijective mapping function: assuming a enumerating stream of tuples of {@code Person}s @@ -486,8 +481,8 @@ BiEnumeratingStream groupBy( * kept up to date in memory as the working solution changes. * Use this instead of {@link MoveStreamFactory#pick(UniEnumeratingStream)} * when the dataset is to be consumed from a custom {@link MoveIteratorProvider}. - * Resolve the returned handle against a {@link MoveIteratorSession} - * inside {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. + * Resolve the returned handle against a {@link MoveIteratorSession} inside + * {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. *

    * Repeated calls on the same stream return an equal handle, and the rows are materialized only once. * diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java index a9eb47b1276..072d905f09a 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/RandomSubListSwapMoveSelectorTest.java @@ -534,8 +534,9 @@ void phaseLifecycle() { moveSelector.phaseEnded(phaseScope); moveSelector.solvingEnded(solverScope); - // The invocation counts are multiplied for the entity selector because it is used by both left and right - // subList selectors and each registers the entity selector to its phaseLifecycleSupport. + // The invocation counts are multiplied for the entity selector + // because it is used by both left and right subList selectors + // and each registers the entity selector to its phaseLifecycleSupport. verifyPhaseLifecycle(entitySelector, 2, 2, 4); verifyPhaseLifecycle(leftValueSelector, 1, 1, 2); verifyPhaseLifecycle(rightValueSelector, 1, 1, 2); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 0e4d089a459..50b47882fbc 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -96,8 +96,8 @@ void getValueRangeIncludesNullForUnassignedAllowingVariable() { var moveDirector = new MoveDirector<>(scoreDirector); var entity = solution.getEntityList().getFirst(); - // getValueRange must return the cached range with its NullAllowingValueRange wrapper intact - // (not unwrapped), or isValueInRange - now a default delegating to getValueRange(...).contains(...) - + // getValueRange must return the cached range with its NullAllowingValueRange wrapper intact (not unwrapped), + // or isValueInRange - now a default delegating to getValueRange(...).contains(...) - // would silently start rejecting null for every variable that allows unassigned. var range = moveDirector.getValueRange(variableMetaModel, entity); assertThat(range.contains(null)).isTrue(); @@ -112,8 +112,9 @@ void twoArgIsValueInRangeThrowsForEntityScopedRange() { var mockScoreDirector = mock(InnerScoreDirector.class); var moveDirector = new MoveDirector(mockScoreDirector); - // The 2-arg overload only works for a solution-scoped range; this variable's range is on - // the entity, so it must fail loudly instead of silently checking against a null entity. + // The 2-arg overload only works for a solution-scoped range; + // this variable's range is on the entity, + // so it must fail loudly instead of silently checking against a null entity. assertThatThrownBy(() -> moveDirector.isValueInRange(variableMetaModel, new TestdataValue("value"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("has a value range on an entity, not on the solution"); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java index a2d5941743a..0b065fc7ab2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/AbstractBiasIT.java @@ -38,12 +38,12 @@ public abstract class AbstractBiasIT { /** - * How many standard deviations of sampling noise a category's observed count - * may be away from its expected count before {@link BiasReport#assertWithinSigma(double)} fails. + * How many standard deviations of sampling noise a category's observed count may be away from its expected count + * before {@link BiasReport#assertWithinSigma(double)} fails. *

    * These are max-of-many-categories tests, so the multiple-comparison penalty is real: - * at 5 sigma, a test over {@code k} categories has an expected false-failure rate - * of about {@code k * 5.7e-7} per run (two-sided). + * at 5 sigma, a test over {@code k} categories has an expected false-failure rate of about {@code k * 5.7e-7} per run + * (two-sided). * At the largest fixture here (310 pairs) that is about {@code 1.8e-4} per run; * at 3 sigma it would be about 1 run in 120. * Do not lower this to make a specific case pass; @@ -77,8 +77,8 @@ static Iterator> moveIterator(MoveProvider variable, diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java index 80ac65e69ad..867dd4c81e1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/BiasReport.java @@ -16,17 +16,16 @@ import org.slf4j.LoggerFactory; /** - * Tallies {@code sampleCount} independent draws into categories, then asserts a statistical - * property of the resulting distribution: uniform, weighted by an explicit share per category - * ({@link #assertWithinSigma(double)}), or a deliberately non-uniform ratio between two categories - * ({@link #assertShareRatioAtLeast}). Every assertion logs the reached numbers at INFO before - * asserting, and repeats them in the failure message via AssertJ's {@code as(...)}, so the actual - * margin against the bound is never silently discarded on a pass; see - * {@link AbstractBiasIT#SIGMA_LIMIT}'s javadoc for why the bound is a sigma count, not a - * hand-picked percentage. + * Tallies {@code sampleCount} independent draws into categories, + * then asserts a statistical property of the resulting distribution: + * uniform, weighted by an explicit share per category ({@link #assertWithinSigma(double)}), + * or a deliberately non-uniform ratio between two categories ({@link #assertShareRatioAtLeast}). + * Every assertion logs the reached numbers at INFO before asserting, + * and repeats them in the failure message via AssertJ's {@code as(...)}, + * so the actual margin against the bound is never silently discarded on a pass; + * see {@link AbstractBiasIT#SIGMA_LIMIT}'s javadoc for why the bound is a sigma count, not a hand-picked percentage. * - * @param what a single draw is classified into (a bucket, an entity code, a whole - * draw order, ...) + * @param what a single draw is classified into (a bucket, an entity code, a whole draw order, ...) */ public final class BiasReport { @@ -54,8 +53,8 @@ public static BiasReport tally(String label, int sampleCo /** * Every category in {@code expectedCategoryCollection} is expected in equal share - * ({@code 1 / expectedCategoryCollection.size()}). A category absent from the collection is - * ignored by {@link #assertWithinSigma(double)}, even if it was drawn. + * ({@code 1 / expectedCategoryCollection.size()}). + * A category absent from the collection is ignored by {@link #assertWithinSigma(double)}, even if it was drawn. */ public BiasReport expectUniform(Collection expectedCategoryCollection) { var share = 1.0 / expectedCategoryCollection.size(); @@ -68,9 +67,9 @@ public BiasReport expectUniform(Collection expectedCategor } /** - * As {@link #expectUniform}, but each category's expected share is given explicitly instead of - * assumed equal. Shares need not sum to 1 (a fixture may deliberately omit an unreachable - * category); they are used only to compute each category's own expected count and sigma. + * As {@link #expectUniform}, but each category's expected share is given explicitly instead of assumed equal. + * Shares need not sum to 1 (a fixture may deliberately omit an unreachable category); + * they are used only to compute each category's own expected count and sigma. */ BiasReport expectWeights(Map expectedShareByCategory) { this.expectedShareByCategory = Map.copyOf(expectedShareByCategory); @@ -78,10 +77,10 @@ BiasReport expectWeights(Map expectedShareByCatego } /** - * Asserts every expected category was actually drawn, then that no category's observed count - * deviates from its expected count by more than {@code sigmaLimit} standard deviations of - * binomial sampling noise. Requires {@link #expectUniform} or {@link #expectWeights} to have - * been called first. + * Asserts every expected category was actually drawn, + * then that no category's observed count deviates from its expected count by more than {@code sigmaLimit} standard + * deviations of binomial sampling noise. + * Requires {@link #expectUniform} or {@link #expectWeights} to have been called first. */ public void assertWithinSigma(double sigmaLimit) { assertThat(expectedShareByCategory) @@ -100,10 +99,9 @@ public void assertWithinSigma(double sigmaLimit) { } /** - * For a deliberately non-uniform fixture (no {@code expect*} call needed): asserts that - * {@code smallCategory}'s per-member rate is at least {@code minRatio} times - * {@code largeCategory}'s, where a category's rate is its observed count divided by the given - * member count (e.g. its bucket size). + * For a deliberately non-uniform fixture (no {@code expect*} call needed): + * asserts that {@code smallCategory}'s per-member rate is at least {@code minRatio} times {@code largeCategory}'s, + * where a category's rate is its observed count divided by the given member count (e.g. its bucket size). */ void assertShareRatioAtLeast(Category_ smallCategory, int smallCategorySize, Category_ largeCategory, int largeCategorySize, double minRatio) { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java index 374d4a34a77..b7c188522e9 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SamplingIteratorBiasIT.java @@ -22,17 +22,18 @@ import org.junit.jupiter.params.provider.ValueSource; /** - * End-to-end proof that {@code samplingIterator} inherits the underlying {@code exhaustiveIterator}'s - * guaranteed post-retirement uniformity, through the new sample-drawing surface rather than the raw - * iterator directly. Covers both the Uni and Bi forms. + * End-to-end proof + * that {@code samplingIterator} inherits the underlying {@code exhaustiveIterator}'s guaranteed post-retirement uniformity, + * through the new sample-drawing surface rather than the raw iterator directly. + * Covers both the Uni and Bi forms. */ class SamplingIteratorBiasIT extends AbstractBiasIT { /** - * Each sample is assembled from a fresh {@code exhaustiveIterator} - * ({@code SampleAssembler.iterator}), so drawing many size-1 samples is, per row, equivalent to - * {@code IteratorBiasIT.repeatingRandomIteratorIsUniformAtDraw} at draw #1: every row must be - * seeded about equally often. + * Each sample is assembled from a fresh {@code exhaustiveIterator} ({@code SampleAssembler.iterator}), + * so drawing many size-1 samples is, per row, equivalent to + * {@code IteratorBiasIT.repeatingRandomIteratorIsUniformAtDraw} at draw #1: + * every row must be seeded about equally often. */ @Test void uniSamplingIteratorSeedsEveryRowUniformly() { @@ -61,9 +62,9 @@ void uniSamplingIteratorSeedsEveryRowUniformly() { } /** - * One probing entity and three value buckets matched to it by an indexing {@code equal} - * joiner (sizes 2/3/5, weights 0.2/0.3/0.5) — mirrors {@code DatasetBucketBiasIT}'s fixture at - * the sample level instead of the raw dataset level. + * One probing entity and three value buckets matched to it by an indexing {@code equal} joiner (sizes 2/3/5, weights + * 0.2/0.3/0.5) — + * mirrors {@code DatasetBucketBiasIT}'s fixture at the sample level instead of the raw dataset level. */ @ValueSource(ints = { 1, 2, 5 }) @ParameterizedTest diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java index d14db2d508c..c0c1e442d70 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/bias/SubListSamplingBiasIT.java @@ -22,12 +22,11 @@ import org.junit.jupiter.api.Test; /** - * {@code SubListSampler} composes {@code TriangleElementFactory}'s copied triangle bias with the - * pinned-prefix offset and the value-seeded entity choice. The triangle math itself is copied - * verbatim from the legacy selector and is not retested here; what is new is the composition, and - * only a direct statistical test proves the offset and the seed-to-entity mapping did not - * introduce a bias of their own. Drives {@link SubListSampler} directly, against a - * {@link MoveDirector} built from a real score director, so no move provider or + * {@code SubListSampler} composes {@code TriangleElementFactory}'s copied triangle bias with the pinned-prefix offset + * and the value-seeded entity choice. The triangle math itself is copied verbatim from the legacy selector + * and is not retested here; what is new is the composition, and only a direct statistical test proves the offset + * and the seed-to-entity mapping did not introduce a bias of their own. Drives {@link SubListSampler} directly, + * against a {@link MoveDirector} built from a real score director, so no move provider or * {@code NeighborhoodTester} is involved. */ class SubListSamplingBiasIT extends AbstractBiasIT { @@ -35,9 +34,9 @@ class SubListSamplingBiasIT extends AbstractBiasIT { private static final int TRIAL_COUNT = 200_000; /** - * One entity, list size 7, minimum 2, maximum 5: the worked example in - * {@code TriangleElementFactoryTest} (nthTriangle(6) - nthTriangle(2) = 21 - 3 = 18 admissible - * (fromIndex, length) pairs, each expected to be drawn with equal probability). + * One entity, list size 7, minimum 2, maximum 5: the worked example in {@code TriangleElementFactoryTest} + * (nthTriangle(6) - nthTriangle(2) = 21 - 3 = 18 admissible (fromIndex, length) pairs, + * each expected to be drawn with equal probability). */ @Test void drawnSpanIsUniformOverEveryAdmissiblePair() { @@ -80,9 +79,9 @@ void drawnSpanIsUniformOverEveryAdmissiblePair() { /** * Three entities with unpinned windows of 2, 3 and 5 values (10 total), minimum sub-list size 1 - * so every draw succeeds and cannot skew the tally: proves the value-seeded entity choice is - * weighted by unpinned size, exactly as {@code RandomSubListSelector}'s own javadoc documents - * for the legacy selector ("entities with bigger list variables will be selected more often"). + * so every draw succeeds and cannot skew the tally: proves the value-seeded entity choice is weighted by unpinned size, + * exactly as {@code RandomSubListSelector}'s own javadoc documents for the legacy selector + * ("entities with bigger list variables will be selected more often"). * The seed value is drawn uniformly over every assigned value, mirroring how * {@code MoveStreamFactory#forEachAssignedValue} feeds a real move provider. */ diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 1436bf7dac1..5d3c55c5097 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -104,8 +104,7 @@ public Decision evaluate(int sizeSoFar, TestdataValue candidate) { assertThat(resetCallCount[0]).isEqualTo(1); // decide should have been called for every candidate offered, starting with the first. assertThat(decideCalls).isNotEmpty(); - // First decide call should have sizeSoFar == 0: the first candidate is no longer an - // unconditionally accepted seed. + // First decide call should have sizeSoFar == 0: the first candidate is no longer an unconditionally accepted seed. assertThat(decideCalls.get(0)).isEqualTo(0); } @@ -152,8 +151,9 @@ void sampleCanContainAndCheckForNullability() { void samplerMinimumSizeIsCheckedAfterDeduplication() { record Candidate(String code) { } - // Two distinct instances, equal by content: the assembler must count the sample's - // distinct members, not the raw candidate count, when it checks minimumSize. + // Two distinct instances, equal by content: + // the assembler must count the sample's distinct members, not the raw candidate count, + // when it checks minimumSize. var source = List.of(new Candidate("only"), new Candidate("only")).iterator(); var sampler = new Sampler() { @@ -169,9 +169,9 @@ public Decision evaluate(int sizeSoFar, Candidate candidate) { }; var random = RandomSource.seeded(0L).moveIteratorUsage(); - // Both candidates are equal, so only 1 distinct member is ever accepted - below the - // sampler's minimumSize of 2. Must be discarded entirely (null), not returned as an - // undersized Sample of size 1. + // Both candidates are equal, + // so only 1 distinct member is ever accepted - below the sampler's minimumSize of 2. + // Must be discarded entirely (null), not returned as an undersized Sample of size 1. assertThat(SampleAssembler.assemble(source, random, sampler)).isNull(); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java index 93ce8d72e5f..b9671cc850d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveProviderTest.java @@ -116,8 +116,8 @@ void fromEntityAllowsUnassigned() { // - secondEntity is assigned to secondValue, and the value range only contains firstValue; // so a change to firstValue is generated. // - firstEntity is assigned to firstValue, same as its only possible non-null value; no change. - // crossingNull=false locks in this pre-existing behaviour; see crossingNullDefaultTrueAssignsAndUnassigns - // for the default (true, since this variable allows unassigned values). + // crossingNull=false locks in this pre-existing behaviour; + // see crossingNullDefaultTrueAssignsAndUnassigns for the default (true, since this variable allows unassigned values). var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, secondEntity, firstValue)); @@ -162,8 +162,8 @@ void fromSolutionAllowsUnassigned() { // First entity is assigned to null, so it is filtered out of the source when crossingNull=false. // Second entity is assigned to secondValue, so the only applicable move changes it to firstValue. - // crossingNull=false locks in this pre-existing behaviour; see crossingNullDefaultTrueAssignsAndUnassigns - // for the default (true, since this variable allows unassigned values). + // crossingNull=false locks in this pre-existing behaviour; + // see crossingNullDefaultTrueAssignsAndUnassigns for the default (true, since this variable allows unassigned values). var context = NeighborhoodTester.build(new ChangeMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); context.producesAllOf(Moves.change(variableMetaModel, secondEntity, firstValue)); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java index 9a3e955d9c4..41e09808aef 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMoveTest.java @@ -32,8 +32,8 @@ void changeMoveExecutesPermanently() { @Test void toStringShowsTheOriginalValueAfterAssigningAnUnassignedEntity() { - // getValue() used null as its "not cached yet" sentinel, so it never cached a genuinely - // null current value and re-read the descriptor after execute() had already changed it. + // getValue() used null as its "not cached yet" sentinel, + // so it never cached a genuinely null current value and re-read the descriptor after execute() had already changed it. var solution = TestdataSolution.generateSolution(2, 1); var entity = solution.getEntityList().getFirst(); entity.setValue(null); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java index 20776676b45..b7404ad588d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java @@ -62,10 +62,12 @@ void fromSolution() { // - assignedValue1 (e1@0) <-> assignedValue2 (e2@0) // - assignedValue1 (e1@0) <-> assignedValue3 (e2@1) // - assignedValue2 (e2@0) <-> assignedValue3 (e2@1), within e2 - // The fourth value is unassigned: it swaps against every assigned position too, as a - // composite unassign-then-assign at that same position (this domain disallows unassigned - // values, but nothing in the raw state prevents this - real solving would never leave it - // unassigned in the first place, so the composite would never actually arise in practice). + // The fourth value is unassigned: + // it swaps against every assigned position too, as a composite unassign-then-assign at that same position + // (this domain disallows unassigned values, + // but nothing in the raw state prevents this - + // real solving would never leave it unassigned in the first place, + // so the composite would never actually arise in practice). NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) .using(solution) .producesAllOf( diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java index 674f0af5798..c0bd70c4108 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java @@ -66,8 +66,8 @@ void assignSideDisabledExcludesUnassignedEntities() { solution.setValueList(List.of(value1, value2)); solution.setEntityList(List.of(unassigned, assigned1, assigned2)); - // crossingNull=false: the source excludes unassigned entities entirely - not "isolating" the - // assign side, but removing it, since false now governs the source too (see the class javadoc). + // crossingNull=false: the source excludes unassigned entities entirely - not "isolating" the assign side, + // but removing it, since false now governs the source too (see the class javadoc). var context = NeighborhoodTester .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all(), false), solutionMetaModel) .using(solution); @@ -120,8 +120,8 @@ void mixedSampleMayAssignOrUnassign() { sawAssign = true; // Assigns the unassigned member. } } - // The relaxed assertion above would also pass if one branch never fired; these two lines - // make sure the relaxation isn't silently hiding a broken branch. + // The relaxed assertion above would also pass if one branch never fired; + // these two lines make sure the relaxation isn't silently hiding a broken branch. assertThat(sawAssign).isTrue(); assertThat(sawUnassign).isTrue(); } @@ -136,8 +136,8 @@ void crossingNullDefaultFalseWhenVariableDoesNotAllowUnassigned() { var entity1 = solution.getEntityList().get(1); assertThat(entity0.getValue()).isNotEqualTo(entity1.getValue()); - // Default constructor: crossingNull is false, because this variable does not allow unassigned - // values - the constructor must not throw for the default, unlike an explicit true would. + // Default constructor: crossingNull is false, because this variable does not allow unassigned values - + // the constructor must not throw for the default, unlike an explicit true would. var context = NeighborhoodTester .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) .using(solution); @@ -212,10 +212,10 @@ void emptyIntersectionEndsTheIteratorInsteadOfHanging() { var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); solution.setEntityList(List.of(northEntity, southEntity)); - // Samplers.all() drains the whole 2-entity dataset every draw, so every sample is - // {north, south} and the intersection is always empty. crossingNull=false, explicitly: with - // the default (true, since this variable allows unassigned values) a null destination is - // still legal - see emptyIntersectionStillYieldsNullDestinationWhenCrossingNull below. + // Samplers.all() drains the whole 2-entity dataset every draw, + // so every sample is {north, south} and the intersection is always empty. + // crossingNull=false, explicitly: with the default (true, since this variable allows unassigned values) a null destination is still legal - + // see emptyIntersectionStillYieldsNullDestinationWhenCrossingNull below. var context = NeighborhoodTester .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all(), false), solutionMetaModel) .using(solution); @@ -241,9 +241,10 @@ void emptyIntersectionStillYieldsNullDestinationWhenCrossingNull() { var solution = new TestdataAllowsUnassignedEntityProvidingSolution("s"); solution.setEntityList(List.of(northEntity, southEntity)); - // Default constructor: crossingNull is true. Regression test for the provenEmpty || rollNull - // ordering: the non-null intersection is still empty, but a null destination (unassigning the - // whole sample) is legal for both entities regardless, so it is offered instead of nothing. + // Default constructor: crossingNull is true. Regression test for the provenEmpty || rollNull ordering: + // the non-null intersection is still empty, + // but a null destination (unassigning the whole sample) is legal for both entities regardless, + // so it is offered instead of nothing. var context = NeighborhoodTester .build(new MassChangeMoveProvider<>(variableMetaModel, Samplers.all()), solutionMetaModel) .using(solution); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java index 326b783df02..263e06ef894 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java @@ -93,8 +93,8 @@ void destinationRespectsEveryMembersValueRange() { var v1 = new TestdataValue("v1"); var v2 = new TestdataValue("v2"); - // entityA's range admits both values, so any destination on entityA is legal for a sample drawn - // from entityA and/or entityB (v1 is shared). + // entityA's range admits both values, + // so any destination on entityA is legal for a sample drawn from entityA and/or entityB (v1 is shared). var entityA = new TestdataListUnassignedEntityProvidingEntity("A", List.of(v1, v2), List.of(v1, v2)); var entityB = new TestdataListUnassignedEntityProvidingEntity("B", List.of(v1)); var solution = new TestdataListUnassignedEntityProvidingSolution(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java index 420395edefc..2dad89ff364 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java @@ -55,13 +55,15 @@ void executeGathersEveryMemberConsecutivelyAtDestination() { @Test void destinationIndexIsAdjustedOnlyByMembersOriginallyBeforeIt() { - // A single-pass (mutate-while-reading) implementation double-counts c and d (which sit AT and - // AFTER the destination index, not before it) once a has already been removed and their live - // indices have shifted down: it would count all three of a, c, d as "before" (removedBeforeDestination - // = 3), driving the adjusted index to -1 - an out-of-bounds insertion. The two-pass fix reads every - // member's ORIGINAL position first, correctly counting only a (removedBeforeDestination = 1), - // and produces a real, verifiable rearrangement rather than a coincidental no-op: b - the one - // untouched value before the destination - ends up first, with a, c, d gathered right after it. + // A single-pass (mutate-while-reading) implementation double-counts c and d + // (which sit AT and AFTER the destination index, not before it) + // once a has already been removed and their live indices have shifted down: + // it would count all three of a, c, d as "before" (removedBeforeDestination = 3), + // driving the adjusted index to -1 - an out-of-bounds insertion. + // The two-pass fix reads every member's ORIGINAL position first, + // correctly counting only a (removedBeforeDestination = 1), + // and produces a real, verifiable rearrangement rather than a coincidental no-op: + // b - the one untouched value before the destination - ends up first, with a, c, d gathered right after it. var a = new TestdataListValue("a"); var b = new TestdataListValue("b"); var c = new TestdataListValue("c"); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java index a449b03894f..2a411cb7834 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java @@ -81,8 +81,8 @@ void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { solution.setValueList(List.of(values)); // Samplers.exactly(1) stops right at the seed, so every sample this sampler produces is size-1; - // the provider must not treat that as a rejection, or the iterator would spin (or run dry) instead - // of steadily producing moves. + // the provider must not treat that as a rejection, + // or the iterator would spin (or run dry) instead of steadily producing moves. var context = NeighborhoodTester .build(new MassListUnassignMoveProvider<>(variableMetaModel, Samplers.exactly(1)), solutionMetaModel) .using(solution); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java index 7024b9efe7e..fe8e855b49d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java @@ -67,8 +67,8 @@ void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { var solutionMetaModel = TestdataAllowsUnassignedSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); - // A Sampler that always stops right after the seed, i.e. a Sampler that always produces - // size-1 samples: unlike MassChange/MassAssign, this must not end the provider early. + // A Sampler that always stops right after the seed, i.e. a Sampler that always produces size-1 samples: + // unlike MassChange/MassAssign, this must not end the provider early. var solution = TestdataAllowsUnassignedSolution.generateSolution(3, 5); var context = diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java index 822f2b7ab58..9d904803542 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java @@ -74,8 +74,9 @@ void pinnedEntityExcludedFromPillar() { var v0 = new TestdataValue("v0"); var v1 = new TestdataValue("v1"); - // pinnedEntity shares v0 with free1 and free2, but forEach(..., false) excludes pinned - // entities from the entity source, so it must never join their pillar. + // pinnedEntity shares v0 with free1 and free2, + // but forEach(..., false) excludes pinned entities from the entity source, + // so it must never join their pillar. var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); var free1 = new TestdataPinnedEntity("free1", v0, false); var free2 = new TestdataPinnedEntity("free2", v0, false); @@ -177,10 +178,10 @@ void retirementLeavesOnlyTheLegalPillarsMoves() { @Test void lowLegalFractionDestinationIsStillFound() { - // Originally a regression test for the fixed-width probe (createRightIterator used to try a - // flat 9 draws regardless of pool size, so this 1-in-50 fixture would almost always miss). - // The destination search no longer draws from a 50-value pool at all: it samples from - // entityA2's range ([value1, value2], the smaller of the pillar's two distinct ranges), + // Originally a regression test for the fixed-width probe (createRightIterator used to try a flat 9 draws regardless of pool size, + // so this 1-in-50 fixture would almost always miss). + // The destination search no longer draws from a 50-value pool at all: + // it samples from entityA2's range ([value1, value2], the smaller of the pillar's two distinct ranges), // where value2 is found deterministically, not probabilistically. var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class) @@ -193,8 +194,8 @@ void lowLegalFractionDestinationIsStillFound() { var value1 = values.get(0); var value2 = values.get(1); - // entityA1 accepts every value; entityA2 accepts only value1 (current) and value2, so - // value2 is pillar A's only legal destination out of the 50-value pool. + // entityA1 accepts every value; + // entityA2 accepts only value1 (current) and value2, so value2 is pillar A's only legal destination out of the 50-value pool. var entityA1 = new TestdataEntityProvidingEntity("a1", values); entityA1.setValue(value1); var entityA2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); @@ -265,10 +266,10 @@ void emptyIntersectionStillYieldsNullDestination() { .basicVariable(); var value1 = new TestdataValue("v1"); - // Pillar: two entities sharing value1, each restricted to value1 only. No non-null - // destination exists (the pillar's own value is excluded), but crossingNull=true still - // offers the null destination - this is the provenEmpty || rollNull ordering: a pillar - // whose non-null intersection is empty still has a legal null destination. + // Pillar: two entities sharing value1, each restricted to value1 only. + // No non-null destination exists (the pillar's own value is excluded), + // but crossingNull=true still offers the null destination - + // this is the provenEmpty || rollNull ordering: a pillar whose non-null intersection is empty still has a legal null destination. var entityA1 = new TestdataAllowsUnassignedEntityProvidingEntity("a1", List.of(value1), value1); var entityA2 = new TestdataAllowsUnassignedEntityProvidingEntity("a2", List.of(value1), value1); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java index aa2fb3bb397..e79cbcdac11 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java @@ -38,8 +38,10 @@ void equalValuesProduceNoMove() { .getMovesAsStream() .limit(50) .toList(); - // One value means one pillar; no partner can differ, so the only key retires and the - // iterator ends. This also doubles as the termination test. + // One value means one pillar; + // no partner can differ, + // so the only key retires and the iterator ends. + // This also doubles as the termination test. assertThat(moveList).isEmpty(); } @@ -89,8 +91,8 @@ void outOfRangeCrossValueRejectsWholePair() { a1.setValue(value1); var a2 = new TestdataEntityProvidingEntity("a2", List.of(value1, value2)); a2.setValue(value1); - // Pillar B: both members are restricted to value2 only, so accepting value1 (pillar A's - // current value) is out of range for every member. + // Pillar B: both members are restricted to value2 only, + // so accepting value1 (pillar A's current value) is out of range for every member. var b1 = new TestdataEntityProvidingEntity("b1", List.of(value2)); b1.setValue(value2); var b2 = new TestdataEntityProvidingEntity("b2", List.of(value2)); @@ -104,8 +106,9 @@ void outOfRangeCrossValueRejectsWholePair() { var context = NeighborhoodTester.build(new PillarSwapMoveProvider<>(entityMetaModel), solutionMetaModel) .using(solution); - // Pillar A could accept value2, but pillar B cannot accept value1: the whole pair is - // rejected, in either direction. + // Pillar A could accept value2, + // but pillar B cannot accept value1: + // the whole pair is rejected, in either direction. context.producesNoneOf( Moves.pillarSwap(variableMetaModelList, pillarA, pillarB), Moves.pillarSwap(variableMetaModelList, pillarB, pillarA)); @@ -121,10 +124,12 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { var v3 = new TestdataValue("v3"); var v4 = new TestdataValue("v4"); - // Pillar A and pillar B differ on both "value" and "secondValue". The "value" swap is - // legal in both directions, but the "secondValue" swap is not: pillar A's secondValueRange - // does not contain pillar B's v3. One legal variable is not enough to save the pair; the - // whole swap is rejected. + // Pillar A and pillar B differ on both "value" and "secondValue". + // The "value" swap is legal in both directions, + // but the "secondValue" swap is not: + // pillar A's secondValueRange does not contain pillar B's v3. + // One legal variable is not enough to save the pair; + // the whole swap is rejected. var a1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("a1", List.of(v1, v4), List.of(v1, v4)); a1.setValue(v1); a1.setSecondValue(v1); @@ -132,8 +137,9 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { b1.setValue(v4); b1.setSecondValue(v3); - // The third variable is solution-scoped and both entities leave it null, so it never - // differs and never decides the outcome. + // The third variable is solution-scoped + // and both entities leave it null, + // so it never differs and never decides the outcome. var solution = new TestdataAllowsUnassignedMultiVarEntityProvidingSolution("s", List.of(v1)); solution.setEntityList(List.of(a1, b1)); @@ -154,8 +160,9 @@ void pinnedEntityNeverParticipatesInSwap() { var v0 = new TestdataValue("v0"); var v1 = new TestdataValue("v1"); - // pinnedEntity shares v0 with free1, forming what would be pillar A; forEach(..., false) - // excludes pinned entities from the entity source, so it must never appear in a swap. + // pinnedEntity shares v0 with free1, forming what would be pillar A; + // forEach(..., false) excludes pinned entities from the entity source, + // so it must never appear in a swap. var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); var free1 = new TestdataPinnedEntity("free1", v0, false); var free2 = new TestdataPinnedEntity("free2", v1, false); @@ -186,9 +193,10 @@ void threeAgainstFiveSwapsEveryMember() { var solution = TestdataSolution.generateSolution(2, 8); var entityList = solution.getEntityList(); var valueList = solution.getValueList(); - // First three entities share value0 (pillar A, size 3); the remaining five share value1 - // (pillar B, size 5). The legal-swap fraction stays high: the only two keys in the pool - // both produce a legal swap in either direction. + // First three entities share value0 (pillar A, size 3); + // the remaining five share value1 (pillar B, size 5). + // The legal-swap fraction stays high: + // the only two keys in the pool both produce a legal swap in either direction. for (var i = 0; i < 3; i++) { entityList.get(i).setValue(valueList.get(0)); } @@ -237,10 +245,10 @@ void excludedVariableIsIgnoredForKeyAndSwap() { var value1 = new TestdataValue("v1"); var otherValue0 = new TestdataOtherValue("o0"); - // Same setup as atLeastOneDifferingVariableProducesMove: with the full variable list, A and B - // are distinct pillars (they differ on secondary). Excluding secondary from the key merges - // them into one pillar (all four share primary and tertiary), leaving no second distinct key - // to swap with. + // Same setup as atLeastOneDifferingVariableProducesMove: + // with the full variable list, A and B are distinct pillars (they differ on secondary). + // Excluding secondary from the key merges them into one pillar (all four share primary and tertiary), + // leaving no second distinct key to swap with. var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java index d43427acb63..9df1a76a4f7 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveTest.java @@ -130,10 +130,11 @@ void multiVariateSwapsEveryDifferingVariable() { @Test void outOfRangeCrossValueIsCallerResponsibility() { - // PillarSwapMove no longer re-checks range at execution time; that check is - // PillarSwapMoveProvider/SubPillarSwapMoveProvider's job. A hand-built move over an invalid - // pair now writes the out-of-range value, which the score director's value-range assertion - // catches. The built-in providers never propose such a pair. + // PillarSwapMove no longer re-checks range at execution time; + // that check is PillarSwapMoveProvider/SubPillarSwapMoveProvider's job. + // A hand-built move over an invalid pair now writes the out-of-range value, + // which the score director's value-range assertion catches. + // The built-in providers never propose such a pair. var solutionMetaModel = TestdataEntityProvidingSolution.buildMetaModel(); var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntityProvidingEntity.class).basicVariable(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java index 17f1d68fd4c..e7db361de59 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java @@ -65,8 +65,8 @@ void pinnedEntityNeverUnassigned() { .basicVariable(); var v0 = new TestdataValue("v0"); - // pinnedEntity shares v0 with freeEntity, but forEach(..., false) excludes pinned entities - // from the entity source, so it must never be unassigned. + // pinnedEntity shares v0 with freeEntity, but forEach(..., false) excludes pinned entities from the entity source, + // so it must never be unassigned. var pinnedEntity = new TestdataPinnedAllowsUnassignedEntity("pinned", v0, true); var freeEntity = new TestdataPinnedAllowsUnassignedEntity("free", v0, false); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java index 99424d01992..f0ec0161664 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesBiasIT.java @@ -15,8 +15,8 @@ /** * {@link SampleValueRanges#pickExactly} is new reservoir-sampling selection code, * not a reuse of anything already bias-tested elsewhere in this package: - * an off-by-one in its {@code random.nextInt(admittedCount) == 0} pick would silently bias - * every exact-fallback draw a sample-drawing move iterator makes, + * an off-by-one in its {@code random.nextInt(admittedCount) == 0} pick would silently bias every exact-fallback draw a + * sample-drawing move iterator makes, * and only a direct, algorithm-level test on the helper itself proves it is uniform. */ class SampleValueRangesBiasIT extends AbstractBiasIT { @@ -24,8 +24,9 @@ class SampleValueRangesBiasIT extends AbstractBiasIT { private static final int TRIAL_COUNT = 200_000; /** - * {@code excludedValue} is exercised by {@code MassChangeMoveProvider} whenever a drawn sample - * happens to be homogeneous; the {@code null} case is the ordinary, no-exclusion draw. + * {@code excludedValue} is exercised by {@code MassChangeMoveProvider} + * whenever a drawn sample happens to be homogeneous; + * the {@code null} case is the ordinary, no-exclusion draw. */ @MethodSource("pickExactlyArguments") @ParameterizedTest @@ -48,12 +49,12 @@ private static List pickExactlyArguments() { } /** - * {@code findDestination}'s FilteringIterator-based sampling path (distinct from - * {@code pickExactly}'s reservoir pass, bias-tested directly above) is exercised here with a - * high legal fraction, so the bail-out budget - ten times the smallest range's size - is never - * exhausted in practice: this isolates the sampling path's own predicate and range-selection - * wiring from the exact fallback. {@code excludedValue} is again the - * {@code MassChangeMoveProvider} homogeneous-sample path, untested by the {@code null} case. + * {@code findDestination}'s FilteringIterator-based sampling path (distinct from {@code pickExactly}'s reservoir pass, + * bias-tested directly above) is exercised here with a high legal fraction, + * so the bail-out budget - ten times the smallest range's size - is never exhausted in practice: + * this isolates the sampling path's own predicate and range-selection wiring from the exact fallback. + * {@code excludedValue} is again the {@code MassChangeMoveProvider} homogeneous-sample path, untested by the {@code null} + * case. */ @MethodSource("findDestinationArguments") @ParameterizedTest diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java index 134598837a1..6a14169e3a2 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java @@ -51,15 +51,15 @@ void of_sampleBased_excludesNullAndChecksLegalityAcrossEveryMember() { // v1 is legal for every member; v2 is not (out of range for C). assertThat(ranges.containsInEvery(v1)).isTrue(); assertThat(ranges.containsInEvery(v2)).isFalse(); - // The variable allows unassigned, so the cached range is null-wrapped; of(...) must unwrap - // it, or null would be treated as a legal destination. + // The variable allows unassigned, so the cached range is null-wrapped; + // of(...) must unwrap it, or null would be treated as a legal destination. assertThat(ranges.containsInEvery(null)).isFalse(); } @Test void bailOutSizeOf_clampsHugeSizeInsteadOfOverflowing() { - // Long.MAX_VALUE * BAIL_OUT_SAFETY_MULTIPLIER would overflow negative, and FilteringIterator - // reads a negative bailOutSize as "bail-out disabled" - turning hasNext() into an infinite loop. + // Long.MAX_VALUE * BAIL_OUT_SAFETY_MULTIPLIER would overflow negative, + // and FilteringIterator reads a negative bailOutSize as "bail-out disabled" - turning hasNext() into an infinite loop. assertThat(SampleValueRanges.bailOutSizeOf(new HugeValueRange())).isPositive(); } @@ -99,9 +99,9 @@ void findDestination_singleRangeExcludesTheGivenValueEvenAsTheOnlyCandidate() { var only = new ListValueRange<>(List.of("only")); var random = new Random(0); - // The single-distinct-range case still has to honor the exclusion (MassChange's "not the - // current value" rule): here the range's one element IS the excluded value, so no - // destination exists at all - this must come back null, not the excluded value itself. + // The single-distinct-range case still has to honor the exclusion (MassChange's "not the current value" rule): + // here the range's one element IS the excluded value, + // so no destination exists at all - this must come back null, not the excluded value itself. var ranges = SampleValueRanges.of(Set.of(only)); assertThat(ranges.findDestination(random, "only")).isNull(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java index 445ac37ed95..39f3c864c49 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java @@ -78,9 +78,10 @@ void eachSideRespectsItsOwnSizeBounds() { allValues.addAll(List.of(valuesB)); solution.setValueList(allValues); - // One side is always drawn at length 1, the other always in [3, 5] - but SubListSwapMove's - // constructor normalizes same-entity pairs so that left precedes right, which can swap which - // getter reports which drawn side. Assert on the unordered pair of lengths instead. + // One side is always drawn at length 1, the other always in [3, 5] - + // but SubListSwapMove's constructor normalizes same-entity pairs + // so that left precedes right, which can swap which getter reports which drawn side. + // Assert on the unordered pair of lengths instead. var context = NeighborhoodTester .build(new SubListSwapMoveProvider<>(variableMetaModel, 1, 1, 3, 5), solutionMetaModel) .using(solution); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java index d083e5c1ec4..082d3f2c4f5 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java @@ -86,8 +86,9 @@ void pinnedEntityExcludedFromSubpillar() { var v0 = new TestdataValue("v0"); var v1 = new TestdataValue("v1"); - // pinnedEntity shares v0 with free1 and free2, but forEach(..., false) excludes pinned - // entities from the entity source, so it must never join their subpillar, regardless of rule. + // pinnedEntity shares v0 with free1 and free2, + // but forEach(..., false) excludes pinned entities from the entity source, + // so it must never join their subpillar, regardless of rule. var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); var free1 = new TestdataPinnedEntity("free1", v0, false); var free2 = new TestdataPinnedEntity("free2", v0, false); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java index 59723a261d4..51e7c1e72b3 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java @@ -108,8 +108,8 @@ void sharedSamplerInstanceOnBothSidesMatchesTwoSeparateInstances() { entityList.get(i).setValue(valueList.get(1)); } - // Two separate, independently-stateful sampler instances - the ordinary way to call this - // constructor. + // Two separate, independently-stateful sampler instances - + // the ordinary way to call this constructor. var separateContext = NeighborhoodTester .build(new SubPillarSwapMoveProvider<>(entityMetaModel, Samplers.pillar(Samplers.between(2, 4)), Samplers.pillar(Samplers.between(2, 4))), @@ -122,8 +122,9 @@ void sharedSamplerInstanceOnBothSidesMatchesTwoSeparateInstances() { // One stateful sampler instance shared by both sides. Safe per Sampler's class documentation: // reset() runs before every sample and a sample is assembled in full before it is returned, - // so sharing does not change which moves are produced - NeighborhoodTester always reseeds the - // working random the same way, so the two runs are directly comparable. + // so sharing does not change which moves are produced - + // NeighborhoodTester always reseeds the working random the same way, + // so the two runs are directly comparable. var sharedSampler = Samplers., TestdataEntity> pillar(Samplers.between(2, 4)); var sharedContext = NeighborhoodTester .build(new SubPillarSwapMoveProvider<>(entityMetaModel, sharedSampler, sharedSampler), solutionMetaModel) @@ -174,9 +175,9 @@ void pinnedEntityNeverParticipatesInSubpillarSwap() { var v0 = new TestdataValue("v0"); var v1 = new TestdataValue("v1"); var pinnedEntity = new TestdataPinnedEntity("pinned", v0, true); - // Two free members on v0, not one: pinnedEntity is excluded from the entity source (as - // asserted below), so the v0 group's own minimum size for Samplers.exactly(2) must be met by - // free entities alone. + // Two free members on v0, not one: + // pinnedEntity is excluded from the entity source (as asserted below), + // so the v0 group's own minimum size for Samplers.exactly(2) must be met by free entities alone. var free1 = new TestdataPinnedEntity("free1", v0, false); var free1b = new TestdataPinnedEntity("free1b", v0, false); var free2 = new TestdataPinnedEntity("free2", v1, false); @@ -213,10 +214,10 @@ void multivariateSwapUsesEveryListedVariable() { var value1 = new TestdataValue("v1"); var otherValue0 = new TestdataOtherValue("o0"); - // Pillar A and pillar B agree on primary and tertiary, differing only on secondary. Both - // groups have exactly 2 members, so a Samplers.exactly(2) sampler always draws the whole group, - // making the subpillar swap deterministic - same setup as - // PillarSwapMoveProviderTest.atLeastOneDifferingVariableProducesMove. + // Pillar A and pillar B agree on primary and tertiary, differing only on secondary. + // Both groups have exactly 2 members, + // so a Samplers.exactly(2) sampler always draws the whole group, making the subpillar swap deterministic - + // same setup as PillarSwapMoveProviderTest.atLeastOneDifferingVariableProducesMove. var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); @@ -253,9 +254,9 @@ void excludedVariableIsIgnoredForKeyAndSwap() { var value1 = new TestdataValue("v1"); var otherValue0 = new TestdataOtherValue("o0"); - // Same setup as multivariateSwapUsesEveryListedVariable. Excluding secondary from the key - // merges A and B into one pillar (all four share primary and tertiary), leaving no second - // distinct key to swap with. + // Same setup as multivariateSwapUsesEveryListedVariable. + // Excluding secondary from the key merges A and B into one pillar (all four share primary and tertiary), + // leaving no second distinct key to swap with. var a1 = new TestdataMultiVarEntity("a1", value0, value0, otherValue0); var a2 = new TestdataMultiVarEntity("a2", value0, value0, otherValue0); var b1 = new TestdataMultiVarEntity("b1", value0, value1, otherValue0); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java index 1e20ca9a598..c6f22d1e3ed 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -86,8 +86,10 @@ void survivesSlicesSmallerThanTheSamplersMinimumSize() { // One value shared by 2 entities (a legal pillar for a minimumSize-2 sampler), // and 20 values each held by exactly 1 entity (a slice too small to ever succeed). - // Skewed heavily toward the too-small slices, so a provider that gives up on its first - // bad draw - instead of retrying another slice value - reliably produces zero moves here. + // Skewed heavily toward the too-small slices, + // so a provider that gives up on its first bad draw + // - instead of retrying another slice value - + // reliably produces zero moves here. var goodValue = new TestdataValue("good"); List valueList = new ArrayList<>(); valueList.add(goodValue); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java index ecb23bf6af9..53fbd348b65 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProviderTest.java @@ -64,8 +64,8 @@ void multivariate() { var e3 = solution.getMultiVarEntityList().get(2); // With 3 entities, only 3 swap moves are possible: e1 <-> e2, e1 <-> e3, e2 <-> e3. - // But we only have 2 unique combinations of values, guaranteeing that two entities (e1 and e3) - // share values, making that swap a no-op. Each remaining pair is produced in both directions. + // But we only have 2 unique combinations of values, guaranteeing that two entities (e1 and e3) share values, + // making that swap a no-op. Each remaining pair is produced in both directions. var context = NeighborhoodTester.build(new SwapMoveProvider<>(entityMetaModel), solutionMetaModel) .using(solution); context.producesAllOf( @@ -177,10 +177,11 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { var v3 = new TestdataValue("v3"); var v4 = new TestdataValue("v4"); - // e1 and e3 differ on both "value" and "secondValue". The "value" swap is legal in both - // directions (v1 and v4 are in both ranges), but the "secondValue" swap is not: e1's - // secondValueRange does not contain e3's v3. One legal variable is not enough to save the - // pair; the whole swap is rejected. + // e1 and e3 differ on both "value" and "secondValue". + // The "value" swap is legal in both directions (v1 and v4 are in both ranges), + // but the "secondValue" swap is not: e1's secondValueRange does not contain e3's v3. + // One legal variable is not enough to save the pair; + // the whole swap is rejected. var e1 = new TestdataAllowsUnassignedMultiVarEntityProvidingEntity("e1", List.of(v1, v4), List.of(v1, v4)); e1.setValue(v1); e1.setSecondValue(v1); @@ -188,8 +189,8 @@ void mixedLegalityAcrossVariablesRejectsWholePair() { e3.setValue(v4); e3.setSecondValue(v3); - // The third variable is solution-scoped and both entities leave it null, so it never - // differs and never decides the outcome. + // The third variable is solution-scoped and both entities leave it null, + // so it never differs and never decides the outcome. var solution = new TestdataAllowsUnassignedMultiVarEntityProvidingSolution("s", List.of(v1)); solution.setEntityList(List.of(e1, e3)); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java index b241b6118fe..efb14483ddd 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveTest.java @@ -42,9 +42,9 @@ void swapMoveExecutesPermanently() { @Test @SuppressWarnings("unchecked") void sameValueSwapWritesBothVariables() { - // SwapMove does not skip a variable whose values already match; that exclusion is the - // provider's job (SwapMoveProvider.isValidSwap). A hand-built move over equal values now - // performs two writes that produce no net change, instead of none. + // SwapMove does not skip a variable whose values already match; + // that exclusion is the provider's job (SwapMoveProvider.isValidSwap). + // A hand-built move over equal values now performs two writes that produce no net change, instead of none. var solution = TestdataSolution.generateSolution(2, 2); var entity1 = solution.getEntityList().get(0); var entity2 = solution.getEntityList().get(1); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java index 0d2ceced338..a2603f3358d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SamplersTest.java @@ -161,8 +161,8 @@ void rejectAtSizeZeroKeepsDrawingForAFirstMember() { callCount[0]++; return callCount[0] < 3 ? Decision.REJECT : Decision.ACCEPT_AND_STOP; }; - // sourceOf(5) offers 0, 1, 2, 3, 4 in order: the first two candidates are rejected, the third - // (value 2) is accepted and stops the sample. + // sourceOf(5) offers 0, 1, 2, 3, 4 in order: the first two candidates are rejected, + // the third (value 2) is accepted and stops the sample. var sample = SampleAssembler.assemble(sourceOf(5).iterator(), seededRandom(), sampler); assertThat(sample).isNotNull(); assertThat(sample.size()).isEqualTo(1); @@ -237,8 +237,8 @@ public Decision evaluate(int sizeSoFar, Integer candidate) { return Decision.ACCEPT; } }; - // Only 2 candidates ever exist: the source runs dry before the sampler ever gets a chance to - // stop, so this is an undersized world, not a contract violation. + // Only 2 candidates ever exist: the source runs dry before the sampler ever gets a chance to stop, + // so this is an undersized world, not a contract violation. var sample = SampleAssembler.assemble(sourceOf(2).iterator(), seededRandom(), sampler); assertThat(sample).isNull(); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java index 6974ba677bc..cc290fc3a6c 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/PillarDatasetTest.java @@ -29,10 +29,12 @@ /** * Proves that {@code groupBy(key, collectAndThen(toList(), Sample::of))} yields a cached - * {@code UniDataset>} that is safe for a pillar move to hold across a - * settle: each settle that changes a group produces a fresh, immutable {@link Sample}, never a live - * view into the group's mutating accumulator. This is the mechanism {@code PillarDatasetUtil} and the - * four full-pillar move providers are built on; this test predates and does not depend on either. + * {@code UniDataset>} + * that is safe for a pillar move to hold across a settle: + * each settle that changes a group produces a fresh, immutable {@link Sample}, never a live view into the group's mutating + * accumulator. + * This is the mechanism {@code PillarDatasetUtil} and the four full-pillar move providers are built on; + * this test predates and does not depend on either. */ class PillarDatasetTest { diff --git a/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java index aa1bd436e85..1ee3b9b3e9f 100644 --- a/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java +++ b/tools/migration/src/main/java/ai/timefold/solver/migration/preview/package-info.java @@ -1,7 +1,7 @@ /** * This package contains recipes to migrate preview features of Timefold Solver. - * Preview APIs carry no backwards-compatibility guarantee and may change or be removed - * in any release, so their migrations are kept separate from the version-keyed + * Preview APIs carry no backwards-compatibility guarantee and may change or be removed in any release, + * so their migrations are kept separate from the version-keyed * {@link ai.timefold.solver.migration.v1} and {@link ai.timefold.solver.migration.v2} packages. */ package ai.timefold.solver.migration.preview; From 32642c93bdd1c30f9b850b7032c316e6be85fcb1 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Mon, 24 Aug 2026 14:18:33 +0200 Subject: [PATCH 10/16] Add a fixture necessary for benchmarks --- .../solver/core/impl/move/MoveDirector.java | 26 +++++++++++++++++ .../core/impl/move/MoveDirectorTest.java | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index ac706402f8a..9072523f7d7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -632,6 +632,32 @@ public final InnerScore executeTemporary(Move move) { return result; } + /** + * Like {@link #executeTemporary(Move, Function, boolean)}, but never calculates score - + * not before {@code postprocessor} runs, not after, not at all. + * Every other {@code executeTemporary} overload exists to learn the score effect of a move + * (that is the entire point of trying a move temporarily in local search or exhaustive search), + * so all of them pay for at least one {@code calculateScore()}. + * This overload is for the rarer case of a caller that wants the move genuinely, temporarily applied - + * so it can observe or exercise something other than score, + * such as a neighborhood dataset network reacting to the change - + * and has no use for the resulting score at all. + *

    + * Because score is never touched, there is nothing to restore afterward either: + * the working solution's score field is left exactly as the caller found it, + * not merely restored to it. + * + * @return whatever {@code postprocessor} returns + */ + public @Nullable Result_ executeTemporaryWithoutScoring(Move move, + Function postprocessor) { + var ephemeralMoveDirector = ephemeral(); + ephemeralMoveDirector.execute(move); + var result = postprocessor.apply(backingScoreDirector.getWorkingSolution()); + ephemeralMoveDirector.close(); // This undoes the move. + return result; + } + @Override public final Value_ getValue(PlanningVariableMetaModel variableMetaModel, Entity_ entity) { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 50b47882fbc..27169463657 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -42,6 +43,7 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement; import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.preview.api.move.builtin.Moves; import ai.timefold.solver.core.testdomain.TestdataEntity; import ai.timefold.solver.core.testdomain.TestdataSolution; import ai.timefold.solver.core.testdomain.TestdataValue; @@ -171,6 +173,33 @@ void readListVariable() { assertThat(actualPosition).isEqualTo(expectedLocation); } + @Test + void executeTemporaryWithoutScoringNeverCalculatesScoreAndAlwaysUndoes() { + var solutionMetaModel = TestdataSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataEntity.class) + .basicVariable("value", TestdataValue.class); + + var originalValue = new TestdataValue("original"); + var entity = new TestdataEntity("A", originalValue); + var newValue = new TestdataValue("new"); + var move = Moves.change(variableMetaModel, entity, newValue); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var moveDirector = new MoveDirector<>(mockScoreDirector); + + var valueSeenByPostprocessor = new TestdataValue[1]; + var result = moveDirector.executeTemporaryWithoutScoring(move, workingSolution -> { + // The move must already be applied by the time the postprocessor runs. + valueSeenByPostprocessor[0] = entity.getValue(); + return "postprocessor result"; + }); + + assertThat(result).isEqualTo("postprocessor result"); + assertThat(valueSeenByPostprocessor[0]).isEqualTo(newValue); + assertThat(entity.getValue()).isEqualTo(originalValue); // Undone automatically once the postprocessor returns. + verify(mockScoreDirector, never()).calculateScore(); + } + @Nested class ValueAssignment { From 88cf516471e58398b4ed275279cc8a1d654c06bd Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Tue, 25 Aug 2026 16:40:18 +0200 Subject: [PATCH 11/16] Significant perf improvements --- .../ExternalizedListVariableStateSupply.java | 2 +- .../domain/variable/ListVariableState.java | 37 +++- .../descriptor/ListVariableDescriptor.java | 15 +- .../solver/core/impl/move/MoveDirector.java | 111 ++++++++++- .../stream/DefaultMoveStreamFactory.java | 107 ++++------- .../stream/dataset/sample/DefaultSample.java | 78 ++++++++ .../dataset/sample/SampleAssembler.java | 20 +- .../enumerating/EnumeratingStreamFactory.java | 7 +- .../bi/AbstractBiEnumeratingStream.java | 36 +++- .../bi/BiConcatBiEnumeratingStream.java | 81 ++++++++ .../bi/UniConcatBiEnumeratingStream.java | 116 +++++++++++ .../common/ConcatEnumeratingStream.java | 11 ++ .../EnumeratingStreamBinaryOperation.java | 2 +- .../bridge/ForeBridgeBiEnumeratingStream.java | 31 +++ .../uni/AbstractUniEnumeratingStream.java | 32 ++++ .../uni/UniConcatUniEnumeratingStream.java | 80 ++++++++ .../bi/BavetUniConcatBiConstraintStream.java | 5 +- .../BavetBiConcatQuadConstraintStream.java | 6 +- .../BavetTriConcatQuadConstraintStream.java | 5 +- .../BavetUniConcatQuadConstraintStream.java | 8 +- .../tri/BavetBiConcatTriConstraintStream.java | 5 +- .../BavetUniConcatTriConstraintStream.java | 6 +- .../preview/api/move/MutableSolutionView.java | 57 ++++-- .../core/preview/api/move/SolutionView.java | 124 ++++++------ .../move/builtin/ListChangeMoveProvider.java | 149 ++++++++++----- .../api/move/builtin/MassChangeMove.java | 2 +- .../api/move/builtin/MassListChangeMove.java | 40 +--- .../api/move/builtin/MoveProviderUtil.java | 68 +++++-- .../builtin/PillarChangeMoveProvider.java | 123 ++++++++---- .../move/builtin/PillarSwapMoveProvider.java | 81 ++++---- .../api/move/builtin/SampleValueRanges.java | 102 ++++++---- .../builtin/SubPillarSwapMoveProvider.java | 67 +++---- .../stream/dataset/sample/DefaultSample.java | 61 ------ .../stream/dataset/sample/Sample.java | 2 + .../enumerating/BiEnumeratingStream.java | 23 ++- .../enumerating/UniEnumeratingStream.java | 31 ++- ...ternalizedListVariableStateSupplyTest.java | 167 +++++++++++++++- .../core/impl/move/MoveDirectorTest.java | 180 +++++++++++++++--- .../stream/DefaultMoveStreamFactoryTest.java | 147 ++++++++++++++ .../ConstraintStreamNodeSharingTest.java | 6 +- ...ractBiConstraintStreamNodeSharingTest.java | 11 ++ ...ctQuadConstraintStreamNodeSharingTest.java | 14 ++ ...actTriConstraintStreamNodeSharingTest.java | 12 ++ ...actUniConstraintStreamNodeSharingTest.java | 11 ++ .../move/builtin/SampleValueRangesTest.java | 43 ++++- .../enumerating/BiEnumeratingStreamTest.java | 104 ++++++++++ .../enumerating/UniEnumeratingStreamTest.java | 63 ++++++ 47 files changed, 1912 insertions(+), 577 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiConcatBiEnumeratingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/UniConcatBiEnumeratingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/ConcatEnumeratingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/bridge/ForeBridgeBiEnumeratingStream.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniConcatUniEnumeratingStream.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactoryTest.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupply.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupply.java index 8d5635c8941..67b0fea8e00 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupply.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupply.java @@ -127,7 +127,7 @@ public ElementPosition getElementPosition(Object planningValue) { @Override public boolean isAssigned(Object element) { - return getInverseSingleton(element) != null; + return listVariableState.isElementAssigned(element); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java index 17f20ff3862..bf03ce61140 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java @@ -70,7 +70,8 @@ public void initialize(InnerScoreDirector scoreDirector, int initi } // If the elements have any shadows, set them to null if no entity has their values - // We do not want to do this eagerly, since shadow variable update events are not triggered. + // We do not want to do this eagerly, + // since shadow variable update events are not triggered. var shouldUnassignElements = !scoreDirector.expectShadowVariablesInCorrectState() && (externalizedIndexProcessor != null || externalizedInverseProcessor != null || @@ -116,7 +117,7 @@ public void initialize(InnerScoreDirector scoreDirector, int initi } } - public void addElement(Object entity, List elements, Object element, int index) { + private void addElement(Object entity, List elements, Object element, int index) { if (requiresPositionMap) { var oldPosition = elementPositionMap.put(element, new MutablePosition(entity, index)); if (oldPosition != null) { @@ -141,8 +142,12 @@ public void addElement(Object entity, List elements, Object element, int externalizedNextElementProcessor.setElement(scoreDirector, elements, element, index) || elementUpdateSent; } unassignedCount--; - // Trigger notifier if none of the previous methods triggered a shadow var update for this element. - if (!elementUpdateSent) { + // Trigger the notifier only if some piece of state is not externalized + // and could therefore have changed without any shadow variable update event to report it. + // If every piece of state is externalized (!requiresPositionMap) + // and none of it fired an event, + // nothing actually changed. + if (!elementUpdateSent && requiresPositionMap) { notifier.accept(element); } } @@ -186,7 +191,8 @@ public boolean changeElement(Object entity, List elements, int index) { if (difference.entityChanged && externalizedInverseProcessor != null) { elementUpdateSent = externalizedInverseProcessor.changeElement(scoreDirector, entity, element) || elementUpdateSent; } - // Next and previous still might have changed, even if the index and entity did not. + // Next and previous still might have changed, + // even if the index and entity did not. // Those are based on what happened elsewhere in the list. if (externalizedPreviousElementProcessor != null) { elementUpdateSent = externalizedPreviousElementProcessor.setElement(scoreDirector, elements, element, index) @@ -196,8 +202,12 @@ public boolean changeElement(Object entity, List elements, int index) { elementUpdateSent = externalizedNextElementProcessor.setElement(scoreDirector, elements, element, index) || elementUpdateSent; } - // Trigger notifier if none of the previous methods triggered a shadow var update for this element. - if (!elementUpdateSent) { + // Trigger the notifier only if some piece of state is not externalized + // and could therefore have changed without any shadow variable update event to report it. + // If every piece of state is externalized (!requiresPositionMap) + // and none of it fired an event, + // nothing actually changed. + if (!elementUpdateSent && requiresPositionMap) { notifier.accept(element); } return difference.anythingChanged; @@ -245,6 +255,14 @@ private static ChangeType comparePositions(Object entity, Object otherEntity, in } } + public boolean isElementAssigned(Object planningValue) { + if (requiresPositionMap) { + return elementPositionMap.containsKey(planningValue); + } else { // At this point, all shadows are externalized. + return externalizedInverseProcessor.getInverseSingleton(planningValue) != null; + } + } + public ElementPosition getElementPosition(Object planningValue) { if (requiresPositionMap) { var mutablePosition = elementPositionMap.get(planningValue); @@ -252,7 +270,7 @@ public ElementPosition getElementPosition(Object planningValue) { return ElementPosition.unassigned(); } return mutablePosition.getPosition(); - } else { // At this point, both inverse and index are externalized. + } else { // At this point, all shadows are externalized. var inverse = externalizedInverseProcessor.getInverseSingleton(planningValue); if (inverse == null) { return ElementPosition.unassigned(); @@ -340,7 +358,8 @@ private enum ChangeType { /** * This class is used to avoid creating a new {@link PositionInList} object every time we need to return a position. - * The actual value is held in a map and can be updated without doing a put() operation, which is more efficient. + * The actual value is held in a map and can be updated without doing a put() operation, + * which is more efficient. * The {@link PositionInList} object is only created when it is actually requested, * and stored until the next time the mutable state is updated and therefore the cache invalidated. */ 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 db80845762b..f36229b53b8 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 @@ -14,9 +14,8 @@ import ai.timefold.solver.core.impl.domain.policy.DescriptorPolicy; 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.MoveDirector; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; -import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsPredicate; +import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsPredicate; public final class ListVariableDescriptor extends GenuineVariableDescriptor { @@ -25,11 +24,9 @@ public final class ListVariableDescriptor extends GenuineVariableDesc var list = getValue(entity); return list.contains(element); }; - private final BiNeighborhoodsPredicate entityContainsPinnedValuePredicate = - (solutionView, value, entity) -> { - var moveDirector = (MoveDirector) solutionView; - return moveDirector.isPinned(this, value); - }; + private final UniNeighborhoodsPredicate valueMovablePredicate = + (solutionView, value) -> value == null + || !solutionView.isPinned(this. getVariableMetaModel(), value); private boolean allowsUnassignedValues = true; @@ -48,8 +45,8 @@ public BiPredicate getInListPredicate() { } @SuppressWarnings("unchecked") - public BiNeighborhoodsPredicate getEntityContainsPinnedValuePredicate() { - return (BiNeighborhoodsPredicate) entityContainsPinnedValuePredicate; + public UniNeighborhoodsPredicate getValueMovablePredicate() { + return (UniNeighborhoodsPredicate) valueMovablePredicate; } public boolean allowsUnassignedValues() { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index 9072523f7d7..974b3733b71 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -1,8 +1,11 @@ package ai.timefold.solver.core.impl.move; import java.util.ArrayList; +import java.util.BitSet; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.BiFunction; import java.util.function.Function; @@ -25,6 +28,7 @@ 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.PlanningVariableMetaModel; +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.Move; @@ -45,8 +49,10 @@ public MoveDirector(InnerScoreDirector scoreDirector) { // and the external director is no longer an instance of InnerScoreDirector. // However, some pieces of code need methods from InnerScoreDirector, // in which case we turn to the backing score director. - // This is only safe for operations that do not need to be undone, such as calculateScore(). - // Operations which need undo must go through the external score director, which is recording in this case. + // This is only safe for operations that do not need to be undone, + // such as calculateScore(). + // Operations which need undo must go through the external score director, + // which is recording in this case. this.externalScoreDirector = new VariableChangeRecordingScoreDirector<>(scoreDirector, false); } else { this.externalScoreDirector = scoreDirector; @@ -87,6 +93,14 @@ public void assignValuesAndAdd( if (!(getPositionOf(variableMetaModel, value) instanceof UnassignedElement)) { throw new IllegalStateException("Cannot assign an already assigned value (%s).".formatted(value)); } + } + assignValuesAndAddElements(variableDescriptor, values, destinationEntity, destinationIndex); + externalScoreDirector.updateShadowVariables(); + } + + private void assignValuesAndAddElements(ListVariableDescriptor variableDescriptor, + List values, Entity_ destinationEntity, int destinationIndex) { + for (var value : values) { externalScoreDirector.beforeListVariableElementAssigned(variableDescriptor, value); } externalScoreDirector.beforeListVariableChanged(variableDescriptor, destinationEntity, destinationIndex, @@ -97,7 +111,6 @@ public void assignValuesAndAdd( for (var value : values) { externalScoreDirector.afterListVariableElementAssigned(variableDescriptor, value); } - externalScoreDirector.updateShadowVariables(); } @Override @@ -156,12 +169,17 @@ private void unassignValue(PlanningListVariableMetaModel) variableMetaModel).variableDescriptor(); + unassignValueElement(variableDescriptor, movedValue, entity, index); + externalScoreDirector.updateShadowVariables(); + } + + private void unassignValueElement(ListVariableDescriptor variableDescriptor, + Value_ movedValue, Entity_ entity, int index) { externalScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, movedValue); externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, index, index + 1); variableDescriptor.getValue(entity).remove(index); externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, index, index); externalScoreDirector.afterListVariableElementUnassigned(variableDescriptor, movedValue); - externalScoreDirector.updateShadowVariables(); } @Override @@ -305,8 +323,8 @@ public Value_ replaceValue( * *

    * Why position matters: {@code remove+add} shifts {@code (n−1−from) + (n−1−to)} elements in total. - * When one endpoint is near the tail, one of those copies is nearly free, - * making {@code remove+add} cheap even for large lists. + * When one endpoint is near the tail, + * one of those copies is nearly free, making {@code remove+add} cheap even for large lists. * {@code rotate} always pays for the full sublist span, * so it only wins when that span is short relative to what {@code removeAdd} would have to copy. * @@ -545,6 +563,77 @@ public final List unassignValues( return (List) values; } + @Override + public List massMoveValues( + PlanningListVariableMetaModel variableMetaModel, Iterable values, + @Nullable PositionInList destination) { + var variableDescriptor = extractVariableDescriptor(variableMetaModel); + // One pass: materialize the values, and group every currently-assigned member's ORIGINAL index by its + // source entity (one getPositionOf() call per value, not two). Reading positions inside a mutate-as-you-go + // loop is WRONG - a member's live index can shift once an earlier same-entity member is removed. + var valueList = new ArrayList(); + Map indexesByEntity = new LinkedHashMap<>(); + var removedBeforeDestination = 0; + for (var value : values) { + valueList.add(value); + if (getPositionOf(variableMetaModel, value) instanceof PositionInList assigned) { + Entity_ sourceEntity = assigned.entity(); + indexesByEntity.computeIfAbsent(sourceEntity, e -> new BitSet()).set(assigned.index()); + if (destination != null && sourceEntity == destination.entity() && assigned.index() < destination.index()) { + removedBeforeDestination++; + } + } + } + // One bracket per affected entity - not one per removed value - so the shadow-variable position rescan + // that fires on every beforeListVariableChanged/afterListVariableChanged pair runs once per entity, + // not once per sample member sharing that entity. + for (var entry : indexesByEntity.entrySet()) { + unassignValueElements(variableDescriptor, entry.getKey(), entry.getValue()); + } + if (destination != null) { + assignValuesAndAddElements(variableDescriptor, valueList, destination. entity(), + destination.index() - removedBeforeDestination); + } + externalScoreDirector.updateShadowVariables(); + return valueList; + } + + /** + * Removes the elements at the given (pre-removal) indexes from one entity's list variable, under a single + * before/after bracket - honestly reporting the whole affected span, not just the removed indexes, so that + * undo recording and the declarative shadow-variable graph (both of which trust the reported range to match + * what actually changed) stay correct. Survivors within the span are removed and immediately reinserted in + * their original relative order, exactly like {@link #unassignValues} already does when the entire span is + * removed (the {@code indexes.cardinality() == span size} case this generalizes). + */ + private void unassignValueElements(ListVariableDescriptor variableDescriptor, Entity_ entity, + BitSet indexes) { + var list = variableDescriptor.getValue(entity); + var minIndex = indexes.nextSetBit(0); + var maxIndexExclusive = indexes.length(); + var originalSpan = List.copyOf(list.subList(minIndex, maxIndexExclusive)); + var removedValues = new ArrayList<>(indexes.cardinality()); + var survivors = new ArrayList<>(originalSpan.size() - indexes.cardinality()); + for (var i = 0; i < originalSpan.size(); i++) { + var value = originalSpan.get(i); + if (indexes.get(minIndex + i)) { + removedValues.add(value); + } else { + survivors.add(value); + } + } + for (var value : removedValues) { + externalScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, value); + } + externalScoreDirector.beforeListVariableChanged(variableDescriptor, entity, minIndex, maxIndexExclusive); + list.subList(minIndex, maxIndexExclusive).clear(); + list.addAll(minIndex, survivors); + externalScoreDirector.afterListVariableChanged(variableDescriptor, entity, minIndex, minIndex + survivors.size()); + for (var value : removedValues) { + externalScoreDirector.afterListVariableElementUnassigned(variableDescriptor, value); + } + } + @Override public ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity) { @@ -635,8 +724,8 @@ public final InnerScore executeTemporary(Move move) { /** * Like {@link #executeTemporary(Move, Function, boolean)}, but never calculates score - * not before {@code postprocessor} runs, not after, not at all. - * Every other {@code executeTemporary} overload exists to learn the score effect of a move - * (that is the entire point of trying a move temporarily in local search or exhaustive search), + * Every other {@code executeTemporary} overload exists to learn the score effect of a move (that is the entire point of + * trying a move temporarily in local search or exhaustive search), * so all of them pay for at least one {@code calculateScore()}. * This overload is for the rarer case of a caller that wants the move genuinely, temporarily applied - * so it can observe or exercise something other than score, @@ -683,6 +772,12 @@ public final Value_ getValueAtIndex( return (Value_) extractVariableDescriptor(variableMetaModel).getValue(entity).get(index); } + @Override + public boolean isAssigned(PlanningListVariableMetaModel variableMetaModel, + Value_ value) { + return backingScoreDirector.getListVariableStateSupply(extractVariableDescriptor(variableMetaModel)).isAssigned(value); + } + @Override public ElementPosition getPositionOf(PlanningListVariableMetaModel variableMetaModel, Value_ value) { 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 1baee243c13..e7642b5148a 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,7 +1,5 @@ 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; @@ -18,13 +16,12 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; 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.neighborhood.MoveIteratorProvider; 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.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; -import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsMapper; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.BiNeighborhoodsPredicate; +import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsMapper; import ai.timefold.solver.core.preview.api.neighborhood.stream.function.UniNeighborhoodsPredicate; import ai.timefold.solver.core.preview.api.neighborhood.stream.picking.UniPickingStream; @@ -36,10 +33,8 @@ public final class DefaultMoveStreamFactory private final EnumeratingStreamFactory enumeratingStreamFactory; private final DatasetSessionFactory datasetSessionFactory; - // In order for node sharing to work properly, - // the function instances must be identical. - // Since these functions require the variable meta model, - // we need to cache them per variable meta model. + // In order for node sharing to work properly, the function instances must be identical. + // Since these functions require the variable meta model, we need to cache them per variable meta model. private final Map, NodeSharingSupportFunctions> nodeSharingSupportFunctionMap = new HashMap<>(); private final Map, ListVariableNodeSharingSupportFunctions> listVariableNodeSharingSupportFunctionsMap = @@ -118,20 +113,16 @@ public UniEnumeratingStream forEachUnfiltered(Class sourceC @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(); + // Insert-before an unpinned assigned value: the value's own current position, entity-independent. + // A value assigned to entity E is always in E's value range, so no join or range check is needed here: + // the entity a position's join used to bring in was never anything other than the value's own entity. + var valuePositions = forEachAssignedValue(variableMetaModel) + .map(nodeSharingSupportFunctions.toOwnPositionMapper); + // End-of-list slot, one per unpinned entity. + var endPositions = forEach(variableMetaModel.entity().type(), false) + .map(nodeSharingSupportFunctions.toEndOfListPositionMapper); + return valuePositions.concat(endPositions); } @SuppressWarnings({ "unchecked", "rawtypes" }) @@ -141,21 +132,14 @@ public UniEnumeratingStream forEachUnfiltered(Class sourceC if (!variableMetaModel.allowsUnassignedValues()) { return (UniEnumeratingStream) forEachDestination(variableMetaModel); } - // We include null, as that signifies the future unassigned element. - 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.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.toElementPositionMapper) - .distinct(); + // The single UnassignedElement row; forEach(_, true) yields the null-entity row exactly once. + var unassigned = forEach(variableMetaModel.entity().type(), true) + .filter(nodeSharingSupportFunctions.isNullEntityFilter) + .map(nodeSharingSupportFunctions.toUnassignedElementMapper); + UniEnumeratingStream destinations = + (UniEnumeratingStream) forEachDestination(variableMetaModel); + return destinations.concat(unassigned); } @SuppressWarnings("unchecked") @@ -197,60 +181,35 @@ public NodeSharingSupportFunctions(PlanningVariableMetaModel( PlanningListVariableMetaModel variableMetaModel, - UniNeighborhoodsPredicate unpinnedValueFilter, UniNeighborhoodsPredicate assignedValueOrNullFilter, UniNeighborhoodsPredicate assignedValueFilter, UniNeighborhoodsPredicate unassignedValueFilter, - BiNeighborhoodsPredicate valueInRangeFilter, BiNeighborhoodsPredicate valueInRangeFilterForPosition, - BiNeighborhoodsMapper toElementPositionMapper, - BiNeighborhoodsMapper toPositionInListMapper) { + UniNeighborhoodsMapper toOwnPositionMapper, + UniNeighborhoodsMapper toEndOfListPositionMapper, + UniNeighborhoodsPredicate isNullEntityFilter, + UniNeighborhoodsMapper toUnassignedElementMapper) { 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, value) -> solutionView.getPositionOf(variableMetaModel, value) instanceof UnassignedElement, - (solutionView, entity, value) -> { - // Null entity means we need to unassign the value; unassigning is always in range. - // Null value is necessary for the null to survive until the later stage, - // where we will use it as a special marker to move it to the end of list. - if (entity == null || value == null) { - return true; - } - return solutionView.isValueInRange(variableMetaModel, entity, value); - }, + (solutionView, value) -> value == null || solutionView.isAssigned(variableMetaModel, value), + (solutionView, value) -> solutionView.isAssigned(variableMetaModel, value), + (solutionView, value) -> !solutionView.isAssigned(variableMetaModel, value), (solutionView, value, positionInList) -> { Entity_ entity = positionInList.entity(); if (value == null) { - // Necessary for the null to survive until the later stage, - // where we will use it as a special marker to move it to the end of list. + // Necessary for the null to survive until the later stage, where we will use it as a special marker to move it to the end of list. return true; } return solutionView.isValueInRange(variableMetaModel, entity, value); }, - (solutionView, entity, value) -> { - if (entity == null) { // Null entity means we need to unassign the value. - return ElementPosition.unassigned(); - } - 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); - } - }, - (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(); - } - }); + // Insert-before this value: the value's own current position. + (solutionView, value) -> solutionView.getPositionOf(variableMetaModel, value).ensureAssigned(), + // Insert at the end of this entity's list. + (solutionView, entity) -> ElementPosition.of(entity, solutionView.countValues(variableMetaModel, entity)), + (solutionView, entity) -> entity == null, + (solutionView, entity) -> ElementPosition.unassigned()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java new file mode 100644 index 00000000000..bf9c8cb29be --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java @@ -0,0 +1,78 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.SequencedSet; + +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class DefaultSample implements Sample { + + private final SequencedSet<@Nullable A> memberSet; + private final SequencedSet<@Nullable A> unmodifiableMemberSet; + + public DefaultSample(Collection<@Nullable A> memberCollection) { + this(new LinkedHashSet<>(memberCollection)); + } + + /** + * Adopts an already-deduplicated, already-owned, order-stable set without copying it - for {@link SampleAssembler}'s use + * only, + * which builds exactly such a set while assembling a candidate and never touches it again afterward. + * Every other caller goes through the copying constructor above, since {@link Sample#of} promises the input collection is + * not retained. + */ + DefaultSample(SequencedSet<@Nullable A> ownedMemberSet) { + if (ownedMemberSet.isEmpty()) { + throw new IllegalArgumentException("The memberCollection (%s) of a sample must not be empty." + .formatted(ownedMemberSet)); + } + this.memberSet = ownedMemberSet; + this.unmodifiableMemberSet = Collections.unmodifiableSequencedSet(ownedMemberSet); + } + + @Override + public int size() { + return memberSet.size(); + } + + @Override + public boolean contains(@Nullable A element) { + return memberSet.contains(element); + } + + @Override + public @Nullable A representative() { + // A SequencedSet is what makes "the same member on every call" true. + return memberSet.getFirst(); + } + + @Override + public Iterator<@Nullable A> iterator() { + return unmodifiableMemberSet.iterator(); + } + + @Override + public boolean equals(Object o) { + return o instanceof DefaultSample other && + Objects.equals(memberSet, other.memberSet); + } + + @Override + public int hashCode() { + return memberSet.hashCode(); + } + + @Override + public String toString() { + return "Sample(" + memberSet + ")"; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 7ea69c9e914..86e7292a964 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -7,7 +7,6 @@ import java.util.Iterator; import java.util.LinkedHashSet; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.function.Supplier; import java.util.random.RandomGenerator; @@ -25,10 +24,8 @@ public final class SampleAssembler { * Assembles one sample, eagerly. *

    * The source iterator must be an exhaustive (without-replacement) iterator: - * it retires every element it hands out, - * so a candidate is offered to a given sample exactly once, - * and it has no bail-out, - * so it does not report "nothing left" while elements remain. + * it retires every element it hands out, so a candidate is offered to a given sample exactly once, + * and it has no bail-out, so it does not report "nothing left" while elements remain. * * @return null if the source is empty, * if the sampler refuses the sample, @@ -56,10 +53,8 @@ public final class SampleAssembler { throw new IllegalArgumentException("The minimumSize (%d) of sampler (%s) must be at least 1." .formatted(minimumSize, sampler)); } - // A set, not a list: - // sizeSoFar passed to evaluate() and the minimumSize check below must both see the distinct member count, - // since Sample.of() deduplicates anyway - - // otherwise an accepted duplicate would consume a slot the sampler believes it filled. + // A set, not a list: sizeSoFar passed to evaluate() and the minimumSize check below must both see the distinct member count, + // since Sample.of() deduplicates anyway - otherwise an accepted duplicate would consume a slot the sampler believes it filled. var memberSet = new LinkedHashSet<@Nullable A>(); var stoppedBySampler = false; while (sourceIterator.hasNext()) { @@ -74,7 +69,10 @@ public final class SampleAssembler { } } if (memberSet.size() >= minimumSize) { - return Sample.of(memberSet); + // The package-private adopting constructor, not Sample.of(memberSet): this set is already + // deduplicated and owned exclusively by this method, which never touches it again - + // no need to pay for Sample.of's defensive copy. + return new DefaultSample<>(memberSet); } if (stoppedBySampler) { // The sampler chose to stop itself, below its own declared floor: @@ -135,7 +133,7 @@ public Sample next() { if (!hasNext()) { throw new NoSuchElementException(); } - var sample = Objects.requireNonNull(pendingSample); + var sample = pendingSample; pendingSample = null; return sample; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/EnumeratingStreamFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/EnumeratingStreamFactory.java index ce95f4130b5..504c8141a09 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/EnumeratingStreamFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/EnumeratingStreamFactory.java @@ -17,7 +17,6 @@ import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.ForEachIncludingPinnedEnumeratingStream; import ai.timefold.solver.core.impl.score.director.SessionContext; import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; -import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; import org.jspecify.annotations.NullMarked; @@ -61,10 +60,8 @@ public UniEnumeratingStream forEachExcludingPinned(Class so .formatted(listVariableDescriptor.getVariableName())); } // The predicate is cached to allow for node-sharing, which expects identical lambdas. - var stream = forEachNonDiscriminating(sourceClass, includeNull) - .ifNotExists(parentEntityDescriptor.getEntityClass(), - NeighborhoodsJoiners.filtering(listVariableDescriptor.getEntityContainsPinnedValuePredicate())); - return share((AbstractUniEnumeratingStream) stream); + return share((AbstractUniEnumeratingStream) forEachNonDiscriminating(sourceClass, includeNull) + .filter(listVariableDescriptor.getValueMovablePredicate())); } public void assertValidForEachType(Class fromType) { 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 e3c46207c50..ef8eb977230 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 @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; +import java.util.function.Function; + import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.EnumeratingStreamFactory; @@ -7,6 +9,8 @@ import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.NeighborhoodsGroupNodeConstructor; 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.ForeBridgeBiEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeUniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.BiEnumeratingStream; @@ -36,6 +40,34 @@ public final BiEnumeratingStream filter(BiNeighborhoodsPredicat return shareAndAddChild(new FilterBiEnumeratingStream<>(enumeratingStreamFactory, this, filter)); } + @Override + public BiEnumeratingStream concat(BiEnumeratingStream otherStream) { + var other = (AbstractBiEnumeratingStream) otherStream; + var leftBridge = new ForeBridgeBiEnumeratingStream(enumeratingStreamFactory, this); + var rightBridge = new ForeBridgeBiEnumeratingStream(enumeratingStreamFactory, other); + var concatStream = new BiConcatBiEnumeratingStream<>(enumeratingStreamFactory, leftBridge, rightBridge); + return enumeratingStreamFactory.share(concatStream, concatStream_ -> { + // Connect the bridges upstream, as it is an actual new concat. + getChildStreamList().add(leftBridge); + other.getChildStreamList().add(rightBridge); + }); + } + + @Override + public BiEnumeratingStream concat(UniEnumeratingStream otherStream, + Function paddingFunction) { + var other = (AbstractUniEnumeratingStream) otherStream; + var leftBridge = new ForeBridgeBiEnumeratingStream(enumeratingStreamFactory, this); + var rightBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, other); + var concatStream = + new UniConcatBiEnumeratingStream<>(enumeratingStreamFactory, leftBridge, rightBridge, paddingFunction); + return enumeratingStreamFactory.share(concatStream, concatStream_ -> { + // Connect the bridges upstream, as it is an actual new concat. + getChildStreamList().add(leftBridge); + other.getChildStreamList().add(rightBridge); + }); + } + @Override public AbstractUniEnumeratingStream groupBy( BiNeighborhoodsMapper key) { @@ -101,8 +133,8 @@ public AbstractBiEnumeratingStream distinct() { @Override public BiLeftDataset asCachedDataset() { - var stream = shareAndAddChild(new LeftTerminalBiEnumeratingStream<>(enumeratingStreamFactory, this)); - return stream.getDataset(); + return shareAndAddChild(new LeftTerminalBiEnumeratingStream<>(enumeratingStreamFactory, this)) + .getDataset(); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiConcatBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiConcatBiEnumeratingStream.java new file mode 100644 index 00000000000..5a5c9a0fcbe --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/BiConcatBiEnumeratingStream.java @@ -0,0 +1,81 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; + +import java.util.Objects; +import java.util.Set; + +import ai.timefold.solver.core.impl.bavet.bi.ConcatBiBiNode; +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.stream.enumerating.EnumeratingStreamFactory; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.ConcatEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeBiEnumeratingStream; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +final class BiConcatBiEnumeratingStream extends AbstractBiEnumeratingStream + implements ConcatEnumeratingStream { + + private final ForeBridgeBiEnumeratingStream leftParent; + private final ForeBridgeBiEnumeratingStream rightParent; + + public BiConcatBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, + ForeBridgeBiEnumeratingStream leftParent, + ForeBridgeBiEnumeratingStream rightParent) { + super(enumeratingStreamFactory); + this.leftParent = leftParent; + this.rightParent = rightParent; + } + + @Override + public void collectActiveEnumeratingStreams(Set> enumeratingStreamSet) { + leftParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + rightParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + enumeratingStreamSet.add(this); + } + + @Override + public void buildNode(DataNodeBuildHelper buildHelper) { + TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); + var leftCloneStoreIndex = buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + var rightCloneStoreIndex = buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + var outputStoreSize = buildHelper.extractTupleStoreSize(this); + var node = new ConcatBiBiNode<>(downstream, leftCloneStoreIndex, rightCloneStoreIndex, outputStoreSize); + buildHelper.addNode(node, this, leftParent, rightParent); + } + + @Override + public boolean equals(Object o) { + /* + * Bridge streams do not implement equality because their equals() would have to point back to this stream, + * resulting in StackOverflowError. + * Therefore we need to check bridge parents to see where this concat node comes from. + */ + return o instanceof BiConcatBiEnumeratingStream other + && Objects.equals(leftParent.getParent(), other.leftParent.getParent()) + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + } + + @Override + public int hashCode() { + return Objects.hash(BiConcatBiEnumeratingStream.class, leftParent.getParent(), rightParent.getParent()); + } + + @Override + public String toString() { + return "BiConcat() with " + childStreamList.size() + " children"; + } + + @Override + public AbstractEnumeratingStream getLeftParent() { + return leftParent; + } + + @Override + public AbstractEnumeratingStream getRightParent() { + return rightParent; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/UniConcatBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/UniConcatBiEnumeratingStream.java new file mode 100644 index 00000000000..a649b04426f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/bi/UniConcatBiEnumeratingStream.java @@ -0,0 +1,116 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +import ai.timefold.solver.core.impl.bavet.bi.ConcatBiUniNode; +import ai.timefold.solver.core.impl.bavet.bi.ConcatUniBiNode; +import ai.timefold.solver.core.impl.bavet.common.AbstractConcatNode; +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.stream.enumerating.EnumeratingStreamFactory; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.ConcatEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeBiEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeUniEnumeratingStream; + +import org.jspecify.annotations.NullMarked; + +/** + * Covers both {@code Uni.concat(Bi, paddingFunction)} and {@code Bi.concat(Uni, paddingFunction)}: same underlying + * {@link AbstractConcatNode}, selected via {@link ConcatNodeConstructor} depending on which side is the {@code Uni}. + */ +@NullMarked +public final class UniConcatBiEnumeratingStream extends AbstractBiEnumeratingStream + implements ConcatEnumeratingStream { + + private final AbstractEnumeratingStream leftParent; + private final AbstractEnumeratingStream rightParent; + private final Function paddingFunction; + private final ConcatNodeConstructor nodeConstructor; + + public UniConcatBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, + ForeBridgeUniEnumeratingStream leftParent, + ForeBridgeBiEnumeratingStream rightParent, + Function paddingFunction) { + super(enumeratingStreamFactory); + this.leftParent = leftParent; + this.rightParent = rightParent; + this.paddingFunction = paddingFunction; + this.nodeConstructor = ConcatUniBiNode::new; + } + + public UniConcatBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, + ForeBridgeBiEnumeratingStream leftParent, + ForeBridgeUniEnumeratingStream rightParent, + Function paddingFunction) { + super(enumeratingStreamFactory); + this.leftParent = leftParent; + this.rightParent = rightParent; + this.paddingFunction = paddingFunction; + this.nodeConstructor = ConcatBiUniNode::new; + } + + @Override + public void collectActiveEnumeratingStreams(Set> enumeratingStreamSet) { + leftParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + rightParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + enumeratingStreamSet.add(this); + } + + @Override + public void buildNode(DataNodeBuildHelper buildHelper) { + TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); + var leftCloneStoreIndex = buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + var rightCloneStoreIndex = buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + var outputStoreSize = buildHelper.extractTupleStoreSize(this); + var node = + nodeConstructor.apply(paddingFunction, downstream, leftCloneStoreIndex, rightCloneStoreIndex, outputStoreSize); + buildHelper.addNode(node, this, leftParent, rightParent); + } + + @Override + public boolean equals(Object o) { + /* + * Bridge streams do not implement equality because their equals() would have to point back to this stream, + * resulting in StackOverflowError. + * Therefore we need to check bridge parents to see where this concat node comes from. + */ + return o instanceof UniConcatBiEnumeratingStream other + && Objects.equals(leftParent.getParent(), other.leftParent.getParent()) + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunction, other.paddingFunction); + } + + @Override + public int hashCode() { + return Objects.hash(UniConcatBiEnumeratingStream.class, leftParent.getParent(), rightParent.getParent(), + paddingFunction); + } + + @Override + public String toString() { + return "UniConcat() with " + childStreamList.size() + " children"; + } + + @Override + public AbstractEnumeratingStream getLeftParent() { + return leftParent; + } + + @Override + public AbstractEnumeratingStream getRightParent() { + return rightParent; + } + + private interface ConcatNodeConstructor { + + AbstractConcatNode apply(Function paddingFunction, + TupleLifecycle> nextNodesTupleLifecycle, + int leftCloneStoreIndex, int rightCloneStoreIndex, int outputStoreSize); + + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/ConcatEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/ConcatEnumeratingStream.java new file mode 100644 index 00000000000..7de4eecd3ca --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/ConcatEnumeratingStream.java @@ -0,0 +1,11 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common; + +import ai.timefold.solver.core.impl.bavet.common.TupleSource; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public non-sealed interface ConcatEnumeratingStream + extends EnumeratingStreamBinaryOperation, TupleSource { + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/EnumeratingStreamBinaryOperation.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/EnumeratingStreamBinaryOperation.java index 89a1a3ef3ef..f2cfbf1c2dd 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/EnumeratingStreamBinaryOperation.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/EnumeratingStreamBinaryOperation.java @@ -7,6 +7,6 @@ @NullMarked public sealed interface EnumeratingStreamBinaryOperation extends BavetStreamBinaryOperation> - permits IfExistsEnumeratingStream, JoinEnumeratingStream { + permits ConcatEnumeratingStream, IfExistsEnumeratingStream, JoinEnumeratingStream { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/bridge/ForeBridgeBiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/bridge/ForeBridgeBiEnumeratingStream.java new file mode 100644 index 00000000000..1b130f1fdc3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/common/bridge/ForeBridgeBiEnumeratingStream.java @@ -0,0 +1,31 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge; + +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.common.AbstractEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.BiEnumeratingStream; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class ForeBridgeBiEnumeratingStream + extends AbstractBiEnumeratingStream + implements BiEnumeratingStream { + + public ForeBridgeBiEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, + AbstractEnumeratingStream parent) { + super(enumeratingStreamFactory, parent); + } + + @Override + public void buildNode(DataNodeBuildHelper buildHelper) { + // Do nothing. The child stream builds everything. + } + + @Override + public String toString() { + return "Generic bridge"; + } + +} 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 7dcf9ee2d48..3e79e2e7089 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 @@ -1,14 +1,18 @@ package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni; +import java.util.function.Function; + import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; 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.bi.JoinBiEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.UniConcatBiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.NeighborhoodsGroupNodeConstructor; 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.ForeBridgeBiEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeUniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.joiner.BiNeighborhoodsJoinerComber; import ai.timefold.solver.core.impl.util.ConstantLambdaUtils; @@ -40,6 +44,34 @@ public final UniEnumeratingStream filter(UniNeighborhoodsPredicate return shareAndAddChild(new FilterUniEnumeratingStream<>(enumeratingStreamFactory, this, filter)); } + @Override + public UniEnumeratingStream concat(UniEnumeratingStream otherStream) { + var other = (AbstractUniEnumeratingStream) otherStream; + var leftBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, this); + var rightBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, other); + var concatStream = new UniConcatUniEnumeratingStream<>(enumeratingStreamFactory, leftBridge, rightBridge); + return enumeratingStreamFactory.share(concatStream, concatStream_ -> { + // Connect the bridges upstream, as it is an actual new concat. + getChildStreamList().add(leftBridge); + other.getChildStreamList().add(rightBridge); + }); + } + + @Override + public BiEnumeratingStream concat(BiEnumeratingStream otherStream, + Function paddingFunction) { + var other = (AbstractBiEnumeratingStream) otherStream; + var leftBridge = new ForeBridgeUniEnumeratingStream(enumeratingStreamFactory, this); + var rightBridge = new ForeBridgeBiEnumeratingStream(enumeratingStreamFactory, other); + var concatStream = + new UniConcatBiEnumeratingStream<>(enumeratingStreamFactory, leftBridge, rightBridge, paddingFunction); + return enumeratingStreamFactory.share(concatStream, concatStream_ -> { + // Connect the bridges upstream, as it is an actual new concat. + getChildStreamList().add(leftBridge); + other.getChildStreamList().add(rightBridge); + }); + } + @Override public BiEnumeratingStream join(UniEnumeratingStream otherStream, BiNeighborhoodsJoiner... joiners) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniConcatUniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniConcatUniEnumeratingStream.java new file mode 100644 index 00000000000..768cb9da58b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/enumerating/uni/UniConcatUniEnumeratingStream.java @@ -0,0 +1,80 @@ +package ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni; + +import java.util.Objects; +import java.util.Set; + +import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle; +import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; +import ai.timefold.solver.core.impl.bavet.uni.ConcatUniUniNode; +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.ConcatEnumeratingStream; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.DataNodeBuildHelper; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.bridge.ForeBridgeUniEnumeratingStream; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +final class UniConcatUniEnumeratingStream extends AbstractUniEnumeratingStream + implements ConcatEnumeratingStream { + + private final ForeBridgeUniEnumeratingStream leftParent; + private final ForeBridgeUniEnumeratingStream rightParent; + + public UniConcatUniEnumeratingStream(EnumeratingStreamFactory enumeratingStreamFactory, + ForeBridgeUniEnumeratingStream leftParent, ForeBridgeUniEnumeratingStream rightParent) { + super(enumeratingStreamFactory); + this.leftParent = leftParent; + this.rightParent = rightParent; + } + + @Override + public void collectActiveEnumeratingStreams(Set> enumeratingStreamSet) { + leftParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + rightParent.collectActiveEnumeratingStreams(enumeratingStreamSet); + enumeratingStreamSet.add(this); + } + + @Override + public void buildNode(DataNodeBuildHelper buildHelper) { + TupleLifecycle> downstream = buildHelper.getAggregatedTupleLifecycle(childStreamList); + var leftCloneStoreIndex = buildHelper.reserveTupleStoreIndex(leftParent.getTupleSource()); + var rightCloneStoreIndex = buildHelper.reserveTupleStoreIndex(rightParent.getTupleSource()); + var outputStoreSize = buildHelper.extractTupleStoreSize(this); + var node = new ConcatUniUniNode<>(downstream, leftCloneStoreIndex, rightCloneStoreIndex, outputStoreSize); + buildHelper.addNode(node, this, leftParent, rightParent); + } + + @Override + public boolean equals(Object o) { + /* + * Bridge streams do not implement equality because their equals() would have to point back to this stream, + * resulting in StackOverflowError. + * Therefore we need to check bridge parents to see where this concat node comes from. + */ + return o instanceof UniConcatUniEnumeratingStream other + && Objects.equals(leftParent.getParent(), other.leftParent.getParent()) + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + } + + @Override + public int hashCode() { + return Objects.hash(UniConcatUniEnumeratingStream.class, leftParent.getParent(), rightParent.getParent()); + } + + @Override + public String toString() { + return "UniConcat() with " + childStreamList.size() + " children"; + } + + @Override + public AbstractEnumeratingStream getLeftParent() { + return leftParent; + } + + @Override + public AbstractEnumeratingStream getRightParent() { + return rightParent; + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetUniConcatBiConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetUniConcatBiConstraintStream.java index d056d28ec02..bcdaa5f0b90 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetUniConcatBiConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/bi/BavetUniConcatBiConstraintStream.java @@ -98,12 +98,13 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunction, other.paddingFunction); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunction); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetBiConcatQuadConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetBiConcatQuadConstraintStream.java index 84fe4c9b0cc..1750852cb3f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetBiConcatQuadConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetBiConcatQuadConstraintStream.java @@ -102,12 +102,14 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunctionC, other.paddingFunctionC) + && Objects.equals(paddingFunctionD, other.paddingFunctionD); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunctionC, paddingFunctionD); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetTriConcatQuadConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetTriConcatQuadConstraintStream.java index d887f3eb522..b31a381fd3a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetTriConcatQuadConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetTriConcatQuadConstraintStream.java @@ -99,12 +99,13 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunctionD, other.paddingFunctionD); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunctionD); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetUniConcatQuadConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetUniConcatQuadConstraintStream.java index baa03a7ce8f..63262c67406 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetUniConcatQuadConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/quad/BavetUniConcatQuadConstraintStream.java @@ -105,12 +105,16 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunctionB, other.paddingFunctionB) + && Objects.equals(paddingFunctionC, other.paddingFunctionC) + && Objects.equals(paddingFunctionD, other.paddingFunctionD); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunctionB, paddingFunctionC, + paddingFunctionD); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetBiConcatTriConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetBiConcatTriConstraintStream.java index 56033eecb92..00efcc403e0 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetBiConcatTriConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetBiConcatTriConstraintStream.java @@ -99,12 +99,13 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunction, other.paddingFunction); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunction); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetUniConcatTriConstraintStream.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetUniConcatTriConstraintStream.java index 4ef03653ab5..9544e8e017f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetUniConcatTriConstraintStream.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/bavet/tri/BavetUniConcatTriConstraintStream.java @@ -102,12 +102,14 @@ public boolean equals(Object o) { * Therefore we need to check bridge parents to see where this concat node comes from. */ return Objects.equals(leftParent.getParent(), other.leftParent.getParent()) - && Objects.equals(rightParent.getParent(), other.rightParent.getParent()); + && Objects.equals(rightParent.getParent(), other.rightParent.getParent()) + && Objects.equals(paddingFunctionB, other.paddingFunctionB) + && Objects.equals(paddingFunctionC, other.paddingFunctionC); } @Override public int hashCode() { - return Objects.hash(leftParent.getParent(), rightParent.getParent()); + return Objects.hash(leftParent.getParent(), rightParent.getParent(), paddingFunctionB, paddingFunctionC); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java index dbc3ca9e5e1..c37d5a4868f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/MutableSolutionView.java @@ -14,19 +14,20 @@ import org.jspecify.annotations.Nullable; /** - * Contains all reading and mutating methods available to a {@link Move} - * in order to change the state of a {@link PlanningSolution planning solution}. + * Contains all reading and mutating methods available to a {@link Move} in order to change the state of a + * {@link PlanningSolution planning solution}. *

    * This package and all of its contents are part of the Neighborhoods API, * which is under development and is only offered as a preview feature. - * There are no guarantees for backward compatibility; - * any class, method, or field may change or be removed without prior notice, + * There are no guarantees for backward compatibility; any class, method, or field may change or be removed without prior + * notice, * although we will strive to avoid this as much as possible. *

    * We encourage you to try the API and give us feedback on your experience with it, * before we finalize the API. * Please direct your feedback to Timefold Solver GitHub - * or to Timefold Discord. + * or to + * Timefold Discord. * * @param */ @@ -35,7 +36,8 @@ public interface MutableSolutionView extends SolutionView /** * Puts a given value at a particular index in a given entity's {@link PlanningListVariable planning list variable}. - * Moves all values at or after the index to the right, much like {@link List#add(int, Object)}. + * Moves all values at or after the index to the right, + * much like {@link List#add(int, Object)}. * * @param variableMetaModel Describes the variable to be changed. * @param value The value to be assigned to a list variable. @@ -60,7 +62,8 @@ default void assignValueAndAdd( /** * Puts given sequence of values at a particular index in a given entity's {@link PlanningListVariable planning list * variable}. - * Moves all values at or after the index to the right, much like {@link List#addAll(int, Collection)}. + * Moves all values at or after the index to the right, + * much like {@link List#addAll(int, Collection)}. * * @param variableMetaModel Describes the variable to be changed. * @param values The sequence of values to be assigned to a list variable. @@ -155,8 +158,7 @@ void changeVariable(PlanningVariableMetaModel Value_ moveValueBetweenLists( * Behaves as if the value is first removed from the source index, * shifting all later values to the left, * and then inserted at the destination index. - * This means that, to move the item to the end of the list, - * the destination index must equal the list size minus one. + * This means that, to move the item to the end of the list, the destination index must equal the list size minus one. * * @param variableMetaModel Describes the variable to be changed. * @param sourceEntity The entity whose variable value is to be changed. @@ -245,8 +246,8 @@ Value_ moveValueInList(PlanningListVariableMetaModel void swapValuesInList(PlanningListVariableMetaModel List moveValuesInList( * @param sourceFromIndex The index of the first value of the span to be moved, inclusive. * @param sourceToIndex The index one past the last value of the span to be moved, exclusive. * @param destinationEntity The entity to which the span will be added. - * @param destinationIndex The index in the destination entity's list variable at which the span is to be - * inserted; all values at or after the index are shifted to the right. + * @param destinationIndex The index in the destination entity's list variable at which the span is to be inserted; all + * values at or after the index are shifted to the right. * @param reversing if {@code true}, the span is inserted in reverse element order * @return the moved values, in the order they were inserted * @throws IllegalArgumentException if {@code sourceEntity == destinationEntity} or @@ -399,8 +400,8 @@ void swapValuesInList(PlanningListVariableMetaModel void swapValuesBetweenLists( PlanningListVariableMetaModel variableMetaModel, Entity_ leftEntity, @@ -422,4 +423,22 @@ List unassignValues( PlanningListVariableMetaModel variableMetaModel, Entity_ entity, int fromIndex, int toIndex); + /** + * Gathers a set of values - wherever each one currently is, assigned or not - and inserts them consecutively, in iteration + * order, at one destination. + * If a value is currently assigned elsewhere, it is unassigned from its current position first. + * A {@code null} destination unassigns every already-assigned value instead of inserting them anywhere. + *

    + * All shadow variables are updated exactly once, regardless of how many entities are affected. + * + * @param variableMetaModel Describes the variable to be changed. + * @param values The values to be gathered; each may currently be assigned to any entity, or unassigned. + * Must not contain null elements; this is not enforced at runtime. + * @param destination Where to insert the values, or {@code null} to unassign them instead. + * @return the gathered values, in the order they were given + */ + List massMoveValues( + PlanningListVariableMetaModel variableMetaModel, Iterable values, + @Nullable PositionInList destination); + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java index 8479d3b60b4..59810769749 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/SolutionView.java @@ -19,14 +19,12 @@ /** * Allows read-only access to the state of the solution that is being operated on by the {@link Move}. *

    - * This package and all of its contents are part of the Neighborhoods API, - * which is under development and is only offered as a preview feature. - * There are no guarantees for backward compatibility; - * any class, method, or field may change or be removed without prior notice, - * although we will strive to avoid this as much as possible. + * This package and all of its contents are part of the Neighborhoods API, which is under development and is only + * offered as a preview feature. + * There are no guarantees for backward compatibility; any class, method, or field may change or be removed without prior + * notice, although we will strive to avoid this as much as possible. *

    - * We encourage you to try the API and give us feedback on your experience with it, - * before we finalize the API. + * We encourage you to try the API and give us feedback on your experience with it, before we finalize the API. * Please direct your feedback to Timefold Solver GitHub * or to Timefold Discord. * @@ -58,7 +56,7 @@ public interface SolutionView { * @param variableMetaModel Describes the variable whose value is to be read. * @param entity The entity whose variable is to be read. * @return The number of values in the list variable. - * @throws NullPointerException if the value of the list variable is null + * @throws NullPointerException if the value of the list variable is null. */ int countValues(PlanningListVariableMetaModel variableMetaModel, Entity_ entity); @@ -69,41 +67,52 @@ int countValues(PlanningListVariableMetaModel= 0 - * @return The value at the given index in the list variable, valid only at the time of this call; - * it does not update if the list variable changes afterward - * @throws NullPointerException if the value of the list variable is null - * @throws IndexOutOfBoundsException if the index is out of bounds + * @return The value at the given index in the list variable, valid only at the time of this call; it does not update if the + * list variable changes afterward. + * @throws NullPointerException if the value of the list variable is null. + * @throws IndexOutOfBoundsException if the index is out of bounds. */ Value_ getValueAtIndex(PlanningListVariableMetaModel variableMetaModel, Entity_ entity, int index); + /** + * Checks if a given value is assigned in any {@link PlanningListVariable list planning variable}. + * A possibly more efficient variant of {@link #getPositionOf(PlanningListVariableMetaModel, Object)}, in case you don't + * need the actual position. + * + * @param variableMetaModel Describes the variable whose value is to be read. + * @param value The value to locate. + * @return true if the value is assigned in any list variable. + */ + boolean isAssigned( + PlanningListVariableMetaModel variableMetaModel, + Value_ value); + /** * Locates a given value in any {@link PlanningListVariable list planning variable}. * * @param variableMetaModel Describes the variable whose value is to be read. * @param value The value to locate. - * @return the location of the value in the variable, valid only at the time of this call; - * it does not update if the list variable changes afterward + * @return the location of the value in the variable, valid only at the time of this call; it does not update if the list + * variable changes afterward. */ ElementPosition getPositionOf( PlanningListVariableMetaModel variableMetaModel, Value_ value); /** - * Reads the index of the first element of a {@link PlanningListVariable list planning variable} - * that is not pinned. - * The pinned portion of a list variable, if any, is always a prefix: - * every index below the returned value is pinned, every index at or above it is not. + * Reads the index of the first element of a {@link PlanningListVariable list planning variable} that is not pinned. + * The pinned portion of a list variable, if any, is always a prefix: every index below the returned value is pinned, every + * index at or above it is not. *

    - * Caveat: for an entity that is entirely immovable due to {@link PlanningPin}, - * this method returns {@code 0} even though every element of its list is pinned; - * such an entity is excluded from the pinning-filtered enumeration methods on - * {@link ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory}, - * so a caller which only ever enumerates through those methods will never see this case. + * Caveat: for an entity that is entirely immovable due to {@link PlanningPin}, this method returns + * {@code 0} even though every element of its list is pinned; such an entity is excluded from the pinning-filtered + * enumeration methods on {@link ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory}, so a caller + * which only ever enumerates through those methods will never see this case. * * @param variableMetaModel Describes the variable whose value is to be read. * @param entity The entity whose variable is to be read. - * @return 0 or higher; 0 if nothing is pinned; at most {@link #countValues} if the entire list is pinned + * @return 0 or higher; 0 if nothing is pinned; at most {@link #countValues} if the entire list is pinned. */ int getFirstUnpinnedIndex( PlanningListVariableMetaModel variableMetaModel, Entity_ entity); @@ -113,7 +122,7 @@ int getFirstUnpinnedIndex( * * @param variableMetaModel Describes the variable whose value is to be read. * @param entity The entity to check if it is pinned. - * @return boolean indicating if the value is pinned in the variable + * @return boolean indicating if the value is pinned in the variable. */ boolean isPinned(PlanningVariableMetaModel variableMetaModel, @Nullable Entity_ entity); @@ -128,23 +137,24 @@ boolean isPinned(PlanningVariableMetaModel boolean isPinned(PlanningListVariableMetaModel variableMetaModel, @Nullable Value_ value); /** - * Checks if a given value is present in the value range of a genuine planning variable, - * when the value range is defined on {@link PlanningSolution}. + * Checks if a given value is present in the value range of a genuine planning variable, when the value range is defined on + * {@link PlanningSolution}. * - * @param variableMetaModel variable in question - * @param value value to check - * @param generic type of the entity that the variable is defined on - * @param generic type of the value that the variable can take - * @return true if the value is acceptable for the variable - * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution; - * use {@link #isValueInRange(GenuineVariableMetaModel, Object, Object)} to provide the entity instance. + * @param variableMetaModel variable in question. + * @param value value to check. + * @param generic type of the entity that the variable is defined on. + * @param generic type of the value that the variable can take. + * @return true if the value is acceptable for the variable. + * @throws IllegalArgumentException if the value range is on an entity as opposed to a solution; use + * {@link #isValueInRange(GenuineVariableMetaModel, Object, Object)} to provide the entity instance. */ default boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Value_ value) { @@ -159,16 +169,14 @@ Maybe use isValueInRange(GenuineVariableMetaModel, Object, Object) to provide th /** * Checks if a given value is present in the value range of a genuine planning variable. - * If the value range is defined on {@link PlanningEntity entity}, - * the {@code entity} argument must not be null. + * If the value range is defined on {@link PlanningEntity entity}, the {@code entity} argument must not be null. * - * @param variableMetaModel variable in question - * @param entity entity that the value would be applied to; - * must be of a type that the variable is defined on - * @param value value to check - * @param generic type of the entity that the variable is defined on - * @param generic type of the value that the variable can take - * @return true if the value is acceptable for the variable + * @param variableMetaModel variable in question. + * @param entity entity that the value would be applied to; must be of a type that the variable is defined on. + * @param value value to check. + * @param generic type of the entity that the variable is defined on. + * @param generic type of the value that the variable can take. + * @return true if the value is acceptable for the variable. */ default boolean isValueInRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity, @Nullable Value_ value) { @@ -178,22 +186,20 @@ default boolean isValueInRange(GenuineVariableMetaModel - * The returned range may include {@code null} - * if the variable {@link PlanningVariable#allowsUnassigned() allows unassigned} values; - * use {@link ValueRange#contains(Object)} to check {@code null} the same way as any other value. + * The returned range may include {@code null} if the variable {@link PlanningVariable#allowsUnassigned() allows unassigned} + * values; use {@link ValueRange#contains(Object)} to check {@code null} the same way as any other value. * - * @param variableMetaModel variable in question - * @param entity entity that the value would be applied to; - * must be of a type that the variable is defined on; - * may be null only if the value range is defined on {@link PlanningSolution} - * @param generic type of the entity that the variable is defined on - * @param generic type of the value that the variable can take - * @return the value range of the variable - * @throws IllegalArgumentException if {@code entity} is null - * and the value range is defined on the entity, not on {@link PlanningSolution} + * @param variableMetaModel variable in question. + * @param entity entity that the value would be applied to; must be of a type that the variable is defined on; may be null + * only if the value range is defined on {@link PlanningSolution}. + * @param generic type of the entity that the variable is defined on. + * @param generic type of the value that the variable can take. + * @return the value range of the variable. + * @throws IllegalArgumentException if {@code entity} is null and the value range is defined on the entity, not on + * {@link PlanningSolution}. */ ValueRange getValueRange(GenuineVariableMetaModel variableMetaModel, @Nullable Entity_ entity); 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 704bb6bea19..d6e62418244 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 @@ -1,24 +1,34 @@ package ai.timefold.solver.core.preview.api.move.builtin; +import java.util.Iterator; +import java.util.NoSuchElementException; import java.util.Objects; +import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.bavet.common.index.RetiringRandomIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.FilteringIterator; +import ai.timefold.solver.core.impl.neighborhood.stream.RetiringBiWalk; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.DefaultUniDatasetInstance; 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.move.Move; import ai.timefold.solver.core.preview.api.move.SolutionView; +import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; 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.preview.api.neighborhood.stream.enumerating.UniEnumeratingStream; -import ai.timefold.solver.core.preview.api.neighborhood.stream.joiner.NeighborhoodsJoiners; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; +import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDatasetInstance; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * For each assigned value that is not pinned, creates a move to reassign it to a different position in a list variable. *

    - * When {@code crossingNull} is {@code true} - * (the default when the variable {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}), + * When {@code crossingNull} is {@code true} (the default when the variable + * {@link PlanningListVariableMetaModel#allowsUnassignedValues() allows unassigned values}), * this provider also creates unassigned-to-list (assign) and list-to-unassigned (unassign) moves. *

    * This does not remove the need for {@code ListAssignMoveProvider} and {@code ListUnassignMoveProvider}: @@ -78,57 +88,110 @@ public MoveStream build(MoveStreamFactory moveStreamFactor // unlike forEachDestination, it represents the unassigned destination with a null entity internally, // which entity-provided value ranges cannot resolve - // avoid tripping that path when this provider has no use for it anyway. - UniEnumeratingStream destinations = crossingNull + var destinationDataset = (crossingNull ? moveStreamFactory.forEachDestinationIncludingUnassigned(variableMetaModel) - : moveStreamFactory.forEachDestination(variableMetaModel).map((solutionView, position) -> position); + : moveStreamFactory.forEachDestination(variableMetaModel) + .map((solutionView, position) -> (ElementPosition) position)) + .asCachedDataset(); // Unassigned values are admitted too when crossingNull, unconditionally otherwise excluded, // mirroring ChangeMoveProvider's own source-side widening. - var sources = crossingNull ? moveStreamFactory.forEach(variableMetaModel.type(), false) - : moveStreamFactory.forEachAssignedValue(variableMetaModel); - return moveStreamFactory.pick(destinations) - .pick(sources, - NeighborhoodsJoiners.filtering(this::isValidChange)) - .asMove((solutionView, targetPosition, value) -> { - var currentPosition = solutionView.getPositionOf(variableMetaModel, Objects.requireNonNull(value)); - if (currentPosition instanceof PositionInList currentAssigned) { - if (targetPosition instanceof PositionInList targetAssigned) { - return Moves.change(variableMetaModel, currentAssigned, targetAssigned); - } else { // Assigned value moving to the unassigned pool. - return Moves.unassign(variableMetaModel, currentAssigned); - } - } else { // Unassigned value moving into the list; isValidChange already excluded both-unassigned. - return Moves.assign(variableMetaModel, value, (PositionInList) targetPosition); - } - }); + var sourceDataset = (crossingNull ? moveStreamFactory.forEach(variableMetaModel.type(), false) + : moveStreamFactory.forEachAssignedValue(variableMetaModel)) + .asCachedDataset(); + // A source value paired with a destination searched by isValidChange, same as SubListChangeMoveProvider and MassListChangeMoveProvider - + // not a persisted destinations x sources join, which used to make this provider pay for a second full cross product on top of forEachDestination's own entities x values join. + return moveStreamFactory.buildMoveStream((session, random) -> new ListChangeMoveIterator<>(session, random, + variableMetaModel, sourceDataset, destinationDataset)); } - private boolean isValidChange(SolutionView solutionView, ElementPosition targetPosition, Value_ value) { - var currentPosition = solutionView.getPositionOf(variableMetaModel, value); - if (currentPosition.equals(targetPosition)) { // No change needed; also excludes both-unassigned. - return false; + @NullMarked + private static final class ListChangeMoveIterator + implements Iterator>, RetiringBiWalk { + + private final PlanningListVariableMetaModel variableMetaModel; + private final RandomGenerator random; + private final SolutionView solutionView; + private final RetiringRandomIterator sourceValueIterator; + private final UniDatasetInstance destinationInstance; + + private @Nullable Move nextMove = null; + + ListChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, + PlanningListVariableMetaModel variableMetaModel, + UniDataset sourceDataset, UniDataset destinationDataset) { + this.variableMetaModel = Objects.requireNonNull(variableMetaModel); + this.random = Objects.requireNonNull(random); + this.solutionView = session.getSolutionView(); + var sourceInstance = (DefaultUniDatasetInstance) session.getInstance(sourceDataset); + this.sourceValueIterator = sourceInstance.retiringRandomIterator(random); + this.destinationInstance = session.getInstance(destinationDataset); + } + + @Override + public boolean hasNext() { + return nextMove != null || RetiringBiWalk.advance(sourceValueIterator, this); + } + + @Override + public Move next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var move = Objects.requireNonNull(nextMove); + nextMove = null; + return move; + } + + @Override + public Iterator createRightIterator(Value_ sourceValue) { + var bailOutSize = destinationInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return new FilteringIterator<>(destinationInstance.iterator(random), + destination -> isValidChange(sourceValue, destination), bailOutSize); } - if (currentPosition instanceof PositionInList currentAssigned) { - if (targetPosition instanceof PositionInList targetAssigned) { - if (currentAssigned.entity() == targetAssigned.entity()) { // The value is already in the list. - var valueCount = solutionView.countValues(variableMetaModel, currentAssigned.entity()); - if (valueCount == 1) { // The value is the only value in the list; no change. - return false; - } else if (targetAssigned.index() == valueCount) { // Trying to move the value past the end of the list. - return false; - } else { // Same list, same position; ignore. - return currentAssigned.index() != targetAssigned.index(); + private boolean isValidChange(Value_ value, ElementPosition targetPosition) { + var currentPosition = solutionView.getPositionOf(variableMetaModel, value); + if (currentPosition.equals(targetPosition)) { // No change needed; also excludes both-unassigned. + return false; + } + + if (currentPosition instanceof PositionInList currentAssigned) { + if (targetPosition instanceof PositionInList targetAssigned) { + if (currentAssigned.entity() == targetAssigned.entity()) { // The value is already in the list. + var valueCount = solutionView.countValues(variableMetaModel, currentAssigned.entity()); + if (valueCount == 1) { // The value is the only value in the list; no change. + return false; + } else if (targetAssigned.index() == valueCount) { // Trying to move the value past the end of the list. + return false; + } else { // Same list, same position; ignore. + return currentAssigned.index() != targetAssigned.index(); + } } + // We can move freely between entities, assuming the target entity accepts the value. + return solutionView.isValueInRange(variableMetaModel, targetAssigned.entity(), value); + } else { // Unassigning an assigned value never violates a value range. + return true; } - // We can move freely between entities, assuming the target entity accepts the value. + } else { // Assigning a currently unassigned value; targetPosition is a PositionInList (checked above). + var targetAssigned = (PositionInList) targetPosition; return solutionView.isValueInRange(variableMetaModel, targetAssigned.entity(), value); - } else { // Unassigning an assigned value never violates a value range. - return true; } - } else { // Assigning a currently unassigned value; targetPosition is a PositionInList (checked above). - var targetAssigned = (PositionInList) targetPosition; - return solutionView.isValueInRange(variableMetaModel, targetAssigned.entity(), value); } + + @Override + public void accept(Value_ sourceValue, ElementPosition targetPosition) { + var currentPosition = solutionView.getPositionOf(variableMetaModel, sourceValue); + if (currentPosition instanceof PositionInList currentAssigned) { + if (targetPosition instanceof PositionInList targetAssigned) { + nextMove = Moves.change(variableMetaModel, currentAssigned, targetAssigned); + } else { // Assigned value moving to the unassigned pool. + nextMove = Moves.unassign(variableMetaModel, currentAssigned); + } + } else { // Unassigned value moving into the list; isValidChange already excluded both-unassigned. + nextMove = Moves.assign(variableMetaModel, sourceValue, (PositionInList) targetPosition); + } + } + } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java index 0d71e92b10e..a8c05756694 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java @@ -65,7 +65,7 @@ public MassChangeMove rebase(Lookup lookup) { @Override public SequencedCollection getPlanningEntities() { - var entityList = new ArrayList(sample.size()); + var entityList = new ArrayList<>(sample.size()); for (var entity : sample) { entityList.add(entity); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java index d894cefb44e..6b8c42ed8c8 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -20,11 +20,9 @@ import org.jspecify.annotations.Nullable; /** - * Gathers every member of a {@link Sample} of a {@link PlanningListVariable list variable} - - * wherever each one currently is, assigned or not - - * and inserts them consecutively, - * in sample iteration order, - * at one destination position. + * Gathers every member of a {@link Sample} of a {@link PlanningListVariable list variable} - wherever each one currently is, + * assigned or not + * and inserts them consecutively, in sample iteration order, at one destination position. * A {@code null} destination unassigns every member instead of inserting them anywhere. *

    * This is the list-variable equivalent of {@code MassChangeMove}: @@ -65,37 +63,7 @@ public List> variableM @Override public void execute(MutableSolutionView solutionView) { - // Two passes, deliberately: pass 1 reads every member's ORIGINAL position before anything is removed. - // Reading positions inside a single mutate-as-you-go loop is WRONG - - // a member sitting at or after destination.index() can have its live index shift below destination.index() - // once earlier same-entity members are removed, - // double-counting it as "before" and corrupting the adjusted index. - // Worked counterexample: - // entity [a,b,c,d,e], destination index 2 (before c), sample {a,b,c} (c sits AT the destination, not before it) - - // a single-pass implementation gives removedBeforeDestination=3 and an adjusted index of -1. - var valueList = new ArrayList(sample.size()); - var removedBeforeDestination = 0; - for (var member : sample) { - var value = Objects.requireNonNull(member); - valueList.add(value); - if (destination != null - && solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList assigned) { - Entity_ sourceEntity = assigned.entity(); - Entity_ destinationEntity = destination.entity(); - if (sourceEntity == destinationEntity && assigned.index() < destination.index()) { - removedBeforeDestination++; - } - } - } - for (var value : valueList) { - if (solutionView.getPositionOf(variableMetaModel, value) instanceof PositionInList) { - solutionView.unassignValue(variableMetaModel, value); - } - } - if (destination != null) { - solutionView.assignValuesAndAdd(variableMetaModel, valueList, destination.entity(), - destination.index() - removedBeforeDestination); - } + solutionView.massMoveValues(variableMetaModel, sample, destination); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java index 2106831de63..69afebeac00 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MoveProviderUtil.java @@ -100,8 +100,15 @@ public static List compositeKeyOf(Entity_ entity, /** * One cached row per distinct assigned value, * each row a whole {@link Sample} of that value's members. - * Built once per settle per changed group, never re-assembled per draw - - * the drawing move providers read a row directly instead of running {@code SampleAssembler} over an index every time. + * Built once per settle per changed group, + * never re-assembled per draw - the drawing move providers read a row directly instead of running {@code SampleAssembler} + * over an index every time. + *

    + * TODO: this (and {@link #assignedEntities}) captures {@code variableMetaModel} in each lambda inline, + * so it never hits the framework's cross-provider node-sharing cache the way + * {@code ChangeMoveProvider}/{@code AssignMoveProvider} do via their cached {@code NodeSharingSupportFunctions}. + * Two providers grouping the same variable (e.g. {@code PillarChangeMoveProvider} and {@code PillarUnassignMoveProvider}) + * each build their own independent pipeline today. */ public static UniDataset> assignedPillars( MoveStreamFactory moveStreamFactory, @@ -201,11 +208,27 @@ public static boolean anyAssigned(Sample s } /** - * Whether swapping {@code leftPillar} and {@code rightPillar} is legal - * across every variable in {@code variableMetaModelList}, - * checked against each pillar's own {@link SampleValueRanges} - * ({@code leftRangesPerVariable} for the left side, computed fresh for the right side) + * One {@link SampleValueRanges} per variable in {@code variableMetaModelList}, in that order. + */ + public static List> rangesPerVariableOf(Sample pillar, + List> variableMetaModelList, + SolutionView solutionView) { + var perVariableList = new ArrayList>(variableMetaModelList.size()); + for (var variableMetaModel : variableMetaModelList) { + perVariableList.add(SampleValueRanges.of(pillar, variableMetaModel, solutionView)); + } + return perVariableList; + } + + /** + * Whether swapping {@code leftPillar} and {@code rightPillar} is legal across every variable in + * {@code variableMetaModelList}, + * checked against each pillar's own {@link SampleValueRanges} ({@code leftRangesPerVariable}, + * {@code rightRangesPerVariable}) * rather than an {@code isValueInRange} call per member. + * Computing those ranges is the caller's job, not this method's: + * whether a range is worth caching across calls depends on whether its pillar is stable across those calls, + * which only the caller knows. * * @return {@code true} if the swap changes at least one variable, * and every changed variable is legal on both sides; @@ -217,22 +240,20 @@ public static boolean anyAssigned(Sample s */ public static boolean isValidSwap(SolutionView solutionView, List> variableMetaModelList, - Sample leftPillar, List> leftRangesPerVariable, Sample rightPillar) { + Sample leftPillar, List> leftRangesPerVariable, Sample rightPillar, + List> rightRangesPerVariable) { var change = false; + var leftPillarRepresentative = Objects.requireNonNull(leftPillar.representative()); + var rightPillarRepresentative = Objects.requireNonNull(rightPillar.representative()); for (var i = 0; i < variableMetaModelList.size(); i++) { var variableMetaModel = variableMetaModelList.get(i); - var oldLeftValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(leftPillar.representative())); - var oldRightValue = - solutionView.getValue(variableMetaModel, Objects.requireNonNull(rightPillar.representative())); + var oldLeftValue = solutionView.getValue(variableMetaModel, leftPillarRepresentative); + var oldRightValue = solutionView.getValue(variableMetaModel, rightPillarRepresentative); if (Objects.equals(oldLeftValue, oldRightValue)) { continue; } - // Unlike leftRangesPerVariable, the right side is rebuilt from scratch on every call: - // the left pillar is stable for a whole settle-to-settle window and worth caching across candidates, - // but each rightPillar here is a fresh draw, - // so a cache on it would almost never hit and would cost more than it saves. - var rightRanges = SampleValueRanges.of(rightPillar, variableMetaModel, solutionView); - if (leftRangesPerVariable.get(i).containsInEvery(oldRightValue) && rightRanges.containsInEvery(oldLeftValue)) { + if (leftRangesPerVariable.get(i).containsInEvery(oldRightValue) + && rightRangesPerVariable.get(i).containsInEvery(oldLeftValue)) { change = true; } else { // One of the swaps falls out of range, skip this pair altogether. @@ -243,8 +264,8 @@ public static boolean isValidSwap(SolutionView s } /** - * The values {@code leftEntity} and {@code rightEntity} would take - * after swapping every variable in {@code variableMetaModelList}, + * The values {@code leftEntity} and {@code rightEntity} would take after swapping every variable in + * {@code variableMetaModelList}, * one pair per variable in that order: * for variables v1 and v2, the result is [left.v1, right.v1, left.v2, right.v2]. */ @@ -277,12 +298,19 @@ public static void appendInterleavedRow(StringBuilder s, List<@Nullable Object> } } - private static UniEnumeratingStream assignedEntities( + /** + * Package-visible so {@link PillarChangeMoveProvider} can build its own pipeline on top of it (a settle-cached row bundling + * a pillar with its precomputed ranges), + * instead of the plain-{@link Sample} row {@link #assignedPillars} returns for {@code PillarUnassignMoveProvider}, + * which never needs ranges and shouldn't pay to compute them. + */ + static UniEnumeratingStream assignedEntities( MoveStreamFactory moveStreamFactory, PlanningVariableMetaModel variableMetaModel) { var entityStream = moveStreamFactory.forEach(variableMetaModel.entity().type(), false); // Filtering to assigned entities keeps null out of the group key: - // a pillar is keyed on a shared value, and unassigned is the absence of a value, not one. + // a pillar is keyed on a shared value, + // and unassigned is the absence of a value, not one. // This is why PillarChangeMoveProvider/SubPillarChangeMoveProvider can only ever cross null downward (unassign), // regardless of their own crossingNull flag. return entityStream.filter((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity) != null); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java index 1eb3f5a2dbb..78b51351126 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProvider.java @@ -2,7 +2,6 @@ import java.util.Collections; import java.util.Iterator; -import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; import java.util.random.RandomGenerator; @@ -20,29 +19,27 @@ import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; +import ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating.collector.NeighborhoodsCollectors; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; /** - * For each pillar of entities sharing a non-null value of the given variable, - * creates a move to change every member's value to a different value that is legal for every member. - * The pillar is keyed on this one variable alone; - * members may differ in every other variable. + * For each pillar of entities sharing a non-null value of the given variable, creates a move to change every member's + * value to a different value that is legal for every member. + * The pillar is keyed on this one variable alone; members may differ in every other variable. * The pillar's own current value is never offered as a destination. *

    * When {@code crossingNull} is {@code true} * (the default whenever the variable {@link PlanningVariableMetaModel#allowsUnassigned() allows unassigned values}), - * this provider also creates a move that unassigns the whole pillar - - * probability {@code 1/(s+1)} per drawn pillar, where {@code s} is the size of the pillar members' value range. - * This provider never assigns: - * a pillar's key is a value shared by its members, and unassigned is the absence of a value, not one. + * this provider also creates a move that unassigns the whole pillar - probability {@code 1/(s+1)} per drawn pillar, + * where {@code s} is the size of the pillar members' value range. + * This provider never assigns: a pillar's key is a value shared by its members, and unassigned is the absence of a + * value, not one. * Use {@code PillarUnassignMoveProvider} for unassign moves at a much higher rate; - * there is no pillar equivalent for assign, - * since it would require drawing a pillar keyed on "unassigned". + * there is no pillar equivalent for assign, since it would require drawing a pillar keyed on "unassigned". *

    - * Draws whole pillars only, unbounded by design: - * a pillar move is defined as moving every member of the pillar, + * Draws whole pillars only, unbounded by design: a pillar move is defined as moving every member of the pillar, * so its cost is linear in the pillar's size with no cap. * * @see PillarUnassignMoveProvider Unassigning the whole pillar at a much higher rate. @@ -66,8 +63,8 @@ public PillarChangeMoveProvider(PlanningVariableMetaModel variableMetaModel, @@ -85,20 +82,33 @@ The crossingNull (true) of variableMetaModel (%s) requires a variable \ @Override public MoveStream build(MoveStreamFactory moveStreamFactory) { - var pillarDataset = MoveProviderUtil.assignedPillars(moveStreamFactory, variableMetaModel); + var pillarDataset = MoveProviderUtil.assignedEntities(moveStreamFactory, variableMetaModel) + .groupBy((solutionView, entity) -> solutionView.getValue(variableMetaModel, entity), + NeighborhoodsCollectors.collectAndThen( + NeighborhoodsCollectors. toList(), Sample::of)) + .map((solutionView, value, pillar) -> new PillarWithRange<>(pillar, + SampleValueRanges.of(pillar, variableMetaModel, solutionView))) + .asCachedDataset(); return moveStreamFactory.buildMoveStream((session, random) -> new PillarChangeMoveIterator<>(session, random, variableMetaModel, pillarDataset, crossingNull)); } + /** + * A pillar bundled with its {@link SampleValueRanges}, computed once when the {@code .map(...)} step settles for a + * dirty group (see {@link #build}), not once per redraw across local search steps. + */ + private record PillarWithRange(Sample pillar, SampleValueRanges ranges) { + } + /** * Draws whole pillars (one cached row per assigned value) and pairs each with a destination value, * producing a {@code MassChangeMove}. * Left = pillar, right = destination value. *

    - * The destination is drawn from the pillar members' own {@link ValueRange}s ({@link SampleValueRanges#findDestination}) - * rather than from a global candidate pool: - * every candidate offered is already legal for every member and different from the pillar's own value, - * so no {@code isValueInRange} filtering or bail-out sampling is needed. + * The destination is drawn from the pillar members' own {@link ValueRange}s + * ({@link SampleValueRanges#findDestination}) rather than from a global candidate pool: every candidate offered is + * already legal for every member and different from the pillar's own value, so no {@code isValueInRange} filtering + * or bail-out sampling is needed. * * @param the solution type * @param the entity type @@ -106,40 +116,39 @@ public MoveStream build(MoveStreamFactory moveStreamFactor */ @NullMarked private static final class PillarChangeMoveIterator - implements Iterator>, RetiringBiWalk, Value_> { + implements Iterator>, RetiringBiWalk, Value_> { private final PlanningVariableMetaModel variableMetaModel; private final boolean crossingNull; private final RandomGenerator random; private final SolutionView solutionView; - private final RetiringRandomIterator> sampleIterator; + private final RetiringRandomIterator> sampleIterator; private @Nullable Move nextMove = null; private @Nullable Sample cachedPillar = null; /** - * Computed once per {@link #cachedPillar}, not once per probe: - * {@code createRightIterator} can be called up to {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} times for the same left - * pillar - - * an equal pillar every time, since a pillar row is stable for the whole settle-to-settle window - - * and the ranges (and, once found, the proven-empty verdict below) do not change between those calls. + * Scoped to one step's up-to-3 {@link RetiringBiWalk#PROBE_ATTEMPT_COUNT} probes of the same left pillar, + * reset whenever {@link #cachedPillar} changes - unlike the ranges themselves + * (now precomputed on {@link PillarWithRange}, settle-cached, and shared across every step until the pillar's + * group changes), this latch is cheap to recompute, so caching it across steps isn't worth the extra bookkeeping. + * Compared by {@code .pillar()}, not whole-record equality, since a record's auto-generated equals would also + * compare {@code ranges} for no reason. *

    - * The {@link #cachedPillarProvenEmpty} latch is also safe without the slice value in its key, - * unlike {@code MassDestinationMoveIterator}'s equivalent memo: - * {@link MoveProviderUtil#assignedPillars} groups entities by their assigned value, - * so a pillar's members determine its slice value, - * and an equal pillar therefore has an equal slice value too. + * Also safe without the slice value in its key, unlike {@code MassDestinationMoveIterator}'s equivalent memo: + * {@link MoveProviderUtil#assignedEntities} groups entities by their assigned value, + * so a pillar's members determine its slice value, and an equal pillar therefore has an equal slice value too. */ - private @Nullable SampleValueRanges cachedRanges = null; private boolean cachedPillarProvenEmpty = false; public PillarChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, PlanningVariableMetaModel variableMetaModel, - UniDataset> pillarDataset, boolean crossingNull) { + UniDataset> pillarDataset, boolean crossingNull) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.crossingNull = crossingNull; this.random = Objects.requireNonNull(random); this.solutionView = session.getSolutionView(); - var pillarInstance = (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); + var pillarInstance = + (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); this.sampleIterator = pillarInstance.retiringRandomIterator(random); } @@ -159,22 +168,25 @@ public Move next() { } @Override - public Iterator createRightIterator(Sample pillar) { + public Iterator createRightIterator(PillarWithRange pillarEntry) { + var pillar = pillarEntry.pillar(); if (pillar.size() < 2) { // Size-1 pillars are excluded from change: ChangeMoveProvider already covers them. + return Collections.emptyIterator(); } if (!Objects.equals(cachedPillar, pillar)) { cachedPillar = pillar; - cachedRanges = SampleValueRanges.of(pillar, variableMetaModel, solutionView); cachedPillarProvenEmpty = false; } - var ranges = Objects.requireNonNull(cachedRanges); + var ranges = pillarEntry.ranges(); // A pillar whose non-null intersection is empty still has a legal null destination (unassign), // so the null branch is tried first and also whenever the latch below is set. + if (crossingNull && (cachedPillarProvenEmpty || ranges.rollNull(random))) { // List.of(null) throws; Collections.singletonList allows a null element. - return Collections.singletonList((Value_) null).iterator(); + + return new SingletonIterator<>(null); } if (cachedPillarProvenEmpty) { // Already proven empty for this pillar; no need to search again. @@ -182,18 +194,45 @@ public Iterator createRightIterator(Sample pillar) { } // The pillar is homogeneous by construction (one cached row per assigned value); // recover the slice value from any one member to exclude it as a no-op destination. + var sliceValue = solutionView.getValue(variableMetaModel, Objects.requireNonNull(pillar.representative())); var destination = ranges.findDestination(random, sliceValue); if (destination == null) { cachedPillarProvenEmpty = true; return Collections.emptyIterator(); } - return List.of(destination).iterator(); + return new SingletonIterator<>(destination); + } + + @Override + public void accept(PillarWithRange pillarEntry, Value_ destination) { + nextMove = Moves.massChange(variableMetaModel, pillarEntry.pillar(), destination); + } + + } + + private static final class SingletonIterator + implements Iterator { + + private final A singleton; + private boolean returned = false; + + public SingletonIterator(A singleton) { + this.singleton = singleton; + } + + @Override + public boolean hasNext() { + return !returned; } @Override - public void accept(Sample pillar, Value_ destination) { - nextMove = Moves.massChange(variableMetaModel, pillar, destination); + public A next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + returned = true; + return singleton; } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java index fe1b56aaa5e..c9edc03d285 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; @@ -29,19 +28,16 @@ import org.jspecify.annotations.Nullable; /** - * Draws two pillars, - * each a set of entities sharing the same combination of values across every variable given to the constructor, - * and swaps that combination between the two pillars, + * Draws two pillars, each a set of entities sharing the same combination of values across every variable given to the + * constructor, and swaps that combination between the two pillars, * provided at least one variable differs and every differing variable is legal on both sides; * if any differing variable is out of range, the pair is skipped entirely. - * A pillar's composite key is a list with one value per variable, in {@link PlanningEntityMetaModel#variables()} declaration - * order, - * regardless of the order the constructor was given. - * Size-1 pillars are legal on both sides of the swap, - * since we need to be able to swap 1-sized pillar with an n-sized pillar. + * A pillar's composite key is a list with one value per variable, in {@link PlanningEntityMetaModel#variables()} + * declaration order, regardless of the order the constructor was given. + * Size-1 pillars are legal on both sides of the swap, since we need to be able to swap 1-sized pillar with an n-sized + * pillar. *

    - * Draws whole pillars only, unbounded by design: - * a pillar move is defined as moving every member of the pillar, + * Draws whole pillars only, unbounded by design: a pillar move is defined as moving every member of the pillar, * so its cost is linear in the pillar's size with no cap. * * @see SubPillarSwapMoveProvider A sampler-driven, size-bounded subset of the pillar. @@ -91,22 +87,30 @@ public MoveStream build(MoveStreamFactory moveStreamFactor .groupBy((solutionView, entity) -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList), NeighborhoodsCollectors.collectAndThen( NeighborhoodsCollectors. toList(), Sample::of)) - .map((solutionView, key, pillar) -> pillar) + .map((solutionView, key, pillar) -> new PillarWithRanges<>(pillar, + MoveProviderUtil.rangesPerVariableOf(pillar, variableMetaModelList, solutionView))) .asCachedDataset(); return moveStreamFactory.buildMoveStream((session, random) -> new PillarSwapMoveIterator<>(session, random, variableMetaModelList, pillarDataset)); } + /** + * A pillar bundled with its per-variable {@link SampleValueRanges}, computed once when the {@code .map(...)} step + * settles for a dirty group (see {@link #build}), not once per candidate probe - the row is stable, and reused, + * for as long as the pillar's group is unchanged, which can span many local search steps, unlike a cache field + * owned by a move iterator that gets rebuilt every step. + */ + private record PillarWithRanges(Sample pillar, List> rangesPerVariable) { + } + /** * Draws two independently sampled whole pillars and swaps every listed variable's value between them, * producing a {@code PillarSwapMove}. - * Left and right are both pillars, each independently drawn from the same cached dataset; - * the two pillars must differ. + * Left and right are both pillars, each independently drawn from the same cached dataset; the two pillars must differ. *

    * Legality is checked against the pillars' own {@link ValueRange}s ({@link SampleValueRanges#of}) - * rather than by an {@code isValueInRange} call per member: - * the candidate partner pillar still has to be searched for - * (its "value" is the other side's current value, not something drawn from a range), + * rather than by an {@code isValueInRange} call per member: the candidate partner pillar still has to be searched + * for (its "value" is the other side's current value, not something drawn from a range), * therefore the {@link FilteringIterator} search. * * @param the solution type @@ -114,29 +118,24 @@ NeighborhoodsCollectors. toList(), Sample::of)) */ @NullMarked private static final class PillarSwapMoveIterator - implements Iterator>, RetiringBiWalk, Sample> { + implements Iterator>, RetiringBiWalk, PillarWithRanges> { private final List> variableMetaModelList; private final RandomGenerator random; private final SolutionView solutionView; - private final RetiringRandomIterator> leftPillarIterator; - private final DefaultUniDatasetInstance> pillarInstance; + private final RetiringRandomIterator> leftPillarIterator; + private final DefaultUniDatasetInstance> pillarInstance; private @Nullable Move nextMove = null; - private @Nullable Sample cachedLeftPillar = null; - /** - * Computed once per {@link #cachedLeftPillar}, - * one entry per variable in {@link #variableMetaModelList}. - */ - private @Nullable List> cachedLeftRangesPerVariable = null; public PillarSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, List> variableMetaModelList, - UniDataset> pillarDataset) { + UniDataset> pillarDataset) { this.variableMetaModelList = Objects.requireNonNull(variableMetaModelList); this.random = Objects.requireNonNull(random); this.solutionView = session.getSolutionView(); - this.pillarInstance = (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); + this.pillarInstance = + (DefaultUniDatasetInstance>) session.getInstance(pillarDataset); this.leftPillarIterator = pillarInstance.retiringRandomIterator(random); } @@ -156,31 +155,23 @@ public Move next() { } @Override - public Iterator> createRightIterator(Sample leftPillar) { - if (!Objects.equals(cachedLeftPillar, leftPillar)) { - cachedLeftPillar = leftPillar; - var perVariableList = new ArrayList>(variableMetaModelList.size()); - for (var variableMetaModel : variableMetaModelList) { - perVariableList.add(SampleValueRanges.of(leftPillar, variableMetaModel, solutionView)); - } - cachedLeftRangesPerVariable = perVariableList; - } - var leftRangesPerVariable = Objects.requireNonNull(cachedLeftRangesPerVariable); + public Iterator> createRightIterator(PillarWithRanges leftEntry) { // Scaled to the candidate pool's size. - // Each candidate is read directly off a cached row - // so only isValidSwap costs anything per candidate. + // Each candidate is read directly off a cached row, ranges included, + // so isValidSwap is the only cost per candidate - nothing here recomputes anything. + var candidateIterator = pillarInstance.iterator(random); var bailOutSize = pillarInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; return new FilteringIterator<>(candidateIterator, - candidate -> !candidate.equals(leftPillar) && - MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftPillar, leftRangesPerVariable, - candidate), + candidate -> !candidate.pillar().equals(leftEntry.pillar()) && + MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftEntry.pillar(), + leftEntry.rangesPerVariable(), candidate.pillar(), candidate.rangesPerVariable()), bailOutSize); } @Override - public void accept(Sample leftPillar, Sample rightPillar) { - nextMove = Moves.pillarSwap(variableMetaModelList, leftPillar, rightPillar); + public void accept(PillarWithRanges leftEntry, PillarWithRanges rightEntry) { + nextMove = Moves.pillarSwap(variableMetaModelList, leftEntry.pillar(), rightEntry.pillar()); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java index 32036fc6ea0..e825edcd1de 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRanges.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.LinkedHashSet; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.random.RandomGenerator; @@ -18,17 +19,16 @@ import org.jspecify.annotations.Nullable; /** - * The distinct {@link ValueRange}s of one {@link Sample}'s members, for one variable: - * everything a sample-drawing move iterator needs to find a destination legal for every member, - * without ever building a global candidate pool or calling {@code isValueInRange} once per member. + * The distinct {@link ValueRange}s of one {@link Sample}'s members, for one variable: everything a sample-drawing move + * iterator needs to find a destination legal for every member, without ever building a global candidate pool or calling + * {@code isValueInRange} once per member. *

    * Every {@link ValueRange} implementation has a content-based {@code equals}/{@code hashCode}, * so the distinct set - and the smallest range within it - are both found in one O(sample size) pass; - * usually the set holds a single, shared range instance, - * since {@code ValueRangeState} already deduplicates equal ranges to one cached instance. + * usually the set holds a single, shared range instance, since {@code ValueRangeState} already deduplicates equal ranges + * to one cached instance. *

    - * Two instances are {@link #equals equal} - * when they hold the same distinct ranges, regardless of order - + * Two instances are {@link #equals equal} when they hold the same distinct ranges, regardless of order - * used to remember a proven-empty verdict across redraws of the same sample under a deterministic {@link Sampler}. * * @param the variable's value type @@ -39,26 +39,46 @@ record SampleValueRanges(Set> distinctRangeSet, Value private static final long MAX_SAFE_RANGE_SIZE = Long.MAX_VALUE / FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; /** - * @return the sample members' distinct {@link ValueRange}s for {@code variableMetaModel}, - * with any {@link NullAllowingValueRange} wrapper removed - * so {@code null} is never a candidate destination out of {@link #findDestination}/ {@link #pickExactly} - - * it would collide with their "not found" signal. + * @return the sample members' distinct {@link ValueRange}s for {@code variableMetaModel}. + * Ranges may still be {@link NullAllowingValueRange}-wrapped; {@code null} is never a candidate destination + * out of {@link #findDestination}/{@link #pickExactly} regardless, + * since {@link #containsInEvery} rejects it directly - it would otherwise collide with their "not found" signal. * A caller that wants a null destination decides on it separately with {@link #rollNull}, * before calling either method. */ public static SampleValueRanges of(Sample sample, GenuineVariableMetaModel variableMetaModel, SolutionView solutionView) { - var distinctRangeSet = new LinkedHashSet>(); + if (variableMetaModel.isValueRangeOnSolution()) { + // Every member provably shares one range instance (the range doesn't depend on the entity + // argument at all), so one lookup answers for the whole sample - no dedup, no unwrap needed. + var range = solutionView.getValueRange(variableMetaModel, null); + return new SampleValueRanges<>(Set.of(range), range); + } + // Tracks distinct ranges without a hash table until a genuinely second one shows up: ValueRangeState already + // deduplicates equal ranges to one shared instance, + // so the common case is exactly one distinct range, and a HashSet's backing table would be pure overhead for that. + ValueRange firstRange = null; + List> distinctRangeList = null; for (var entity : sample) { - distinctRangeSet.add(unwrap(solutionView.getValueRange(variableMetaModel, entity))); + var range = solutionView.getValueRange(variableMetaModel, entity); + if (firstRange == null) { + firstRange = range; + } else if (distinctRangeList == null) { + if (!range.equals(firstRange)) { + distinctRangeList = new ArrayList<>(4); + distinctRangeList.add(firstRange); + distinctRangeList.add(range); + } + } else if (!distinctRangeList.contains(range)) { + distinctRangeList.add(range); + } } - return of(distinctRangeSet); + return of(distinctRangeList == null ? Set.of(firstRange) : Set.copyOf(distinctRangeList)); } /** - * @return an instance over an already-known set of distinct ranges; - * exposed mainly so the reservoir-sampling fallback ({@link #pickExactly}) can be bias-tested directly against - * plain {@link ValueRange} fixtures, + * @return an instance over an already-known set of distinct ranges; exposed mainly so the reservoir-sampling fallback + * ({@link #pickExactly}) can be bias-tested directly against plain {@link ValueRange} fixtures, * without needing a {@link Sample} or a solution. */ public static SampleValueRanges of(Set> distinctRangeSet) { @@ -71,28 +91,29 @@ public static SampleValueRanges of(Set> dist return new SampleValueRanges<>(distinctRangeSet, Objects.requireNonNull(smallestRange)); } - @SuppressWarnings("unchecked") - private static ValueRange unwrap(ValueRange range) { - if (range instanceof NullAllowingValueRange nullAllowingValueRange) { - return (ValueRange) nullAllowingValueRange.getChildValueRange(); - } - return range; + /** + * @return {@code range}'s size, excluding the {@code null} pseudo-value a {@link NullAllowingValueRange} wrapper + * would otherwise add to it - {@code null} is decided separately by {@link #rollNull}, + * never counted as one of the range's own values. + */ + private static long unwrappedSize(ValueRange range) { + return range instanceof NullAllowingValueRange nullAllowing + ? nullAllowing.getChildValueRange().getSize() + : range.getSize(); } static long bailOutSizeOf(ValueRange range) { - // Clamped since a range's {@link ValueRange#getSize()} can be large enough - // that multiplying it here would overflow a {@code long} negative - - // which {@link FilteringIterator} reads as "bail-out disabled", + // Clamped since a range's {@link ValueRange#getSize()} can be large enough that multiplying it here would + // overflow a {@code long} negative - which {@link FilteringIterator} reads as "bail-out disabled", // turning {@code hasNext()} into an infinite loop. - return Math.min(range.getSize(), MAX_SAFE_RANGE_SIZE) * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; + return Math.min(unwrappedSize(range), MAX_SAFE_RANGE_SIZE) * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; } /** * Finds one value legal for every distinct range and not equal to {@code excludedValue} * (pass {@code null} when there is nothing to exclude). *

    - * Samples {@link #smallestRange} first - - * for the common single-range case this is the whole cost, + * Samples {@link #smallestRange} first - for the common single-range case this is the whole cost, * since every candidate it offers is already legal for every member and the first one is always accepted. * Only when sampling bails out does this fall back to {@link #pickExactly}, * which proves the answer exactly instead of guessing again. @@ -112,14 +133,12 @@ static long bailOutSizeOf(ValueRange range) { /** * The exhaustive fallback: proves whether a destination legal for every distinct range exists, * without ever materializing the intersection. - * One pass over the smallest range, - * reservoir-sampling a single uniform pick among the values admitted by every distinct range - * and not equal to {@code excludedValue} - + * One pass over the smallest range, reservoir-sampling a single uniform pick among the values admitted by every + * distinct range and not equal to {@code excludedValue} - * so the result is either a uniform draw from the true intersection, or a proof that no legal destination exists. * * @return {@code null} if no member of the smallest range is admitted by every distinct range - * (after excluding {@code excludedValue}), - * meaning the intersection is empty + * (after excluding {@code excludedValue}), meaning the intersection is empty */ public @Nullable Value_ pickExactly(RandomGenerator random, @Nullable Value_ excludedValue) { Value_ chosen = null; @@ -140,23 +159,26 @@ static long bailOutSizeOf(ValueRange range) { /** * @return {@code true} with probability {@code 1/(size+1)}, - * where {@code size} is {@link #smallestRange}'s clamped size - - * the same probability a {@link NullAllowingValueRange} wrapper on {@link #smallestRange} would have given - * {@code null} as a candidate, + * where {@code size} is {@link #smallestRange}'s clamped size - the same probability a + * {@link NullAllowingValueRange} wrapper on {@link #smallestRange} would have given {@code null} as a candidate, * without ever handing {@code null} to {@link #findDestination}/{@link #pickExactly}, * where it would collide with their "not found" signal. * Null is legal for every member whenever the variable allows unassigned values, * so no range intersection is needed here - only the coin flip. */ public boolean rollNull(RandomGenerator random) { - var size = Math.min(smallestRange.getSize(), MAX_SAFE_RANGE_SIZE); + var size = Math.min(unwrappedSize(smallestRange), MAX_SAFE_RANGE_SIZE); return RandomUtils.nextLong(random, size + 1L) == 0L; } /** - * @return true if {@code value} is legal for every distinct range in this instance + * @return true if {@code value} is legal for every distinct range in this instance; {@code false} for {@code null}, + * decided separately by {@link #rollNull} */ public boolean containsInEvery(@Nullable Value_ value) { + if (value == null) { + return false; + } for (var range : distinctRangeSet) { if (!range.contains(value)) { return false; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 12c055bdeea..2c99c7c2a59 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -1,6 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -42,17 +41,14 @@ * and swaps that combination between the two subpillars, * provided at least one variable differs and every differing variable is legal on both sides; * if any differing variable is out of range, the pair is skipped entirely. - * A pillar's composite key is a list with one value per variable, - * in {@link GenuineEntityMetaModel#variables()} declaration order, - * regardless of the order the constructor was given. + * A pillar's composite key is a list with one value per variable, in {@link GenuineEntityMetaModel#variables()} + * declaration order, regardless of the order the constructor was given. *

    * For the whole pillar on both sides at once, see {@code PillarSwapMoveProvider}. *

    - * There is no single-sampler overload: - * the two sides may want different policies (a tighter cap on one side, say), + * There is no single-sampler overload: the two sides may want different policies (a tighter cap on one side, say), * and passing two parameters says so explicitly. - * Sharing one instance between both sides is safe - - * see {@link PillarSampler}'s class documentation for why - + * Sharing one instance between both sides is safe - see {@link PillarSampler}'s class documentation for why - * but is rarely what a caller wants. * * @see PillarSwapMoveProvider The whole pillar on both sides at once. @@ -107,15 +103,15 @@ public SubPillarSwapMoveProvider(List build(MoveStreamFactory moveStreamFactory) { var entityStream = moveStreamFactory.forEach(entityMetaModel.type(), false); - // groupBy yields one element per group, - // which is exactly the set of distinct composite keys; + // groupBy yields one element per group, which is exactly the set of distinct composite keys; // no joiner can express "the distinct set of keys", so groupBy is required here. var distinctKeys = entityStream .groupBy((solutionView, entity) -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList)) .asCachedDataset(); - // A real equal-join: - // the joiner's plain Function reads every variable directly, bypassing SolutionView, + // A real equal-join: the joiner's plain Function reads every variable directly, bypassing SolutionView, // so UniDataset.join(...) resolves to an indexed JustInTimeBiDataset lookup. + // TODO possible performance improvement: list whole pillars, caching their ranges as PillarSwap does, + // and then subsample them, carrying that information over. var pillarSourceDataset = distinctKeys.join(entityStream, NeighborhoodsJoiners.equal(Function.identity(), entity -> MoveProviderUtil.compositeKeyOf(entity, variableMetaModelList))); @@ -124,26 +120,21 @@ public MoveStream build(MoveStreamFactory moveStreamFactor } /** - * Draws two independently sampled pillar-slice keys - * and swaps every listed variable's value between a subpillar drawn from each, - * producing a {@code PillarSwapMove}. + * Draws two independently sampled pillar-slice keys and swaps every listed variable's value between a subpillar drawn + * from each, producing a {@code PillarSwapMove}. * Left and right are both composite keys, each independently drawn from the same {@code distinctKeys} dataset; * the two keys must differ. *

    - * Unlike the whole-pillar variant, - * a fresh left subpillar is assembled on every {@link #createRightIterator} call, - * never cached across probes: - * a real {@link PillarSampler} can legitimately draw a different subpillar for the same left key on each attempt - * (and a different subpillar can have different legal ranges, since fewer members mean fewer constraints), - * so caching the first one would turn {@link RetiringBiWalk}'s remaining probes into deterministic no-ops for the left - * side. - * The right side is drawn fresh per candidate probed, - * same as the whole-pillar variant. + * Unlike the whole-pillar variant, a fresh left subpillar is assembled on every + * {@link #createRightIterator} call, never cached across probes: a real {@link PillarSampler} can legitimately draw + * a different subpillar for the same left key on each attempt (and a different subpillar can have different legal + * ranges, since fewer members mean fewer constraints), so caching the first one would turn + * {@link RetiringBiWalk}'s remaining probes into deterministic no-ops for the left side. + * The right side is drawn fresh per candidate probed, same as the whole-pillar variant. *

    * Legality is checked against the subpillars' own {@link ValueRange}s ({@link SampleValueRanges#of}) - * rather than by an {@code isValueInRange} call per member: - * the candidate partner subpillar still has to be searched for - * (its "value" is the other side's current value, not something drawn from a range), + * rather than by an {@code isValueInRange} call per member: the candidate partner subpillar still has to be searched + * for (its "value" is the other side's current value, not something drawn from a range), * therefore the {@link FilteringIterator} search. * * @param the solution type @@ -199,20 +190,18 @@ public Move next() { @Override public Iterator> createRightIterator(List leftKey) { // Fresh left subpillar and fresh ranges on every call. + var leftPillars = pillarSourceInstance.samplingIterator(leftKey, leftSampler, random); var leftPillar = leftPillars.hasNext() ? leftPillars.next() : null; if (leftPillar == null) { pendingLeftPillar = null; return Collections.emptyIterator(); } - var leftRangesPerVariable = new ArrayList>(variableMetaModelList.size()); - for (var variableMetaModel : variableMetaModelList) { - leftRangesPerVariable.add(SampleValueRanges.of(leftPillar, variableMetaModel, solutionView)); - } + var leftRangesPerVariable = MoveProviderUtil.rangesPerVariableOf(leftPillar, variableMetaModelList, solutionView); // Scaled to the candidate pool's size. - // Each candidate key is checked cheaply first (it must differ from leftKey) - // before paying to assemble the right subpillar and run isValidSwap; - // a match sets pendingLeftPillar/pendingRightPillar as a side effect for accept() to consume. + // Each candidate key is checked cheaply first (it must differ from leftKey) before paying to assemble the + // right subpillar and run isValidSwap; a match sets pendingLeftPillar/pendingRightPillar as a side effect + // for accept() to consume. var keyIterator = distinctKeysInstance.iterator(random); var bailOutSize = distinctKeysInstance.size() * FilteringIterator.BAIL_OUT_SAFETY_MULTIPLIER; return new FilteringIterator<>(keyIterator, candidateKey -> { @@ -221,9 +210,13 @@ public Iterator> createRightIterator(List leftKey) { } var rightPillars = pillarSourceInstance.samplingIterator(candidateKey, rightSampler, random); var rightPillar = rightPillars.hasNext() ? rightPillars.next() : null; - if (rightPillar != null && - MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftPillar, leftRangesPerVariable, - rightPillar)) { + if (rightPillar == null) { + return false; + } + var rightRangesPerVariable = + MoveProviderUtil.rangesPerVariableOf(rightPillar, variableMetaModelList, solutionView); + if (MoveProviderUtil.isValidSwap(solutionView, variableMetaModelList, leftPillar, leftRangesPerVariable, + rightPillar, rightRangesPerVariable)) { pendingLeftPillar = leftPillar; pendingRightPillar = rightPillar; return true; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java deleted file mode 100644 index 4c5281ea0c3..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSample.java +++ /dev/null @@ -1,61 +0,0 @@ -package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; - -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.Objects; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -final class DefaultSample implements Sample { - - private final LinkedHashSet<@Nullable A> memberSet; - - DefaultSample(Collection<@Nullable A> memberCollection) { - this.memberSet = new LinkedHashSet<>(Objects.requireNonNull(memberCollection, "memberCollection")); - if (memberSet.isEmpty()) { - throw new IllegalArgumentException("The memberCollection (%s) of a sample must not be empty." - .formatted(memberCollection)); - } - } - - @Override - public int size() { - return memberSet.size(); - } - - @Override - public boolean contains(@Nullable A element) { - return memberSet.contains(element); - } - - @Override - public @Nullable A representative() { - // A LinkedHashSet is what makes "the same member on every call" true. - return memberSet.getFirst(); - } - - @Override - public Iterator<@Nullable A> iterator() { - return Collections.unmodifiableSet(memberSet).iterator(); - } - - @Override - public boolean equals(Object o) { - return o instanceof DefaultSample other && memberSet.equals(other.memberSet); - } - - @Override - public int hashCode() { - return memberSet.hashCode(); - } - - @Override - public String toString() { - return "Sample(" + memberSet + ")"; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 59836568f13..7abc78ed7f3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -2,6 +2,8 @@ import java.util.Collection; +import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.DefaultSample; + import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java index 06f52448307..a5ba8f76223 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStream.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating; +import java.util.function.Function; + import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorProvider; import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; import ai.timefold.solver.core.preview.api.neighborhood.stream.MoveStreamFactory; @@ -21,6 +23,19 @@ public interface BiEnumeratingStream extends EnumeratingStream */ BiEnumeratingStream filter(BiNeighborhoodsPredicate filter); + /** + * As defined by {@link UniEnumeratingStream#concat(UniEnumeratingStream)}. + */ + BiEnumeratingStream concat(BiEnumeratingStream otherStream); + + /** + * As defined by {@link #concat(BiEnumeratingStream)}, + * except {@code otherStream} only has a single fact per tuple; + * {@code paddingFunction} derives the missing second fact from the first. + */ + BiEnumeratingStream concat(UniEnumeratingStream otherStream, + Function paddingFunction); + // ************************************************************************ // Operations with duplicate tuple possibility // ************************************************************************ @@ -62,10 +77,10 @@ BiEnumeratingStream groupBy( BiEnumeratingStream distinct(); /** - * Terminal operation: materializes this stream as a {@link BiDataset}, - * kept up to date in memory as the working solution changes. - * Use this to consume the dataset from a custom {@link MoveIteratorProvider}, - * as opposed to being consumed by {@link MoveStreamFactory#pick(UniEnumeratingStream)} + * Terminal operation: materializes this stream as a {@link BiDataset}, kept up to date in memory as the working solution + * changes. + * Use this to consume the dataset from a custom {@link MoveIteratorProvider}, as opposed to being consumed by + * {@link MoveStreamFactory#pick(UniEnumeratingStream)} * Resolve the returned handle against a {@link MoveIteratorSession} inside * {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. *

    diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java index cb9625d71fe..06f58b36d5b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStream.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating; +import java.util.function.Function; + import ai.timefold.solver.core.preview.api.move.SolutionView; import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorProvider; import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; @@ -23,6 +25,28 @@ public interface UniEnumeratingStream extends EnumeratingStream { */ UniEnumeratingStream filter(UniNeighborhoodsPredicate filter); + /** + * Concatenates the tuples of both enumerating streams into one. + * Unlike {@link #join(UniEnumeratingStream) join}, this doesn't create any new combinations, + * it just merges the two streams as they are, keeping every tuple from both, including duplicates. + * For example, if this stream consists of {@code [A, B, C]} + * and {@code otherStream} consists of {@code [C, D, E]}, + * {@code this.concat(otherStream)} will consist of {@code [A, B, C, C, D, E]}. + *

    + * Use {@link #distinct()} afterward if duplicate tuples are undesired. + * + * @return a stream containing every tuple of both streams + */ + UniEnumeratingStream concat(UniEnumeratingStream otherStream); + + /** + * As defined by {@link #concat(UniEnumeratingStream)}, + * except {@code otherStream} has an extra fact per tuple that this stream does not have; + * {@code paddingFunction} derives that missing fact from the one this stream does have. + */ + BiEnumeratingStream concat(BiEnumeratingStream otherStream, + Function paddingFunction); + /** * As defined by {@link #join(UniEnumeratingStream, BiNeighborhoodsJoiner[])}, with the array being empty. */ @@ -481,10 +505,11 @@ BiEnumeratingStream groupBy( * kept up to date in memory as the working solution changes. * Use this instead of {@link MoveStreamFactory#pick(UniEnumeratingStream)} * when the dataset is to be consumed from a custom {@link MoveIteratorProvider}. - * Resolve the returned handle against a {@link MoveIteratorSession} inside - * {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. + * Resolve the returned handle against a {@link MoveIteratorSession} + * inside {@link MoveStreamFactory#buildMoveStream(MoveIteratorProvider)}. *

    - * Repeated calls on the same stream return an equal handle, and the rows are materialized only once. + * Repeated calls on the same stream return an equal handle, + * and the rows are materialized only once. * * @see UniPickingStream For the declarative alternative, which reads from this stream directly. * @return Any operations called on the returned instance will not be cached. diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupplyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupplyTest.java index 52538ec1d42..562e4e7a486 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupplyTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ExternalizedListVariableStateSupplyTest.java @@ -3,17 +3,25 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.function.Consumer; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.PreviousElementShadowVariableDescriptor; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.score.director.ValueRangeManager; import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.TestdataListValue; import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; @@ -55,8 +63,7 @@ void initializeRoundTrip() { verify(notifier).accept(v1); verifyNoMoreInteractions(notifier); - // v2 and v3 are not visited since they are unassigned so their state isn't updated - // by initialization + // v2 and v3 are not visited since they are unassigned so their state isn't updated by initialization } } @@ -93,8 +100,7 @@ void assignRoundTrip() { verify(notifier).accept(v1); verifyNoMoreInteractions(notifier); - // v2 and v3 are not visited since they are unassigned so their state isn't updated - // by initialization + // v2 and v3 are not visited since they are unassigned so their state isn't updated by initialization Mockito.reset(notifier); supply.afterListElementUnassigned(scoreDirector, v1); @@ -113,4 +119,157 @@ void assignRoundTrip() { } } + /** + * Replicates {@code ShadowVariableSupport.linkShadowVariables()}'s wiring by hand: finds whichever of the four list shadow + * variable descriptors are declared on the value class and externalizes them. + * A value class with all four externalizes fully ({@code requiresPositionMap} becomes {@code false}); + * one with fewer stays partially externalized ({@code requiresPositionMap} stays {@code true}). + */ + private static void externalizeDeclaredShadowVariables(ExternalizedListVariableStateSupply supply, + ListVariableDescriptor listVariableDescriptor) { + var valueEntityDescriptor = listVariableDescriptor.getEntityDescriptor().getSolutionDescriptor() + .findEntityDescriptor(listVariableDescriptor.getElementType()); + for (var shadow : valueEntityDescriptor.getShadowVariableDescriptors()) { + switch (shadow) { + case IndexShadowVariableDescriptor d -> supply.externalize(d); + case PreviousElementShadowVariableDescriptor d -> supply.externalize(d); + case NextElementShadowVariableDescriptor d -> supply.externalize(d); + default -> { + // Not a list shadow variable this test cares about. + } + } + } + var inverseDescriptor = listVariableDescriptor.getInverseRelationShadowVariableDescriptor(); + if (inverseDescriptor != null) { + supply.externalize(inverseDescriptor); + } + } + + @Test + void changeElementDoesNotRenotifyUnchangedElementWhenAllFourVariablesAreExternalized() { + // TestdataAllowsUnassignedValuesListValue declares index, inverse, previous and next, so once + // all four are externalized, requiresPositionMap is false: every piece of position state has a + // shadow variable event to report it, and the notifier fallback must not fire for an element + // nothing changed on. + var variableDescriptor = TestdataAllowsUnassignedValuesListEntity.buildVariableDescriptorForValueList(); + @SuppressWarnings("unchecked") + var notifier = (Consumer) mock(Consumer.class); + try (var supply = new ExternalizedListVariableStateSupply<>(variableDescriptor, notifier)) { + externalizeDeclaredShadowVariables(supply, variableDescriptor); + + var v1 = new TestdataAllowsUnassignedValuesListValue("1"); + var v2 = new TestdataAllowsUnassignedValuesListValue("2"); + var v3 = new TestdataAllowsUnassignedValuesListValue("3"); + var e1 = new TestdataAllowsUnassignedValuesListEntity("e1", v1, v2, v3); + + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(new ArrayList<>(List.of(e1))); + solution.setValueList(Arrays.asList(v1, v2, v3)); + var scoreDirector = mock(InnerScoreDirector.class); + var valueRangeManager = + ValueRangeManager.of(variableDescriptor.getEntityDescriptor().getSolutionDescriptor(), solution); + when(scoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + supply.resetWorkingSolution(scoreDirector); + Mockito.reset(notifier); + + // Remove v1: v2 and v3 genuinely shift down by one index each. Their shadow variables + // change, so the events themselves report it - the notifier fallback must stay silent. + e1.getValueList().remove(v1); + supply.afterListVariableChanged(scoreDirector, e1, 0, 0); + assertSoftly(softly -> { + softly.assertThat(v2.getIndex()).isEqualTo(0); + softly.assertThat(v3.getIndex()).isEqualTo(1); + }); + verify(notifier, never()).accept(v2); + verify(notifier, never()).accept(v3); + + // Re-run the same bracket with no further mutation: the rescan revisits v2 and v3, but neither their position nor their neighbours changed this time (NEITHER, no shadow event fires). + // Before the fix, the fallback notified them anyway; it must not now. + Mockito.reset(notifier); + supply.afterListVariableChanged(scoreDirector, e1, 0, 0); + verify(notifier, never()).accept(v2); + verify(notifier, never()).accept(v3); + } + } + + @Test + void addElementDoesNotNotifyAlreadyConsistentElementWhenAllFourVariablesAreExternalized() { + // Same rationale as changeElementDoesNotRenotifyUnchangedElementWhenAllFourVariablesAreExternalized, + // but for initialize()'s addElement() rescan: if the shadows already hold the values this rescan + // would compute anyway (e.g. re-initializing an already-consistent solution), and all four variables + // are externalized, nothing changed and the notifier fallback must not fire. + var variableDescriptor = TestdataAllowsUnassignedValuesListEntity.buildVariableDescriptorForValueList(); + @SuppressWarnings("unchecked") + var notifier = (Consumer) mock(Consumer.class); + try (var supply = new ExternalizedListVariableStateSupply<>(variableDescriptor, notifier)) { + externalizeDeclaredShadowVariables(supply, variableDescriptor); + + var v1 = new TestdataAllowsUnassignedValuesListValue("1"); + var v2 = new TestdataAllowsUnassignedValuesListValue("2"); + var e1 = new TestdataAllowsUnassignedValuesListEntity("e1", v1, v2); + + // Pre-populate the shadows to the values initialize()'s rescan will compute anyway, + // simulating a working solution that is already internally consistent (e.g. a clone). + v1.setIndex(0); + v1.setEntity(e1); + v1.setPrevious(null); + v1.setNext(v2); + v2.setIndex(1); + v2.setEntity(e1); + v2.setPrevious(v1); + v2.setNext(null); + + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(new ArrayList<>(List.of(e1))); + solution.setValueList(List.of(v1, v2)); + var scoreDirector = mock(InnerScoreDirector.class); + var valueRangeManager = + ValueRangeManager.of(variableDescriptor.getEntityDescriptor().getSolutionDescriptor(), solution); + when(scoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + + supply.resetWorkingSolution(scoreDirector); + + // Nothing about v1/v2 actually changed, so the notifier fallback must stay silent. + verify(notifier, never()).accept(v1); + verify(notifier, never()).accept(v2); + } + } + + @Test + void changeElementStillNotifiesUnchangedElementWhenNotAllVariablesAreExternalized() { + // TestdataListValue declares only index and inverse - there is no previous/next shadow + // variable to externalize at all, so requiresPositionMap stays true: the notifier remains the + // only signal for state that has no shadow variable to report it, and must keep firing even + // when nothing else changed. + var variableDescriptor = TestdataListEntity.buildVariableDescriptorForValueList(); + @SuppressWarnings("unchecked") + var notifier = (Consumer) mock(Consumer.class); + try (var supply = new ExternalizedListVariableStateSupply<>(variableDescriptor, notifier)) { + externalizeDeclaredShadowVariables(supply, variableDescriptor); + + var v1 = new TestdataListValue("1"); + var v2 = new TestdataListValue("2"); + var v3 = new TestdataListValue("3"); + var e1 = new TestdataListEntity("e1", v1, v2, v3); + + var solution = new TestdataListSolution(); + solution.setEntityList(new ArrayList<>(List.of(e1))); + solution.setValueList(Arrays.asList(v1, v2, v3)); + var scoreDirector = mock(InnerScoreDirector.class); + var valueRangeManager = + ValueRangeManager.of(variableDescriptor.getEntityDescriptor().getSolutionDescriptor(), solution); + when(scoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + supply.resetWorkingSolution(scoreDirector); + Mockito.reset(notifier); + + // Re-run the bracket for the last element with no mutation at all: index and entity are both unchanged (NEITHER), and there is no previous/next processor to report it either way. + // With nothing externalized to tell Neighborhoods about it, the notifier must fire. + supply.afterListVariableChanged(scoreDirector, e1, 2, 2); + verify(notifier).accept(v3); + } + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java index 27169463657..225c7687206 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/move/MoveDirectorTest.java @@ -98,9 +98,7 @@ void getValueRangeIncludesNullForUnassignedAllowingVariable() { var moveDirector = new MoveDirector<>(scoreDirector); var entity = solution.getEntityList().getFirst(); - // getValueRange must return the cached range with its NullAllowingValueRange wrapper intact (not unwrapped), - // or isValueInRange - now a default delegating to getValueRange(...).contains(...) - - // would silently start rejecting null for every variable that allows unassigned. + // getValueRange must return the cached range with its NullAllowingValueRange wrapper intact (not unwrapped), or isValueInRange - now a default delegating to getValueRange(...).contains(...) - would silently start rejecting null for every variable that allows unassigned. var range = moveDirector.getValueRange(variableMetaModel, entity); assertThat(range.contains(null)).isTrue(); assertThat(moveDirector.isValueInRange(variableMetaModel, entity, null)).isTrue(); @@ -114,9 +112,7 @@ void twoArgIsValueInRangeThrowsForEntityScopedRange() { var mockScoreDirector = mock(InnerScoreDirector.class); var moveDirector = new MoveDirector(mockScoreDirector); - // The 2-arg overload only works for a solution-scoped range; - // this variable's range is on the entity, - // so it must fail loudly instead of silently checking against a null entity. + // The 2-arg overload only works for a solution-scoped range; this variable's range is on the entity, so it must fail loudly instead of silently checking against a null entity. assertThatThrownBy(() -> moveDirector.isValueInRange(variableMetaModel, new TestdataValue("value"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("has a value range on an entity, not on the solution"); @@ -228,7 +224,8 @@ void assignValueAndSetInMiddle() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign unassignedValue to index 1, replacing value2 which becomes unassigned. + // Assign unassignedValue to index 1, + // replacing value2 which becomes unassigned. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndSet(variableMetaModel, unassignedValue, entity, 1); assertSoftly(softly -> { @@ -274,7 +271,8 @@ void assignValueAndSetAtStart() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign unassignedValue to index 0, replacing value1 which becomes unassigned. + // Assign unassignedValue to index 0, + // replacing value1 which becomes unassigned. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndSet(variableMetaModel, unassignedValue, entity, 0); assertSoftly(softly -> { @@ -324,7 +322,8 @@ void assignValueAndSetAtEnd() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign unassignedValue to index 2 (which equals the list size), which delegates to assignValueAndInsert. + // Assign unassignedValue to index 2 (which equals the list size), + // which delegates to assignValueAndInsert. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndSet(variableMetaModel, unassignedValue, entity, 2); assertSoftly(softly -> { @@ -364,7 +363,8 @@ void assignValueAndSetOnEmptyList() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign to empty list at index 0, which delegates to assignValueAndInsert. + // Assign to empty list at index 0, + // which delegates to assignValueAndInsert. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndSet(variableMetaModel, unassignedValue, entity, 0); assertThat(entity.getValueList()).containsExactly(unassignedValue); @@ -438,7 +438,8 @@ void assignValueAndAddAtStart() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign unassignedValue to index 0, shifting value1 and value2 to the right. + // Assign unassignedValue to index 0, + // shifting value1 and value2 to the right. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndAdd(variableMetaModel, unassignedValue, entity, 0); assertSoftly(softly -> { @@ -489,7 +490,8 @@ void assignValueAndAddInMiddle() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign unassignedValue to index 1, shifting value2 and value3 to the right. + // Assign unassignedValue to index 1, + // shifting value2 and value3 to the right. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValueAndAdd(variableMetaModel, unassignedValue, entity, 1); assertSoftly(softly -> { @@ -656,7 +658,8 @@ void assignValuesAndAddAtStart() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign two unassigned values at index 0, shifting value1 and value2 to the right. + // Assign two unassigned values at index 0, + // shifting value1 and value2 to the right. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValuesAndAdd(variableMetaModel, List.of(unassigned1, unassigned2), entity, 0); assertThat(entity.getValueList()).containsExactly(unassigned1, unassigned2, value1, value2); @@ -702,7 +705,8 @@ void assignValuesAndAddInMiddle() { scoreDirector.setWorkingSolution(solution); scoreDirector.calculateScore(); - // Assign two unassigned values at index 1, shifting value2 and value3 to the right. + // Assign two unassigned values at index 1, + // shifting value2 and value3 to the right. var moveDirector = new MoveDirector<>(scoreDirector).ephemeral(); moveDirector.assignValuesAndAdd(variableMetaModel, List.of(unassigned1, unassigned2), entity, 1); assertThat(entity.getValueList()) @@ -926,7 +930,8 @@ void moveValueInListToEnd() { var expectedValue3 = new TestdataListValue("value3"); var entity = new TestdataListEntity("A", expectedValue1, expectedValue2, expectedValue3); - // Move value from first position to the end (after removing, destination index is 2). + // Move value from first position to the end + // (after removing, destination index is 2). var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); moveDirector.moveValueInList(variableMetaModel, entity, 0, 2); @@ -957,7 +962,8 @@ void shiftValueRight() { var value4 = new TestdataListValue("value4"); var entity = new TestdataListEntity("A", value1, value2, value3, value4); - // Shift value2 (index 1) right by 2 positions, so it ends up at index 3. + // Shift value2 (index 1) right by 2 positions, + // so it ends up at index 3. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var shiftedValue = moveDirector.shiftValue(variableMetaModel, entity, 1, 2); @@ -988,7 +994,8 @@ void shiftValueRightByOne() { var value3 = new TestdataListValue("value3"); var entity = new TestdataListEntity("A", value1, value2, value3); - // Shift value1 (index 0) right by 1 position, so it ends up at index 1. + // Shift value1 (index 0) right by 1 position, + // so it ends up at index 1. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var shiftedValue = moveDirector.shiftValue(variableMetaModel, entity, 0, 1); @@ -1020,7 +1027,8 @@ void shiftValueLeft() { var value4 = new TestdataListValue("value4"); var entity = new TestdataListEntity("A", value1, value2, value3, value4); - // Shift value3 (index 2) left by 2 positions, so it ends up at index 0. + // Shift value3 (index 2) left by 2 positions, + // so it ends up at index 0. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var shiftedValue = moveDirector.shiftValue(variableMetaModel, entity, 2, -2); @@ -1051,7 +1059,8 @@ void shiftValueLeftByOne() { var value3 = new TestdataListValue("value3"); var entity = new TestdataListEntity("A", value1, value2, value3); - // Shift value3 (index 2) left by 1 position, so it ends up at index 1. + // Shift value3 (index 2) left by 1 position, + // so it ends up at index 1. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var shiftedValue = moveDirector.shiftValue(variableMetaModel, entity, 2, -1); @@ -1247,7 +1256,8 @@ void moveValueInListAdjacentSwap() { var value3 = new TestdataListValue("value3"); var entity = new TestdataListEntity("A", value1, value2, value3); - // Move by one step forward (distance=1, triggers swap path in moveInList). + // Move by one step forward + // (distance=1, triggers swap path in moveInList). var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var moved = moveDirector.moveValueInList(variableMetaModel, entity, 0, 1); @@ -1273,14 +1283,16 @@ void moveValueInListRotate() { ((DefaultPlanningListVariableMetaModel) variableMetaModel) .variableDescriptor(); - // 17 values: distance=2, 2*8=16 < 17-0=17, triggering the rotate path in moveInList. + // 17 values: distance=2, 2*8=16 < 17-0=17, + // triggering the rotate path in moveInList. var values = new TestdataListValue[17]; for (var i = 0; i < 17; i++) { values[i] = new TestdataListValue("value" + (i + 1)); } var entity = new TestdataListEntity("A", values); - // Move forward: from index 0 to index 2 (rotate left on subList(0, 3)). + // Move forward: from index 0 to index 2 + // (rotate left on subList(0, 3)). var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var moved = moveDirector.moveValueInList(variableMetaModel, entity, 0, 2); @@ -1299,7 +1311,8 @@ void moveValueInListRotate() { verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entity, 0, 3); reset(mockScoreDirector); - // Move backward: from index 2 to index 0 (rotate right on subList(0, 3)). + // Move backward: from index 2 to index 0 + // (rotate right on subList(0, 3)). moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); moved = moveDirector.moveValueInList(variableMetaModel, entity, 2, 0); assertThat(moved).isSameAs(values[2]); @@ -1338,7 +1351,8 @@ void moveValuesInListForward() { var value5 = new TestdataListValue("value5"); var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); - // Move the span [1, 3) (value2, value3) to the end of the (post-removal) list. + // Move the span [1, 3) + // (value2, value3) to the end of the (post-removal) list. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var movedValues = moveDirector.moveValuesInList(variableMetaModel, entity, 1, 3, 3, false); @@ -1371,7 +1385,8 @@ void moveValuesInListBackwardReversing() { var value5 = new TestdataListValue("value5"); var entity = new TestdataListEntity("A", value1, value2, value3, value4, value5); - // Move the span [3, 5) (value4, value5) to the start of the list, reversed. + // Move the span [3, 5) + // (value4, value5) to the start of the list, reversed. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var movedValues = moveDirector.moveValuesInList(variableMetaModel, entity, 3, 5, 0, true); @@ -1443,7 +1458,8 @@ void moveValuesBetweenLists() { var valueB3 = new TestdataListValue("valueB3"); var entityB = new TestdataListEntity("B", valueB1, valueB2, valueB3); - // Move the span [1, 3) (valueA2, valueA3) from A into B at index 1. + // Move the span [1, 3) + // (valueA2, valueA3) from A into B at index 1. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var movedValues = moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 1, 3, entityB, 1, false); @@ -1480,7 +1496,8 @@ void moveValuesBetweenListsReversingToEnd() { var valueB2 = new TestdataListValue("valueB2"); var entityB = new TestdataListEntity("B", valueB1, valueB2); - // Move the span [0, 2) (valueA1, valueA2) from A to the end of B, reversed. + // Move the span [0, 2) + // (valueA1, valueA2) from A to the end of B, reversed. var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); var movedValues = moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 0, 2, entityB, 2, true); @@ -1520,7 +1537,8 @@ void moveValuesBetweenListsThrowsOnDestinationIndexOutOfBounds() { var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); var moveDirector = new MoveDirector<>(mockScoreDirector); - // entityB's list has 1 element, so the only valid destinationIndex values are 0 and 1. + // entityB's list has 1 element, + // so the only valid destinationIndex values are 0 and 1. assertThatThrownBy( () -> moveDirector.moveValuesBetweenLists(variableMetaModel, entityA, 0, 1, entityB, 2, false)) .isInstanceOf(IllegalArgumentException.class) @@ -2089,6 +2107,112 @@ void unassignValuesThrowsOnEmptySpan() { } + @Nested + class MassMove { + + @Test + void massMoveValuesGathersFromMultipleEntitiesToOneDestinationInOneFlush() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var a1 = new TestdataListValue("a1"); + var a2 = new TestdataListValue("a2"); + var b1 = new TestdataListValue("b1"); + var b2 = new TestdataListValue("b2"); + var c1 = new TestdataListValue("c1"); + var entityA = new TestdataListEntity("A", a1, a2); + var entityB = new TestdataListEntity("B", b1, b2); + var entityC = new TestdataListEntity("C", c1); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var supplyMock = mock(ListVariableStateSupply.class); + when(supplyMock.getElementPosition(a2)).thenReturn(ElementPosition.of(entityA, 1)); + when(supplyMock.getElementPosition(b1)).thenReturn(ElementPosition.of(entityB, 0)); + when(mockScoreDirector.getListVariableStateSupply(any())).thenReturn(supplyMock); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + + // Gather a2 (from A) and b1 (from B) to the front of C - two source entities, one destination. + var destination = ElementPosition.of(entityC, 0); + var gathered = moveDirector.massMoveValues(variableMetaModel, List.of(a2, b1), destination); + + assertThat(gathered).containsExactly(a2, b1); + assertThat(entityA.getValueList()).containsExactly(a1); + assertThat(entityB.getValueList()).containsExactly(b2); + assertThat(entityC.getValueList()).containsExactly(a2, b1, c1); + + var inOrder = inOrder(mockScoreDirector); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityA, 1, 2); + inOrder.verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 1); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, b1); + inOrder.verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityB, 0, 1); + inOrder.verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityB, 0, 0); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, b1); + inOrder.verify(mockScoreDirector).beforeListVariableElementAssigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).beforeListVariableElementAssigned(variableDescriptor, b1); + inOrder.verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityC, 0, 0); + inOrder.verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityC, 0, 2); + inOrder.verify(mockScoreDirector).afterListVariableElementAssigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).afterListVariableElementAssigned(variableDescriptor, b1); + // The whole point of this method: one flush, no matter how many entities or values were touched. + verify(mockScoreDirector, times(1)).updateShadowVariables(); + + // Undo restores every affected entity. + moveDirector.close(); + assertThat(entityA.getValueList()).containsExactly(a1, a2); + assertThat(entityB.getValueList()).containsExactly(b1, b2); + assertThat(entityC.getValueList()).containsExactly(c1); + } + + @Test + void massMoveValuesBatchesMultipleMembersOfTheSameEntityIntoOneBracket() { + var solutionMetaModel = TestdataListSolution.buildSolutionDescriptor().getMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + var variableDescriptor = + ((DefaultPlanningListVariableMetaModel) variableMetaModel) + .variableDescriptor(); + + var a1 = new TestdataListValue("a1"); + var a2 = new TestdataListValue("a2"); + var a3 = new TestdataListValue("a3"); + var a4 = new TestdataListValue("a4"); + var entityA = new TestdataListEntity("A", a1, a2, a3, a4); + + var mockScoreDirector = (InnerScoreDirector) mock(InnerScoreDirector.class); + var supplyMock = mock(ListVariableStateSupply.class); + when(supplyMock.getElementPosition(a2)).thenReturn(ElementPosition.of(entityA, 1)); + when(supplyMock.getElementPosition(a4)).thenReturn(ElementPosition.of(entityA, 3)); + when(mockScoreDirector.getListVariableStateSupply(any())).thenReturn(supplyMock); + var moveDirector = new MoveDirector<>(mockScoreDirector).ephemeral(); + + // a2 and a4 both come from A - this must collapse into ONE bracket for A, not two, with a3 surviving + // in between them. + var gathered = moveDirector.massMoveValues(variableMetaModel, List.of(a2, a4), null); + + assertThat(gathered).containsExactly(a2, a4); + assertThat(entityA.getValueList()).containsExactly(a1, a3); + + var inOrder = inOrder(mockScoreDirector); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).beforeListVariableElementUnassigned(variableDescriptor, a4); + inOrder.verify(mockScoreDirector).beforeListVariableChanged(variableDescriptor, entityA, 1, 4); + inOrder.verify(mockScoreDirector).afterListVariableChanged(variableDescriptor, entityA, 1, 2); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, a2); + inOrder.verify(mockScoreDirector).afterListVariableElementUnassigned(variableDescriptor, a4); + verify(mockScoreDirector, times(1)).updateShadowVariables(); + + moveDirector.close(); + assertThat(entityA.getValueList()).containsExactly(a1, a2, a3, a4); + } + + } + @Test void lookUpWorkingObject() { var mockScoreDirector = mock(InnerScoreDirector.class); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactoryTest.java new file mode 100644 index 00000000000..b3f0178ab75 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/DefaultMoveStreamFactoryTest.java @@ -0,0 +1,147 @@ +package ai.timefold.solver.core.impl.neighborhood.stream; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; +import ai.timefold.solver.core.impl.score.director.SessionContext; +import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition; +import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; +import ai.timefold.solver.core.testdomain.list.TestdataListEntity; +import ai.timefold.solver.core.testdomain.list.TestdataListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListEntity; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListSolution; +import ai.timefold.solver.core.testdomain.list.unassignedvar.TestdataAllowsUnassignedValuesListValue; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingEntity; +import ai.timefold.solver.core.testdomain.list.valuerange.TestdataListEntityProvidingSolution; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +/** + * Direct test of {@link DefaultMoveStreamFactory#forEachDestination} and + * {@link DefaultMoveStreamFactory#forEachDestinationIncludingUnassigned}: the exact set of positions they produce, not just + * membership as the builtin/example move provider tests exercise indirectly. + *

    + * These two methods no longer build their destination set via a join between entities and values (see the class's + * javadoc history) - the join used to duplicate a value's position once per unpinned entity whose value range + * accepted the value, relying on {@code .distinct()} to collapse the duplicates back to one row. + * The rewrite instead concatenates two join-free streams: one row per unpinned assigned value (that value's own position) and + * one row per unpinned entity (the end-of-list slot). + * This pins down that the two are equivalent, including for entities with disjoint, entity-provided value ranges - the one case + * where the dropped value-range check could theoretically have mattered. + */ +@NullMarked +class DefaultMoveStreamFactoryTest { + + private static DefaultNeighborhoodSession createSession( + DefaultMoveStreamFactory factory, + SolutionDescriptor solutionDescriptor, + Solution_ solution) { + var scoreDirector = new EasyScoreDirectorFactory<>(solutionDescriptor, s -> SimpleScore.ZERO, + EnvironmentMode.PHASE_ASSERT) + .buildScoreDirector(); + scoreDirector.setWorkingSolution(solution); + var session = factory.createSession(new SessionContext<>(scoreDirector)); + solutionDescriptor.visitAll(solution, session::insert); + session.settle(); + return session; + } + + @Test + void forEachDestination_valueAndEndOfListPositions() { + var solutionDescriptor = TestdataListSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataListEntity.class) + .listVariable(); + var factory = new DefaultMoveStreamFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT); + var dataset = ((AbstractUniEnumeratingStream) factory + .forEachDestination(variableMetaModel)).asCachedDataset(); + + // Round-robin over 2 entities: e0 = [v0, v2], e1 = [v1, v3]. + var solution = TestdataListSolution.generateInitializedSolution(4, 2); + var session = createSession(factory, solutionDescriptor, solution); + var instance = session.getLeftDatasetInstance(dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + + assertThat(instance.iterator()).toIterable() + .map(UniTuple::getA) + .containsExactlyInAnyOrder( + // Insert-before an unpinned assigned value: one per value, at that value's own position. + ElementPosition.of(e0, 0), ElementPosition.of(e0, 1), + ElementPosition.of(e1, 0), ElementPosition.of(e1, 1), + // End-of-list slot: one per unpinned entity. + ElementPosition.of(e0, 2), ElementPosition.of(e1, 2)); + } + + @Test + void forEachDestination_entityProvidedDisjointValueRanges() { + // The one theoretical edge case the join-free rewrite leans on: entities whose value ranges don't overlap. + // The old join's valueInRangeFilter never gated which positions appeared (a value's own entity always + // satisfies its own range), so this must produce the exact same set as the plain case above. + var solutionDescriptor = TestdataListEntityProvidingSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataListEntityProvidingEntity.class) + .listVariable(); + var factory = new DefaultMoveStreamFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT); + var dataset = ((AbstractUniEnumeratingStream) factory + .forEachDestination(variableMetaModel)).asCachedDataset(); + + // Disjoint value ranges: e0's range is only [v0], e1's range is only [v1]. + var solution = TestdataListEntityProvidingSolution.generateSolution(2, 2, false); + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + e0.setValueList(new ArrayList<>(e0.getValueRange())); + e1.setValueList(new ArrayList<>(e1.getValueRange())); + SolutionManager.updateShadowVariables(solution); + + var session = createSession(factory, solutionDescriptor, solution); + var instance = session.getLeftDatasetInstance(dataset); + + assertThat(instance.iterator()).toIterable() + .map(UniTuple::getA) + .containsExactlyInAnyOrder( + ElementPosition.of(e0, 0), ElementPosition.of(e1, 0), + ElementPosition.of(e0, 1), ElementPosition.of(e1, 1)); + } + + @Test + void forEachDestinationIncludingUnassigned_addsExactlyOneUnassignedRow() { + var solutionDescriptor = TestdataAllowsUnassignedValuesListSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) + .listVariable(); + var factory = new DefaultMoveStreamFactory<>(solutionDescriptor, EnvironmentMode.PHASE_ASSERT); + var dataset = + ((AbstractUniEnumeratingStream) factory + .forEachDestinationIncludingUnassigned(variableMetaModel)).asCachedDataset(); + + var value0 = new TestdataAllowsUnassignedValuesListValue("v0"); + var value1 = new TestdataAllowsUnassignedValuesListValue("v1"); + var entity0 = new TestdataAllowsUnassignedValuesListEntity("e0", value0, value1); + var solution = new TestdataAllowsUnassignedValuesListSolution(); + solution.setEntityList(List.of(entity0)); + solution.setValueList(List.of(value0, value1)); + SolutionManager.updateShadowVariables(solution); + + var session = createSession(factory, solutionDescriptor, solution); + var instance = session.getLeftDatasetInstance(dataset); + + assertThat(instance.iterator()).toIterable() + .map(UniTuple::getA) + .containsExactlyInAnyOrder( + ElementPosition.of(entity0, 0), ElementPosition.of(entity0, 1), ElementPosition.of(entity0, 2), + ElementPosition.unassigned()); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/ConstraintStreamNodeSharingTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/ConstraintStreamNodeSharingTest.java index 89ade9de591..6b52783a538 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/ConstraintStreamNodeSharingTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/ConstraintStreamNodeSharingTest.java @@ -1,8 +1,8 @@ package ai.timefold.solver.core.impl.score.stream.common; /** - * Defines methods that every constraint stream node sharing test must have. These methods are enforced because they test each - * method of the API for correctness. + * Defines methods that every constraint stream node sharing test must have. + * These methods are enforced because they test each method of the API for correctness. */ public interface ConstraintStreamNodeSharingTest { @@ -223,6 +223,8 @@ default void sameParentSameFunctionFlatten() { void sameSourcesConcat(); + void differentPaddingFunctionConcat(); + void sameDataPrecompute(); void differentDataPrecompute(); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/bi/AbstractBiConstraintStreamNodeSharingTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/bi/AbstractBiConstraintStreamNodeSharingTest.java index 82184b975b2..8c90766e76b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/bi/AbstractBiConstraintStreamNodeSharingTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/bi/AbstractBiConstraintStreamNodeSharingTest.java @@ -575,6 +575,17 @@ public void sameSourcesConcat() { .isSameAs(baseStream.concat(baseStream.filter(filter1))); } + @Override + @TestTemplate + public void differentPaddingFunctionConcat() { + var uniStream = constraintFactory.forEach(TestdataEntity.class); + Function paddingA = a -> a; + Function paddingB = a -> null; + + assertThat(baseStream.concat(uniStream, paddingA)) + .isNotSameAs(baseStream.concat(uniStream, paddingB)); + } + @Override @TestTemplate public void sameDataPrecompute() { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/quad/AbstractQuadConstraintStreamNodeSharingTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/quad/AbstractQuadConstraintStreamNodeSharingTest.java index 9b958482456..4fe7accb6aa 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/quad/AbstractQuadConstraintStreamNodeSharingTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/quad/AbstractQuadConstraintStreamNodeSharingTest.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.api.function.QuadFunction; import ai.timefold.solver.core.api.function.QuadPredicate; import ai.timefold.solver.core.api.function.ToLongQuadFunction; +import ai.timefold.solver.core.api.function.TriFunction; import ai.timefold.solver.core.api.score.stream.ConstraintCollectors; import ai.timefold.solver.core.api.score.stream.ConstraintFactory; import ai.timefold.solver.core.api.score.stream.Joiners; @@ -478,6 +479,19 @@ public void sameSourcesConcat() { .isSameAs(baseStream.concat(baseStream.filter(filter1))); } + @Override + @TestTemplate + public void differentPaddingFunctionConcat() { + var triStream = constraintFactory.forEach(TestdataEntity.class) + .join(TestdataEntity.class) + .join(TestdataEntity.class); + TriFunction paddingA = (a, b, c) -> a; + TriFunction paddingB = (a, b, c) -> null; + + assertThat(baseStream.concat(triStream, paddingA)) + .isNotSameAs(baseStream.concat(triStream, paddingB)); + } + @Override @TestTemplate public void sameDataPrecompute() { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/tri/AbstractTriConstraintStreamNodeSharingTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/tri/AbstractTriConstraintStreamNodeSharingTest.java index 74da92a9544..4695724d351 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/tri/AbstractTriConstraintStreamNodeSharingTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/tri/AbstractTriConstraintStreamNodeSharingTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; @@ -608,6 +609,17 @@ public void sameSourcesConcat() { .isSameAs(baseStream.concat(baseStream.filter(filter1))); } + @Override + @TestTemplate + public void differentPaddingFunctionConcat() { + var biStream = constraintFactory.forEach(TestdataEntity.class).join(TestdataEntity.class); + BiFunction paddingA = (a, b) -> a; + BiFunction paddingB = (a, b) -> null; + + assertThat(baseStream.concat(biStream, paddingA)) + .isNotSameAs(baseStream.concat(biStream, paddingB)); + } + @Override @TestTemplate public void sameDataPrecompute() { diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamNodeSharingTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamNodeSharingTest.java index 0776c6bb77f..2960635b1a1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamNodeSharingTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamNodeSharingTest.java @@ -745,6 +745,17 @@ public void sameSourcesConcat() { .isSameAs(baseStream.concat(baseStream.filter(filter1))); } + @Override + @TestTemplate + public void differentPaddingFunctionConcat() { + var biStream = constraintFactory.forEach(TestdataEntity.class).join(TestdataEntity.class); + Function paddingA = a -> a; + Function paddingB = a -> null; + + assertThat(baseStream.concat(biStream, paddingA)) + .isNotSameAs(baseStream.concat(biStream, paddingB)); + } + @Override @TestTemplate public void sameDataPrecompute() { diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java index 6a14169e3a2..54812c7cfcf 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SampleValueRangesTest.java @@ -1,6 +1,11 @@ package ai.timefold.solver.core.preview.api.move.builtin; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.util.Iterator; import java.util.List; @@ -15,6 +20,9 @@ import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.testdomain.TestdataValue; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEasyScoreCalculator; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedEntity; +import ai.timefold.solver.core.testdomain.unassignedvar.TestdataAllowsUnassignedSolution; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingEntity; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingScoreCalculator; import ai.timefold.solver.core.testdomain.valuerange.entityproviding.unassignedvar.TestdataAllowsUnassignedEntityProvidingSolution; @@ -52,10 +60,43 @@ void of_sampleBased_excludesNullAndChecksLegalityAcrossEveryMember() { assertThat(ranges.containsInEvery(v1)).isTrue(); assertThat(ranges.containsInEvery(v2)).isFalse(); // The variable allows unassigned, so the cached range is null-wrapped; - // of(...) must unwrap it, or null would be treated as a legal destination. + // containsInEvery(...) must reject null directly, or it would be treated as a legal destination. assertThat(ranges.containsInEvery(null)).isFalse(); } + @Test + void of_solutionScoped_readsOneRangeForTheWholeSample() { + var solutionDescriptor = TestdataAllowsUnassignedSolution.buildSolutionDescriptor(); + var variableMetaModel = solutionDescriptor.getMetaModel() + .genuineEntity(TestdataAllowsUnassignedEntity.class).basicVariable(); + var scoreDirectorFactory = new EasyScoreDirectorFactory<>(solutionDescriptor, + new TestdataAllowsUnassignedEasyScoreCalculator(), EnvironmentMode.PHASE_ASSERT); + var scoreDirector = scoreDirectorFactory.buildScoreDirector(); + + var v1 = new TestdataValue("v1"); + var v2 = new TestdataValue("v2"); + var entityA = new TestdataAllowsUnassignedEntity("a", v1); + var entityB = new TestdataAllowsUnassignedEntity("b", v1); + var entityC = new TestdataAllowsUnassignedEntity("c", v1); + + var solution = new TestdataAllowsUnassignedSolution("s"); + solution.setValueList(List.of(v1, v2)); + solution.setEntityList(List.of(entityA, entityB, entityC)); + scoreDirector.setWorkingSolution(solution); + + var moveDirector = spy(new MoveDirector<>(scoreDirector)); + var sample = Sample.of(List.of(entityA, entityB, entityC)); + + var ranges = SampleValueRanges.of(sample, variableMetaModel, moveDirector); + + // Every member shares one solution-scoped range instance: one lookup, not one per member. + assertThat(ranges.distinctRangeSet()).hasSize(1); + assertThat(ranges.containsInEvery(v1)).isTrue(); + assertThat(ranges.containsInEvery(v2)).isTrue(); + assertThat(ranges.containsInEvery(null)).isFalse(); + verify(moveDirector, times(1)).getValueRange(eq(variableMetaModel), any()); + } + @Test void bailOutSizeOf_clampsHugeSizeInsteadOfOverflowing() { // Long.MAX_VALUE * BAIL_OUT_SAFETY_MULTIPLIER would overflow negative, diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStreamTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStreamTest.java index 4b30e346f9a..67946592329 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStreamTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/BiEnumeratingStreamTest.java @@ -1,13 +1,18 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.enumerating; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.bavet.common.tuple.BiTuple; import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.DatasetSession; 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.bi.BiLeftDataset; +import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.bi.BiLeftDatasetInstance; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.common.AbstractLeftDataset; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.AbstractUniEnumeratingStream; import ai.timefold.solver.core.impl.neighborhood.stream.enumerating.uni.UniLeftDataset; @@ -47,6 +52,12 @@ private static UniLeftDatasetInstance getInstance(Datas .getInstance((AbstractLeftDataset>) dataset); } + private static BiLeftDatasetInstance getBiInstance( + DatasetSession session, BiLeftDataset dataset) { + return (BiLeftDatasetInstance) session + .getInstance((AbstractLeftDataset>) dataset); + } + private static DatasetSession createSession( EnumeratingStreamFactory enumeratingStreamFactory, TestdataSolution solution) { @@ -63,6 +74,99 @@ private static DatasetSession createSession( return datasetSession; } + // ************************************************************************ + // concat + // ************************************************************************ + + @Test + void concat_biBi() { + var factory = factory(); + var valueStream = (AbstractUniEnumeratingStream) factory + .forEachNonDiscriminating(TestdataValue.class, false); + var biStream = entityStream(factory).join(valueStream, + NeighborhoodsJoiners.equal(TestdataEntity::getValue, v -> v)); + // generateSolution(2 values, 4 entities): e0→v0, e1→v1, e2→v0, e3→v1 + var v0Stream = biStream.filter((view, entity, value) -> value.getCode().equals("Generated Value 0")); + var v1Stream = biStream.filter((view, entity, value) -> value.getCode().equals("Generated Value 1")); + var dataset = ((AbstractBiEnumeratingStream) v0Stream + .concat(v1Stream)).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(2, 4); + var session = createSession(factory, solution); + var instance = getBiInstance(session, dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + var e2 = solution.getEntityList().get(2); + var e3 = solution.getEntityList().get(3); + + assertThat(instance.iterator()).toIterable() + .extracting(BiTuple::getA, BiTuple::getB) + .containsExactlyInAnyOrder( + tuple(e0, e0.getValue()), tuple(e1, e1.getValue()), + tuple(e2, e2.getValue()), tuple(e3, e3.getValue())); + } + + @Test + void concat_uniToBiWithPadding() { + var factory = factory(); + var valueStream = (AbstractUniEnumeratingStream) factory + .forEachNonDiscriminating(TestdataValue.class, false); + // generateSolution(2 values, 4 entities): e0→v0, e1→v1, e2→v0, e3→v1 + var uniStream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 0")); + var biStream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 1")) + .join(valueStream, NeighborhoodsJoiners.equal(TestdataEntity::getValue, v -> v)); + var dataset = ((AbstractBiEnumeratingStream) uniStream + .concat(biStream, TestdataEntity::getValue)).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(2, 4); + var session = createSession(factory, solution); + var instance = getBiInstance(session, dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + var e2 = solution.getEntityList().get(2); + var e3 = solution.getEntityList().get(3); + + assertThat(instance.iterator()).toIterable() + .extracting(BiTuple::getA, BiTuple::getB) + .containsExactlyInAnyOrder( + tuple(e0, e0.getValue()), tuple(e2, e2.getValue()), + tuple(e1, e1.getValue()), tuple(e3, e3.getValue())); + } + + @Test + void concat_biToUniWithPadding() { + var factory = factory(); + var valueStream = (AbstractUniEnumeratingStream) factory + .forEachNonDiscriminating(TestdataValue.class, false); + // generateSolution(2 values, 4 entities): e0→v0, e1→v1, e2→v0, e3→v1 + var biStream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 0")) + .join(valueStream, NeighborhoodsJoiners.equal(TestdataEntity::getValue, v -> v)); + var uniStream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 1")); + var dataset = ((AbstractBiEnumeratingStream) biStream + .concat(uniStream, TestdataEntity::getValue)).asCachedDataset(); + + var solution = TestdataSolution.generateSolution(2, 4); + var session = createSession(factory, solution); + var instance = getBiInstance(session, dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + var e2 = solution.getEntityList().get(2); + var e3 = solution.getEntityList().get(3); + + assertThat(instance.iterator()).toIterable() + .extracting(BiTuple::getA, BiTuple::getB) + .containsExactlyInAnyOrder( + tuple(e0, e0.getValue()), tuple(e2, e2.getValue()), + tuple(e1, e1.getValue()), tuple(e3, e3.getValue())); + } + // ************************************************************************ // groupBy // ************************************************************************ diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStreamTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStreamTest.java index 0bcb3a68d98..795182c9648 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStreamTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/enumerating/UniEnumeratingStreamTest.java @@ -562,6 +562,69 @@ void forEachExcludingPinnedIncludingNull_listVarValues() { .containsExactly(null, value2, value3, value4); } + // ************************************************************************ + // concat + // ************************************************************************ + + @Test + void concat_uniUni() { + var factory = factory(); + // generateSolution(2 values, 4 entities): e0→v0, e1→v1, e2→v0, e3→v1 + var v0Stream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 0")); + var v1Stream = entityStream(factory) + .filter((view, entity) -> entity.getValue().getCode().equals("Generated Value 1")); + var concatStream = (AbstractUniEnumeratingStream) v0Stream.concat(v1Stream); + var dataset = concatStream.asCachedDataset(); + + var solution = TestdataSolution.generateSolution(2, 4); + var session = createSession(factory, solution); + var instance = getInstance(session, dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + var e2 = solution.getEntityList().get(2); + var e3 = solution.getEntityList().get(3); + + assertThat(instance.iterator()).toIterable().map(UniTuple::getA) + .containsExactlyInAnyOrder(e0, e1, e2, e3); + + // Reassign e1 from v1 to v0 → e1 leaves the right side, joins the left side. + var v0 = solution.getValueList().getFirst(); + e1.setValue(v0); + session.update(e1); + session.settle(); + + assertThat(instance.iterator()).toIterable().map(UniTuple::getA) + .containsExactlyInAnyOrder(e0, e1, e2, e3); + + // Retract e3, the only remaining row on the right side. + session.retract(e3); + session.settle(); + + assertThat(instance.iterator()).toIterable().map(UniTuple::getA) + .containsExactlyInAnyOrder(e0, e1, e2); + } + + @Test + void concat_selfProducesDuplicates() { // Concat is a true multiset union, unlike join or groupBy. + var factory = factory(); + var entityStream = entityStream(factory); + var concatStream = (AbstractUniEnumeratingStream) entityStream + .concat(entityStream); + var dataset = concatStream.asCachedDataset(); + + var solution = TestdataSolution.generateSolution(2, 2); + var session = createSession(factory, solution); + var instance = getInstance(session, dataset); + + var e0 = solution.getEntityList().get(0); + var e1 = solution.getEntityList().get(1); + + assertThat(instance.iterator()).toIterable().map(UniTuple::getA) + .containsExactlyInAnyOrder(e0, e0, e1, e1); + } + // ************************************************************************ // groupBy // ************************************************************************ From 23cd10bfca34c74e92b31139212d99d3ad93074f Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Tue, 25 Aug 2026 19:40:25 +0200 Subject: [PATCH 12/16] Claude review --- .../index/MappingRetiringRandomIterator.java | 47 ----------------- .../common/index/RetiringRandomIterator.java | 30 ++++++++++- .../domain/variable/ListVariableState.java | 5 +- .../api/move/builtin/ListAssignMove.java | 2 +- .../move/builtin/ListAssignMoveProvider.java | 2 +- .../builtin/ListTailSwapMoveProvider.java | 4 +- .../api/move/builtin/ListUnassignMove.java | 4 +- .../api/move/builtin/MassListChangeMove.java | 33 ++++++++++-- .../builtin/MassListUnassignMoveProvider.java | 48 +++-------------- .../builtin/MassUnassignMoveProvider.java | 48 +++-------------- .../core/preview/api/move/builtin/Moves.java | 18 ++++--- .../api/move/builtin/PillarSwapMove.java | 9 +++- .../move/builtin/PillarSwapMoveProvider.java | 3 ++ .../builtin/PillarUnassignMoveProvider.java | 52 +++---------------- .../api/move/builtin/SubListChangeMove.java | 5 +- .../api/move/builtin/SubListUnassignMove.java | 2 +- .../builtin/SubPillarSwapMoveProvider.java | 3 ++ .../builtin/ListTailSwapMoveProviderTest.java | 25 +++++++++ .../move/builtin/MassListChangeMoveTest.java | 31 +++++++++-- 19 files changed, 172 insertions(+), 199 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java deleted file mode 100644 index 7710cb26986..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/MappingRetiringRandomIterator.java +++ /dev/null @@ -1,47 +0,0 @@ -package ai.timefold.solver.core.impl.bavet.common.index; - -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.function.Function; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -/** - * Adapts a {@link RetiringRandomIterator} of one type to another, - * by mapping each element through a function, - * without changing which element is retired: - * {@link #retire()} still retires whatever the delegate last handed out, - * keyed by the delegate's own identity, not by the mapped value. - */ -@NullMarked -final class MappingRetiringRandomIterator - implements RetiringRandomIterator { - - private final RetiringRandomIterator delegate; - private final Function mapper; - - MappingRetiringRandomIterator(RetiringRandomIterator delegate, Function mapper) { - this.delegate = Objects.requireNonNull(delegate, "delegate"); - this.mapper = Objects.requireNonNull(mapper, "mapper"); - } - - @Override - public boolean hasNext() { - return delegate.hasNext(); - } - - @Override - public T next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - return mapper.apply(delegate.next()); - } - - @Override - public void retire() { - delegate.retire(); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java index 911c4bab808..9e457615441 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/index/RetiringRandomIterator.java @@ -31,7 +31,7 @@ @NullMarked public sealed interface RetiringRandomIterator extends Iterator - permits DefaultRetiringRandomIterator, MappingRetiringRandomIterator { + permits DefaultRetiringRandomIterator, RetiringRandomIterator.MappingRetiringRandomIterator { static RetiringRandomIterator of(ElementAwareArrayList list, RandomGenerator random) { return new DefaultRetiringRandomIterator<>(list, random); @@ -86,4 +86,32 @@ Maybe use hasNext() and next() with your own stop condition instead.""" .formatted(this)); } + /** + * Adapts a {@link RetiringRandomIterator} of one type to another, + * by mapping each element through a function, + * without changing which element is retired: + * {@link #retire()} still retires whatever the delegate last handed out, + * keyed by the delegate's own identity, not by the mapped value. + */ + record MappingRetiringRandomIterator( + RetiringRandomIterator delegate, + Function mapper) implements RetiringRandomIterator { + + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public T next() { + return mapper.apply(delegate.next()); + } + + @Override + public void retire() { + delegate.retire(); + } + + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java index bf03ce61140..3b1c2386266 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ListVariableState.java @@ -175,7 +175,10 @@ public void unassignElement(Object element) { elementUpdateSent = externalizedNextElementProcessor.unsetElement(scoreDirector, element) || elementUpdateSent; } unassignedCount++; - // Trigger notifier if none of the previous methods triggered a shadow var update for this element. + // Unlike addElement()/changeElement(), this does not also guard on requiresPositionMap: + // externalizedIndexProcessor is non-null whenever requiresPositionMap is false, + // and it always fires on this index-to-null transition, so elementUpdateSent is already true + // and the notifier is never spuriously triggered here. if (!elementUpdateSent) { notifier.accept(element); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java index a22697b6846..ed15925f27e 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java @@ -20,7 +20,7 @@ public class ListAssignMove extends AbstractMove variableMetaModel, Value_ planningValue, + ListAssignMove(PlanningListVariableMetaModel variableMetaModel, Value_ planningValue, Entity_ destinationEntity, int destinationIndex) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.planningValue = Objects.requireNonNull(planningValue); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java index 06c4f40a0ca..516df187af2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMoveProvider.java @@ -23,7 +23,7 @@ * @see MassListAssignMoveProvider A set of unassigned values drawn together. */ @NullMarked -public class ListAssignMoveProvider +public final class ListAssignMoveProvider implements MoveProvider { private final PlanningListVariableMetaModel variableMetaModel; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java index 584c6ebceb6..0ff9da924e1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java @@ -169,7 +169,9 @@ public void accept(Value_ leftValue, Value_ rightValue) { Entity_ rightEntity = rightPosition.entity(); var leftSize = solutionView.countValues(variableMetaModel, leftEntity); var rightSize = solutionView.countValues(variableMetaModel, rightEntity); - var reversing = selectReversingMoveToo && random.nextBoolean(); + var reversing = + selectReversingMoveToo && (leftSize - leftPosition.index() > 1 || rightSize - rightPosition.index() > 1) + && random.nextBoolean(); nextMove = Moves.swap(variableMetaModel, Range.of(leftEntity, leftPosition.index(), leftSize), Range.of(rightEntity, rightPosition.index(), rightSize), reversing); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java index e9637a69dd3..2f5aea03579 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java @@ -15,7 +15,7 @@ import org.jspecify.annotations.Nullable; @NullMarked -public class ListUnassignMove extends AbstractMove { +public final class ListUnassignMove extends AbstractMove { private final PlanningListVariableMetaModel variableMetaModel; private final Entity_ sourceEntity; @@ -23,7 +23,7 @@ public class ListUnassignMove extends AbstractMove variableMetaModel, + ListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Entity_ sourceEntity, int sourceIndex) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java index 6b8c42ed8c8..474f984cf76 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -99,15 +99,42 @@ public SequencedCollection getPlanningValues() { public boolean equals(Object o) { return o instanceof MassListChangeMove other && Objects.equals(variableMetaModel, other.variableMetaModel) - && Objects.equals(sample, other.sample) - && Objects.equals(destination, other.destination); + && Objects.equals(destination, other.destination) + && sampleEquals(other); + } + + // Insertion is order-sensitive (execute() gathers members in sample iteration order), + // so equality must be too: Move requires that equal moves produce the exact same solution. + // A null destination unassigns every member instead, where order does not affect the + // resulting solution, so Sample's own order-insensitive equality is correct there. + private boolean sampleEquals(MassListChangeMove other) { + if (destination == null) { + return Objects.equals(sample, other.sample); + } + if (sample.size() != other.sample.size()) { + return false; + } + var iterator = sample.iterator(); + var otherIterator = other.sample.iterator(); + while (iterator.hasNext()) { + if (!Objects.equals(iterator.next(), otherIterator.next())) { + return false; + } + } + return true; } @Override public int hashCode() { var hash = 31 + Objects.hashCode(variableMetaModel); - hash = hash * 31 + Objects.hashCode(sample); hash = hash * 31 + Objects.hashCode(destination); + if (destination == null) { + hash = hash * 31 + Objects.hashCode(sample); + } else { + for (var member : sample) { + hash = hash * 31 + Objects.hashCode(member); + } + } return hash; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java index f29cd7f94be..6bfdfe03f77 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProvider.java @@ -1,17 +1,13 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.Iterator; import java.util.Objects; -import java.util.random.RandomGenerator; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.util.MappingIterator; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; -import ai.timefold.solver.core.preview.api.move.Move; -import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; 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.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; @@ -61,13 +57,6 @@ public MassListUnassignMoveProvider(PlanningListVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var assignedValueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); - return moveStreamFactory.buildMoveStream((session, random) -> new MassListUnassignMoveIterator<>(session, random, - variableMetaModel, assignedValueDataset, sampler)); - } - /** * Draws mixed samples of currently-assigned values and unassigns every member, * producing a {@code MassListChangeMove} with a null destination. @@ -84,36 +73,13 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, * ending this iterator without spinning, * since each call is independent and tries a fresh source. - * - * @param the solution type - * @param the entity type - * @param the variable type */ - @NullMarked - private static final class MassListUnassignMoveIterator - implements Iterator> { - - private final PlanningListVariableMetaModel variableMetaModel; - private final Iterator> sampleIterator; - - MassListUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, - PlanningListVariableMetaModel variableMetaModel, - UniDataset assignedValueDataset, Sampler sampler) { - this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - var assignedValueInstance = session.getInstance(assignedValueDataset); - this.sampleIterator = assignedValueInstance.samplingIterator(Objects.requireNonNull(sampler), random); - } - - @Override - public boolean hasNext() { - return sampleIterator.hasNext(); - } - - @Override - public Move next() { - return Moves.massChange(variableMetaModel, sampleIterator.next(), null); - } - + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var assignedValueDataset = moveStreamFactory.forEachAssignedValue(variableMetaModel).asCachedDataset(); + return moveStreamFactory.buildMoveStream((session, random) -> new MappingIterator<>( + session.getInstance(assignedValueDataset).samplingIterator(sampler, random), + sample -> Moves.massChange(variableMetaModel, sample, null))); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java index 39937ed1e7d..d46e99a14c3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProvider.java @@ -1,16 +1,12 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.Iterator; import java.util.Objects; -import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.util.MappingIterator; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; -import ai.timefold.solver.core.preview.api.move.Move; -import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; 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.preview.api.neighborhood.stream.dataset.UniDataset; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sampler; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Samplers; @@ -64,13 +60,6 @@ public MassUnassignMoveProvider(PlanningVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var assignedEntityDataset = MoveProviderUtil.assignedEntityDataset(moveStreamFactory, variableMetaModel); - return moveStreamFactory.buildMoveStream((session, random) -> new MassUnassignMoveIterator<>(session, random, - variableMetaModel, assignedEntityDataset, sampler)); - } - /** * Draws mixed-value samples of currently-assigned entities and unassigns every member, * producing a {@code MassChangeMove} with a null destination. @@ -86,36 +75,13 @@ public MoveStream build(MoveStreamFactory moveStreamFactor * {@code samplingIterator}'s {@code hasNext()} then simply returns {@code false} for that call, * ending this iterator without spinning, * since each call is independent and tries a fresh source. - * - * @param the solution type - * @param the entity type - * @param the variable type */ - @NullMarked - private static final class MassUnassignMoveIterator - implements Iterator> { - - private final PlanningVariableMetaModel variableMetaModel; - private final Iterator> sampleIterator; - - MassUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, - PlanningVariableMetaModel variableMetaModel, - UniDataset assignedEntityDataset, Sampler sampler) { - this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - var assignedEntityInstance = session.getInstance(assignedEntityDataset); - this.sampleIterator = assignedEntityInstance.samplingIterator(Objects.requireNonNull(sampler), random); - } - - @Override - public boolean hasNext() { - return sampleIterator.hasNext(); - } - - @Override - public Move next() { - return Moves.massChange(variableMetaModel, sampleIterator.next(), null); - } - + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var assignedEntityDataset = MoveProviderUtil.assignedEntityDataset(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new MappingIterator<>( + session.getInstance(assignedEntityDataset).samplingIterator(sampler, random), + sample -> Moves.massChange(variableMetaModel, sample, null))); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index 6da7eff416e..688d59a040f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -95,9 +95,11 @@ public static Move change( * Both entities must be different instances. * After execution, the left entity will have the value that the right entity had, and vice versa. *

    - * Only provide entities whose values can be swapped; + * The caller MUST only provide entities whose values can be swapped; * for example, if one of the values is not in the value range of the other entity's variable, - * then swapping would lead to an invalid solution. + * swapping would lead to an invalid solution. + * This is not re-checked by the move; + * see {@link SwapMove} for what happens when a caller violates it. * * @param variableMetaModel describes the planning variable to swap * @param leftEntity the first entity participating in the swap @@ -122,9 +124,11 @@ public static Move swap( * For each variable in the list, after execution, the left entity will have the value that the right entity had, and vice * versa. *

    - * Only provide entities whose values can be swapped; + * The caller MUST only provide entities whose values can be swapped; * for example, if one of the values is not in the value range of the other entity's variable, - * then swapping would lead to an invalid solution. + * swapping would lead to an invalid solution. + * This is not re-checked by the move; + * see {@link SwapMove} for what happens when a caller violates it. * * @param variableMetaModelList the list of planning variables to swap; must not be empty. * Keep the variableMetaModelList list in stable order, @@ -180,8 +184,8 @@ public static Move pillarSwap( * Creates a move that swaps the values of one or more planning variables between the members of two {@link Sample}s. *

    * This is the pillar equivalent of {@link #swap(List, Object, Object)}. - * The two pillars must not share any members; violating this throws {@link IllegalArgumentException}. - * The caller MUST only pass homogeneous pillars (every member of a pillar holding the same value for each listed variable) + * The caller MUST only pass pillars that do not share any members, + * homogeneous pillars (every member of a pillar holding the same value for each listed variable), * and pillars whose values can be swapped; * for example, if one of the values is not in the value range of a member of the other pillar, * swapping would lead to an invalid solution. @@ -197,7 +201,7 @@ public static Move pillarSwap( * @param the solution type * @param the entity type * @return a move that, when executed, swaps all variable values between the two pillars - * @throws IllegalArgumentException if the list is empty or if the two pillars share any members + * @throws IllegalArgumentException if the list is empty */ @SuppressWarnings("unchecked") public static Move pillarSwap( diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java index 5732f4f560d..ef51c7a8eb6 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -23,8 +23,11 @@ * Requires to specify a (sub)set of variables to swap values of, * all of which must belong to the same entity class. *

    - * The two pillars must not share any members. - * The constructor does not otherwise check that the pillars are safe to swap; see below. + * The caller MUST only provide pillars that do not share any members. + * The constructor does not check this. + * If the pillars overlap, the shared member is written twice - once for each pillar it belongs to - + * and ends up holding whichever value was written last, losing its own original value with no exception. + * {@code PillarSwapMoveProvider} and {@code SubPillarSwapMoveProvider} only ever build disjoint pillars. *

    * Every member of a pillar is assumed to hold the same value for each of the listed variables * ({@link #getCachedValues()} reads the value off a single representative member, not off every member). @@ -136,6 +139,8 @@ public PillarSwapMove rebase(Lookup lookup) { @Override public SequencedCollection getPlanningEntities() { + // Does not deduplicate; if the pillars overlap (a caller error, see the class javadoc), + // the shared member is reported twice. var entityList = new ArrayList<>(leftPillar.size() + rightPillar.size()); for (var entity : leftPillar) { entityList.add(entity); diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java index c9edc03d285..0cd1f94a085 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProvider.java @@ -39,6 +39,9 @@ *

    * Draws whole pillars only, unbounded by design: a pillar move is defined as moving every member of the pillar, * so its cost is linear in the pillar's size with no cap. + *

    + * Unlike {@code SwapMoveProvider}, this never moves an unassigned value across: + * a pillar is keyed on a shared value, and unassigned is the absence of a value, not one. * * @see SubPillarSwapMoveProvider A sampler-driven, size-bounded subset of the pillar. * diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java index 250618d8100..bb7ad569dce 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProvider.java @@ -1,17 +1,12 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.Iterator; import java.util.Objects; -import java.util.random.RandomGenerator; +import ai.timefold.solver.core.impl.util.MappingIterator; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel; -import ai.timefold.solver.core.preview.api.move.Move; -import ai.timefold.solver.core.preview.api.neighborhood.MoveIteratorSession; import ai.timefold.solver.core.preview.api.neighborhood.MoveProvider; 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.preview.api.neighborhood.stream.dataset.UniDataset; -import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; import org.jspecify.annotations.NullMarked; @@ -59,50 +54,19 @@ public PillarUnassignMoveProvider(PlanningVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - var pillarDataset = MoveProviderUtil.assignedPillars(moveStreamFactory, variableMetaModel); - return moveStreamFactory.buildMoveStream((session, random) -> new PillarUnassignMoveIterator<>(session, random, - variableMetaModel, pillarDataset)); - } - /** * Draws whole pillars (one cached row per assigned value) and unassigns every member, * producing a {@code MassChangeMove} with a null destination. * The destination is fixed at null, so nothing can ever be rejected: * every drawn pillar yields a valid move, with no probing and no left value to retire. - * - * @param the solution type - * @param the entity type - * @param the variable type + * Plain sampling-with-replacement: nothing needs retiring, since a destination of null can never be rejected. */ - @NullMarked - private static final class PillarUnassignMoveIterator implements Iterator> { - - private final PlanningVariableMetaModel variableMetaModel; - private final Iterator> sampleIterator; - - public PillarUnassignMoveIterator(MoveIteratorSession session, RandomGenerator random, - PlanningVariableMetaModel variableMetaModel, - UniDataset> pillarDataset) { - this.variableMetaModel = Objects.requireNonNull(variableMetaModel); - var pillarInstance = session.getInstance(pillarDataset); - // Plain sampling-with-replacement: nothing needs retiring, - // since a destination of null can never be rejected. - this.sampleIterator = pillarInstance.iterator(random); - } - - @Override - public boolean hasNext() { - return sampleIterator.hasNext(); - } - - @Override - public Move next() { - var pillar = sampleIterator.next(); - return Moves.massChange(variableMetaModel, pillar, null); - } - + @Override + public MoveStream build(MoveStreamFactory moveStreamFactory) { + var pillarDataset = MoveProviderUtil.assignedPillars(moveStreamFactory, variableMetaModel); + return moveStreamFactory.buildMoveStream((session, random) -> new MappingIterator<>( + session.getInstance(pillarDataset).iterator(random), + pillar -> Moves.massChange(variableMetaModel, pillar, null))); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java index f39253315b2..b23f04705b1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java @@ -23,8 +23,9 @@ * Moves a contiguous span of a {@link PlanningListVariable list variable} to a different position, * possibly on a different entity. * The moved span is identified by a {@link Range}. - * The span is inserted starting at the given destination position, - * optionally in reverse element order. + * The span is inserted starting at the given destination position, optionally in reverse element order. + * When the destination is on the same entity as the source, the destination index is interpreted + * as if the span had already been removed - see {@link MutableSolutionView#moveValuesInList} for the exact contract. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the entity type, the class with the {@link PlanningEntity} annotation diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java index 14c565100d6..161149510fa 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java @@ -34,7 +34,7 @@ public class SubListUnassignMove extends AbstractMov private @Nullable List unassignedValues; - protected SubListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Range range) { + SubListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Range range) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.range = Objects.requireNonNull(range); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java index 2c99c7c2a59..2545d4dceb1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProvider.java @@ -50,6 +50,9 @@ * and passing two parameters says so explicitly. * Sharing one instance between both sides is safe - see {@link PillarSampler}'s class documentation for why - * but is rarely what a caller wants. + *

    + * Unlike {@code SwapMoveProvider}, this never moves an unassigned value across: + * a pillar is keyed on a shared value, and unassigned is the absence of a value, not one. * * @see PillarSwapMoveProvider The whole pillar on both sides at once. * diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java index e9df4d48268..ee89cf831b5 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java @@ -87,6 +87,31 @@ void reversingAppearsByDefaultAndCanBeDisabled() { assertThat(movesWithoutReversing).noneMatch(SubListSwapMove::isReversing); } + @Test + void reversingNeverHappensWhenBothTailsAreSingleElement() { + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + // Each entity holds a single value, so its only possible tail has length 1. + var entityA = new TestdataListEntity("A", new TestdataListValue("a0")); + var entityB = new TestdataListEntity("B", new TestdataListValue("b0")); + var solution = new TestdataListSolution(); + solution.setEntityList(List.of(entityA, entityB)); + solution.setValueList(List.of(entityA.getValueList().get(0), entityB.getValueList().get(0))); + + var context = NeighborhoodTester + .build(new ListTailSwapMoveProvider<>(variableMetaModel, true), solutionMetaModel) + .using(solution); + var moves = context + .getMovesAsStream( + move -> (SubListSwapMove) move) + .limit(300) + .toList(); + assertThat(moves).isNotEmpty(); + assertThat(moves).noneMatch(SubListSwapMove::isReversing); + } + @Test void sameEntityNeverProducesAMove() { var solutionMetaModel = TestdataListSolution.buildMetaModel(); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java index 2dad89ff364..45e61c12a34 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveTest.java @@ -164,7 +164,26 @@ void undoRestoresEveryMembersOriginalPosition() { } @Test - void equalsAndHashCodeIgnoreMemberDrawOrder() { + void equalsAndHashCodeIgnoreMemberDrawOrderWhenUnassigning() { + var value1 = new TestdataListValue("v1"); + var value2 = new TestdataListValue("v2"); + + var solutionMetaModel = TestdataListSolution.buildMetaModel(); + var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) + .listVariable("valueList", TestdataListValue.class); + + // A null destination unassigns every member; order does not affect the resulting solution. + var move1 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), null); + var move2 = Moves.massChange(variableMetaModel, Sample.of(List.of(value2, value1)), null); + + assertThat(move1).isEqualTo(move2); + assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isNotEqualTo(null); + assertThat(move1).isNotEqualTo("not a move"); + } + + @Test + void equalsAndHashCodeRespectMemberDrawOrderWhenInserting() { var value1 = new TestdataListValue("v1"); var value2 = new TestdataListValue("v2"); var entity = new TestdataListEntity("A", value1, value2); @@ -176,12 +195,16 @@ void equalsAndHashCodeIgnoreMemberDrawOrder() { var destination = ElementPosition.of(entity, 0); var otherDestination = ElementPosition.of(entity, 1); var move1 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), destination); - var move2 = Moves.massChange(variableMetaModel, Sample.of(List.of(value2, value1)), destination); + var move1SameOrder = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), destination); + // Insertion is order-sensitive, so equality must be too: + // Move requires that equal moves produce the exact same solution. + var move2DifferentOrder = Moves.massChange(variableMetaModel, Sample.of(List.of(value2, value1)), destination); var move3 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), otherDestination); var move4 = Moves.massChange(variableMetaModel, Sample.of(List.of(value1, value2)), null); - assertThat(move1).isEqualTo(move2); - assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); + assertThat(move1).isEqualTo(move1SameOrder); + assertThat(move1.hashCode()).isEqualTo(move1SameOrder.hashCode()); + assertThat(move1).isNotEqualTo(move2DifferentOrder); assertThat(move1).isNotEqualTo(move3); assertThat(move1).isNotEqualTo(move4); assertThat(move1).isNotEqualTo(null); From fe3083bce3a5929a2099a640a6c73c2106792989 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Wed, 26 Aug 2026 08:26:26 +0200 Subject: [PATCH 13/16] Finishing touches --- CONSTITUTION.md | 14 +++++++------- build/build-parent/pom.xml | 1 + .../gizmo/GizmoSolutionClonerImplementor.java | 12 ++++++------ .../mimic/MimicReplayingEntitySelector.java | 3 ++- .../stream/dataset/sample/SampleAssembler.java | 12 ++++++++++-- .../core/impl/util/TriangleElementFactory.java | 5 +++++ .../stream/dataset/sample/Sample.java | 7 ++++++- .../dataset/sample/SamplingIteratorTest.java | 16 ++++++++++++++++ .../ScoreDirectorFactoryFactoryTest.java | 3 ++- .../impl/util/TriangleElementFactoryTest.java | 13 +++++++++++++ .../builtin/SubListSwapMoveProviderTest.java | 3 ++- .../classloader/TestdataDomainImplementor.java | 12 ++++++------ .../json/api/schema/OffsetDateTimeFormat.java | 4 +++- .../deployment/TimefoldRESTProcessor.java | 3 ++- .../rest/ModelResourceBeanGenerator.java | 3 ++- .../worker/impl/DefaultSolverWorkerFacade.java | 3 ++- 16 files changed, 85 insertions(+), 29 deletions(-) diff --git a/CONSTITUTION.md b/CONSTITUTION.md index d4980867a99..d333a1e373f 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -13,13 +13,14 @@ Key words per [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html): **MUST** **Documentation**: -1. **Public API** (MUST): All public classes/interfaces/methods MUST have Javadoc with `@param`, `@return`, `@throws` (omit NPE for null params; omit if present on referenced overload). `@since` optional. -2. **User-facing** (MUST): +1. **Public API**: All public classes/interfaces/methods MUST have Javadoc with `@param`, `@return`, `@throws` (omit NPE for null params; omit if present on referenced overload). `@since` optional. +2. **User-facing**: - New features → user guide; - Config changes → reference docs; - Breaking changes → migration guides. -3. **Examples** (SHOULD): Docs SHOULD include code examples; quickstart examples SHOULD be added externally. -4. **Implementation** (SHOULD): Complex classes SHOULD have class-level Javadoc; non-obvious details SHOULD have comments. +3. **Examples**: Docs SHOULD include code examples; quickstart examples SHOULD be added externally. +4. **Implementation**: Complex classes SHOULD have class-level Javadoc; non-obvious details SHOULD have comments. +5. **Formatting**: Any form of documentation (being source code comments, Javadoc, or user documentation) must follow [Semantic Line Breaks](https://sembr.org). ### II. Consistent Terminology @@ -88,11 +89,10 @@ Performance/stress tests are in other repositories; not applicable here. **Style**: 1. Newlines: formatter preserves them; use sparingly to separate logical blocks 2. Field access order in methods SHOULD match declaration order -3. Imports MUST be used; no fully qualified names in source (exception: disambiguating same simple name) -4. `var` preferred over diamond; diamond MUST be used when `var` not used and type is inferable +3. `var` preferred over diamond; diamond MUST be used when `var` not used and type is inferable - ✅ `var list = new ArrayList();`; ✅ `List list = new ArrayList<>();` - ❌ `var list = new ArrayList<>();` (compiler error); ❌ `List list = new ArrayList();` -5. Asterisk imports MUST NOT be used +4. Asterisk imports MUST NOT be used **SonarCloud Quality Gates** (MUST): - Reliability and Maintainability grades MUST be B or better; PRs worsening below B fail CI diff --git a/build/build-parent/pom.xml b/build/build-parent/pom.xml index c748caa6cca..2a6541d8ba7 100644 --- a/build/build-parent/pom.xml +++ b/build/build-parent/pom.xml @@ -593,6 +593,7 @@ eclipse.importorder + Remove wildcard imports import\s+[^\*\s]+\*;(\r\n|\r|\n) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java index e01c81142fb..e85deee9280 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java @@ -516,7 +516,7 @@ private static void writeDeepCloneInstructions(ClonerDescriptor clonerDescriptor */ private static void writeDeepCloneInstructions(ClonerDescriptor clonerDescriptor, ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, + Class deeplyClonedFieldClass, Type type, Var toClone, Var cloneResultHolder) { BlockCreator blockCreator = clonerMethodDescriptor.blockCreator; @@ -593,7 +593,7 @@ private static void writeDeepCloneSolutionInstructions( **/ private static void writeDeepCloneCollectionInstructions(ClonerDescriptor clonerDescriptor, ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, + Class deeplyClonedFieldClass, Type type, Var toClone, Var cloneResultHolder) { var blockCreator = clonerMethodDescriptor.blockCreator; @@ -607,7 +607,7 @@ private static void writeDeepCloneCollectionInstructions(ClonerDescriptor cloner blockCreator.localVar(toClone.name() + "$Iterator", blockCreator.withCollection(toClone).iterator()); blockCreator.while_(condition -> condition.yield(condition.withIterator(iterator).hasNext()), whileLoopBlock -> { Class elementClass; - java.lang.reflect.Type elementClassType; + Type elementClassType; if (type instanceof ParameterizedType parameterizedType) { // Assume Collection follow Collection convention of first type argument = element class elementClassType = parameterizedType.getActualTypeArguments()[0]; @@ -688,7 +688,7 @@ private static void checkCastAndAssign(BlockCreator blockCreator, Class deepl **/ private static void writeDeepCloneMapInstructions(ClonerDescriptor clonerDescriptor, ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, + Class deeplyClonedFieldClass, Type type, Var toClone, Var cloneResultHolder) { var blockCreator = clonerMethodDescriptor.blockCreator; @@ -705,8 +705,8 @@ private static void writeDeepCloneMapInstructions(ClonerDescriptor clonerDescrip blockCreator.while_(condition -> condition.yield(condition.withIterator(iterator).hasNext()), whileLoopBlock -> { Class keyClass; Class elementClass; - java.lang.reflect.Type keyType; - java.lang.reflect.Type elementClassType; + Type keyType; + Type elementClassType; if (type instanceof ParameterizedType parameterizedType) { // Assume Map follow Map convention of second type argument = value class keyType = parameterizedType.getActualTypeArguments()[0]; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/mimic/MimicReplayingEntitySelector.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/mimic/MimicReplayingEntitySelector.java index ebea5b2ecd3..5e38617a7e6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/mimic/MimicReplayingEntitySelector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/entity/mimic/MimicReplayingEntitySelector.java @@ -8,11 +8,12 @@ import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.heuristic.selector.AbstractDemandEnabledSelector; import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.SelectionIterator; +import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope; public class MimicReplayingEntitySelector extends AbstractDemandEnabledSelector - implements ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector { + implements EntitySelector { private final EntityMimicRecorder entityMimicRecorder; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 86e7292a964..23fd801be82 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -61,9 +61,17 @@ public final class SampleAssembler { var candidate = sourceIterator.next(); var decision = evaluator.evaluate(memberSet.size(), candidate); if (decision == ACCEPT || decision == ACCEPT_AND_STOP) { - memberSet.add(candidate); + // A duplicate candidate (possible with concat/join sources) does not grow memberSet, + // so an ACCEPT_AND_STOP on one must not end the sample early: the sampler's size + // accounting assumed a new member, per the contract documented on Sample.Decision. + var isNewMember = memberSet.add(candidate); + if (decision == ACCEPT_AND_STOP && isNewMember) { + stoppedBySampler = true; + break; + } + continue; } - if (decision == STOP || decision == ACCEPT_AND_STOP) { + if (decision == STOP) { stoppedBySampler = true; break; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java index 89df3f276f4..c69397d271e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/TriangleElementFactory.java @@ -50,6 +50,11 @@ public static void validateSizes(int minimumSubListSize, int maximumSubListSize) * @throws IllegalArgumentException if {@code listSize} is less than {@code minimumSubListSize} */ public TriangleElement nextElement(int listSize) throws IllegalArgumentException { + if (listSize < minimumSubListSize) { + throw new IllegalArgumentException( + "The listSize (%d) must be at least the minimumSubListSize (%d)." + .formatted(listSize, minimumSubListSize)); + } // Reduce the triangle base by the minimum subList size. var subListCount = nthTriangle(listSize - minimumSubListSize + 1); // The top triangle represents all subLists of size greater or equal to maximum subList size. Remove them all. diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 7abc78ed7f3..958ce0f5c37 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -73,7 +73,12 @@ enum Decision { ACCEPT, /** Leave the candidate out and continue. */ REJECT, - /** Take the candidate and finish the sample. */ + /** + * Take the candidate and finish the sample. + * If the candidate is already a member (a duplicate emitted by the source iterator), + * it does not grow the sample and is treated as {@link #ACCEPT} instead of stopping - + * a sample is never finished early on the strength of a size count that didn't actually increase. + */ ACCEPT_AND_STOP, /** Leave the candidate out and finish the sample. */ STOP diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 5d3c55c5097..500c5ecae01 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -175,6 +175,22 @@ public Decision evaluate(int sizeSoFar, Candidate candidate) { assertThat(SampleAssembler.assemble(source, random, sampler)).isNull(); } + @Test + void duplicateCandidateDoesNotEndSampleBelowMinimumSize() { + // A duplicate right where Samplers.exactly(2) would decide ACCEPT_AND_STOP (sizeSoFar + 1 >= 2) + // must not end the sample: the duplicate does not grow the distinct member set, so the sampler's + // size accounting was wrong about having reached its target. The source must still be drained + // until a genuinely new member arrives, per the contract now documented on Sample.Decision.ACCEPT_AND_STOP. + var source = List.of("a", "a", "b").iterator(); + var random = RandomSource.seeded(0L).moveIteratorUsage(); + + var sample = SampleAssembler.assemble(source, random, Samplers. exactly(2)); + + assertThat(sample).isNotNull(); + assertThat(sample.size()).isEqualTo(2); + assertThat(sample).contains("a", "b"); + } + // ===== Bi tests ===== @Test diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java index 1f2076cfe1a..edbdf2fe84d 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java @@ -15,6 +15,7 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; +import ai.timefold.solver.core.testdomain.TestdataConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; @@ -94,7 +95,7 @@ public static class TestdataConstraintProvider implements ConstraintProvider { @Test void incrementalMultipleScoreCalculations_throwsException() { var config = new ScoreDirectorFactoryConfig() - .withConstraintProviderClass(ai.timefold.solver.core.testdomain.TestdataConstraintProvider.class) + .withConstraintProviderClass(TestdataConstraintProvider.class) .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> buildTestdataScoreDirectoryFactory(config)) .withMessageContaining("scoreDirectorFactory") diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java index ae026ed55bf..bcf7e7cdee4 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/TriangleElementFactoryTest.java @@ -84,4 +84,17 @@ void nextElement_invalidListSize() { var factory = new TriangleElementFactory(minSize, minSize + 1, new Random()); assertThatIllegalArgumentException().isThrownBy(() -> factory.nextElement(minSize - 1)); } + + @Test + void nextElement_listSizeFarBelowMinimum() { + // A listSize this far below minimumSubListSize makes nthTriangle()'s argument negative enough + // (listSize - minimumSubListSize + 1 == -2) that it returns a small positive, non-throwing result + // (nthTriangle(-2) == 1) instead of the 0 that the listSize == minimumSubListSize - 1 case above + // happens to produce. Without an explicit guard, nextElement() would silently return a bogus + // TriangleElement instead of enforcing its documented contract. + var minSize = 5; + var factory = new TriangleElementFactory(minSize, minSize + 1, new Random()); + assertThatIllegalArgumentException().isThrownBy(() -> factory.nextElement(minSize - 3)) + .withMessageContaining("must be at least the minimumSubListSize"); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java index 39f3c864c49..4b107419b0d 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.stream.Collectors; @@ -73,7 +74,7 @@ void eachSideRespectsItsOwnSizeBounds() { var entityB = new TestdataListEntity("B", valuesB); var solution = new TestdataListSolution(); solution.setEntityList(List.of(entityA, entityB)); - var allValues = new java.util.ArrayList(); + var allValues = new ArrayList(); allValues.addAll(List.of(valuesA)); allValues.addAll(List.of(valuesB)); solution.setValueList(allValues); diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/classloader/TestdataDomainImplementor.java b/core/src/test/java/ai/timefold/solver/core/testdomain/classloader/TestdataDomainImplementor.java index 1ae51a85d8a..3f42ab969c0 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/classloader/TestdataDomainImplementor.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/classloader/TestdataDomainImplementor.java @@ -273,13 +273,13 @@ private static void generateTestdataSolution(Gizmo gizmo) { // private List valueList; var valueListField = classCreator.field("valueList", fieldCreator -> { fieldCreator.private_(); - fieldCreator.setType(java.util.List.class); + fieldCreator.setType(List.class); }); // private List entityList; var entityListField = classCreator.field("entityList", fieldCreator -> { fieldCreator.private_(); - fieldCreator.setType(java.util.List.class); + fieldCreator.setType(List.class); }); // private SimpleScore score; @@ -311,7 +311,7 @@ private static void generateTestdataSolution(Gizmo gizmo) { // @ValueRangeProvider(id = "valueRange") @ProblemFactCollectionProperty public List getValueList() classCreator.method("getValueList", methodCreator -> { methodCreator.public_(); - methodCreator.returning(GenericType.of(java.util.List.class, List.of(TypeArgument.of(valueClassDesc)))); + methodCreator.returning(GenericType.of(List.class, List.of(TypeArgument.of(valueClassDesc)))); methodCreator.addAnnotation(ValueRangeProvider.class, annotationCreator -> annotationCreator.add("id", "valueRange")); methodCreator.addAnnotation(ProblemFactCollectionProperty.class); @@ -322,7 +322,7 @@ private static void generateTestdataSolution(Gizmo gizmo) { classCreator.method("setValueList", methodCreator -> { methodCreator.public_(); methodCreator.returning(void.class); - var valueList = methodCreator.parameter("valueList", java.util.List.class); + var valueList = methodCreator.parameter("valueList", List.class); methodCreator.body(blockCreator -> { blockCreator.set(methodCreator.this_().field(valueListField), valueList); blockCreator.return_(); @@ -332,7 +332,7 @@ private static void generateTestdataSolution(Gizmo gizmo) { // @PlanningEntityCollectionProperty public List getEntityList() classCreator.method("getEntityList", methodCreator -> { methodCreator.public_(); - methodCreator.returning(GenericType.of(java.util.List.class, List.of(TypeArgument.of(entityClassDesc)))); + methodCreator.returning(GenericType.of(List.class, List.of(TypeArgument.of(entityClassDesc)))); methodCreator.addAnnotation(PlanningEntityCollectionProperty.class); methodCreator.body(blockCreator -> blockCreator.return_(methodCreator.this_().field(entityListField))); }); @@ -341,7 +341,7 @@ private static void generateTestdataSolution(Gizmo gizmo) { classCreator.method("setEntityList", methodCreator -> { methodCreator.public_(); methodCreator.returning(void.class); - var entityList = methodCreator.parameter("entityList", java.util.List.class); + var entityList = methodCreator.parameter("entityList", List.class); methodCreator.body(blockCreator -> { blockCreator.set(methodCreator.this_().field(entityListField), entityList); blockCreator.return_(); diff --git a/service/json/src/main/java/ai/timefold/solver/service/json/api/schema/OffsetDateTimeFormat.java b/service/json/src/main/java/ai/timefold/solver/service/json/api/schema/OffsetDateTimeFormat.java index a182502eb66..b6c86ce0b49 100644 --- a/service/json/src/main/java/ai/timefold/solver/service/json/api/schema/OffsetDateTimeFormat.java +++ b/service/json/src/main/java/ai/timefold/solver/service/json/api/schema/OffsetDateTimeFormat.java @@ -1,5 +1,7 @@ package ai.timefold.solver.service.json.api.schema; +import java.time.format.DateTimeParseException; + import com.networknt.schema.format.AbstractFormat; /** @@ -21,7 +23,7 @@ public boolean matches(String value) { try { java.time.OffsetDateTime.parse(value); return true; - } catch (java.time.format.DateTimeParseException e) { + } catch (DateTimeParseException e) { return false; } } diff --git a/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/TimefoldRESTProcessor.java b/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/TimefoldRESTProcessor.java index 0af0898f232..28254ebb213 100644 --- a/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/TimefoldRESTProcessor.java +++ b/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/TimefoldRESTProcessor.java @@ -33,6 +33,7 @@ import ai.timefold.solver.service.quarkus.deployment.util.ProcessorUtils; import ai.timefold.solver.service.rest.api.ModelRest; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.AnnotationTarget.Kind; import org.jboss.jandex.ClassInfo; @@ -273,7 +274,7 @@ private void generateDemoDataRestAPI(CombinedIndexBuildItem combinedIndex, .build(); beanCreator.addAnnotation(Path.class).addValue("value", resourcePath); - beanCreator.addAnnotation(org.eclipse.microprofile.openapi.annotations.tags.Tag.class) + beanCreator.addAnnotation(Tag.class) .add("description", "Generated demo data for the model").add("name", "Demo data"); ClassInfo restResourceClassInfo = combinedIndex.getIndex().getClassByName(ABSTRACT_DEMO_DATA_RESOURCE); diff --git a/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/rest/ModelResourceBeanGenerator.java b/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/rest/ModelResourceBeanGenerator.java index 916d73f5613..7abd88416cc 100644 --- a/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/rest/ModelResourceBeanGenerator.java +++ b/service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/rest/ModelResourceBeanGenerator.java @@ -6,6 +6,7 @@ import java.lang.reflect.Modifier; import jakarta.inject.Inject; +import jakarta.ws.rs.Path; import ai.timefold.solver.service.quarkus.deployment.GeneratedJaxRsResourceGizmoAdaptor; import ai.timefold.solver.service.quarkus.deployment.builditem.ModelComponentsBuildItem; @@ -140,7 +141,7 @@ private void createConstructors(String beanName, ModelResourceTypeInfo resourceB */ private void copyMethodPathAnnotations(ClassInfo restAPI, ClassCreator beanCreator) { for (MethodInfo method : restAPI.methods()) { - if (method.hasAnnotation(jakarta.ws.rs.Path.class)) { + if (method.hasAnnotation(Path.class)) { MethodCreator additionalRestMethod = beanCreator .getMethodCreator(method.name(), method.returnType().toString(), method.parameters().stream().map(p -> p.type().toString()).toArray(String[]::new)) diff --git a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/DefaultSolverWorkerFacade.java b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/DefaultSolverWorkerFacade.java index 2d8fefce53c..49b8469ff1e 100644 --- a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/DefaultSolverWorkerFacade.java +++ b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/DefaultSolverWorkerFacade.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Set; @@ -205,7 +206,7 @@ ModelRequest getModelRequest(String i public LogInfo getLogs(String id) { LogInfo podLogInfo = null; LogInfo logs = storageService.getLogs(id); - java.nio.file.Path solverLogPath = Paths.get(LoggingConstants.SOLVER_LOG_PATH); + Path solverLogPath = Paths.get(LoggingConstants.SOLVER_LOG_PATH); if (Files.exists(solverLogPath)) { try { podLogInfo = new LogInfo( From 4d4db10a8d38cff63bb8f5a750d08d46b042d024 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Wed, 26 Aug 2026 10:05:50 +0200 Subject: [PATCH 14/16] Sonar and others --- .../move/builtin/ListSwapMoveProvider.java | 9 ++- .../stream/dataset/sample/Sample.java | 5 +- .../stream/dataset/sample/SubListSampler.java | 3 + .../builtin/ListSwapMoveProviderTest.java | 45 +++++------ .../builtin/MassAssignMoveProviderTest.java | 20 +++-- .../builtin/MassChangeMoveProviderTest.java | 76 +++++++++--------- .../MassListAssignMoveProviderTest.java | 59 +++++++------- .../MassListChangeMoveProviderTest.java | 78 +++++++++---------- .../MassListUnassignMoveProviderTest.java | 22 ++---- .../builtin/MassUnassignMoveProviderTest.java | 34 ++++---- .../builtin/PillarChangeMoveProviderTest.java | 12 +-- .../builtin/PillarSwapMoveProviderTest.java | 16 ++-- .../PillarUnassignMoveProviderTest.java | 46 +++++------ .../SubListChangeMoveProviderTest.java | 47 +++++------ .../builtin/SubListSwapMoveProviderTest.java | 46 ++++++----- .../SubListUnassignMoveProviderTest.java | 14 ++-- .../SubPillarChangeMoveProviderTest.java | 26 +++---- .../SubPillarSwapMoveProviderTest.java | 27 +++---- .../SubPillarUnassignMoveProviderTest.java | 36 ++++----- .../builtin/TwoOptListMoveProviderTest.java | 51 ++++++------ 20 files changed, 332 insertions(+), 340 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java index 7a161465e37..26dad5484c1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProvider.java @@ -26,9 +26,12 @@ public ListSwapMoveProvider(PlanningListVariableMetaModel build(MoveStreamFactory moveStreamFactory) { - // Unassigned values are admitted too, unconditionally, mirroring SwapMoveProvider: - // for a basic variable, unassigned is just the value null, and swap handles it for free. - var valueStream = moveStreamFactory.forEach(variableMetaModel.type(), false) + // Unassigned values are admitted only when the variable allows them: + // the one-side-unassigned case below composes unassign+assign, which would otherwise + // leave a value unassigned on a variable that forbids it. + var valueStream = (variableMetaModel.allowsUnassignedValues() + ? moveStreamFactory.forEach(variableMetaModel.type(), false) + : moveStreamFactory.forEachAssignedValue(variableMetaModel)) .map((solutionView, value) -> new FullElementPosition<>(value, solutionView.getPositionOf(variableMetaModel, value))); var predicate = diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 958ce0f5c37..9b176652e85 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -17,8 +17,11 @@ * but it says nothing about the solution, * so every value a move needs must be read from the live solution. *

    - * Two samples are equal when they hold the same members, + * Two samples built by this framework are equal + * when they hold the same members, * whatever the order they were drawn in. + * Equality is not defined between a framework sample + * and a foreign implementation of this interface. *

    * An implementation must be immutable: * {@link #size()} must always agree with {@link #iterator()}, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java index cdc2ff00595..476375cc2b1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -38,6 +38,7 @@ public interface SubListSampler { int DEFAULT_MAXIMUM_SUB_LIST_SIZE = 10; /** + * @param solutionView the view of the solution the span is drawn from * @param seedValue an assigned value; only used to pick the entity, never anchored to its own position * @return a freshly drawn span over the entity's whole unpinned window, or {@code null} * if that window is smaller than the minimum sub-list size @@ -46,6 +47,8 @@ public interface SubListSampler { Range byValue(SolutionView solutionView, Value_ seedValue); /** + * @param solutionView the view of the solution the span is drawn from + * @param entity the entity whose unpinned window the span is drawn over * @return a freshly drawn span over the entity's whole unpinned window, * or {@code null} if that window is smaller than the minimum sub-list size */ diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java index b7404ad588d..20a9300fca0 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMoveProviderTest.java @@ -62,30 +62,27 @@ void fromSolution() { // - assignedValue1 (e1@0) <-> assignedValue2 (e2@0) // - assignedValue1 (e1@0) <-> assignedValue3 (e2@1) // - assignedValue2 (e2@0) <-> assignedValue3 (e2@1), within e2 - // The fourth value is unassigned: - // it swaps against every assigned position too, as a composite unassign-then-assign at that same position - // (this domain disallows unassigned values, - // but nothing in the raw state prevents this - - // real solving would never leave it unassigned in the first place, - // so the composite would never actually arise in practice). - NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) - .using(solution) - .producesAllOf( - Moves.swap(variableMetaModel, e1, 0, e2, 0), - Moves.swap(variableMetaModel, e2, 0, e1, 0), - Moves.swap(variableMetaModel, e1, 0, e2, 1), - Moves.swap(variableMetaModel, e2, 1, e1, 0), - Moves.swap(variableMetaModel, e2, 0, e2, 1), - Moves.swap(variableMetaModel, e2, 1, e2, 0), - Moves.compose( - Moves.unassign(variableMetaModel, e1, 0), - Moves.assign(variableMetaModel, unassignedValue, e1, 0)), - Moves.compose( - Moves.unassign(variableMetaModel, e2, 0), - Moves.assign(variableMetaModel, unassignedValue, e2, 0)), - Moves.compose( - Moves.unassign(variableMetaModel, e2, 1), - Moves.assign(variableMetaModel, unassignedValue, e2, 1))); + // The fourth value is unassigned; this domain disallows unassigned values, + // so it must never enter a swap at all - not even as a composite unassign-then-assign. + var context = NeighborhoodTester.build(new ListSwapMoveProvider<>(variableMetaModel), solutionMetaModel) + .using(solution); + context.producesAllOf( + Moves.swap(variableMetaModel, e1, 0, e2, 0), + Moves.swap(variableMetaModel, e2, 0, e1, 0), + Moves.swap(variableMetaModel, e1, 0, e2, 1), + Moves.swap(variableMetaModel, e2, 1, e1, 0), + Moves.swap(variableMetaModel, e2, 0, e2, 1), + Moves.swap(variableMetaModel, e2, 1, e2, 0)); + context.producesNoneOf( + Moves.compose( + Moves.unassign(variableMetaModel, e1, 0), + Moves.assign(variableMetaModel, unassignedValue, e1, 0)), + Moves.compose( + Moves.unassign(variableMetaModel, e2, 0), + Moves.assign(variableMetaModel, unassignedValue, e2, 0)), + Moves.compose( + Moves.unassign(variableMetaModel, e2, 1), + Moves.assign(variableMetaModel, unassignedValue, e2, 1))); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java index 824354d30d3..8dd672b7201 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassAssignMoveProviderTest.java @@ -48,14 +48,12 @@ void sampleMembersAreAlwaysASubsetOfTheUnassignedEntities() { .toList(); assertThat(moves).isNotEmpty(); var unassignedEntitySet = new HashSet<>(allUnassigned); - for (var move : moves) { - var members = move.getPlanningEntities(); - assertThat(members).hasSizeLessThanOrEqualTo(2); - for (var member : members) { - assertThat(unassignedEntitySet.contains(member)).isTrue(); - } + assertThat(moves).allSatisfy(move -> { + assertThat(move.getPlanningEntities()) + .hasSizeLessThanOrEqualTo(2) + .allMatch(unassignedEntitySet::contains); assertThat(move.getPlanningValues().getFirst()).isEqualTo(value); - } + }); } @Test @@ -108,10 +106,10 @@ void pinnedUnassignedEntityNeverJoinsASample() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java index c0bd70c4108..972245b3ab5 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMoveProviderTest.java @@ -41,14 +41,13 @@ void mixedValueSampleYieldsOneMoveSettingEveryMemberToALegalValue() { var moves = context.getMovesAsStream(move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getPlanningValues().getFirst(); - for (var member : move.getPlanningEntities()) { - assertThat(member).isIn(entity0, entity1); - } - assertThat(destination).isIn(solution.getValueList()); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getPlanningValues().getFirst(); + assertThat(move.getPlanningEntities()).isSubsetOf(entity0, entity1); + assertThat(destination).isIn(solution.getValueList()); + }); } @Test @@ -77,11 +76,12 @@ void assignSideDisabledExcludesUnassignedEntities() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(unassigned); - assertThat(move.getPlanningValues().getFirst()).isNotNull(); // No unassign either, without crossingNull. - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getPlanningEntities()).doesNotContain(unassigned); + assertThat(move.getPlanningValues().getFirst()).isNotNull(); // No unassign either, without crossingNull. + }); } @Test @@ -107,23 +107,21 @@ void mixedSampleMayAssignOrUnassign() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - var sawAssign = false; - var sawUnassign = false; - for (var move : moves) { - assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(unassigned, assigned); - var destination = move.getPlanningValues().getFirst(); - if (destination == null) { - sawUnassign = true; // Unassigns the assigned member; the already-unassigned one stays as is. - } else { - assertThat(destination).isEqualTo(value); - sawAssign = true; // Assigns the unassigned member. - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(unassigned, assigned); + var destination = move.getPlanningValues().getFirst(); + if (destination != null) { + assertThat(destination).isEqualTo(value); + } + }); // The relaxed assertion above would also pass if one branch never fired; // these two lines make sure the relaxation isn't silently hiding a broken branch. - assertThat(sawAssign).isTrue(); - assertThat(sawUnassign).isTrue(); + // A null destination unassigns the assigned member; a non-null destination assigns the unassigned member. + assertThat(moves) + .anyMatch(move -> move.getPlanningValues().getFirst() == null) + .anyMatch(move -> move.getPlanningValues().getFirst() != null); } @Test @@ -174,10 +172,9 @@ void homogeneousSampleNeverProducesANoOpMove() { var moves = context.getMovesAsStream(move -> (MassChangeMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningValues().getFirst()).isNotEqualTo(sharedValue); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getPlanningValues().getFirst()).isNotEqualTo(sharedValue)); } @Test @@ -253,10 +250,9 @@ void emptyIntersectionStillYieldsNullDestinationWhenCrossingNull() { move -> (MassChangeMove) move) .limit(10) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getPlanningValues().getFirst()).isNull()); } @Test @@ -282,10 +278,10 @@ void pinnedEntitySharingAValueWithFreeEntitiesNeverJoinsASample() { .getMovesAsStream(move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java index f3f8bf19b6e..749bd5ece3c 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListAssignMoveProviderTest.java @@ -60,13 +60,12 @@ void sampleMembersAreAlwaysASubsetOfTheUnassignedValues() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getDestination()).isNotNull(); - for (var member : move.getSample()) { - assertThat(member).isIn(unassigned1, unassigned2).isNotEqualTo(assigned); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getDestination()).isNotNull(); + assertThat(move.getSample()).isSubsetOf(unassigned1, unassigned2).doesNotContain(assigned); + }); } @Test @@ -90,15 +89,14 @@ void destinationRespectsEveryMembersValueRange() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getDestination(); - assertThat(destination).isNotNull(); - TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); - for (var member : move.getSample()) { - assertThat(destinationEntity.getValueRange()).contains(member); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); + assertThat(destinationEntity.getValueRange()).containsAll(move.getSample()); + }); } @Test @@ -126,12 +124,13 @@ void destinationNeverFallsInThePinnedPrefix() { move -> (MassListChangeMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getDestination(); - assertThat(destination).isNotNull(); - assertThat(destination.index()).isGreaterThanOrEqualTo(2); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + assertThat(destination.index()).isGreaterThanOrEqualTo(2); + }); } @Test @@ -149,7 +148,7 @@ void fullyPinnedEntityIsNeverAssignedInto() { var solution = new TestdataPinnedAllowsUnassignedValuesListSolution(); solution.setEntityList(List.of(pinnedEntity, freeEntity)); - solution.setValueList(List.of(pinnedValues.get(0), unassigned1, unassigned2)); + solution.setValueList(List.of(pinnedValues.getFirst(), unassigned1, unassigned2)); var context = NeighborhoodTester .build(new MassListAssignMoveProvider<>(variableMetaModel, Samplers.exactly(2)), solutionMetaModel) @@ -159,12 +158,14 @@ void fullyPinnedEntityIsNeverAssignedInto() { move -> (MassListChangeMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getDestination(); - assertThat(destination).isNotNull(); - assertThat(destination. entity()).isNotEqualTo(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getDestination(); + assertThat(destination).isNotNull(); + assertThat(destination. entity()) + .isNotEqualTo(pinnedEntity); + }); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java index 263e06ef894..fca48ae1cfd 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProviderTest.java @@ -77,12 +77,9 @@ void sampleNeverIncludesAnUnassignedValue() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - for (var member : move.getSample()) { - assertThat(member).isNotEqualTo(unassigned); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSample()).doesNotContain(unassigned)); } @Test @@ -108,17 +105,15 @@ void destinationRespectsEveryMembersValueRange() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getDestination(); - if (destination == null) { - continue; - } - TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); - for (var member : move.getSample()) { - assertThat(destinationEntity.getValueRange()).contains(member); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getDestination(); + if (destination != null) { + TestdataListUnassignedEntityProvidingEntity destinationEntity = destination.entity(); + assertThat(destinationEntity.getValueRange()).containsAll(move.getSample()); + } + }); } @Test @@ -168,8 +163,9 @@ void crossingNullFalseNeverUnassignsSample() { move -> (MassListChangeMove) move) .limit(500) .toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(move -> move.getDestination() == null); + assertThat(moves) + .isNotEmpty() + .noneMatch(move -> move.getDestination() == null); } @Test @@ -233,12 +229,9 @@ void pinnedValueNeverJoinsASample() { move -> (MassListChangeMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - for (var member : move.getSample()) { - assertThat(member).isNotIn(pinned1, pinned2); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSample()).doesNotContain(pinned1, pinned2)); } @Test @@ -266,13 +259,14 @@ void destinationNeverFallsInThePinnedPrefix() { move -> (MassListChangeMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var destination = move.getDestination(); - if (destination != null) { - assertThat(destination.index()).isGreaterThanOrEqualTo(2); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var destination = move.getDestination(); + if (destination != null) { + assertThat(destination.index()).isGreaterThanOrEqualTo(2); + } + }); } @Test @@ -309,16 +303,16 @@ void fullyPinnedEntityIsNeverDrawnFromOrInto() { move -> (MassListChangeMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - for (var member : move.getSample()) { - assertThat(pinnedEntity.getValueList()).doesNotContain(member); - } - var destination = move.getDestination(); - if (destination != null) { - assertThat(destination. entity()).isNotEqualTo(pinnedEntity); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(pinnedEntity.getValueList()).doesNotContainAnyElementsOf(move.getSample()); + var destination = move.getDestination(); + if (destination != null) { + assertThat(destination. entity()) + .isNotEqualTo(pinnedEntity); + } + }); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java index 2a411cb7834..63f967c59e0 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassListUnassignMoveProviderTest.java @@ -60,9 +60,7 @@ void everyMoveHasANullDestination() { .limit(50) .toList(); assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getDestination()).isNull(); - } + assertThat(moves).allSatisfy(move -> assertThat(move.getDestination()).isNull()); } @Test @@ -115,12 +113,9 @@ void pinnedValueNeverJoinsASample() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - for (var member : move.getSample()) { - assertThat(member).isNotIn(pinned1, pinned2); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSample()).doesNotContain(pinned1, pinned2)); } @Test @@ -154,12 +149,9 @@ void fullyPinnedEntityContributesNoMembers() { move -> (MassListChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - for (var member : move.getSample()) { - assertThat(pinnedValues).doesNotContain(member); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSample()).doesNotContainAnyElementsOf(pinnedValues)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java index fe8e855b49d..a05e2aa8bd3 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/MassUnassignMoveProviderTest.java @@ -53,13 +53,14 @@ void mixedValueSampleYieldsOneMoveNullingEveryMember() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - // Only already-assigned entities can be drawn; already-unassigned ones own no move here. - assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); - assertThat(move.getPlanningEntities()).isSubsetOf(e0, e1); - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + // Only already-assigned entities can be drawn; already-unassigned ones own no move here. + assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); + assertThat(move.getPlanningEntities()).isSubsetOf(e0, e1); + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test @@ -80,11 +81,12 @@ void sizeOneSampleStillYieldsAMoveAndTheProviderIsNotCutOffEarly() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).hasSize(50); - for (var move : moves) { - assertThat(move.getPlanningEntities()).hasSize(1); - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .hasSize(50) + .allSatisfy(move -> { + assertThat(move.getPlanningEntities()).hasSize(1); + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test @@ -109,10 +111,10 @@ void pinnedEntityNeverJoinsASample() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java index 9d904803542..ac3c984df3f 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarChangeMoveProviderTest.java @@ -52,7 +52,7 @@ void homogeneityAndNoOpExcluded() { .limit(200) .toList(); assertThat(moves).isNotEmpty(); - for (var move : moves) { + assertThat(moves).allSatisfy(move -> { var entities = move.getPlanningEntities(); // Every generated pillar must be homogeneous: all members share the same (pre-move) value. var currentValues = entities.stream() @@ -63,7 +63,7 @@ void homogeneityAndNoOpExcluded() { assertThat(move.getPlanningValues()).doesNotContain(currentValues.iterator().next()); // Size-1 pillars are never generated for change. assertThat(entities).hasSizeGreaterThanOrEqualTo(2); - } + }); } @Test @@ -92,10 +92,10 @@ void pinnedEntityExcludedFromPillar() { .getMovesAsStream(move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java index e79cbcdac11..07ef027e467 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMoveProviderTest.java @@ -178,10 +178,10 @@ void pinnedEntityNeverParticipatesInSwap() { var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(PillarSwapMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } @Test @@ -198,7 +198,7 @@ void threeAgainstFiveSwapsEveryMember() { // The legal-swap fraction stays high: // the only two keys in the pool both produce a legal swap in either direction. for (var i = 0; i < 3; i++) { - entityList.get(i).setValue(valueList.get(0)); + entityList.get(i).setValue(valueList.getFirst()); } for (var i = 3; i < 8; i++) { entityList.get(i).setValue(valueList.get(1)); @@ -227,10 +227,8 @@ void sizeOnePillarsAreGenerated() { .limit(100) .toList(); assertThat(moveList).isNotEmpty(); - for (var move : moveList) { - // Unlike PillarChangeMoveProvider, size-1 pillars are legal on both sides of a swap. - assertThat(move.getPlanningEntities()).hasSize(2); - } + // Unlike PillarChangeMoveProvider, size-1 pillars are legal on both sides of a swap. + assertThat(moveList).allSatisfy(move -> assertThat(move.getPlanningEntities()).hasSize(2)); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java index e7db361de59..b8b1e86d97f 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/PillarUnassignMoveProviderTest.java @@ -48,14 +48,15 @@ void onlyDrawsAssignedEntitiesAndAlwaysAssignsNull() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - // Only the already-assigned entity can be a pillar member. - assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); - assertThat(move.getPlanningEntities()).contains(assignedEntity); - // The destination is always null. - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + // Only the already-assigned entity can be a pillar member. + assertThat(move.getPlanningEntities()).doesNotContain(unassignedEntity); + assertThat(move.getPlanningEntities()).contains(assignedEntity); + // The destination is always null. + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test @@ -82,10 +83,9 @@ void pinnedEntityNeverUnassigned() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).containsExactly(freeEntity); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getPlanningEntities()).containsExactly(freeEntity)); } @Test @@ -113,17 +113,17 @@ void unassignPillarIsHomogeneous() { move -> (MassChangeMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var entities = move.getPlanningEntities(); - // Every generated pillar must be homogeneous: all members share the same (pre-move) value. - var currentValues = entities.stream() - .map(e -> ((TestdataAllowsUnassignedEntity) e).getValue()) - .collect(Collectors.toSet()); - assertThat(currentValues).hasSize(1).doesNotContainNull(); - // Unassign always sets the destination to null; unlike MassChangeMove, size-1 is legal. - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + // Every generated pillar must be homogeneous: all members share the same (pre-move) value. + var currentValues = move.getPlanningEntities().stream() + .map(e -> ((TestdataAllowsUnassignedEntity) e).getValue()) + .collect(Collectors.toSet()); + assertThat(currentValues).hasSize(1).doesNotContainNull(); + // Unassign always sets the destination to null; unlike MassChangeMove, size-1 is legal. + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java index 6dc9de90d00..41ec467eccf 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProviderTest.java @@ -50,10 +50,9 @@ void drawnSpanLengthsRespectMinAndMax() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getSource().length()).isBetween(2, 5); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSource().length()).isBetween(2, 5)); } @Test @@ -81,10 +80,10 @@ void noArgConstructorNeverExceedsDefaultMaximumSubListSize() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getSource().length()).isLessThanOrEqualTo(SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSource().length()) + .isLessThanOrEqualTo(SubListSampler.DEFAULT_MAXIMUM_SUB_LIST_SIZE)); // The explicit constructor is untouched by the no-arg default: a larger maximum still applies. var contextWithLargerMax = NeighborhoodTester @@ -124,11 +123,12 @@ void drawnSpanNeverTouchesPinnedPrefix() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3); - assertThat(move.getDestination().index()).isGreaterThanOrEqualTo(3); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3); + assertThat(move.getDestination().index()).isGreaterThanOrEqualTo(3); + }); } @Test @@ -166,11 +166,12 @@ void fullyPinnedEntityNeverDrawnFromOrInto() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getSource(). entity()).isNotEqualTo(pinnedEntity); - assertThat(move.getDestination(). entity()).isNotEqualTo(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getSource(). entity()).isNotEqualTo(pinnedEntity); + assertThat(move.getDestination(). entity()).isNotEqualTo(pinnedEntity); + }); } @Test @@ -260,8 +261,9 @@ void reversingNeverHappensForSingleElementSpan() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(SubListChangeMove::isReversing); + assertThat(moves) + .isNotEmpty() + .noneMatch(SubListChangeMove::isReversing); } @Test @@ -306,8 +308,9 @@ void crossingNullFalseNeverUnassignsSpan() { solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(500).toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(move -> move instanceof SubListUnassignMove); + assertThat(moves) + .isNotEmpty() + .noneMatch(move -> move instanceof SubListUnassignMove); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java index 4b107419b0d..e8bbb7fb1f9 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProviderTest.java @@ -46,14 +46,15 @@ void swappedSpansNeverOverlapOnSameEntity() { move -> (SubListSwapMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var left = move.getLeftRange(); - var right = move.getRightRange(); - if (left. entity() == right. entity()) { - assertThat(left.toIndex() <= right.fromIndex() || right.toIndex() <= left.fromIndex()).isTrue(); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var left = move.getLeftRange(); + var right = move.getRightRange(); + if (left. entity() == right. entity()) { + assertThat(left.toIndex() <= right.fromIndex() || right.toIndex() <= left.fromIndex()).isTrue(); + } + }); } @Test @@ -92,12 +93,13 @@ void eachSideRespectsItsOwnSizeBounds() { move -> (SubListSwapMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - var lengths = List.of(move.getLeftRange().length(), move.getRightRange().length()); - assertThat(lengths).anyMatch(length -> length == 1); - assertThat(lengths).anyMatch(length -> length >= 3 && length <= 5); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + var lengths = List.of(move.getLeftRange().length(), move.getRightRange().length()); + assertThat(lengths).anyMatch(length -> length == 1); + assertThat(lengths).anyMatch(length -> length >= 3 && length <= 5); + }); } @Test @@ -187,8 +189,9 @@ void reversingNeverHappensWhenBothSpansAreSingleElement() { move -> (SubListSwapMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(SubListSwapMove::isReversing); + assertThat(moves) + .isNotEmpty() + .noneMatch(SubListSwapMove::isReversing); } @Test @@ -215,11 +218,12 @@ void drawnSpansNeverTouchPinnedPrefix() { move -> (SubListSwapMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getLeftRange().fromIndex()).isGreaterThanOrEqualTo(4); - assertThat(move.getRightRange().fromIndex()).isGreaterThanOrEqualTo(4); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getLeftRange().fromIndex()).isGreaterThanOrEqualTo(4); + assertThat(move.getRightRange().fromIndex()).isGreaterThanOrEqualTo(4); + }); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java index 19c4facb60b..c6970cd7f1a 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveProviderTest.java @@ -48,10 +48,9 @@ void unassignsSpansOfBoundedLength() { move -> (SubListUnassignMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getRange().length()).isBetween(2, 5); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getRange().length()).isBetween(2, 5)); } @Test @@ -107,10 +106,9 @@ void drawnSpanNeverTouchesPinnedPrefix() { move -> (SubListUnassignMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getRange().fromIndex()).isGreaterThanOrEqualTo(3); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getRange().fromIndex()).isGreaterThanOrEqualTo(3)); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java index 082d3f2c4f5..6373dc57257 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarChangeMoveProviderTest.java @@ -43,15 +43,12 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { var moves = context.getMovesAsStream(move -> (MassChangeMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); var fullPillarMembers = new HashSet<>(entityList); - for (var move : moves) { - var members = move.getPlanningEntities(); - assertThat(members).hasSizeLessThanOrEqualTo(2); - for (var member : members) { - assertThat(fullPillarMembers).contains((TestdataEntity) member); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getPlanningEntities()) + .hasSizeLessThanOrEqualTo(2) + .allMatch(member -> fullPillarMembers.contains((TestdataEntity) member))); } @Test @@ -106,10 +103,10 @@ void pinnedEntityExcludedFromSubpillar() { .getMovesAsStream(move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } @Test @@ -160,8 +157,9 @@ void crossingNullFalseNeverUnassignsSubpillar() { move -> (MassChangeMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(move -> move.getPlanningValues().getFirst() == null); + assertThat(moves) + .isNotEmpty() + .noneMatch(move -> move.getPlanningValues().getFirst() == null); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java index 51e7c1e72b3..0cd82865054 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarSwapMoveProviderTest.java @@ -56,13 +56,11 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { .limit(200) .toList(); assertThat(moves).isNotEmpty(); - for (var move : moves) { - var members = move.getPlanningEntities(); - assertThat(members).hasSizeLessThanOrEqualTo(4); // At most 2 per side. - for (var member : members) { - assertThat(pillarA.contains(member) || pillarB.contains(member)).isTrue(); - } - } + assertThat(moves).allSatisfy(move -> { + assertThat(move.getPlanningEntities()) + .hasSizeLessThanOrEqualTo(4) // At most 2 per side. + .allMatch(member -> pillarA.contains(member) || pillarB.contains(member)); + }); } @Test @@ -161,10 +159,9 @@ void bothSidesRespectTheirOwnRule() { var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).hasSizeLessThanOrEqualTo(1 + 3); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getPlanningEntities()).hasSizeLessThanOrEqualTo(1 + 3)); } @Test @@ -196,10 +193,10 @@ void pinnedEntityNeverParticipatesInSubpillarSwap() { var moves = context.getMovesAsStream(move -> (PillarSwapMove) move) .limit(100) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(PillarSwapMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java index c6f22d1e3ed..602921453a9 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubPillarUnassignMoveProviderTest.java @@ -42,16 +42,15 @@ void subpillarMembersAreAlwaysASubsetOfTheFullPillar() { move -> (MassChangeMove) move) .limit(200) .toList(); - assertThat(moves).isNotEmpty(); var fullPillarMembers = new HashSet<>(entityList); - for (var move : moves) { - var members = move.getPlanningEntities(); - assertThat(members).hasSizeLessThanOrEqualTo(2); - for (var member : members) { - assertThat(fullPillarMembers).contains((TestdataAllowsUnassignedEntity) member); - } - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getPlanningEntities()) + .hasSizeLessThanOrEqualTo(2) + .allMatch(member -> fullPillarMembers.contains((TestdataAllowsUnassignedEntity) member)); + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test @@ -114,11 +113,12 @@ void survivesSlicesSmallerThanTheSamplersMinimumSize() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityList.get(0), entityList.get(1)); - assertThat(move.getPlanningValues().getFirst()).isNull(); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityList.get(0), entityList.get(1)); + assertThat(move.getPlanningValues().getFirst()).isNull(); + }); } @Test @@ -144,10 +144,10 @@ void pinnedEntityNeverUnassigned() { move -> (MassChangeMove) move) .limit(50) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getPlanningEntities()).doesNotContain(pinnedEntity); - } + assertThat(moves) + .isNotEmpty() + .flatExtracting(MassChangeMove::getPlanningEntities) + .doesNotContain(pinnedEntity); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java index ce4fd8349b5..9b990120181 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java @@ -48,12 +48,13 @@ void sameEntityAlwaysProducesReversal() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getDestination().index()).isEqualTo(move.getSource().fromIndex()); - assertThat(move.isReversing()).isTrue(); - assertThat(move.getSource().length()).isGreaterThanOrEqualTo(2); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + assertThat(move.getDestination().index()).isEqualTo(move.getSource().fromIndex()); + assertThat(move.isReversing()).isTrue(); + assertThat(move.getSource().length()).isGreaterThanOrEqualTo(2); + }); } @Test @@ -68,8 +69,9 @@ void crossingEntityFalseNeverProducesTailSwap() { .build(new TwoOptListMoveProvider<>(variableMetaModel, false), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(500).toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(move -> move instanceof SubListSwapMove); + assertThat(moves) + .isNotEmpty() + .noneMatch(move -> move instanceof SubListSwapMove); } @Test @@ -88,8 +90,9 @@ void crossingEntityTrueProducesNonReversingTailSwapToo() { .map(SubListSwapMove.class::cast) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - assertThat(moves).noneMatch(SubListSwapMove::isReversing); + assertThat(moves) + .isNotEmpty() + .noneMatch(SubListSwapMove::isReversing); } private static TestdataListSolution twoEntitySolution() { @@ -136,10 +139,9 @@ void pinnedPrefixNeverTouched() { move -> (SubListChangeMove) move) .limit(300) .toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3); - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> assertThat(move.getSource().fromIndex()).isGreaterThanOrEqualTo(3)); } @Test @@ -172,15 +174,18 @@ void fullyPinnedEntityNeverInvolved() { .build(new TwoOptListMoveProvider<>(variableMetaModel), solutionMetaModel) .using(solution); var moves = context.getMovesAsStream().limit(300).toList(); - assertThat(moves).isNotEmpty(); - for (var move : moves) { - if (move instanceof SubListChangeMove reversal) { - assertThat(reversal.getSource(). entity()).isNotEqualTo(pinnedEntity); - } else if (move instanceof SubListSwapMove swap) { - assertThat(swap.getLeftRange(). entity()).isNotEqualTo(pinnedEntity); - assertThat(swap.getRightRange(). entity()).isNotEqualTo(pinnedEntity); - } - } + assertThat(moves) + .isNotEmpty() + .allSatisfy(move -> { + if (move instanceof SubListChangeMove reversal) { + assertThat(reversal.getSource(). entity()) + .isNotEqualTo(pinnedEntity); + } else if (move instanceof SubListSwapMove swap) { + assertThat(swap.getLeftRange(). entity()).isNotEqualTo(pinnedEntity); + assertThat(swap.getRightRange(). entity()) + .isNotEqualTo(pinnedEntity); + } + }); } @Test From 4014d0f41065fbae6cc458ecc3b687cdffcc5465 Mon Sep 17 00:00:00 2001 From: Lukas Petrovicky Date: Wed, 26 Aug 2026 12:08:38 +0200 Subject: [PATCH 15/16] Sonar is going nuts --- .../solver/core/api/domain/common/Lookup.java | 10 ++ .../stream/dataset/sample/DefaultSample.java | 78 ------------- .../dataset/sample/SampleAssembler.java | 7 +- .../core/impl/util/CollectionUtils.java | 4 +- .../metamodel/DefaultPositionInList.java | 7 ++ .../api/domain/metamodel/PositionInList.java | 4 + .../preview/api/move/builtin/ChangeMove.java | 2 +- .../api/move/builtin/ListAssignMove.java | 4 +- .../api/move/builtin/ListChangeMove.java | 4 +- .../api/move/builtin/ListSwapMove.java | 5 +- .../builtin/ListTailSwapMoveProvider.java | 4 +- .../api/move/builtin/ListUnassignMove.java | 2 +- .../api/move/builtin/MassChangeMove.java | 7 +- .../api/move/builtin/MassListChangeMove.java | 10 +- .../core/preview/api/move/builtin/Moves.java | 10 +- .../api/move/builtin/PillarSwapMove.java | 11 +- .../api/move/builtin/SubListChangeMove.java | 14 +-- .../builtin/SubListChangeMoveProvider.java | 4 +- .../api/move/builtin/SubListSwapMove.java | 45 +++---- .../move/builtin/SubListSwapMoveProvider.java | 6 +- .../api/move/builtin/SubListUnassignMove.java | 14 +-- .../preview/api/move/builtin/SwapMove.java | 4 +- .../move/builtin/TwoOptListMoveProvider.java | 6 +- .../stream/dataset/sample/DefaultRange.java | 47 -------- .../dataset/sample/DefaultSubListSampler.java | 6 +- .../stream/dataset/sample/Range.java | 63 +++++----- .../stream/dataset/sample/Sample.java | 110 +++++++++++++----- .../stream/dataset/sample/SubListSampler.java | 4 +- .../dataset/sample/SamplingIteratorTest.java | 6 +- .../ScoreDirectorFactoryFactoryTest.java | 1 - .../builtin/ListTailSwapMoveProviderTest.java | 14 +-- .../move/builtin/SubListChangeMoveTest.java | 28 ++--- .../api/move/builtin/SubListSwapMoveTest.java | 26 ++--- .../move/builtin/SubListUnassignMoveTest.java | 18 +-- .../builtin/TwoOptListMoveProviderTest.java | 12 +- .../stream/dataset/sample/SampleTest.java | 11 ++ 36 files changed, 265 insertions(+), 343 deletions(-) delete mode 100644 core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java delete mode 100644 core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/common/Lookup.java b/core/src/main/java/ai/timefold/solver/core/api/domain/common/Lookup.java index f9c9d3692fc..8fb17fbb43a 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/common/Lookup.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/common/Lookup.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.api.domain.common; +import java.util.Objects; + import ai.timefold.solver.core.api.solver.change.ProblemChange; import ai.timefold.solver.core.preview.api.move.Move; @@ -29,4 +31,12 @@ public interface Lookup { */ @Nullable T lookUpWorkingObject(@Nullable T problemFactOrPlanningEntity); + /** + * As defined by {@link #lookUpWorkingObject(Object)}, + * but does not accept null arguments and cannot return null. + */ + default T lookUpNonNullWorkingObject(T problemFactOrPlanningEntity) { + return lookUpWorkingObject(Objects.requireNonNull(problemFactOrPlanningEntity)); + } + } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java deleted file mode 100644 index bf9c8cb29be..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/DefaultSample.java +++ /dev/null @@ -1,78 +0,0 @@ -package ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample; - -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.Objects; -import java.util.SequencedSet; - -import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Sample; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -public final class DefaultSample implements Sample { - - private final SequencedSet<@Nullable A> memberSet; - private final SequencedSet<@Nullable A> unmodifiableMemberSet; - - public DefaultSample(Collection<@Nullable A> memberCollection) { - this(new LinkedHashSet<>(memberCollection)); - } - - /** - * Adopts an already-deduplicated, already-owned, order-stable set without copying it - for {@link SampleAssembler}'s use - * only, - * which builds exactly such a set while assembling a candidate and never touches it again afterward. - * Every other caller goes through the copying constructor above, since {@link Sample#of} promises the input collection is - * not retained. - */ - DefaultSample(SequencedSet<@Nullable A> ownedMemberSet) { - if (ownedMemberSet.isEmpty()) { - throw new IllegalArgumentException("The memberCollection (%s) of a sample must not be empty." - .formatted(ownedMemberSet)); - } - this.memberSet = ownedMemberSet; - this.unmodifiableMemberSet = Collections.unmodifiableSequencedSet(ownedMemberSet); - } - - @Override - public int size() { - return memberSet.size(); - } - - @Override - public boolean contains(@Nullable A element) { - return memberSet.contains(element); - } - - @Override - public @Nullable A representative() { - // A SequencedSet is what makes "the same member on every call" true. - return memberSet.getFirst(); - } - - @Override - public Iterator<@Nullable A> iterator() { - return unmodifiableMemberSet.iterator(); - } - - @Override - public boolean equals(Object o) { - return o instanceof DefaultSample other && - Objects.equals(memberSet, other.memberSet); - } - - @Override - public int hashCode() { - return memberSet.hashCode(); - } - - @Override - public String toString() { - return "Sample(" + memberSet + ")"; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java index 23fd801be82..89b6e1e72c7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SampleAssembler.java @@ -77,10 +77,9 @@ public final class SampleAssembler { } } if (memberSet.size() >= minimumSize) { - // The package-private adopting constructor, not Sample.of(memberSet): this set is already - // deduplicated and owned exclusively by this method, which never touches it again - - // no need to pay for Sample.of's defensive copy. - return new DefaultSample<>(memberSet); + // memberSet is already a deduplicated, order-stable LinkedHashSet owned exclusively by this method, + // which never touches it again - Sample.of() adopts a SequencedSet like this one instead of copying it. + return Sample.of(memberSet); } if (stoppedBySampler) { // The sampler chose to stop itself, below its own declared floor: diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/CollectionUtils.java b/core/src/main/java/ai/timefold/solver/core/impl/util/CollectionUtils.java index b31a1dcee83..423f0cac376 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/CollectionUtils.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/CollectionUtils.java @@ -52,8 +52,8 @@ public static List copy(List originalList, boolean reverse) { } } - public static List concat(List left, List right) { - List result = new ArrayList<>(left.size() + right.size()); + public static List concat(List left, List right) { + var result = new ArrayList(left.size() + right.size()); result.addAll(left); result.addAll(right); return result; diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/DefaultPositionInList.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/DefaultPositionInList.java index 3e857ba0e02..5b63e78ac71 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/DefaultPositionInList.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/DefaultPositionInList.java @@ -3,6 +3,8 @@ import java.util.Objects; import java.util.function.Supplier; +import ai.timefold.solver.core.api.domain.common.Lookup; + import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -50,4 +52,9 @@ public String toString() { public int compareTo(PositionInList other) { return Integer.compare(index, other.index()); } + + @Override + public PositionInList rebase(Lookup lookup) { + return ElementPosition.of(lookup.lookUpNonNullWorkingObject(entity), index); + } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PositionInList.java b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PositionInList.java index cd762dba800..b8e0b83c3fb 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PositionInList.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/domain/metamodel/PositionInList.java @@ -1,5 +1,7 @@ package ai.timefold.solver.core.preview.api.domain.metamodel; +import ai.timefold.solver.core.api.domain.common.Lookup; + import org.jspecify.annotations.NullMarked; /** @@ -31,4 +33,6 @@ public sealed interface PositionInList int index(); + PositionInList rebase(Lookup lookup); + } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java index 69de7dacedd..bcd101f226f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ChangeMove.java @@ -55,7 +55,7 @@ public void execute(MutableSolutionView solutionView) { @Override public ChangeMove rebase(Lookup lookup) { - return new ChangeMove<>(variableMetaModel, lookup.lookUpWorkingObject(entity), + return new ChangeMove<>(variableMetaModel, lookup.lookUpNonNullWorkingObject(entity), lookup.lookUpWorkingObject(toPlanningValue)); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java index ed15925f27e..72bc05f23d5 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListAssignMove.java @@ -38,8 +38,8 @@ public void execute(MutableSolutionView mutableSolutionView) { @Override public Move rebase(Lookup lookup) { - return new ListAssignMove<>(variableMetaModel, lookup.lookUpWorkingObject(planningValue), - lookup.lookUpWorkingObject(destinationEntity), destinationIndex); + return new ListAssignMove<>(variableMetaModel, lookup.lookUpNonNullWorkingObject(planningValue), + lookup.lookUpNonNullWorkingObject(destinationEntity), destinationIndex); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMove.java index fa39b10c418..5aac5098049 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListChangeMove.java @@ -135,8 +135,8 @@ public void execute(MutableSolutionView solutionView) { @Override public ListChangeMove rebase(Lookup lookup) { - return new ListChangeMove<>(variableMetaModel, lookup.lookUpWorkingObject(sourceEntity), sourceIndex, - lookup.lookUpWorkingObject(destinationEntity), destinationIndex); + return new ListChangeMove<>(variableMetaModel, lookup.lookUpNonNullWorkingObject(sourceEntity), sourceIndex, + lookup.lookUpNonNullWorkingObject(destinationEntity), destinationIndex); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMove.java index 76348d0fc48..cf944b06bc2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListSwapMove.java @@ -135,9 +135,8 @@ public void execute(MutableSolutionView solutionView) { @Override public ListSwapMove rebase(Lookup lookup) { - return new ListSwapMove<>(variableMetaModel, lookup.lookUpWorkingObject(leftEntity), leftIndex, - lookup.lookUpWorkingObject(rightEntity), - rightIndex); + return new ListSwapMove<>(variableMetaModel, lookup.lookUpNonNullWorkingObject(leftEntity), leftIndex, + lookup.lookUpNonNullWorkingObject(rightEntity), rightIndex); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java index 0ff9da924e1..c1dbc69e914 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProvider.java @@ -172,8 +172,8 @@ public void accept(Value_ leftValue, Value_ rightValue) { var reversing = selectReversingMoveToo && (leftSize - leftPosition.index() > 1 || rightSize - rightPosition.index() > 1) && random.nextBoolean(); - nextMove = Moves.swap(variableMetaModel, Range.of(leftEntity, leftPosition.index(), leftSize), - Range.of(rightEntity, rightPosition.index(), rightSize), reversing); + nextMove = Moves.swap(variableMetaModel, new Range<>(leftEntity, leftPosition.index(), leftSize), + new Range<>(rightEntity, rightPosition.index(), rightSize), reversing); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java index 2f5aea03579..518f0ac0e0b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/ListUnassignMove.java @@ -41,7 +41,7 @@ public void execute(MutableSolutionView solutionView) { @Override public Move rebase(Lookup lookup) { - return new ListUnassignMove<>(variableMetaModel, lookup.lookUpWorkingObject(sourceEntity), sourceIndex); + return new ListUnassignMove<>(variableMetaModel, lookup.lookUpNonNullWorkingObject(sourceEntity), sourceIndex); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java index a8c05756694..d21c8338c5b 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassChangeMove.java @@ -55,12 +55,7 @@ public void execute(MutableSolutionView solutionView) { @Override public MassChangeMove rebase(Lookup lookup) { - var rebasedEntityList = new ArrayList(sample.size()); - for (var entity : sample) { - rebasedEntityList.add(lookup.lookUpWorkingObject(entity)); - } - return new MassChangeMove<>(variableMetaModel, Sample.of(rebasedEntityList), - lookup.lookUpWorkingObject(toPlanningValue)); + return new MassChangeMove<>(variableMetaModel, sample.rebase(lookup), lookup.lookUpWorkingObject(toPlanningValue)); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java index 474f984cf76..6d256332367 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMove.java @@ -10,7 +10,6 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.impl.move.AbstractMove; -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.move.MutableSolutionView; @@ -68,13 +67,8 @@ public void execute(MutableSolutionView solutionView) { @Override public MassListChangeMove rebase(Lookup lookup) { - var rebasedValueList = new ArrayList(sample.size()); - for (var value : sample) { - rebasedValueList.add(lookup.lookUpWorkingObject(value)); - } - var rebasedDestination = destination == null ? null - : ElementPosition.of(lookup.lookUpWorkingObject(destination. entity()), destination.index()); - return new MassListChangeMove<>(variableMetaModel, Sample.of(rebasedValueList), rebasedDestination); + var rebasedDestination = destination == null ? null : destination.rebase(lookup); + return new MassListChangeMove<>(variableMetaModel, sample.rebase(lookup), rebasedDestination); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java index 688d59a040f..18417ec4896 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/Moves.java @@ -363,7 +363,7 @@ public static Move swap( * @return a move that, when executed, relocates the span to the destination position */ public static Move change( - PlanningListVariableMetaModel variableMetaModel, Range source, + PlanningListVariableMetaModel variableMetaModel, Range source, PositionInList destination, boolean reversing) { return new SubListChangeMove<>(variableMetaModel, source, destination, reversing); } @@ -386,8 +386,8 @@ public static Move change( * @return a move that, when executed, swaps the two spans */ public static Move swap( - PlanningListVariableMetaModel variableMetaModel, Range left, Range right, - boolean reversing) { + PlanningListVariableMetaModel variableMetaModel, Range left, + Range right, boolean reversing) { return new SubListSwapMove<>(variableMetaModel, left, right, reversing); } @@ -406,7 +406,7 @@ public static Move swap( * @throws IllegalArgumentException if the range's length is less than 2 */ public static Move reverse( - PlanningListVariableMetaModel variableMetaModel, Range range) { + PlanningListVariableMetaModel variableMetaModel, Range range) { if (range.length() < 2) { throw new IllegalArgumentException("The length (%d) of range (%s) must be at least 2." .formatted(range.length(), range)); @@ -427,7 +427,7 @@ public static Move reverse( * @return a move that, when executed, removes every value of the span from the list variable */ public static Move unassign( - PlanningListVariableMetaModel variableMetaModel, Range range) { + PlanningListVariableMetaModel variableMetaModel, Range range) { return new SubListUnassignMove<>(variableMetaModel, range); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java index ef51c7a8eb6..8041c2d6d02 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/PillarSwapMove.java @@ -125,16 +125,7 @@ public void execute(MutableSolutionView solutionView) { @Override public PillarSwapMove rebase(Lookup lookup) { - var rebasedLeftEntityList = new ArrayList(leftPillar.size()); - for (var entity : leftPillar) { - rebasedLeftEntityList.add(lookup.lookUpWorkingObject(entity)); - } - var rebasedRightEntityList = new ArrayList(rightPillar.size()); - for (var entity : rightPillar) { - rebasedRightEntityList.add(lookup.lookUpWorkingObject(entity)); - } - return new PillarSwapMove<>(variableMetaModelList, Sample.of(rebasedLeftEntityList), - Sample.of(rebasedRightEntityList)); + return new PillarSwapMove<>(variableMetaModelList, leftPillar.rebase(lookup), rightPillar.rebase(lookup)); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java index b23f04705b1..954458d757c 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMove.java @@ -10,7 +10,6 @@ import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.impl.move.AbstractMove; -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.move.MutableSolutionView; @@ -35,13 +34,13 @@ public final class SubListChangeMove extends AbstractMove { private final PlanningListVariableMetaModel variableMetaModel; - private final Range source; + private final Range source; private final PositionInList destination; private final boolean reversing; private @Nullable List movedValues; - SubListChangeMove(PlanningListVariableMetaModel variableMetaModel, Range source, + SubListChangeMove(PlanningListVariableMetaModel variableMetaModel, Range source, PositionInList destination, boolean reversing) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.source = Objects.requireNonNull(source); @@ -49,7 +48,7 @@ public final class SubListChangeMove extends Abstrac this.reversing = reversing; } - public Range getSource() { + public Range getSource() { return source; } @@ -87,10 +86,7 @@ public void execute(MutableSolutionView solutionView) { @Override public SubListChangeMove rebase(Lookup lookup) { - return new SubListChangeMove<>(variableMetaModel, - Range.of(lookup.lookUpWorkingObject(source. entity()), source.fromIndex(), source.toIndex()), - ElementPosition.of(lookup.lookUpWorkingObject(destination. entity()), destination.index()), - reversing); + return new SubListChangeMove<>(variableMetaModel, source.rebase(lookup), destination.rebase(lookup), reversing); } @Override @@ -131,7 +127,7 @@ public int hashCode() { @Override public String toString() { return "|%d| {%s[%d..%d] -%s> %s[%d]}".formatted( - source.length(), source. entity(), source.fromIndex(), source.toIndex() - 1, + source.length(), source.entity(), source.fromIndex(), source.toIndex() - 1, reversing ? "reversing-" : "", destination. entity(), destination.index()); } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java index 18c20a0fe63..c96e238d224 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveProvider.java @@ -146,7 +146,7 @@ private static final class SubListChangeMoveIterator private final SubListSampler sampler; private @Nullable Move nextMove = null; - private @Nullable Range pendingRange = null; + private @Nullable Range pendingRange = null; SubListChangeMoveIterator(MoveIteratorSession session, RandomGenerator random, PlanningListVariableMetaModel variableMetaModel, @@ -191,7 +191,7 @@ public Iterator createRightIterator(Value_ sliceValue) { destination -> isValidChange(range, destination), bailOutSize); } - private boolean isValidChange(Range range, ElementPosition destination) { + private boolean isValidChange(Range range, ElementPosition destination) { if (destination instanceof UnassignedElement) { return true; } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java index 9879a3b9170..aa66324c78f 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMove.java @@ -1,7 +1,5 @@ package ai.timefold.solver.core.preview.api.move.builtin; -import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.SequencedCollection; @@ -12,6 +10,7 @@ import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.impl.move.AbstractMove; +import ai.timefold.solver.core.impl.util.CollectionUtils; import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel; import ai.timefold.solver.core.preview.api.move.MutableSolutionView; import ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample.Range; @@ -34,15 +33,15 @@ public final class SubListSwapMove extends AbstractMove { private final PlanningListVariableMetaModel variableMetaModel; - private final Range leftRange; - private final Range rightRange; + private final Range leftRange; + private final Range rightRange; private final boolean reversing; private @Nullable List leftValues; private @Nullable List rightValues; - SubListSwapMove(PlanningListVariableMetaModel variableMetaModel, Range leftRange, - Range rightRange, boolean reversing) { + SubListSwapMove(PlanningListVariableMetaModel variableMetaModel, Range leftRange, + Range rightRange, boolean reversing) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); // Normalize so that, when both spans are on the same entity, left always precedes right; // this is what makes the spans satisfy MutableSolutionView.swapValuesInList's ordering precondition. @@ -56,11 +55,11 @@ public final class SubListSwapMove extends AbstractM this.reversing = reversing; } - public Range getLeftRange() { + public Range getLeftRange() { return leftRange; } - public Range getRightRange() { + public Range getRightRange() { return rightRange; } @@ -95,8 +94,8 @@ public void execute(MutableSolutionView solutionView) { // Cache the pre-move values now, before either side is mutated. getLeftValues(); getRightValues(); - Entity_ leftEntity = leftRange.entity(); - Entity_ rightEntity = rightRange.entity(); + var leftEntity = leftRange.entity(); + var rightEntity = rightRange.entity(); if (leftEntity == rightEntity) { solutionView.swapValuesInList(variableMetaModel, leftEntity, leftRange.fromIndex(), leftRange.toIndex(), rightRange.fromIndex(), rightRange.toIndex(), reversing); @@ -108,12 +107,7 @@ public void execute(MutableSolutionView solutionView) { @Override public SubListSwapMove rebase(Lookup lookup) { - return new SubListSwapMove<>(variableMetaModel, - Range.of(lookup.lookUpWorkingObject(leftRange. entity()), leftRange.fromIndex(), - leftRange.toIndex()), - Range.of(lookup.lookUpWorkingObject(rightRange. entity()), rightRange.fromIndex(), - rightRange.toIndex()), - reversing); + return new SubListSwapMove<>(variableMetaModel, leftRange.rebase(lookup), rightRange.rebase(lookup), reversing); } @Override @@ -124,22 +118,17 @@ public List> variableM @Override public SequencedCollection getPlanningEntities() { // Use LinkedHashSet for predictable iteration order. - Entity_ leftEntity = leftRange.entity(); - Entity_ rightEntity = rightRange.entity(); - var entities = LinkedHashSet. newLinkedHashSet(2); - entities.add(leftEntity); - entities.add(rightEntity); - return entities; + var leftEntity = leftRange.entity(); + var rightEntity = rightRange.entity(); + if (leftEntity == rightEntity) { + return List.of(leftEntity); + } + return List.of(leftEntity, rightEntity); } @Override public SequencedCollection getPlanningValues() { - var leftValues = getLeftValues(); - var rightValues = getRightValues(); - var values = new ArrayList<>(leftValues.size() + rightValues.size()); - values.addAll(leftValues); - values.addAll(rightValues); - return values; + return CollectionUtils.concat(getLeftValues(), getRightValues()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java index d1ad446c85e..cd324dec23c 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveProvider.java @@ -120,8 +120,8 @@ private static final class SubListSwapMoveIterator private final SubListSampler rightSampler; private @Nullable Move nextMove = null; - private @Nullable Range pendingLeftRange = null; - private @Nullable Range pendingRightRange = null; + private @Nullable Range pendingLeftRange = null; + private @Nullable Range pendingRightRange = null; SubListSwapMoveIterator(MoveIteratorSession session, RandomGenerator random, PlanningListVariableMetaModel variableMetaModel, @@ -173,7 +173,7 @@ public Iterator createRightIterator(Value_ leftValue) { }, bailOutSize); } - private boolean isValidSwap(Range left, Range right) { + private boolean isValidSwap(Range left, Range right) { Entity_ leftEntity = left.entity(); Entity_ rightEntity = right.entity(); if (leftEntity == rightEntity) { diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java index 161149510fa..2104c2186b3 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMove.java @@ -30,16 +30,17 @@ public class SubListUnassignMove extends AbstractMove { private final PlanningListVariableMetaModel variableMetaModel; - private final Range range; + private final Range range; private @Nullable List unassignedValues; - SubListUnassignMove(PlanningListVariableMetaModel variableMetaModel, Range range) { + SubListUnassignMove(PlanningListVariableMetaModel variableMetaModel, + Range range) { this.variableMetaModel = Objects.requireNonNull(variableMetaModel); this.range = Objects.requireNonNull(range); } - public Range getRange() { + public Range getRange() { return range; } @@ -59,18 +60,17 @@ private List getUnassignedValues() { @Override public void execute(MutableSolutionView solutionView) { unassignedValues = - solutionView.unassignValues(variableMetaModel, range. entity(), range.fromIndex(), range.toIndex()); + solutionView.unassignValues(variableMetaModel, range.entity(), range.fromIndex(), range.toIndex()); } @Override public SubListUnassignMove rebase(Lookup lookup) { - return new SubListUnassignMove<>(variableMetaModel, - Range.of(lookup.lookUpWorkingObject(range. entity()), range.fromIndex(), range.toIndex())); + return new SubListUnassignMove<>(variableMetaModel, range.rebase(lookup)); } @Override public SequencedCollection getPlanningEntities() { - return List.of(range. entity()); + return List.of(range.entity()); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java index da978a3b0d0..4a8781b23d2 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMove.java @@ -92,8 +92,8 @@ public Entity_ getRightEntity() { @Override public SwapMove rebase(Lookup lookup) { - return new SwapMove<>(variableMetaModelList, lookup.lookUpWorkingObject(leftEntity), - lookup.lookUpWorkingObject(rightEntity)); + return new SwapMove<>(variableMetaModelList, lookup.lookUpNonNullWorkingObject(leftEntity), + lookup.lookUpNonNullWorkingObject(rightEntity)); } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java index e2e34eadfcd..bff58114255 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProvider.java @@ -107,12 +107,12 @@ private Move buildMove(SolutionView solutionView, Position if (leftEntity == rightEntity) { var fromIndex = Math.min(left.index(), right.index()); var toIndex = Math.max(left.index(), right.index()) + 1; - return Moves.reverse(variableMetaModel, Range.of(leftEntity, fromIndex, toIndex)); + return Moves.reverse(variableMetaModel, new Range<>(leftEntity, fromIndex, toIndex)); } var leftSize = solutionView.countValues(variableMetaModel, leftEntity); var rightSize = solutionView.countValues(variableMetaModel, rightEntity); - return Moves.swap(variableMetaModel, Range.of(leftEntity, left.index(), leftSize), - Range.of(rightEntity, right.index(), rightSize), false); + return Moves.swap(variableMetaModel, new Range<>(leftEntity, left.index(), leftSize), + new Range<>(rightEntity, right.index(), rightSize), false); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java deleted file mode 100644 index 0552501f711..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultRange.java +++ /dev/null @@ -1,47 +0,0 @@ -package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; - -import java.util.Objects; - -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; - -@NullMarked -record DefaultRange(Object entity, int fromIndex, int toIndex) - implements - Range { - - DefaultRange { - Objects.requireNonNull(entity, "entity"); - if (fromIndex < 0) { - throw new IllegalArgumentException("The fromIndex (%d) must not be negative." - .formatted(fromIndex)); - } - if (toIndex <= fromIndex) { - throw new IllegalArgumentException("The toIndex (%d) must be greater than the fromIndex (%d)." - .formatted(toIndex, fromIndex)); - } - } - - @Override - public boolean equals(@Nullable Object o) { - if (!(o instanceof DefaultRange(var entity1, var index, var toIndex1))) { - return false; - } - return fromIndex == index && toIndex == toIndex1 && entity == entity1; - } - - @Override - public int hashCode() { - var result = 1; - result = 31 * result + System.identityHashCode(entity); - result = 31 * result + Integer.hashCode(fromIndex); - result = 31 * result + Integer.hashCode(toIndex); - return result; - } - - @Override - public String toString() { - return entity + "[" + fromIndex + ".." + (toIndex - 1) + "]"; - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java index 2cb47ce75b0..38f8db438a1 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/DefaultSubListSampler.java @@ -26,7 +26,7 @@ final class DefaultSubListSampler implements SubList @Override @Nullable - public Range byValue(SolutionView solutionView, Value_ seedValue) { + public Range byValue(SolutionView solutionView, Value_ seedValue) { var position = solutionView.getPositionOf(variableMetaModel, seedValue); if (!(position instanceof PositionInList assigned)) { throw new IllegalArgumentException("The seedValue (%s) is not assigned.".formatted(seedValue)); @@ -36,7 +36,7 @@ public Range byValue(SolutionView solutionView, Value_ seedValue) { @Override @Nullable - public Range byEntity(SolutionView solutionView, Entity_ entity) { + public Range byEntity(SolutionView solutionView, Entity_ entity) { var firstUnpinned = solutionView.getFirstUnpinnedIndex(variableMetaModel, entity); var listSize = solutionView.countValues(variableMetaModel, entity) - firstUnpinned; if (listSize < minimumSubListSize) { @@ -45,7 +45,7 @@ public Range byEntity(SolutionView solutionView, Entity_ entity) { var element = triangleElementFactory.nextElement(listSize); var length = listSize - element.level() + 1; var fromIndex = element.indexOnLevel() - 1 + firstUnpinned; - return Range.of(entity, fromIndex, fromIndex + length); + return new Range<>(entity, fromIndex, fromIndex + length); } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java index a3a2b77e632..0bc2a5e6466 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Range.java @@ -1,5 +1,8 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.common.Lookup; import ai.timefold.solver.core.preview.api.domain.metamodel.PositionInList; import org.jspecify.annotations.NullMarked; @@ -15,50 +18,52 @@ * but it says nothing about the solution, * so every value a move needs must be read from the live solution. * Instances are produced by {@link SubListSampler}. + * + * @param entity never null + * @param fromIndex inclusive, 0 or higher + * @param toIndex exclusive, greater than {@code fromIndex} + * @param the type of the entity whose list variable this range points into */ @NullMarked -public sealed interface Range - permits DefaultRange { - - /** - * @param entity never null - * @param fromIndex inclusive, 0 or higher - * @param toIndex exclusive, greater than {@code fromIndex} - * @return never null - */ - static Range of(Object entity, int fromIndex, int toIndex) { - return new DefaultRange(entity, fromIndex, toIndex); - } +public record Range(Entity_ entity, int fromIndex, int toIndex) { /** * @param from the start of the range, inclusive * @param length 1 or higher * @return never null */ - static Range of(PositionInList from, int length) { - return new DefaultRange(from.entity(), from.index(), from.index() + length); + public static Range of(PositionInList from, int length) { + return new Range<>(from.entity(), from.index(), from.index() + length); } - /** - * @return the entity whose list variable this range points into; never null - */ - Entity_ entity(); + public Range(Entity_ entity, int fromIndex, int toIndex) { + this.entity = Objects.requireNonNull(entity, "entity"); + if (fromIndex < 0) { + throw new IllegalArgumentException("The fromIndex (%d) must not be negative." + .formatted(fromIndex)); + } + if (toIndex <= fromIndex) { + throw new IllegalArgumentException("The toIndex (%d) must be greater than the fromIndex (%d)." + .formatted(toIndex, fromIndex)); + } + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } /** - * @return the index of the first element of this range, inclusive + * @return {@link #toIndex()} minus {@link #fromIndex()}; always 1 or higher */ - int fromIndex(); + public int length() { + return toIndex - fromIndex; + } - /** - * @return the index one past the last element of this range, exclusive - */ - int toIndex(); + public Range rebase(Lookup lookup) { + return new Range<>(lookup.lookUpNonNullWorkingObject(entity), fromIndex, toIndex); + } - /** - * @return {@link #toIndex()} minus {@link #fromIndex()}; always 1 or higher - */ - default int length() { - return toIndex() - fromIndex(); + @Override + public String toString() { + return entity + "[" + fromIndex + ".." + (toIndex - 1) + "]"; } } diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java index 9b176652e85..66be48e8b2a 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/Sample.java @@ -1,8 +1,13 @@ package ai.timefold.solver.core.preview.api.neighborhood.stream.dataset.sample; import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.SequencedSet; -import ai.timefold.solver.core.impl.neighborhood.stream.dataset.sample.DefaultSample; +import ai.timefold.solver.core.api.domain.common.Lookup; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -17,34 +22,60 @@ * but it says nothing about the solution, * so every value a move needs must be read from the live solution. *

    - * Two samples built by this framework are equal - * when they hold the same members, - * whatever the order they were drawn in. - * Equality is not defined between a framework sample - * and a foreign implementation of this interface. - *

    - * An implementation must be immutable: - * {@link #size()} must always agree with {@link #iterator()}, - * and the iteration order must be stable across calls. - * A move holds a {@code Sample} across probes and undo, - * so it must read the same members every time. + * Two samples are equal when they hold the same members, whatever the order they were drawn in. * * @param the type of the sample's members */ @NullMarked -public interface Sample - extends Iterable<@Nullable A> { +public final class Sample + implements Iterable<@Nullable A> { + + private final SequencedSet<@Nullable A> memberSet; + private final SequencedSet<@Nullable A> unmodifiableMemberSet; + + /** + * Creates a sample from a collection, removing duplicates, + * so that {@link #size()} never disagrees with {@link Object#equals(Object)}. + * A {@link SequencedSet} is adopted directly and must not be modified afterward; + * every other collection is copied. + * + * @param memberCollection may contain nulls + * @param the type of the sample's members + * @return never null + * @throws NullPointerException if memberCollection is null + * @throws IllegalArgumentException if memberCollection is empty + */ + public static Sample of(Collection<@Nullable A> memberCollection) { + return new Sample<>(memberCollection); + } + + private Sample(Collection<@Nullable A> memberCollection) { + // A SequencedSet is already deduplicated and order-stable, so it is adopted, not copied. + // SampleAssembler relies on this: it builds exactly such a set and never touches it again afterward. + this.memberSet = memberCollection instanceof SequencedSet<@Nullable A> sequencedSet + ? sequencedSet + : new LinkedHashSet<>(memberCollection); + if (memberSet.isEmpty()) { + throw new IllegalArgumentException("The memberCollection (%s) of a sample must not be empty." + .formatted(memberSet)); + } + this.unmodifiableMemberSet = Collections.unmodifiableSequencedSet(memberSet); + } /** * @return the number of members; at least 1 for a drawn sample */ - int size(); + public int size() { + return memberSet.size(); + } /** * @param element may be null, as null members are legal * @return true if the element is a member */ - boolean contains(@Nullable A element); + public boolean contains(@Nullable A element) { + return memberSet.contains(element); + } /** * @return a representative member of the sample - which one is unspecified, @@ -53,24 +84,41 @@ public interface Sample * Useful for reading a value every member is known to share, * such as a homogeneous pillar's current variable value. */ - @Nullable - A representative(); + public @Nullable A representative() { + // A SequencedSet is what makes "the same member on every call" true. + return memberSet.getFirst(); + } - /** - * Creates a sample from a collection, copying it and removing duplicates, - * so that {@link #size()} never disagrees with {@link Object#equals(Object)}. - * - * @param memberCollection may contain nulls; not retained - * @param the type of the sample's members - * @return never null - * @throws NullPointerException if memberCollection is null - * @throws IllegalArgumentException if memberCollection is empty - */ - static Sample of(Collection<@Nullable A> memberCollection) { - return new DefaultSample<>(memberCollection); + public Sample rebase(Lookup lookup) { + var rebasedSet = new LinkedHashSet<@Nullable A>(); + for (var member : memberSet) { + rebasedSet.add(lookup.lookUpWorkingObject(member)); + } + return new Sample<>(rebasedSet); + } + + @Override + public Iterator<@Nullable A> iterator() { + return unmodifiableMemberSet.iterator(); + } + + @Override + public boolean equals(@Nullable Object o) { + return o instanceof Sample other && + Objects.equals(memberSet, other.memberSet); + } + + @Override + public int hashCode() { + return memberSet.hashCode(); + } + + @Override + public String toString() { + return "Sample(" + memberSet + ")"; } - enum Decision { + public enum Decision { /** Take the candidate and continue. */ ACCEPT, diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java index 476375cc2b1..efc4465a162 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SubListSampler.java @@ -44,7 +44,7 @@ public interface SubListSampler { * if that window is smaller than the minimum sub-list size */ @Nullable - Range byValue(SolutionView solutionView, Value_ seedValue); + Range byValue(SolutionView solutionView, Value_ seedValue); /** * @param solutionView the view of the solution the span is drawn from @@ -53,6 +53,6 @@ public interface SubListSampler { * or {@code null} if that window is smaller than the minimum sub-list size */ @Nullable - Range byEntity(SolutionView solutionView, Entity_ entity); + Range byEntity(SolutionView solutionView, Entity_ entity); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java index 500c5ecae01..bf8e5cef5f3 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/neighborhood/stream/dataset/sample/SamplingIteratorTest.java @@ -97,15 +97,15 @@ public Decision evaluate(int sizeSoFar, TestdataValue candidate) { var sampleIterator = instance.samplingIterator(recordingSampler, random); // Draw first sample. - assertThat(sampleIterator.hasNext()).isTrue(); - var firstSample = sampleIterator.next(); + assertThat(sampleIterator).hasNext(); + assertThat(sampleIterator.next()).isNotNull(); // Reset should have been called exactly once. assertThat(resetCallCount[0]).isEqualTo(1); // decide should have been called for every candidate offered, starting with the first. assertThat(decideCalls).isNotEmpty(); // First decide call should have sizeSoFar == 0: the first candidate is no longer an unconditionally accepted seed. - assertThat(decideCalls.get(0)).isEqualTo(0); + assertThat(decideCalls.getFirst()).isZero(); } @Test diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java index edbdf2fe84d..b8357e27249 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java @@ -15,7 +15,6 @@ import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; -import ai.timefold.solver.core.testdomain.TestdataConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java index ee89cf831b5..f4e935af934 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/ListTailSwapMoveProviderTest.java @@ -42,8 +42,8 @@ void producesTailSwapsAcrossEntities() { .build(new ListTailSwapMoveProvider<>(variableMetaModel), solutionMetaModel) .using(solution); context.producesAllOf( - Moves.swap(variableMetaModel, Range.of(entityA, 0, 3), Range.of(entityB, 0, 2), false), - Moves.swap(variableMetaModel, Range.of(entityA, 2, 3), Range.of(entityB, 1, 2), false)); + Moves.swap(variableMetaModel, new Range<>(entityA, 0, 3), new Range<>(entityB, 0, 2), false), + Moves.swap(variableMetaModel, new Range<>(entityA, 2, 3), new Range<>(entityB, 1, 2), false)); } @Test @@ -98,7 +98,7 @@ void reversingNeverHappensWhenBothTailsAreSingleElement() { var entityB = new TestdataListEntity("B", new TestdataListValue("b0")); var solution = new TestdataListSolution(); solution.setEntityList(List.of(entityA, entityB)); - solution.setValueList(List.of(entityA.getValueList().get(0), entityB.getValueList().get(0))); + solution.setValueList(List.of(entityA.getValueList().getFirst(), entityB.getValueList().getFirst())); var context = NeighborhoodTester .build(new ListTailSwapMoveProvider<>(variableMetaModel, true), solutionMetaModel) @@ -154,10 +154,10 @@ void valueRangeOnEntityRejectsInvalidSwap() { .build(new ListTailSwapMoveProvider<>(variableMetaModel), solutionMetaModel) .using(solution); context.producesNoneOf( - Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), false), - Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), true), - Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), false), - Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), true)); + Moves.swap(variableMetaModel, new Range<>(e1, 0, 1), new Range<>(e2, 0, 2), false), + Moves.swap(variableMetaModel, new Range<>(e1, 0, 1), new Range<>(e2, 0, 2), true), + Moves.swap(variableMetaModel, new Range<>(e2, 0, 2), new Range<>(e1, 0, 1), false), + Moves.swap(variableMetaModel, new Range<>(e2, 0, 2), new Range<>(e1, 0, 1), true)); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java index 367994f6edf..d25f45fb7c4 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListChangeMoveTest.java @@ -36,7 +36,7 @@ void moveSpanForward() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.change(variableMetaModel, Range.of(entity, 1, 3), ElementPosition.of(entity, 3), false); + var move = Moves.change(variableMetaModel, new Range<>(entity, 1, 3), ElementPosition.of(entity, 3), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -61,7 +61,7 @@ void moveSpanBackwardReversing() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.change(variableMetaModel, Range.of(entity, 3, 5), ElementPosition.of(entity, 0), true); + var move = Moves.change(variableMetaModel, new Range<>(entity, 3, 5), ElementPosition.of(entity, 0), true); MoveTester.build(solutionMetaModel) .using(solution) @@ -95,7 +95,7 @@ void moveSpanToOtherEntity() { .listVariable("valueList", TestdataListValue.class); var move = - Moves.change(variableMetaModel, Range.of(entityA, 1, 3), ElementPosition.of(entityB, 1), false); + Moves.change(variableMetaModel, new Range<>(entityA, 1, 3), ElementPosition.of(entityB, 1), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -119,7 +119,7 @@ void moveSpanToUnassignedIsSubListUnassignMove() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + var move = Moves.unassign(variableMetaModel, new Range<>(entity, 0, 2)); MoveTester.build(solutionMetaModel) .using(solution) @@ -148,7 +148,7 @@ void executeTemporarilyThenUndo() { .listVariable("valueList", TestdataListValue.class); // destinationIndex is post-removal: after removing [A, B], the list is [C] (size 1), so index 1 appends. - var move = Moves.change(variableMetaModel, Range.of(entity, 0, 2), ElementPosition.of(entity, 1), false); + var move = Moves.change(variableMetaModel, new Range<>(entity, 0, 2), ElementPosition.of(entity, 1), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -172,7 +172,7 @@ void getPlanningEntitiesForSameEntityMove() { .listVariable("valueList", TestdataListValue.class); var move = (SubListChangeMove) Moves.change( - variableMetaModel, Range.of(entity, 0, 1), ElementPosition.of(entity, 2), false); + variableMetaModel, new Range<>(entity, 0, 1), ElementPosition.of(entity, 2), false); assertThat(move.getPlanningEntities()).containsExactly(entity); } @@ -186,7 +186,7 @@ void getPlanningEntitiesForDifferentEntitiesMove() { .listVariable("valueList", TestdataListValue.class); var move = (SubListChangeMove) Moves.change( - variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + variableMetaModel, new Range<>(entityA, 0, 1), ElementPosition.of(entityB, 0), false); assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityA, entityB); } @@ -206,7 +206,7 @@ void getPlanningValuesReturnsMovedValues() { .listVariable("valueList", TestdataListValue.class); var move = (SubListChangeMove) Moves.change( - variableMetaModel, Range.of(entity, 0, 2), ElementPosition.of(entity, 1), false); + variableMetaModel, new Range<>(entity, 0, 2), ElementPosition.of(entity, 1), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -223,9 +223,9 @@ void equalsAndHashCode() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move1 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); - var move2 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); - var move3 = Moves.change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), true); + var move1 = Moves.change(variableMetaModel, new Range<>(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + var move2 = Moves.change(variableMetaModel, new Range<>(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + var move3 = Moves.change(variableMetaModel, new Range<>(entityA, 0, 1), ElementPosition.of(entityB, 0), true); assertThat(move1).isEqualTo(move2); assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); @@ -242,7 +242,7 @@ void toStringContainsMoveDetails() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.change(variableMetaModel, Range.of(entityA, 0, 2), ElementPosition.of(entityB, 1), true); + var move = Moves.change(variableMetaModel, new Range<>(entityA, 0, 2), ElementPosition.of(entityB, 1), true); var toString = move.toString(); assertThat(toString).contains("EntityA").contains("EntityB").contains("reversing-"); @@ -265,7 +265,7 @@ void rebaseCreatesNewMoveWithRebasedEntities() { .listVariable("valueList", TestdataListValue.class); var originalMove = (SubListChangeMove) Moves - .change(variableMetaModel, Range.of(entityA, 0, 1), ElementPosition.of(entityB, 0), false); + .change(variableMetaModel, new Range<>(entityA, 0, 1), ElementPosition.of(entityB, 0), false); var rebasedMove = originalMove.rebase(new Lookup() { @Override @@ -280,7 +280,7 @@ public T lookUpWorkingObject(T object) { } }); - assertThat(rebasedMove.getSource(). entity()).isEqualTo(rebasedEntityA); + assertThat(rebasedMove.getSource().entity()).isEqualTo(rebasedEntityA); assertThat(rebasedMove.getDestination(). entity()).isEqualTo(rebasedEntityB); } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java index fbf06ea2033..95401c29adb 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListSwapMoveTest.java @@ -36,7 +36,7 @@ void swapNonOverlappingSpans() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.swap(variableMetaModel, Range.of(entity, 1, 3), Range.of(entity, 4, 6), false); + var move = Moves.swap(variableMetaModel, new Range<>(entity, 1, 3), new Range<>(entity, 4, 6), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -63,7 +63,7 @@ void swapNormalizesOutOfOrderSpans() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.swap(variableMetaModel, Range.of(entity, 4, 6), Range.of(entity, 1, 3), false); + var move = Moves.swap(variableMetaModel, new Range<>(entity, 4, 6), new Range<>(entity, 1, 3), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -96,7 +96,7 @@ void swapSpansOfDifferingLength() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.swap(variableMetaModel, Range.of(entityA, 1, 3), Range.of(entityB, 0, 1), false); + var move = Moves.swap(variableMetaModel, new Range<>(entityA, 1, 3), new Range<>(entityB, 0, 1), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -126,7 +126,7 @@ void executeTemporarilyThenUndo() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.swap(variableMetaModel, Range.of(entity, 0, 1), Range.of(entity, 3, 4), false); + var move = Moves.swap(variableMetaModel, new Range<>(entity, 0, 1), new Range<>(entity, 3, 4), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -150,7 +150,7 @@ void getPlanningEntitiesForDifferentEntitiesSwap() { .listVariable("valueList", TestdataListValue.class); var move = (SubListSwapMove) Moves.swap( - variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), false); assertThat(move.getPlanningEntities()).containsExactlyInAnyOrder(entityA, entityB); } @@ -169,7 +169,7 @@ void getPlanningValuesReturnsPreMoveValues() { .listVariable("valueList", TestdataListValue.class); var move = (SubListSwapMove) Moves.swap( - variableMetaModel, Range.of(entity, 0, 1), Range.of(entity, 1, 2), false); + variableMetaModel, new Range<>(entity, 0, 1), new Range<>(entity, 1, 2), false); MoveTester.build(solutionMetaModel) .using(solution) @@ -186,9 +186,9 @@ void equalsAndHashCode() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move1 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); - var move2 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); - var move3 = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), true); + var move1 = Moves.swap(variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), false); + var move2 = Moves.swap(variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), false); + var move3 = Moves.swap(variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), true); assertThat(move1).isEqualTo(move2); assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); @@ -205,7 +205,7 @@ void toStringContainsMoveDetails() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataListEntity.class) .listVariable("valueList", TestdataListValue.class); - var move = Moves.swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), true); + var move = Moves.swap(variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), true); var toString = move.toString(); assertThat(toString).contains("EntityA").contains("EntityB").contains("reversing-"); @@ -228,7 +228,7 @@ void rebaseCreatesNewMoveWithRebasedEntities() { .listVariable("valueList", TestdataListValue.class); var originalMove = (SubListSwapMove) Moves - .swap(variableMetaModel, Range.of(entityA, 0, 1), Range.of(entityB, 0, 1), false); + .swap(variableMetaModel, new Range<>(entityA, 0, 1), new Range<>(entityB, 0, 1), false); var rebasedMove = originalMove.rebase(new Lookup() { @Override @@ -243,8 +243,8 @@ public T lookUpWorkingObject(T object) { } }); - assertThat(rebasedMove.getLeftRange(). entity()).isEqualTo(rebasedEntityA); - assertThat(rebasedMove.getRightRange(). entity()).isEqualTo(rebasedEntityB); + assertThat(rebasedMove.getLeftRange().entity()).isEqualTo(rebasedEntityA); + assertThat(rebasedMove.getRightRange().entity()).isEqualTo(rebasedEntityB); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java index bb5ecabd229..4c53939dde6 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/SubListUnassignMoveTest.java @@ -34,7 +34,7 @@ void unassignsSpan() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); - var move = Moves.unassign(variableMetaModel, Range.of(entity, 1, 3)); + var move = Moves.unassign(variableMetaModel, new Range<>(entity, 1, 3)); MoveTester.build(solutionMetaModel) .using(solution) @@ -62,7 +62,7 @@ void executeTemporarilyThenUndo() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); - var move = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + var move = Moves.unassign(variableMetaModel, new Range<>(entity, 0, 2)); MoveTester.build(solutionMetaModel) .using(solution) @@ -87,7 +87,7 @@ void getPlanningEntitiesReturnsOwningEntity() { var move = (SubListUnassignMove) Moves - .unassign(variableMetaModel, Range.of(entity, 0, 1)); + .unassign(variableMetaModel, new Range<>(entity, 0, 1)); assertThat(move.getPlanningEntities()).containsExactly(entity); } @@ -107,7 +107,7 @@ void getPlanningValuesReturnsUnassignedValues() { var move = (SubListUnassignMove) Moves - .unassign(variableMetaModel, Range.of(entity, 0, 2)); + .unassign(variableMetaModel, new Range<>(entity, 0, 2)); MoveTester.build(solutionMetaModel) .using(solution) @@ -125,9 +125,9 @@ void equalsAndHashCode() { var variableMetaModel = solutionMetaModel.genuineEntity(TestdataAllowsUnassignedValuesListEntity.class) .listVariable("valueList", TestdataAllowsUnassignedValuesListValue.class); - var move1 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 1)); - var move2 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 1)); - var move3 = Moves.unassign(variableMetaModel, Range.of(entity, 0, 2)); + var move1 = Moves.unassign(variableMetaModel, new Range<>(entity, 0, 1)); + var move2 = Moves.unassign(variableMetaModel, new Range<>(entity, 0, 1)); + var move3 = Moves.unassign(variableMetaModel, new Range<>(entity, 0, 2)); assertThat(move1).isEqualTo(move2); assertThat(move1.hashCode()).isEqualTo(move2.hashCode()); @@ -154,7 +154,7 @@ void rebaseCreatesNewMoveWithRebasedEntity() { var originalMove = (SubListUnassignMove) Moves - .unassign(variableMetaModel, Range.of(entity, 0, 1)); + .unassign(variableMetaModel, new Range<>(entity, 0, 1)); var rebasedMove = originalMove.rebase(new Lookup() { @Override @@ -167,7 +167,7 @@ public T lookUpWorkingObject(T object) { } }); - assertThat(rebasedMove.getRange(). entity()).isEqualTo(rebasedEntity); + assertThat(rebasedMove.getRange().entity()).isEqualTo(rebasedEntity); } } diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java index 9b990120181..ffd962377fe 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/move/builtin/TwoOptListMoveProviderTest.java @@ -178,11 +178,11 @@ void fullyPinnedEntityNeverInvolved() { .isNotEmpty() .allSatisfy(move -> { if (move instanceof SubListChangeMove reversal) { - assertThat(reversal.getSource(). entity()) + assertThat(reversal.getSource().entity()) .isNotEqualTo(pinnedEntity); } else if (move instanceof SubListSwapMove swap) { - assertThat(swap.getLeftRange(). entity()).isNotEqualTo(pinnedEntity); - assertThat(swap.getRightRange(). entity()) + assertThat(swap.getLeftRange().entity()).isNotEqualTo(pinnedEntity); + assertThat(swap.getRightRange().entity()) .isNotEqualTo(pinnedEntity); } }); @@ -211,8 +211,8 @@ void valueRangeOnEntityRejectsTailSwap() { .build(new TwoOptListMoveProvider<>(variableMetaModel, true), solutionMetaModel) .using(solution); context.producesNoneOf( - Moves.swap(variableMetaModel, Range.of(e1, 0, 1), Range.of(e2, 0, 2), false), - Moves.swap(variableMetaModel, Range.of(e2, 0, 2), Range.of(e1, 0, 1), false)); + Moves.swap(variableMetaModel, new Range<>(e1, 0, 1), new Range<>(e2, 0, 2), false), + Moves.swap(variableMetaModel, new Range<>(e2, 0, 2), new Range<>(e1, 0, 1), false)); } @Test @@ -231,7 +231,7 @@ void executingReversalPinsTheIndexMath() { solution.setEntityList(List.of(entity)); solution.setValueList(List.of(v0, v1, v2, v3, v4)); - var move = Moves.reverse(variableMetaModel, Range.of(entity, 1, 4)); + var move = Moves.reverse(variableMetaModel, new Range<>(entity, 1, 4)); MoveTester.build(solutionMetaModel) .using(solution) .execute(move); diff --git a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java index c55dd61162d..b88fe2207bc 100644 --- a/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java +++ b/core/src/test/java/ai/timefold/solver/core/preview/api/neighborhood/stream/dataset/sample/SampleTest.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import org.junit.jupiter.api.Test; @@ -26,6 +27,16 @@ void ofCopiesSoLaterMutationDoesNotLeak() { assertThat(sample.contains("c")).isFalse(); } + @Test + void ofAdoptsASequencedSetInsteadOfCopyingIt() { + var memberSet = new LinkedHashSet<>(List.of("a", "b")); + var sample = Sample.of(memberSet); + memberSet.add("c"); + // A SequencedSet is adopted, not copied - later mutation of the caller's set leaks into the sample. + assertThat(sample.size()).isEqualTo(3); + assertThat(sample.contains("c")).isTrue(); + } + @Test void nullIsALegalMember() { var sample = Sample.of(Arrays.asList("a", null)); From 459fdec06692ce5c6977a73e40d6efde11b57280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Wed, 26 Aug 2026 13:05:09 +0200 Subject: [PATCH 16/16] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../preview/api/move/builtin/MassListChangeMoveProvider.java | 2 +- .../solver/core/preview/api/move/builtin/SwapMoveProvider.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java index 639b567714e..06e7e0b6266 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/MassListChangeMoveProvider.java @@ -30,7 +30,7 @@ * which this provider discards outright; * a sampler whose {@link Sampler#minimumSize() minimumSize} is at least 2 avoids that - * {@link Samplers#between(int, int) Samplers.between(2, n)} is the recommended choice, - * s unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. + * since an unbounded {@link Samplers#all() Samplers.all()} makes this provider's move cost linear in the data set size. *

    * A sample already sitting consecutively at the destination produces a move that changes nothing; * this is accepted, in the same spirit as a mixed-value {@code MassChangeMoveProvider} sample diff --git a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java index 9e7403b3021..55fb2ee46d7 100644 --- a/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java +++ b/core/src/main/java/ai/timefold/solver/core/preview/api/move/builtin/SwapMoveProvider.java @@ -57,7 +57,7 @@ public SwapMoveProvider(PlanningVariableMetaModel variabl */ public SwapMoveProvider(List> variableMetaModelList) { this.variableMetaModelList = MoveProviderUtil.normalize(variableMetaModelList); - this.entityMetaModel = variableMetaModelList.getFirst().entity(); + this.entityMetaModel = this.variableMetaModelList.getFirst().entity(); } @Override