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 6ad57aaa74..30d4a008e2 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 @@ -641,6 +641,10 @@ public ListVariableDescriptor getListVariableDescriptor() { return listVariableDescriptorList.isEmpty() ? null : listVariableDescriptorList.getFirst(); } + public List> getListVariableDescriptorList() { + return listVariableDescriptorList; + } + public SolutionCloner getSolutionCloner() { return solutionCloner; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSessionFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSessionFactory.java index 99a2aede11..a9db8a3e32 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSessionFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSessionFactory.java @@ -171,6 +171,16 @@ public static VariableReferenceGraph buildGraph(GraphDescriptor VariableReferenceGraph buildGraphForStructureAndDirection( GraphStructure.GraphStructureAndDirection graphStructureAndDirection, GraphDescriptor graphDescriptor) { + if (graphStructureAndDirection.elementCascade() != null) { + var scoreDirector = + graphDescriptor.variableReferenceGraphBuilder().changedVariableNotifier.innerScoreDirector(); + if (scoreDirector == null) { + // Without a score director there is no list state supply to walk the lists with, + // so the whole model is covered by the arbitrary graph. + return buildArbitraryGraph(graphDescriptor); + } + return buildListElementCascadeGraph(graphDescriptor, graphStructureAndDirection); + } return switch (graphStructureAndDirection.structure()) { case EMPTY -> EmptyVariableReferenceGraph.INSTANCE; case SINGLE_DIRECTIONAL_PARENT -> { @@ -207,6 +217,105 @@ static VariableReferenceGraph buildSingleDirectionalParentGraph( graphDescriptor.entities()); } + static VariableReferenceGraph buildListElementCascadeGraph( + GraphDescriptor graphDescriptor, + GraphStructure.GraphStructureAndDirection graphStructureAndDirection) { + var solutionDescriptor = graphDescriptor.solutionDescriptor(); + var elementEntityClass = Objects.requireNonNull(graphStructureAndDirection.elementCascade()).elementEntityClass(); + var allDescriptors = solutionDescriptor.getDeclarativeShadowVariableDescriptors(); + var elementDescriptorList = new ArrayList>(); + var innerDescriptorList = new ArrayList>(); + for (var descriptor : allDescriptors) { + if (elementEntityClass.isAssignableFrom(descriptor.getEntityDescriptor().getEntityClass())) { + elementDescriptorList.add(descriptor); + } else { + innerDescriptorList.add(descriptor); + } + } + var sortedElementDescriptors = topologicallySortedDeclarativeShadowVariables(elementDescriptorList); + var listVariableDescriptor = Objects.requireNonNull(solutionDescriptor.getListVariableDescriptor()); + + // The pre-chain variables the elements read through their inverse: + // when one of them changes during an update, its entity's whole list must be walked, + // since any element may read it. + // These variables are declarative, so they only ever change through a notifier; + // wrapping it observes every such change, including those of the inner graph's updates. + var elementReadVariableSet = new HashSet>(); + for (var descriptor : elementDescriptorList) { + for (var source : descriptor.getSources()) { + if (source.parentVariableType() == ParentVariableType.INVERSE) { + elementReadVariableSet.add(source.variableSourceReferences().get(1).variableMetaModel()); + } + } + } + var wholeChainOwnerSet = Collections.newSetFromMap(new IdentityHashMap<>()); + var changedVariableNotifier = graphDescriptor.changedVariableNotifier(); + var flaggingNotifier = elementReadVariableSet.isEmpty() ? changedVariableNotifier + : new ChangedVariableNotifier<>( + changedVariableNotifier.beforeVariableChanged(), + (variableDescriptor, entity) -> { + if (elementReadVariableSet.contains(variableDescriptor.getVariableMetaModel())) { + wholeChainOwnerSet.add(entity); + } + changedVariableNotifier.afterVariableChanged().accept(variableDescriptor, entity); + }, + changedVariableNotifier.innerScoreDirector()); + + var innerGraphDescriptor = new GraphDescriptor<>(graphDescriptor.consistencyTracker(), solutionDescriptor, + new VariableReferenceGraphBuilder<>(flaggingNotifier), graphDescriptor.entities(), + graphDescriptor.graphCreator()); + var innerGraph = switch (graphStructureAndDirection.structure()) { + case EMPTY -> EmptyVariableReferenceGraph.INSTANCE; + case ARBITRARY_SINGLE_ENTITY_AT_MOST_ONE_DIRECTIONAL_PARENT_TYPE -> + buildArbitrarySingleEntityGraph(innerGraphDescriptor, innerDescriptorList, elementEntityClass); + default -> buildArbitraryGraph(innerGraphDescriptor, innerDescriptorList, elementEntityClass); + }; + + var postChainVariableSet = GraphStructure.computePostChainVariables(allDescriptors, elementEntityClass); + var postChainVariableIdList = innerDescriptorList.stream() + .filter(descriptor -> postChainVariableSet.contains(descriptor.getVariableMetaModel())) + .> map(DeclarativeShadowVariableDescriptor::getVariableMetaModel) + .toList(); + // The elements' consistency follows their owner's, so the cascade needs the owner's state; + // an owner class without declarative variables can never be inconsistent. + var ownerEntityClass = listVariableDescriptor.getEntityDescriptor().getEntityClass(); + var ownerConsistencyState = innerDescriptorList.stream() + .filter(descriptor -> descriptor.getEntityDescriptor().getEntityClass().isAssignableFrom(ownerEntityClass)) + .findFirst() + .map(descriptor -> graphDescriptor.consistencyTracker() + .getDeclarativeEntityConsistencyState(descriptor.getEntityDescriptor())) + .orElse(null); + + // The topologically first chain element is the list's first element for a previous parent, + // but the list's last element for a next parent, whose successor function walks backwards. + var isPreviousDirection = graphStructureAndDirection.direction() == ParentVariableType.PREVIOUS; + Function ownerToFirstElement = owner -> { + var elementList = listVariableDescriptor.getValue(owner); + if (elementList.isEmpty()) { + return null; + } + return isPreviousDirection ? elementList.get(0) : elementList.get(elementList.size() - 1); + }; + var topologicalSorter = getTopologicalSorter(solutionDescriptor, + Objects.requireNonNull(changedVariableNotifier.innerScoreDirector()), + Objects.requireNonNull(graphStructureAndDirection.direction())); + var canTerminateEarly = hasNoNonDeclarativeSourcesFromParent(elementDescriptorList); + + return new ListElementCascadeVariableReferenceGraph<>( + graphDescriptor.consistencyTracker(), + innerGraph, + sortedElementDescriptors, + elementEntityClass, + ownerConsistencyState, + postChainVariableIdList, + wholeChainOwnerSet, + topologicalSorter, + ownerToFirstElement, + canTerminateEarly, + flaggingNotifier, + graphDescriptor.entities()); + } + private static boolean hasNoNonDeclarativeSourcesFromParent( List> declarativeShadowVariables) { for (var declarativeShadowVariable : declarativeShadowVariables) { @@ -281,8 +390,13 @@ yield new TopologicalSorter(listStateSupply::getPreviousElement, } private static VariableReferenceGraph buildArbitraryGraph(GraphDescriptor graphDescriptor) { - var declarativeShadowVariableDescriptors = - graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(); + return buildArbitraryGraph(graphDescriptor, + graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(), null); + } + + private static VariableReferenceGraph buildArbitraryGraph(GraphDescriptor graphDescriptor, + List> declarativeShadowVariableDescriptors, + @Nullable Class cascadedElementClass) { var variableIdToUpdater = EntityVariableUpdaterLookup. entityIndependentLookup(); // Create graph node for each entity/declarative shadow variable pair. @@ -294,17 +408,18 @@ private static VariableReferenceGraph buildArbitraryGraph(GraphDescr graphDescriptor, declarativeShadowVariableDescriptors, variableIdToUpdater); return buildVariableReferenceGraph(graphDescriptor, declarativeShadowVariableDescriptors, - declarativeShadowVariableToAliasMap); + declarativeShadowVariableToAliasMap, cascadedElementClass); } private static VariableReferenceGraph buildVariableReferenceGraph( GraphDescriptor graphDescriptor, List> declarativeShadowVariableDescriptors, - Map, Set> declarativeShadowVariableToAliasMap) { + Map, Set> declarativeShadowVariableToAliasMap, + @Nullable Class cascadedElementClass) { // Create variable processors for each declarative shadow variable descriptor for (var declarativeShadowVariable : declarativeShadowVariableDescriptors) { var fromVariableId = declarativeShadowVariable.getVariableMetaModel(); - createSourceChangeProcessors(graphDescriptor, declarativeShadowVariable, fromVariableId); + createSourceChangeProcessors(graphDescriptor, declarativeShadowVariable, fromVariableId, cascadedElementClass); var aliasSet = declarativeShadowVariableToAliasMap.get(fromVariableId); if (aliasSet != null) { createAliasToVariableChangeProcessors(graphDescriptor.variableReferenceGraphBuilder(), aliasSet, @@ -314,7 +429,7 @@ private static VariableReferenceGraph buildVariableReferenceGraph( // Create the fixed edges in the graph createFixedVariableRelationEdges(graphDescriptor.variableReferenceGraphBuilder(), graphDescriptor.entities(), - declarativeShadowVariableDescriptors); + declarativeShadowVariableDescriptors, cascadedElementClass); return graphDescriptor.variableReferenceGraphBuilder().build(graphDescriptor.graphCreator()); } @@ -451,8 +566,14 @@ public List> getUpdatersForEntityVariable(Object private static VariableReferenceGraph buildArbitrarySingleEntityGraph( GraphDescriptor graphDescriptor) { - var declarativeShadowVariableDescriptors = - graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(); + return buildArbitrarySingleEntityGraph(graphDescriptor, + graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(), null); + } + + private static VariableReferenceGraph buildArbitrarySingleEntityGraph( + GraphDescriptor graphDescriptor, + List> declarativeShadowVariableDescriptors, + @Nullable Class cascadedElementClass) { // Use a dependent lookup; if an entity does not use groups, then all variables can share the same node. // If the entity use groups, then variables must be grouped into their own nodes. var alignmentKeyMappers = new HashMap, Function>(); @@ -482,7 +603,7 @@ private static VariableReferenceGraph buildArbitrarySingleEntityGrap (entity, declarativeShadowVariable, variableId) -> variableIdToGroupedUpdater.get(variableId) .getUpdatersForEntityVariable(entity, declarativeShadowVariable)); return buildVariableReferenceGraph(graphDescriptor, declarativeShadowVariableDescriptors, - declarativeShadowVariableToAliasMap); + declarativeShadowVariableToAliasMap, cascadedElementClass); } private static Map, Set> createGraphNodes( @@ -536,10 +657,11 @@ private static VariableReferenceGraph buildArbitrarySingleEntityGrap private static void createSourceChangeProcessors( GraphDescriptor graphDescriptor, DeclarativeShadowVariableDescriptor declarativeShadowVariable, - VariableMetaModel fromVariableId) { + VariableMetaModel fromVariableId, + @Nullable Class cascadedElementClass) { for (var source : declarativeShadowVariable.getSources()) { if (source.parentVariableType() == ParentVariableType.LIST_ELEMENT) { - createListElementSourceProcessors(graphDescriptor, source, fromVariableId); + createListElementSourceProcessors(graphDescriptor, source, fromVariableId, cascadedElementClass); continue; } var parentVariableList = new ArrayList(); @@ -624,7 +746,8 @@ private static void createSourceChangeProcessors( private static void createListElementSourceProcessors( GraphDescriptor graphDescriptor, RootVariableSource source, - VariableMetaModel fromVariableId) { + VariableMetaModel fromVariableId, + @Nullable Class cascadedElementClass) { var listVariableId = Objects.requireNonNull(source.listVariableMetaModel()); // Mark the target variable changed whenever its list variable changes, // since its dependency set (and possibly its value) changes with the list's contents. @@ -638,6 +761,12 @@ private static void createListElementSourceProcessors( graph.markChanged(changed); } }); + if (cascadedElementClass != null) { + // The list's elements are not part of the graph; the element cascade marks the + // target variable changed when an element changes, so no edges are needed, + // and the graph stays fixed if nothing else needs dynamic edges. + return; + } // Maintain the fan-in edges (element.sourceVariable -> entity.targetVariable) // when the list variable changes. var elementSource = source.variableSourceReferences().get(0); @@ -701,7 +830,8 @@ private static void createAliasToVariableChangeProcessors( private static void createFixedVariableRelationEdges( VariableReferenceGraphBuilder variableReferenceGraphBuilder, Object[] entities, - List> declarativeShadowVariableDescriptors) { + List> declarativeShadowVariableDescriptors, + @Nullable Class cascadedElementClass) { for (var entity : entities) { for (var declarativeShadowVariableDescriptor : declarativeShadowVariableDescriptors) { var entityClass = declarativeShadowVariableDescriptor.getEntityDescriptor().getEntityClass(); @@ -718,6 +848,11 @@ private static void createFixedVariableRelationEdges( // the graph is built and are removed/added as it changes during solving, // so they must not be treated as fixed by the fixed-loop fail-fast. var isListElementSource = sourceRoot.parentVariableType() == ParentVariableType.LIST_ELEMENT; + if (isListElementSource && cascadedElementClass != null) { + // The list's elements are not part of the graph; + // the element cascade covers this source without edges. + break; + } sourceRoot.valueEntityFunction() .accept(entity, fromEntity -> { var from = variableReferenceGraphBuilder.lookupOrError(fromVariableId, fromEntity); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructure.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructure.java index 118f08c4df..b8c30bb5ea 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructure.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructure.java @@ -1,7 +1,10 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; +import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.util.MutableInt; @@ -51,9 +54,28 @@ public enum GraphStructure { private static final Logger LOGGER = LoggerFactory.getLogger(GraphStructure.class); + /** + * When present, the planning list variable's elements are excluded from the variable + * reference graph, which only covers the other entity classes; + * the elements are instead updated by a cascade that walks each dirty entity's list + * in the direction of {@link GraphStructureAndDirection#direction()}. + * This decomposition is valid because the elements only read their chain and, + * through their inverse, pre-chain declarative variables of their own list entity, + * and because the other classes only reach the elements through the list variable itself. + */ + public record ListElementCascade(Class elementEntityClass) { + } + public record GraphStructureAndDirection(GraphStructure structure, @Nullable VariableMetaModel parentMetaModel, - @Nullable ParentVariableType direction) { + @Nullable ParentVariableType direction, + @Nullable ListElementCascade elementCascade) { + + public GraphStructureAndDirection(GraphStructure structure, + @Nullable VariableMetaModel parentMetaModel, + @Nullable ParentVariableType direction) { + this(structure, parentMetaModel, direction, null); + } } public static GraphStructureAndDirection determineGraphStructure( @@ -68,6 +90,31 @@ public static GraphStructureAndDirection determineGraphStructure( return new GraphStructureAndDirection(EMPTY, null, null); } + var elementCascadeAndDirection = determineListElementCascade(solutionDescriptor, + declarativeShadowVariableDescriptors); + if (elementCascadeAndDirection != null) { + var elementEntityClass = elementCascadeAndDirection.elementCascade().elementEntityClass(); + var innerDescriptors = declarativeShadowVariableDescriptors.stream() + .filter(descriptor -> !elementEntityClass + .isAssignableFrom(descriptor.getEntityDescriptor().getEntityClass())) + .toList(); + var innerStructure = determineGraphStructure(innerDescriptors, elementEntityClass, entities); + return new GraphStructureAndDirection(innerStructure.structure(), + elementCascadeAndDirection.parentMetaModel(), + elementCascadeAndDirection.direction(), + elementCascadeAndDirection.elementCascade()); + } + return determineGraphStructure(declarativeShadowVariableDescriptors, null, entities); + } + + private static GraphStructureAndDirection determineGraphStructure( + List> declarativeShadowVariableDescriptors, + @Nullable Class cascadedElementClass, + Object... entities) { + if (declarativeShadowVariableDescriptors.isEmpty() + || !doEntitiesUseDeclarativeShadowVariables(declarativeShadowVariableDescriptors, entities)) { + return new GraphStructureAndDirection(EMPTY, null, null); + } var multipleDeclarativeEntityClasses = declarativeShadowVariableDescriptors.stream() .map(variable -> variable.getEntityDescriptor().getEntityClass()) .distinct().count() > 1; @@ -109,7 +156,15 @@ public static GraphStructureAndDirection determineGraphStructure( } // The group variable is unused/always empty } - case INDIRECT, INVERSE, VARIABLE, LIST_ELEMENT -> isArbitrary = true; + case INDIRECT, INVERSE, VARIABLE -> isArbitrary = true; + case LIST_ELEMENT -> { + // Under an element cascade, the list's elements are not part of the graph; + // a processor recomputes the target variable when the list or its elements change, + // so the source does not need any edges. + if (cascadedElementClass == null) { + isArbitrary = true; + } + } case NEXT, PREVIOUS -> { if (parentMetaModel == null) { parentMetaModel = variableSource.variableSourceReferences().get(0).variableMetaModel(); @@ -136,6 +191,184 @@ public static GraphStructureAndDirection determineGraphStructure( } } + private record ListElementCascadeAndDirection(ListElementCascade elementCascade, + VariableMetaModel parentMetaModel, + ParentVariableType direction) { + } + + /** + * Non-null if the planning list variable's elements can be excluded from the variable + * reference graph and updated by a cascade instead; see {@link ListElementCascade}. + * Only the element class's sources and the references towards the element class are + * checked here: the rest of the model is covered by the graph, whatever its structure. + */ + private static @Nullable ListElementCascadeAndDirection determineListElementCascade( + SolutionDescriptor solutionDescriptor, + List> declarativeShadowVariableDescriptors) { + var listVariableDescriptorList = solutionDescriptor.getListVariableDescriptorList(); + if (listVariableDescriptorList.size() != 1) { + // The detection does not match list element sources against a specific list variable, + // and the wrapper routes every list change event to the cascade; + // both rely on the elements' list being the model's only list variable, + // which SolutionDescriptor currently guarantees. Re-audit both before lifting this. + return null; + } + var listVariableDescriptor = listVariableDescriptorList.getFirst(); + // The element class is the entity class of the single previous or next directional parent. + VariableMetaModel parentMetaModel = null; + ParentVariableType direction = null; + Class elementEntityClass = null; + for (var descriptor : declarativeShadowVariableDescriptors) { + for (var source : descriptor.getSources()) { + var parentVariableType = source.parentVariableType(); + if (parentVariableType == ParentVariableType.PREVIOUS || parentVariableType == ParentVariableType.NEXT) { + var sourceParentMetaModel = source.variableSourceReferences().get(0).variableMetaModel(); + if (parentMetaModel == null) { + parentMetaModel = sourceParentMetaModel; + direction = parentVariableType; + // The class declaring the directional parent, so extended element classes are covered. + elementEntityClass = sourceParentMetaModel.entity().type(); + } else if (!parentMetaModel.equals(sourceParentMetaModel)) { + return null; + } + } + } + } + if (elementEntityClass == null || direction == null) { + return null; + } + var ownerEntityClass = listVariableDescriptor.getEntityDescriptor().getEntityClass(); + if (!elementEntityClass.isAssignableFrom(listVariableDescriptor.getElementType()) + || ownerEntityClass.isAssignableFrom(elementEntityClass) + || elementEntityClass.isAssignableFrom(ownerEntityClass)) { + // The cascade walks the owner's list and classifies entities with instanceof, + // so the element class must cover the list's elements and be distinct from the owner. + return null; + } + var elementDescriptorList = new ArrayList>(); + var hasNonElementDescriptors = false; + for (var descriptor : declarativeShadowVariableDescriptors) { + var entityClass = descriptor.getEntityDescriptor().getEntityClass(); + if (elementEntityClass.isAssignableFrom(entityClass)) { + elementDescriptorList.add(descriptor); + } else if (entityClass.isAssignableFrom(elementEntityClass)) { + // A declarative superclass of the elements would be entangled with the cascade. + return null; + } else { + hasNonElementDescriptors = true; + if (entityClass == ownerEntityClass && descriptor.getAlignmentKeyMap() != null) { + // The cascade recomputes the owner's post-chain variables one entity at a time, + // which an alignment key's grouped updater contradicts. + return null; + } + } + } + if (!hasNonElementDescriptors) { + // A model with only element variables is covered by the existing structures. + return null; + } + var hasElementAlignmentKey = elementDescriptorList.stream() + .anyMatch(descriptor -> descriptor.getAlignmentKeyMap() != null); + if (hasElementAlignmentKey) { + return null; + } + var postChainVariableSet = computePostChainVariables(declarativeShadowVariableDescriptors, elementEntityClass); + for (var descriptor : declarativeShadowVariableDescriptors) { + var isElementSource = elementEntityClass.isAssignableFrom(descriptor.getEntityDescriptor().getEntityClass()); + for (var variableSource : descriptor.getSources()) { + var parentVariableType = variableSource.parentVariableType(); + if (isElementSource) { + switch (parentVariableType) { + case PREVIOUS, NEXT -> { + // Safe: stays within the chain. + } + case NO_PARENT -> { + // Only safe when it does not access a declarative variable + // through another (non-declarative) variable, + // which would require the elements to be part of the graph. + if (variableSource.variableSourceReferences().size() != 1) { + return null; + } + } + case INVERSE -> { + // Only safe when it targets a pre-chain declarative variable of the owner: + // post-chain variables depend on the chain itself, + // and a non-declarative variable change does not trigger a chain walk. + var references = variableSource.variableSourceReferences(); + if (references.size() < 2 + || !references.get(1).isDeclarative() + || postChainVariableSet.contains(references.get(1).variableMetaModel())) { + return null; + } + } + default -> { + return null; + } + } + } else if (parentVariableType == ParentVariableType.LIST_ELEMENT) { + // Only safe when it accesses the list's own elements directly: + // an entity reached through an element's fact may belong to another list, + // whose changes would not recompute this variable. + var reference = variableSource.variableSourceReferences().get(0); + if (!reference.chainFromRootEntityToVariableEntity().isEmpty()) { + return null; + } + } else { + // Elements are not part of the graph, so no other source may reach them. + for (var reference : variableSource.variableSourceReferences()) { + if (elementEntityClass.isAssignableFrom(reference.variableMetaModel().entity().type())) { + return null; + } + } + } + } + } + return new ListElementCascadeAndDirection(new ListElementCascade(elementEntityClass), parentMetaModel, direction); + } + + /** + * Classifies the declarative shadow variables of the classes outside the element cascade: + * a variable is post-chain when it depends on its own entity's list elements, + * directly through a list element source or transitively through another variable + * of the same entity. + * Pre-chain variables can be computed before the entity's chain is walked; + * post-chain variables must be computed after it. + */ + static Set> computePostChainVariables( + List> declarativeShadowVariableDescriptors, + Class elementEntityClass) { + var nonElementDescriptorList = declarativeShadowVariableDescriptors.stream() + .filter(descriptor -> !elementEntityClass + .isAssignableFrom(descriptor.getEntityDescriptor().getEntityClass())) + .toList(); + var postChainVariableSet = new LinkedHashSet>(); + var changed = true; + while (changed) { + changed = false; + for (var descriptor : nonElementDescriptorList) { + var variableMetaModel = descriptor.getVariableMetaModel(); + if (postChainVariableSet.contains(variableMetaModel)) { + continue; + } + for (var source : descriptor.getSources()) { + // A cross-entity source (a fact path or a variable path) is ordered by + // the graph's own edges, so it does not propagate post-chain status. + var isPostChain = source.parentVariableType() == ParentVariableType.LIST_ELEMENT + || (source.parentVariableType() == ParentVariableType.NO_PARENT + && source.variableSourceReferences().stream() + .map(VariableSourceReference::variableMetaModel) + .anyMatch(postChainVariableSet::contains)); + if (isPostChain) { + postChainVariableSet.add(variableMetaModel); + changed = true; + break; + } + } + } + } + return postChainVariableSet; + } + private static boolean doEntitiesUseDeclarativeShadowVariables( List> declarativeShadowVariableDescriptors, Object... entities) { boolean anyDeclarativeEntities = false; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraph.java new file mode 100644 index 0000000000..cfff06d9f3 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraph.java @@ -0,0 +1,322 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A variable reference graph that excludes a planning list variable's elements from the graph + * and updates them by a cascade instead; see {@link GraphStructure.ListElementCascade}. + *

+ * The inner graph covers everything but the elements and is built by the normal machinery. + * The cascade walks each dirty entity's list from the earliest dirty element, + * and marks the entity's post-chain variables changed in the inner graph when an element changed. + * When a pre-chain variable read by the elements changes during an inner update, + * the notifier wrapper flags its entity for a whole-chain walk. + * {@link #updateChanged()} alternates the cascade and the inner graph until neither has work left, + * which terminates because the flagged values converge + * (or stop changing once the inner graph marks their entities inconsistent). + */ +@NullMarked +public final class ListElementCascadeVariableReferenceGraph implements VariableReferenceGraph { + + private final VariableReferenceGraph innerGraph; + private final @Nullable AbstractVariableReferenceGraph innerNodeGraph; + + private final VariableUpdaterInfo[] elementUpdaters; + private final Class elementEntityClass; + private final EntityConsistencyState elementConsistencyState; + private final @Nullable EntityConsistencyState ownerConsistencyState; + private final List> postChainVariableIdList; + + /** + * Filled by the wrapped notifier when a pre-chain variable read by the elements changes; + * see {@link DefaultShadowVariableSessionFactory#buildListElementCascadeGraph}. + */ + private final Set wholeChainOwnerSet; + + private final UnaryOperator<@Nullable Object> nextInChain; + private final UnaryOperator<@Nullable Object> elementToOwner; + private final Comparator chainOrderComparator; + private final Function ownerToFirstElement; + + private final Set> monitoredSourceVariableSet; + private final ChangedVariableNotifier changedVariableNotifier; + private final boolean canTerminateEarly; + + private final List changedElementList; + private boolean isProcessing; + + @SuppressWarnings("unchecked") + ListElementCascadeVariableReferenceGraph( + ConsistencyTracker consistencyTracker, + VariableReferenceGraph innerGraph, + List> sortedElementDescriptorList, + Class elementEntityClass, + @Nullable EntityConsistencyState ownerConsistencyState, + List> postChainVariableIdList, + Set wholeChainOwnerSet, + TopologicalSorter topologicalSorter, + Function ownerToFirstElement, + boolean canTerminateEarly, + ChangedVariableNotifier changedVariableNotifier, + Object[] entities) { + this.innerGraph = innerGraph; + this.innerNodeGraph = innerGraph instanceof AbstractVariableReferenceGraph abstractGraph + ? (AbstractVariableReferenceGraph) abstractGraph + : null; + this.elementEntityClass = elementEntityClass; + this.ownerConsistencyState = ownerConsistencyState; + this.postChainVariableIdList = postChainVariableIdList; + this.wholeChainOwnerSet = wholeChainOwnerSet; + this.nextInChain = topologicalSorter.successor(); + this.elementToOwner = topologicalSorter.key(); + this.chainOrderComparator = topologicalSorter.comparator(); + this.ownerToFirstElement = ownerToFirstElement; + this.canTerminateEarly = canTerminateEarly; + this.changedVariableNotifier = changedVariableNotifier; + this.changedElementList = new ArrayList<>(); + this.isProcessing = false; + + this.elementConsistencyState = consistencyTracker + .getDeclarativeEntityConsistencyState(sortedElementDescriptorList.get(0).getEntityDescriptor()); + this.monitoredSourceVariableSet = new HashSet<>(); + this.elementUpdaters = new VariableUpdaterInfo[sortedElementDescriptorList.size()]; + var updaterId = 0; + for (var descriptor : sortedElementDescriptorList) { + for (var source : descriptor.getSources()) { + for (var sourceReference : source.variableSourceReferences()) { + monitoredSourceVariableSet.add(sourceReference.variableMetaModel()); + } + } + elementUpdaters[updaterId] = new VariableUpdaterInfo<>( + descriptor.getVariableMetaModel(), updaterId, descriptor, elementConsistencyState, + descriptor.getMemberAccessor(), descriptor.getCalculator()); + updaterId++; + } + + // Every element gets an initial computation and starts consistent; + // its consistency can only change when its owner becomes inconsistent. + for (var entity : entities) { + if (elementEntityClass.isInstance(entity)) { + elementConsistencyState.setEntityIsInconsistent(changedVariableNotifier, entity, false); + changedElementList.add(entity); + } + } + // The inner graph computes the pre-chain variables the first cascade reads. + innerGraph.updateChanged(); + updateChanged(); + } + + @Override + public void beforeVariableChanged(VariableMetaModel variableReference, Object entity) { + if (isProcessing) { + // A reentrant event of this graph's own update; the notifier wrapper already observed it. + return; + } + innerGraph.beforeVariableChanged(variableReference, entity); + } + + @Override + public void afterVariableChanged(VariableMetaModel variableReference, Object entity) { + if (isProcessing) { + return; + } + if (monitoredSourceVariableSet.contains(variableReference) && elementEntityClass.isInstance(entity)) { + changedElementList.add(entity); + } + innerGraph.afterVariableChanged(variableReference, entity); + } + + @Override + public void beforeListVariableChanged(VariableMetaModel variableReference, Object entity, + List elementList, int fromIndex, int toIndex) { + if (isProcessing) { + throw new IllegalStateException("Impossible state: list variable changed during shadow variable update."); + } + innerGraph.beforeListVariableChanged(variableReference, entity, elementList, fromIndex, toIndex); + markPostChainVariablesChanged(entity); + } + + @Override + public void afterListVariableChanged(VariableMetaModel variableReference, Object entity, + List elementList, int fromIndex, int toIndex) { + if (isProcessing) { + throw new IllegalStateException("Impossible state: list variable changed during shadow variable update."); + } + for (var elementIndex = fromIndex; elementIndex < toIndex; elementIndex++) { + changedElementList.add(elementList.get(elementIndex)); + } + innerGraph.afterListVariableChanged(variableReference, entity, elementList, fromIndex, toIndex); + // The post-chain variables' dependency set changed with the list's contents, + // even when the changed range is empty (e.g. an element was removed). + markPostChainVariablesChanged(entity); + } + + @Override + public void updateChanged() { + isProcessing = true; + try { + // Classify the changed elements into per-owner dirty ranges; + // an unassigned element is reset immediately, + // so re-assigning it to the same position is detected as a change. + var ownerToDirtyChainStart = new IdentityHashMap(); + var ownerToDirtyChainEnd = new IdentityHashMap(); + for (var element : changedElementList) { + var owner = elementToOwner.apply(element); + if (owner == null) { + if (!elementConsistencyState.isEntityConsistent(element)) { + elementConsistencyState.setEntityIsInconsistent(changedVariableNotifier, element, false); + } + for (var updater : elementUpdaters) { + updater.updateIfChanged(element, changedVariableNotifier); + } + continue; + } + var dirtyChainStart = ownerToDirtyChainStart.get(owner); + if (dirtyChainStart == null || chainOrderComparator.compare(element, dirtyChainStart) < 0) { + ownerToDirtyChainStart.put(owner, element); + } + var dirtyChainEnd = ownerToDirtyChainEnd.get(owner); + if (dirtyChainEnd == null || chainOrderComparator.compare(element, dirtyChainEnd) > 0) { + ownerToDirtyChainEnd.put(owner, element); + } + } + changedElementList.clear(); + + // Alternate the cascade and the inner graph until neither has work left. + // An owner whose pre-chain variables changed in the latest inner update is deferred + // while other owners still need walking: those walks may change its pre-chain + // variables again, and deferring keeps every element at a single computation. + var pendingOwnerSet = Collections.newSetFromMap(new IdentityHashMap<>()); + pendingOwnerSet.addAll(ownerToDirtyChainStart.keySet()); + var wholeChainSet = Collections.newSetFromMap(new IdentityHashMap<>()); + var freshlyFlaggedOwnerList = drainWholeChainOwners(); + pendingOwnerSet.addAll(freshlyFlaggedOwnerList); + wholeChainSet.addAll(freshlyFlaggedOwnerList); + while (true) { + if (!pendingOwnerSet.isEmpty()) { + var deferredOwnerSet = Collections.newSetFromMap(new IdentityHashMap<>()); + deferredOwnerSet.addAll(freshlyFlaggedOwnerList); + var walkedOwnerList = new ArrayList<>(); + for (var owner : pendingOwnerSet) { + if (deferredOwnerSet.size() < pendingOwnerSet.size() && deferredOwnerSet.contains(owner)) { + continue; + } + walkedOwnerList.add(owner); + walkOrMarkChainInconsistent(owner, + ownerToDirtyChainStart.remove(owner), ownerToDirtyChainEnd.remove(owner), + wholeChainSet.contains(owner)); + } + walkedOwnerList.forEach(pendingOwnerSet::remove); + } + innerGraph.updateChanged(); + freshlyFlaggedOwnerList = drainWholeChainOwners(); + if (freshlyFlaggedOwnerList.isEmpty() && pendingOwnerSet.isEmpty()) { + return; + } + pendingOwnerSet.addAll(freshlyFlaggedOwnerList); + wholeChainSet.addAll(freshlyFlaggedOwnerList); + } + } finally { + isProcessing = false; + } + } + + private List drainWholeChainOwners() { + if (wholeChainOwnerSet.isEmpty()) { + return Collections.emptyList(); + } + var drainedOwnerList = new ArrayList<>(wholeChainOwnerSet); + wholeChainOwnerSet.clear(); + return drainedOwnerList; + } + + private void walkOrMarkChainInconsistent(Object owner, @Nullable Object dirtyChainStart, + @Nullable Object dirtyChainEnd, boolean walkWholeChain) { + var isOwnerInconsistent = ownerConsistencyState != null + && Boolean.TRUE.equals(ownerConsistencyState.getEntityInconsistentValue(owner)); + if (isOwnerInconsistent) { + markChainInconsistent(owner); + return; + } + var anyElementChanged = walkChain(owner, dirtyChainStart, dirtyChainEnd, walkWholeChain); + if (anyElementChanged) { + markPostChainVariablesChanged(owner); + } + } + + private boolean walkChain(Object owner, @Nullable Object dirtyChainStart, @Nullable Object dirtyChainEnd, + boolean walkWholeChain) { + var chainStart = dirtyChainStart; + if (walkWholeChain) { + var firstElement = ownerToFirstElement.apply(owner); + if (firstElement != null + && (chainStart == null || chainOrderComparator.compare(firstElement, chainStart) < 0)) { + chainStart = firstElement; + } + } + if (chainStart == null) { + return false; + } + var anyElementChangedInWalk = false; + var current = chainStart; + while (current != null) { + if (!elementConsistencyState.isEntityConsistent(current)) { + // The element's owner recovered from a dependency loop. + elementConsistencyState.setEntityIsInconsistent(changedVariableNotifier, current, false); + } + var anyElementVariableChanged = false; + for (var updater : elementUpdaters) { + anyElementVariableChanged |= updater.updateIfChanged(current, changedVariableNotifier); + } + anyElementChangedInWalk |= anyElementVariableChanged; + if (canTerminateEarly && !walkWholeChain && !anyElementVariableChanged + // A swap can create multiple non-contiguous dirty elements on the same chain, + // so only terminate early once the last dirty element has been reached. + && (dirtyChainEnd == null || chainOrderComparator.compare(current, dirtyChainEnd) >= 0)) { + break; + } + current = nextInChain.apply(current); + } + return anyElementChangedInWalk; + } + + private void markChainInconsistent(Object owner) { + // The owner is part of a dependency loop the solver may break later; + // its elements read its pre-chain variables, so they are inconsistent with it. + var current = ownerToFirstElement.apply(owner); + while (current != null) { + if (elementConsistencyState.isEntityConsistent(current)) { + elementConsistencyState.setEntityIsInconsistent(changedVariableNotifier, current, true); + } + for (var updater : elementUpdaters) { + updater.updateIfChanged(current, null, changedVariableNotifier); + } + current = nextInChain.apply(current); + } + } + + private void markPostChainVariablesChanged(Object owner) { + if (innerNodeGraph == null) { + return; + } + for (var variableId : postChainVariableIdList) { + var node = innerNodeGraph.lookupOrNull(variableId, owner); + if (node != null) { + innerNodeGraph.markChanged(node); + } + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraph.java index 3b610e8b54..e915fda18b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraph.java @@ -5,7 +5,8 @@ import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public sealed interface VariableReferenceGraph - permits AbstractVariableReferenceGraph, EmptyVariableReferenceGraph, SingleDirectionalParentVariableReferenceGraph { + permits AbstractVariableReferenceGraph, EmptyVariableReferenceGraph, ListElementCascadeVariableReferenceGraph, + SingleDirectionalParentVariableReferenceGraph { /** * Update all declarative {@link ai.timefold.solver.core.api.domain.variable.ShadowVariable} that has diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableAssertions.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableAssertions.java new file mode 100644 index 0000000000..d48649eee7 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableAssertions.java @@ -0,0 +1,55 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.api.solver.SolutionManager; +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; + +final class DeclarativeShadowVariableAssertions { + + /** + * Asserts that the incrementally maintained shadow variables equal a from-scratch recomputation, + * which uses the arbitrary graph. + * + * @param shadowValueExtractors one per shadow variable to compare, each returning its value for every entity + */ + @SafeVarargs + static void assertShadowsAreAtFixedPoint(Solution_ solution, + Function>... shadowValueExtractors) { + var incrementalValueLists = Arrays.stream(shadowValueExtractors) + .map(extractor -> new ArrayList(extractor.apply(solution))) + .toList(); + + SolutionManager.updateShadowVariables(solution); + + for (var i = 0; i < shadowValueExtractors.length; i++) { + var recomputedValueList = new ArrayList(shadowValueExtractors[i].apply(solution)); + assertThat(recomputedValueList).containsExactlyElementsOf(incrementalValueLists.get(i)); + } + } + + static Solution_ solveWithFullAssert(Class solutionClass, + Class constraintProviderClass, Solution_ problem, Class... entityClasses) { + var solverConfig = new SolverConfig() + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT) + .withSolutionClass(solutionClass) + .withEntityClasses(entityClasses) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withConstraintProviderClass(constraintProviderClass)) + .withTerminationConfig(new TerminationConfig().withMoveCountLimit(1000L)); + return SolverFactory. create(solverConfig).buildSolver().solve(problem); + } + + private DeclarativeShadowVariableAssertions() { + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructureTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructureTest.java index a46e1e3cf4..da7fd9a4b1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructureTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructureTest.java @@ -20,12 +20,31 @@ import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataListElementEntity; import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataListElementSolution; import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataListElementValue; +import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataMixedListElementEntity; +import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataMixedListElementSolution; +import ai.timefold.solver.core.testdomain.shadow.list_element.TestdataMixedListElementValue; import ai.timefold.solver.core.testdomain.shadow.multi_directional_parent.TestdataMultiDirectionConcurrentEntity; import ai.timefold.solver.core.testdomain.shadow.multi_directional_parent.TestdataMultiDirectionConcurrentSolution; import ai.timefold.solver.core.testdomain.shadow.multi_directional_parent.TestdataMultiDirectionConcurrentValue; import ai.timefold.solver.core.testdomain.shadow.multi_entity.TestdataMultiEntityDependencyEntity; import ai.timefold.solver.core.testdomain.shadow.multi_entity.TestdataMultiEntityDependencySolution; import ai.timefold.solver.core.testdomain.shadow.multi_entity.TestdataMultiEntityDependencyValue; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVisit; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainVisit; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataWatchedVisitsSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataWatchedVisitsVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataWatchedVisitsVisit; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextVisit; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner.TestdataNonOwnerDepot; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner.TestdataNonOwnerSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner.TestdataNonOwnerVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner.TestdataNonOwnerVisit; import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListSolution; import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListValue; @@ -132,9 +151,92 @@ void listElementStructure() { var value = new TestdataListElementValue("v1"); assertThat(GraphStructure.determineGraphStructure( TestdataListElementSolution.buildSolutionDescriptor(), entity, value)) + .hasFieldOrPropertyWithValue("structure", GraphStructure.NO_DYNAMIC_EDGES) + .hasFieldOrPropertyWithValue("direction", ParentVariableType.PREVIOUS) + .hasFieldOrPropertyWithValue("elementCascade", + new GraphStructure.ListElementCascade(TestdataListElementValue.class)); + } + + @Test + void listElementStructureWithGenuineVariableOnElement() { + var entity = new TestdataMixedListElementEntity("e1"); + var value = new TestdataMixedListElementValue("v1"); + assertThat(GraphStructure.determineGraphStructure( + TestdataMixedListElementSolution.buildSolutionDescriptor(), entity, value)) .hasFieldOrPropertyWithValue("structure", ARBITRARY); } + @Test + void multiEntityChainStructure() { + var vehicleA = new TestdataMultiEntityChainVehicle("A", 0); + var vehicleB = new TestdataMultiEntityChainVehicle("B", 0); + vehicleB.getPreviousVehicles().add(vehicleA); + var visit = new TestdataMultiEntityChainVisit("v1"); + // The visits are cascaded; the vehicles' fact collection dependency is the inner graph's. + assertThat(GraphStructure.determineGraphStructure( + TestdataMultiEntityChainSolution.buildSolutionDescriptor(), vehicleA, vehicleB, visit)) + .hasFieldOrPropertyWithValue("structure", + GraphStructure.ARBITRARY_SINGLE_ENTITY_AT_MOST_ONE_DIRECTIONAL_PARENT_TYPE) + .hasFieldOrPropertyWithValue("direction", ParentVariableType.PREVIOUS) + .hasFieldOrPropertyWithValue("elementCascade", + new GraphStructure.ListElementCascade(TestdataMultiEntityChainVisit.class)); + } + + @Test + void multiEntityChainWithFactChainedVehicles() { + var vehicleA = new TestdataFactChainVehicle("A", 0); + var vehicleB = new TestdataFactChainVehicle("B", 0); + vehicleB.setPreviousVehicle(vehicleA); + var visit = new TestdataFactChainVisit("v1"); + assertThat(GraphStructure.determineGraphStructure( + TestdataFactChainSolution.buildSolutionDescriptor(), vehicleA, vehicleB, visit)) + .hasFieldOrPropertyWithValue("structure", + GraphStructure.ARBITRARY_SINGLE_ENTITY_AT_MOST_ONE_DIRECTIONAL_PARENT_TYPE) + .hasFieldOrPropertyWithValue("direction", ParentVariableType.PREVIOUS) + .hasFieldOrPropertyWithValue("elementCascade", + new GraphStructure.ListElementCascade(TestdataFactChainVisit.class)); + } + + @Test + void multiEntityChainNextStructure() { + var vehicle = new TestdataMultiEntityChainNextVehicle("A", 100); + var visit = new TestdataMultiEntityChainNextVisit("v1"); + // The vehicle's fact collection is empty, so the inner graph has no dynamic edges. + assertThat(GraphStructure.determineGraphStructure( + TestdataMultiEntityChainNextSolution.buildSolutionDescriptor(), vehicle, visit)) + .hasFieldOrPropertyWithValue("structure", GraphStructure.NO_DYNAMIC_EDGES) + .hasFieldOrPropertyWithValue("direction", ParentVariableType.NEXT) + .hasFieldOrPropertyWithValue("elementCascade", + new GraphStructure.ListElementCascade(TestdataMultiEntityChainNextVisit.class)); + } + + @Test + void multiEntityChainWithFactCollectionOfElements() { + var vehicle = new TestdataWatchedVisitsVehicle("A", 0); + var watcher = new TestdataWatchedVisitsVehicle("W", 0); + var visit = new TestdataWatchedVisitsVisit("v1", 1); + watcher.getWatchedVisits().add(visit); + assertThat(GraphStructure.determineGraphStructure( + TestdataWatchedVisitsSolution.buildSolutionDescriptor(), vehicle, watcher, visit)) + .hasFieldOrPropertyWithValue("structure", ARBITRARY) + .hasFieldOrPropertyWithValue("elementCascade", null); + } + + @Test + void multiEntityChainWithNonListOwnerEntity() { + var vehicle = new TestdataNonOwnerVehicle("A", 0); + var visit = new TestdataNonOwnerVisit("v1", 1); + var depot = new TestdataNonOwnerDepot("D", 0); + // The depots' dependencies do not involve the visits nor the vehicles, + // so the visits are cascaded and the inner graph only covers the depots. + assertThat(GraphStructure.determineGraphStructure( + TestdataNonOwnerSolution.buildSolutionDescriptor(), vehicle, visit, depot)) + .hasFieldOrPropertyWithValue("structure", GraphStructure.NO_DYNAMIC_EDGES) + .hasFieldOrPropertyWithValue("direction", ParentVariableType.PREVIOUS) + .hasFieldOrPropertyWithValue("elementCascade", + new GraphStructure.ListElementCascade(TestdataNonOwnerVisit.class)); + } + @Test void emptyStructure() { assertThat(GraphStructure.determineGraphStructure( diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeFactChainShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeFactChainShadowVariableTest.java new file mode 100644 index 0000000000..f30c97d97e --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeFactChainShadowVariableTest.java @@ -0,0 +1,111 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import static ai.timefold.solver.core.impl.domain.variable.declarative.DeclarativeShadowVariableAssertions.solveWithFullAssert; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.preview.api.move.builtin.Moves; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainConstraintProvider; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact.TestdataFactChainVisit; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ListElementCascadeVariableReferenceGraph} on a model where + * vehicles are chained to each other by a plain fact instead of a fact collection. + */ +class ListElementCascadeFactChainShadowVariableTest { + + @Test + void changeOnPredecessorVehiclePropagatesThroughFactChain() { + var x1 = new TestdataFactChainVisit("x1"); + var x2 = new TestdataFactChainVisit("x2"); + var x3 = new TestdataFactChainVisit("x3"); // Initially unassigned. + var y1 = new TestdataFactChainVisit("y1"); + var z1 = new TestdataFactChainVisit("z1"); + + var vehicleA = new TestdataFactChainVehicle("A", 0); + var vehicleB = new TestdataFactChainVehicle("B", 0); + var vehicleC = new TestdataFactChainVehicle("C", 1); + vehicleB.setPreviousVehicle(vehicleA); + vehicleC.setPreviousVehicle(vehicleB); + vehicleA.setVisits(new ArrayList<>(List.of(x1, x2))); + vehicleB.setVisits(new ArrayList<>(List.of(y1))); + vehicleC.setVisits(new ArrayList<>(List.of(z1))); + + var solution = new TestdataFactChainSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB, vehicleC)); + solution.setVisits(List.of(x1, x2, x3, y1, z1)); + + var solutionMetaModel = TestdataFactChainSolution.buildMetaModel(); + var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataFactChainVehicle.class) + .listVariable("visits", TestdataFactChainVisit.class); + + var context = MoveTester.build(solutionMetaModel).using(solution); + // A = [1, 2]; B starts at 2 -> [3]; C starts at 3 -> [4]. + assertThat(vehicleA.getEndTime()).isEqualTo(2); + assertThat(vehicleB.getStartTime()).isEqualTo(2); + assertThat(vehicleB.getEndTime()).isEqualTo(3); + assertThat(vehicleC.getStartTime()).isEqualTo(3); + assertThat(z1.getEndServiceTime()).isEqualTo(4); + + // Appending x3 to A shifts both B's and C's whole routes. + context.execute(Moves.assign(listVariableMetaModel, x3, vehicleA, 2)); + assertThat(x3.getEndServiceTime()).isEqualTo(3); + assertThat(vehicleA.getEndTime()).isEqualTo(3); + assertThat(vehicleB.getStartTime()).isEqualTo(3); + assertThat(y1.getEndServiceTime()).isEqualTo(4); + assertThat(vehicleC.getStartTime()).isEqualTo(4); + assertThat(z1.getEndServiceTime()).isEqualTo(5); + assertThat(vehicleC.getEndTime()).isEqualTo(5); + assertShadowsAreAtFixedPoint(solution); + + // Moving the head of A to C rechains all three vehicles. + context.execute(Moves.change(listVariableMetaModel, vehicleA, 0, vehicleC, 0)); + assertThat(vehicleA.getEndTime()).isEqualTo(2); + assertThat(vehicleB.getStartTime()).isEqualTo(2); + assertThat(y1.getEndServiceTime()).isEqualTo(3); + assertThat(vehicleC.getStartTime()).isEqualTo(3); + assertThat(x1.getEndServiceTime()).isEqualTo(4); + assertThat(z1.getEndServiceTime()).isEqualTo(5); + assertThat(vehicleC.getEndTime()).isEqualTo(5); + assertShadowsAreAtFixedPoint(solution); + } + + @Test + void solveFactChainedModelWithFullAssert() { + assertShadowsAreAtFixedPoint(solveWithFullAssert(TestdataFactChainSolution.class, + TestdataFactChainConstraintProvider.class, generateSolution(), + TestdataFactChainVehicle.class, TestdataFactChainVisit.class)); + } + + private static TestdataFactChainSolution generateSolution() { + var vehicles = new ArrayList(); + for (var i = 0; i < 3; i++) { + vehicles.add(new TestdataFactChainVehicle("vehicle" + i, i)); + } + // vehicle0 -> vehicle1 -> vehicle2 chain. + vehicles.get(1).setPreviousVehicle(vehicles.get(0)); + vehicles.get(2).setPreviousVehicle(vehicles.get(1)); + var visits = new ArrayList(); + for (var i = 0; i < 6; i++) { + visits.add(new TestdataFactChainVisit("visit" + i, 1 + (i % 3))); + } + var solution = new TestdataFactChainSolution(); + solution.setVehicles(vehicles); + solution.setVisits(visits); + return solution; + } + + private static void assertShadowsAreAtFixedPoint(TestdataFactChainSolution solution) { + DeclarativeShadowVariableAssertions.assertShadowsAreAtFixedPoint(solution, + s -> s.getVehicles().stream().map(TestdataFactChainVehicle::getStartTime).toList(), + s -> s.getVehicles().stream().map(TestdataFactChainVehicle::getEndTime).toList(), + s -> s.getVisits().stream().map(TestdataFactChainVisit::getEndServiceTime).toList()); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeNextShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeNextShadowVariableTest.java new file mode 100644 index 0000000000..1e14993e45 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeNextShadowVariableTest.java @@ -0,0 +1,48 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import static ai.timefold.solver.core.impl.domain.variable.declarative.DeclarativeShadowVariableAssertions.solveWithFullAssert; + +import java.util.ArrayList; + +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextConstraintProvider; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next.TestdataMultiEntityChainNextVisit; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ListElementCascadeVariableReferenceGraph} on a next-directional model, + * where a vehicle's latest start time is bound by its successor vehicles + * and propagation walks each route backwards. + */ +class ListElementCascadeNextShadowVariableTest { + + @Test + void solveNextDirectionalModelWithFullAssert() { + var vehicles = new ArrayList(); + for (var i = 0; i < 3; i++) { + vehicles.add(new TestdataMultiEntityChainNextVehicle("vehicle" + i, 100)); + } + // vehicle0 -> vehicle1 -> vehicle2 chain. + vehicles.get(0).getNextVehicles().add(vehicles.get(1)); + vehicles.get(1).getNextVehicles().add(vehicles.get(2)); + var visits = new ArrayList(); + for (var i = 0; i < 6; i++) { + visits.add(new TestdataMultiEntityChainNextVisit("visit" + i, 1 + (i % 3))); + } + var problem = new TestdataMultiEntityChainNextSolution(); + problem.setVehicles(vehicles); + problem.setVisits(visits); + + assertShadowsAreAtFixedPoint(solveWithFullAssert(TestdataMultiEntityChainNextSolution.class, + TestdataMultiEntityChainNextConstraintProvider.class, problem, + TestdataMultiEntityChainNextVehicle.class, TestdataMultiEntityChainNextVisit.class)); + } + + private static void assertShadowsAreAtFixedPoint(TestdataMultiEntityChainNextSolution solution) { + DeclarativeShadowVariableAssertions.assertShadowsAreAtFixedPoint(solution, + s -> s.getVehicles().stream().map(TestdataMultiEntityChainNextVehicle::getStartTime).toList(), + s -> s.getVisits().stream().map(TestdataMultiEntityChainNextVisit::getLatestStartTime).toList()); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeShadowVariableTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeShadowVariableTest.java new file mode 100644 index 0000000000..59f04681c8 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeShadowVariableTest.java @@ -0,0 +1,203 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import static ai.timefold.solver.core.impl.domain.variable.declarative.DeclarativeShadowVariableAssertions.solveWithFullAssert; +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.preview.api.move.builtin.Moves; +import ai.timefold.solver.core.preview.api.move.test.MoveTester; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainConstraintProvider; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVisit; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ListElementCascadeVariableReferenceGraph} on a model where + * a vehicle starts where its predecessor vehicles end. + */ +class ListElementCascadeShadowVariableTest { + + @Test + void changeOnPredecessorVehiclePropagates() { + var x1 = new TestdataMultiEntityChainVisit("x1"); + var x2 = new TestdataMultiEntityChainVisit("x2"); + var x3 = new TestdataMultiEntityChainVisit("x3"); // Initially unassigned. + var y1 = new TestdataMultiEntityChainVisit("y1"); + var y2 = new TestdataMultiEntityChainVisit("y2"); + + var vehicleA = new TestdataMultiEntityChainVehicle("A", 0); + var vehicleB = new TestdataMultiEntityChainVehicle("B", 0); + vehicleB.getPreviousVehicles().add(vehicleA); + vehicleA.setVisits(new ArrayList<>(List.of(x1, x2))); + vehicleB.setVisits(new ArrayList<>(List.of(y1, y2))); + + var solution = new TestdataMultiEntityChainSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB)); + solution.setVisits(List.of(x1, x2, x3, y1, y2)); + + var solutionMetaModel = TestdataMultiEntityChainSolution.buildMetaModel(); + var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataMultiEntityChainVehicle.class) + .listVariable("visits", TestdataMultiEntityChainVisit.class); + + var context = MoveTester.build(solutionMetaModel).using(solution); + // A = [1, 2], endTime 2; B starts at 2 -> [3, 4], endTime 4. + assertThat(vehicleA.getEndTime()).isEqualTo(2); + assertThat(vehicleB.getPreviousEndTime()).isEqualTo(2); + assertThat(y2.getEndServiceTime()).isEqualTo(4); + assertThat(vehicleB.getEndTime()).isEqualTo(4); + + // Appending x3 to A shifts B's whole route. + context.execute(Moves.assign(listVariableMetaModel, x3, vehicleA, 2)); + assertThat(x3.getEndServiceTime()).isEqualTo(3); + assertThat(vehicleA.getEndTime()).isEqualTo(3); + assertThat(vehicleB.getPreviousEndTime()).isEqualTo(3); + assertThat(y1.getEndServiceTime()).isEqualTo(4); + assertThat(y2.getEndServiceTime()).isEqualTo(5); + assertThat(vehicleB.getEndTime()).isEqualTo(5); + assertShadowsAreAtFixedPoint(solution); + + // Moving x1 (head of A) to B rechains both vehicles. + context.execute(Moves.change(listVariableMetaModel, vehicleA, 0, vehicleB, 0)); + assertThat(vehicleA.getEndTime()).isEqualTo(2); + assertThat(vehicleB.getPreviousEndTime()).isEqualTo(2); + assertThat(x1.getEndServiceTime()).isEqualTo(3); + assertThat(y2.getEndServiceTime()).isEqualTo(5); + assertThat(vehicleB.getEndTime()).isEqualTo(5); + assertShadowsAreAtFixedPoint(solution); + } + + @Test + void swapCreatesNonContiguousDirtyElements() { + var v1 = new TestdataMultiEntityChainVisit("v1", 1); + var v2 = new TestdataMultiEntityChainVisit("v2", 5); + var v3 = new TestdataMultiEntityChainVisit("v3", 3); + var v4 = new TestdataMultiEntityChainVisit("v4", 2); + var vehicle = new TestdataMultiEntityChainVehicle("A", 0); + vehicle.setVisits(new ArrayList<>(List.of(v1, v2, v3, v4))); + + var solution = new TestdataMultiEntityChainSolution(); + solution.setVehicles(List.of(vehicle)); + solution.setVisits(List.of(v1, v2, v3, v4)); + + var solutionMetaModel = TestdataMultiEntityChainSolution.buildMetaModel(); + var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataMultiEntityChainVehicle.class) + .listVariable("visits", TestdataMultiEntityChainVisit.class); + + var context = MoveTester.build(solutionMetaModel).using(solution); + assertThat(vehicle.getEndTime()).isEqualTo(1 + 5 + 3 + 2); + + // Swap the first and third visits: the dirty elements are non-contiguous. + context.execute(Moves.swap(listVariableMetaModel, vehicle, 0, vehicle, 2)); + assertThat(v3.getEndServiceTime()).isEqualTo(3); + assertThat(v2.getEndServiceTime()).isEqualTo(8); + assertThat(v1.getEndServiceTime()).isEqualTo(9); + assertThat(v4.getEndServiceTime()).isEqualTo(11); + assertThat(vehicle.getEndTime()).isEqualTo(11); + assertShadowsAreAtFixedPoint(solution); + } + + /** + * An element in the middle of the chain reads a pre-chain variable directly, + * so a pre-chain change must reach it even when its predecessors are unchanged. + */ + @Test + void preChainChangeReachesRebasingElement() { + var w = new TestdataMultiEntityChainVisit("w", 5, false); // Initially unassigned. + var v1 = new TestdataMultiEntityChainVisit("v1", 1, false); + var v2 = new TestdataMultiEntityChainVisit("v2", 1, true); + var v3 = new TestdataMultiEntityChainVisit("v3", 1, false); + + var vehicleA = new TestdataMultiEntityChainVehicle("A", 0); + var vehicleB = new TestdataMultiEntityChainVehicle("B", 0); + vehicleB.getPreviousVehicles().add(vehicleA); + vehicleB.setVisits(new ArrayList<>(List.of(v1, v2, v3))); + + var solution = new TestdataMultiEntityChainSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB)); + solution.setVisits(List.of(w, v1, v2, v3)); + + var solutionMetaModel = TestdataMultiEntityChainSolution.buildMetaModel(); + var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataMultiEntityChainVehicle.class) + .listVariable("visits", TestdataMultiEntityChainVisit.class); + + var context = MoveTester.build(solutionMetaModel).using(solution); + assertThat(vehicleB.getPreviousEndTime()).isEqualTo(0); + assertThat(v1.getEndServiceTime()).isEqualTo(1); + assertThat(v2.getEndServiceTime()).isEqualTo(2); + assertThat(v3.getEndServiceTime()).isEqualTo(3); + assertThat(vehicleB.getEndTime()).isEqualTo(3); + + // Assigning w to A changes B's previousEndTime; + // v1 does not read it and stays unchanged, but v2 re-bases on it. + context.execute(Moves.assign(listVariableMetaModel, w, vehicleA, 0)); + assertThat(vehicleA.getEndTime()).isEqualTo(5); + assertThat(vehicleB.getPreviousEndTime()).isEqualTo(5); + assertThat(v1.getEndServiceTime()).isEqualTo(1); + assertThat(v2.getEndServiceTime()).isEqualTo(6); + assertThat(v3.getEndServiceTime()).isEqualTo(7); + assertThat(vehicleB.getEndTime()).isEqualTo(7); + assertShadowsAreAtFixedPoint(solution); + } + + @Test + void cyclicVehicleFactsFailFast() { + var vehicleA = new TestdataMultiEntityChainVehicle("A", 0); + var vehicleB = new TestdataMultiEntityChainVehicle("B", 0); + vehicleA.getPreviousVehicles().add(vehicleB); + vehicleB.getPreviousVehicles().add(vehicleA); + + var solution = new TestdataMultiEntityChainSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB)); + solution.setVisits(List.of(new TestdataMultiEntityChainVisit("v1"))); + + assertThatCode(() -> MoveTester.build(TestdataMultiEntityChainSolution.buildMetaModel()).using(solution)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fixed dependency loops"); + } + + @Test + void solveWithFullAssertFromUninitializedSolution() { + assertShadowsAreAtFixedPoint(solve(generateSolution(false))); + } + + @Test + void solveWithFullAssertWithRebasingElements() { + assertShadowsAreAtFixedPoint(solve(generateSolution(true))); + } + + private static TestdataMultiEntityChainSolution solve(TestdataMultiEntityChainSolution problem) { + return solveWithFullAssert(TestdataMultiEntityChainSolution.class, + TestdataMultiEntityChainConstraintProvider.class, problem, + TestdataMultiEntityChainVehicle.class, TestdataMultiEntityChainVisit.class); + } + + private static TestdataMultiEntityChainSolution generateSolution(boolean alternateRebasing) { + var vehicles = new ArrayList(); + for (var i = 0; i < 3; i++) { + vehicles.add(new TestdataMultiEntityChainVehicle("vehicle" + i, i)); + } + // vehicle0 -> vehicle1 -> vehicle2 chain. + vehicles.get(1).getPreviousVehicles().add(vehicles.get(0)); + vehicles.get(2).getPreviousVehicles().add(vehicles.get(1)); + var visits = new ArrayList(); + for (var i = 0; i < 6; i++) { + visits.add(new TestdataMultiEntityChainVisit("visit" + i, 1 + (i % 3), + !alternateRebasing || i % 2 == 0)); + } + var solution = new TestdataMultiEntityChainSolution(); + solution.setVehicles(vehicles); + solution.setVisits(visits); + return solution; + } + + private static void assertShadowsAreAtFixedPoint(TestdataMultiEntityChainSolution solution) { + DeclarativeShadowVariableAssertions.assertShadowsAreAtFixedPoint(solution, + s -> s.getVehicles().stream().map(TestdataMultiEntityChainVehicle::getEndTime).toList(), + s -> s.getVisits().stream().map(TestdataMultiEntityChainVisit::getEndServiceTime).toList()); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraphTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraphTest.java new file mode 100644 index 0000000000..e0fbbc788d --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraphTest.java @@ -0,0 +1,108 @@ +package ai.timefold.solver.core.impl.domain.variable.declarative; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain.TestdataMultiEntityChainVisit; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Asserts that a change only recomputes the entities it can reach, + * which is the point of {@link ListElementCascadeVariableReferenceGraph}. + */ +class ListElementCascadeVariableReferenceGraphTest { + + @Test + void onlyReachableEntitiesAreRecomputed() { + var solutionDescriptor = TestdataMultiEntityChainSolution.buildSolutionDescriptor(); + + var vehicleA = new TestdataMultiEntityChainVehicle("A", 0); + var vehicleB = new TestdataMultiEntityChainVehicle("B", 0); + vehicleB.getPreviousVehicles().add(vehicleA); + + var a1 = new TestdataMultiEntityChainVisit("a1"); + var a2 = new TestdataMultiEntityChainVisit("a2"); + var a3 = new TestdataMultiEntityChainVisit("a3"); // Initially unassigned. + var b1 = new TestdataMultiEntityChainVisit("b1"); + var b2 = new TestdataMultiEntityChainVisit("b2"); + vehicleA.setVisits(new ArrayList<>(List.of(a1, a2))); + vehicleB.setVisits(new ArrayList<>(List.of(b1, b2))); + + var graphStructureAndDirection = GraphStructure.determineGraphStructure(solutionDescriptor, + vehicleA, vehicleB, a1, a2, a3, b1, b2); + assertThat(graphStructureAndDirection.elementCascade()) + .isEqualTo(new GraphStructure.ListElementCascade(TestdataMultiEntityChainVisit.class)); + + var scoreDirector = Mockito.mock(InnerScoreDirector.class); + var listStateSupply = Mockito.mock(ListVariableStateSupply.class); + Mockito.when(scoreDirector.getListVariableStateSupply(Mockito.any())).thenReturn(listStateSupply); + + // The list variable listeners are not running, so the element shadow variables are set by hand. + link(listStateSupply, vehicleA, a1, null, a2, 0); + link(listStateSupply, vehicleA, a2, a1, null, 1); + link(listStateSupply, vehicleB, b1, null, b2, 0); + link(listStateSupply, vehicleB, b2, b1, null, 1); + link(listStateSupply, null, a3, null, null, -1); + + var graph = DefaultShadowVariableSessionFactory.buildListElementCascadeGraph( + new DefaultShadowVariableSessionFactory.GraphDescriptor<>( + solutionDescriptor, ChangedVariableNotifier.of(scoreDirector), + b2, vehicleB, a1, a3, vehicleA, b1, a2), + graphStructureAndDirection); + + // Vehicle B's chain is deferred until vehicle A's endTime has propagated, + // so every element is computed exactly once even at construction. + assertThat(List.of(a1, a2, b1, b2)).allMatch(visit -> visit.getCalledCount() == 1); + assertThat(a3.getCalledCount()).isOne(); + assertThat(vehicleB.getEndTime()).isEqualTo(4); + + vehicleA.reset(); + vehicleB.reset(); + List.of(a1, a2, a3, b1, b2).forEach(TestdataMultiEntityChainVisit::reset); + + // Append a3 to the end of vehicle A's route. + vehicleA.getVisits().add(a3); + link(listStateSupply, vehicleA, a3, a2, null, 2); + Mockito.when(listStateSupply.getNextElement(a2)).thenReturn(a3); + + var visitMetaModel = solutionDescriptor.getMetaModel().entity(TestdataMultiEntityChainVisit.class); + graph.afterVariableChanged(visitMetaModel.variable("vehicle"), a3); + graph.afterVariableChanged(visitMetaModel.variable("previousVisit"), a3); + graph.updateChanged(); + + // The elements before the insertion point are unreachable from it and are left alone. + assertThat(a1.getCalledCount()).isZero(); + assertThat(a2.getCalledCount()).isZero(); + // Pre-chain variables do not depend on the chain, so a chain-only change never recomputes them. + assertThat(vehicleA.getPreviousEndTimeCalledCount()).isZero(); + // Everything downstream is recomputed exactly once... + assertThat(a3.getCalledCount()).isOne(); + assertThat(vehicleA.getEndTimeCalledCount()).isOne(); + assertThat(vehicleB.getPreviousEndTimeCalledCount()).isOne(); + assertThat(b1.getCalledCount()).isOne(); + assertThat(b2.getCalledCount()).isOne(); + // ...except vehicle B's endTime: the inner graph recomputes it through its + // previousEndTime edge before the cascade re-walks B's chain, and once after. + assertThat(vehicleB.getEndTimeCalledCount()).isEqualTo(2); + assertThat(vehicleB.getEndTime()).isEqualTo(5); + } + + private static void link( + ListVariableStateSupply listStateSupply, + TestdataMultiEntityChainVehicle vehicle, TestdataMultiEntityChainVisit visit, + TestdataMultiEntityChainVisit previousVisit, TestdataMultiEntityChainVisit nextVisit, int index) { + visit.setVehicle(vehicle); + visit.setPreviousVisit(previousVisit); + Mockito.doReturn(index).when(listStateSupply).getIndexOrElse(Mockito.eq(visit), Mockito.anyInt()); + Mockito.when(listStateSupply.getNextElement(visit)).thenReturn(nextVisit); + Mockito.when(listStateSupply.getInverseSingleton(visit)).thenReturn(vehicle); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainConstraintProvider.java new file mode 100644 index 0000000000..062e8428ad --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainConstraintProvider.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +/** + * Penalizes by the shadow variables, so {@code FULL_ASSERT} catches them if they go stale. + */ +public class TestdataMultiEntityChainConstraintProvider implements ConstraintProvider { + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { + constraintFactory.forEachIncludingUnassigned(TestdataMultiEntityChainVisit.class) + .filter(visit -> visit.getVehicle() == null) + .penalize(SimpleScore.of(100)) + .asConstraint("Assign all visits"), + + constraintFactory.forEach(TestdataMultiEntityChainVehicle.class) + .penalize(SimpleScore.ONE, TestdataMultiEntityChainVehicle::getEndTime) + .asConstraint("Minimize end time") + }; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainSolution.java new file mode 100644 index 0000000000..a7e2a008ae --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainSolution.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +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; + +@PlanningSolution +public class TestdataMultiEntityChainSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataMultiEntityChainSolution.class, + TestdataMultiEntityChainVehicle.class, TestdataMultiEntityChainVisit.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + @PlanningEntityCollectionProperty + List vehicles; + + @PlanningEntityCollectionProperty + @ValueRangeProvider + List visits; + + @PlanningScore + SimpleScore score; + + public List getVehicles() { + return vehicles; + } + + public void setVehicles(List vehicles) { + this.vehicles = vehicles; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVehicle.java new file mode 100644 index 0000000000..7be29dfa46 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVehicle.java @@ -0,0 +1,119 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * A vehicle that starts where its predecessor vehicles end, + * so a change on a predecessor's route propagates to this vehicle's visits. + */ +@PlanningEntity +public class TestdataMultiEntityChainVehicle extends TestdataObject { + + List previousVehicles = new ArrayList<>(); + int departureTime; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + @ShadowVariable(supplierName = "previousEndTimeSupplier") + Integer previousEndTime; + + @ShadowVariable(supplierName = "endTimeSupplier") + Integer endTime; + + int previousEndTimeCalledCount = 0; + int endTimeCalledCount = 0; + + public TestdataMultiEntityChainVehicle() { + } + + public TestdataMultiEntityChainVehicle(String code, int departureTime) { + super(code); + this.departureTime = departureTime; + // A head vehicle's only source is an empty fact collection, + // so its supplier is never triggered; initialize to the value it would compute. + this.previousEndTime = departureTime; + } + + @ShadowSources("previousVehicles[].endTime") + public Integer previousEndTimeSupplier() { + previousEndTimeCalledCount++; + var max = departureTime; + for (var previousVehicle : previousVehicles) { + if (previousVehicle.getEndTime() == null) { + return null; + } + max = Math.max(max, previousVehicle.getEndTime()); + } + return max; + } + + @ShadowSources({ "visits[].endServiceTime", "previousEndTime" }) + public Integer endTimeSupplier() { + endTimeCalledCount++; + if (visits.isEmpty()) { + return previousEndTime; + } + return visits.get(visits.size() - 1).getEndServiceTime(); + } + + public List getPreviousVehicles() { + return previousVehicles; + } + + public void setPreviousVehicles(List previousVehicles) { + this.previousVehicles = previousVehicles; + } + + public int getDepartureTime() { + return departureTime; + } + + public void setDepartureTime(int departureTime) { + this.departureTime = departureTime; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public Integer getPreviousEndTime() { + return previousEndTime; + } + + public void setPreviousEndTime(Integer previousEndTime) { + this.previousEndTime = previousEndTime; + } + + public Integer getEndTime() { + return endTime; + } + + public void setEndTime(Integer endTime) { + this.endTime = endTime; + } + + public int getPreviousEndTimeCalledCount() { + return previousEndTimeCalledCount; + } + + public int getEndTimeCalledCount() { + return endTimeCalledCount; + } + + public void reset() { + previousEndTimeCalledCount = 0; + endTimeCalledCount = 0; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVisit.java new file mode 100644 index 0000000000..3452decba6 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVisit.java @@ -0,0 +1,122 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +@PlanningEntity +public class TestdataMultiEntityChainVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataMultiEntityChainVehicle vehicle; + + @PreviousElementShadowVariable(sourceVariableName = "visits") + TestdataMultiEntityChainVisit previousVisit; + + int duration = 1; + boolean chainedToPreviousVehicle = true; + + @ShadowVariable(supplierName = "endServiceTimeSupplier") + Integer endServiceTime; + + int calledCount = 0; + + public TestdataMultiEntityChainVisit() { + } + + public TestdataMultiEntityChainVisit(String code) { + super(code); + } + + public TestdataMultiEntityChainVisit(String code, int duration) { + super(code); + this.duration = duration; + } + + public TestdataMultiEntityChainVisit(String code, int duration, boolean chainedToPreviousVehicle) { + this(code, duration); + this.chainedToPreviousVehicle = chainedToPreviousVehicle; + } + + @ShadowSources({ "vehicle", "vehicle.previousEndTime", "previousVisit", "previousVisit.endServiceTime" }) + public Integer endServiceTimeSupplier() { + calledCount++; + if (vehicle == null) { + return null; + } + Integer base; + if (previousVisit == null) { + // No unboxing: previousEndTime may be null while the vehicles' values converge. + base = chainedToPreviousVehicle ? vehicle.getPreviousEndTime() : (Integer) vehicle.getDepartureTime(); + } else { + var previousEnd = previousVisit.getEndServiceTime(); + if (previousEnd == null) { + return null; + } + if (chainedToPreviousVehicle) { + var vehicleBase = vehicle.getPreviousEndTime(); + if (vehicleBase == null) { + return null; + } + base = Math.max(previousEnd, vehicleBase); + } else { + base = previousEnd; + } + } + if (base == null) { + return null; + } + return base + duration; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public boolean isChainedToPreviousVehicle() { + return chainedToPreviousVehicle; + } + + public void setChainedToPreviousVehicle(boolean chainedToPreviousVehicle) { + this.chainedToPreviousVehicle = chainedToPreviousVehicle; + } + + public TestdataMultiEntityChainVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataMultiEntityChainVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataMultiEntityChainVisit getPreviousVisit() { + return previousVisit; + } + + public void setPreviousVisit(TestdataMultiEntityChainVisit previousVisit) { + this.previousVisit = previousVisit; + } + + public Integer getEndServiceTime() { + return endServiceTime; + } + + public void setEndServiceTime(Integer endServiceTime) { + this.endServiceTime = endServiceTime; + } + + public int getCalledCount() { + return calledCount; + } + + public void reset() { + calledCount = 0; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainConstraintProvider.java new file mode 100644 index 0000000000..ce133a05d9 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainConstraintProvider.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +/** + * Penalizes by the shadow variables, so {@code FULL_ASSERT} catches them if they go stale. + */ +public class TestdataFactChainConstraintProvider implements ConstraintProvider { + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { + constraintFactory.forEachIncludingUnassigned(TestdataFactChainVisit.class) + .filter(visit -> visit.getVehicle() == null) + .penalize(SimpleScore.of(100)) + .asConstraint("Assign all visits"), + + constraintFactory.forEach(TestdataFactChainVehicle.class) + .penalize(SimpleScore.ONE, TestdataFactChainVehicle::getEndTime) + .asConstraint("Minimize end time") + }; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainSolution.java new file mode 100644 index 0000000000..a520a32ab9 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainSolution.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +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; + +@PlanningSolution +public class TestdataFactChainSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataFactChainSolution.class, + TestdataFactChainVehicle.class, TestdataFactChainVisit.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + @PlanningEntityCollectionProperty + List vehicles; + + @PlanningEntityCollectionProperty + @ValueRangeProvider + List visits; + + @PlanningScore + SimpleScore score; + + public List getVehicles() { + return vehicles; + } + + public void setVehicles(List vehicles) { + this.vehicles = vehicles; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVehicle.java new file mode 100644 index 0000000000..b5ec18b6be --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVehicle.java @@ -0,0 +1,119 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * A vehicle that starts where its single predecessor vehicle ends, + * linked by a plain fact instead of a fact collection, + * so a change on the predecessor's route propagates through a fact path. + */ +@PlanningEntity +public class TestdataFactChainVehicle extends TestdataObject { + + TestdataFactChainVehicle previousVehicle; + int departureTime; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + @ShadowVariable(supplierName = "startTimeSupplier") + Integer startTime; + + @ShadowVariable(supplierName = "endTimeSupplier") + Integer endTime; + + int startTimeCalledCount = 0; + int endTimeCalledCount = 0; + + public TestdataFactChainVehicle() { + } + + public TestdataFactChainVehicle(String code, int departureTime) { + super(code); + this.departureTime = departureTime; + // A head vehicle's only source is a null fact, + // so its supplier is never triggered; initialize to the value it would compute. + this.startTime = departureTime; + } + + @ShadowSources("previousVehicle.endTime") + public Integer startTimeSupplier() { + startTimeCalledCount++; + if (previousVehicle == null) { + return departureTime; + } + if (previousVehicle.getEndTime() == null) { + return null; + } + return Math.max(departureTime, previousVehicle.getEndTime()); + } + + @ShadowSources({ "visits[].endServiceTime", "startTime" }) + public Integer endTimeSupplier() { + endTimeCalledCount++; + if (visits.isEmpty()) { + return startTime; + } + return visits.get(visits.size() - 1).getEndServiceTime(); + } + + public TestdataFactChainVehicle getPreviousVehicle() { + return previousVehicle; + } + + public void setPreviousVehicle(TestdataFactChainVehicle previousVehicle) { + this.previousVehicle = previousVehicle; + } + + public int getDepartureTime() { + return departureTime; + } + + public void setDepartureTime(int departureTime) { + this.departureTime = departureTime; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public Integer getStartTime() { + return startTime; + } + + public void setStartTime(Integer startTime) { + this.startTime = startTime; + } + + public Integer getEndTime() { + return endTime; + } + + public void setEndTime(Integer endTime) { + this.endTime = endTime; + } + + public int getStartTimeCalledCount() { + return startTimeCalledCount; + } + + public int getEndTimeCalledCount() { + return endTimeCalledCount; + } + + public void reset() { + startTimeCalledCount = 0; + endTimeCalledCount = 0; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVisit.java new file mode 100644 index 0000000000..72b67fcfd0 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVisit.java @@ -0,0 +1,90 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fact; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +@PlanningEntity +public class TestdataFactChainVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataFactChainVehicle vehicle; + + @PreviousElementShadowVariable(sourceVariableName = "visits") + TestdataFactChainVisit previousVisit; + + int duration = 1; + + @ShadowVariable(supplierName = "endServiceTimeSupplier") + Integer endServiceTime; + + int calledCount = 0; + + public TestdataFactChainVisit() { + } + + public TestdataFactChainVisit(String code) { + super(code); + } + + public TestdataFactChainVisit(String code, int duration) { + super(code); + this.duration = duration; + } + + @ShadowSources({ "vehicle", "vehicle.startTime", "previousVisit", "previousVisit.endServiceTime" }) + public Integer endServiceTimeSupplier() { + calledCount++; + if (vehicle == null) { + return null; + } + var base = previousVisit == null ? vehicle.getStartTime() : previousVisit.getEndServiceTime(); + if (base == null) { + return null; + } + return base + duration; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public TestdataFactChainVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataFactChainVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataFactChainVisit getPreviousVisit() { + return previousVisit; + } + + public void setPreviousVisit(TestdataFactChainVisit previousVisit) { + this.previousVisit = previousVisit; + } + + public Integer getEndServiceTime() { + return endServiceTime; + } + + public void setEndServiceTime(Integer endServiceTime) { + this.endServiceTime = endServiceTime; + } + + public int getCalledCount() { + return calledCount; + } + + public void reset() { + calledCount = 0; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsSolution.java new file mode 100644 index 0000000000..bf51445c87 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsSolution.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +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; + +@PlanningSolution +public class TestdataWatchedVisitsSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataWatchedVisitsSolution.class, + TestdataWatchedVisitsVehicle.class, TestdataWatchedVisitsVisit.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + @PlanningEntityCollectionProperty + List vehicles; + + @PlanningEntityCollectionProperty + @ValueRangeProvider + List visits; + + @PlanningScore + SimpleScore score; + + public List getVehicles() { + return vehicles; + } + + public void setVehicles(List vehicles) { + this.vehicles = vehicles; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVehicle.java new file mode 100644 index 0000000000..412951f9f4 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVehicle.java @@ -0,0 +1,98 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * A vehicle watching a fact collection of visits that may be assigned to other vehicles, + * which the multi-entity chained graph cannot represent. + */ +@PlanningEntity +public class TestdataWatchedVisitsVehicle extends TestdataObject { + + List watchedVisits = new ArrayList<>(); + int departureTime; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + @ShadowVariable(supplierName = "endTimeSupplier") + Integer endTime; + + @ShadowVariable(supplierName = "watchedEndTimeSupplier") + Integer watchedEndTime; + + public TestdataWatchedVisitsVehicle() { + } + + public TestdataWatchedVisitsVehicle(String code, int departureTime) { + super(code); + this.departureTime = departureTime; + } + + @ShadowSources("visits[].endServiceTime") + public Integer endTimeSupplier() { + if (visits.isEmpty()) { + return departureTime; + } + return visits.get(visits.size() - 1).getEndServiceTime(); + } + + @ShadowSources("watchedVisits[].endServiceTime") + public Integer watchedEndTimeSupplier() { + var max = departureTime; + for (var watchedVisit : watchedVisits) { + if (watchedVisit.getEndServiceTime() == null) { + return null; + } + max = Math.max(max, watchedVisit.getEndServiceTime()); + } + return max; + } + + public List getWatchedVisits() { + return watchedVisits; + } + + public void setWatchedVisits(List watchedVisits) { + this.watchedVisits = watchedVisits; + } + + public int getDepartureTime() { + return departureTime; + } + + public void setDepartureTime(int departureTime) { + this.departureTime = departureTime; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public Integer getEndTime() { + return endTime; + } + + public void setEndTime(Integer endTime) { + this.endTime = endTime; + } + + public Integer getWatchedEndTime() { + return watchedEndTime; + } + + public void setWatchedEndTime(Integer watchedEndTime) { + this.watchedEndTime = watchedEndTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVisit.java new file mode 100644 index 0000000000..cea6939dad --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVisit.java @@ -0,0 +1,75 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +@PlanningEntity +public class TestdataWatchedVisitsVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataWatchedVisitsVehicle vehicle; + + @PreviousElementShadowVariable(sourceVariableName = "visits") + TestdataWatchedVisitsVisit previousVisit; + + int duration = 1; + + @ShadowVariable(supplierName = "endServiceTimeSupplier") + Integer endServiceTime; + + public TestdataWatchedVisitsVisit() { + } + + public TestdataWatchedVisitsVisit(String code, int duration) { + super(code); + this.duration = duration; + } + + @ShadowSources({ "vehicle", "previousVisit", "previousVisit.endServiceTime" }) + public Integer endServiceTimeSupplier() { + if (vehicle == null) { + return null; + } + var base = previousVisit != null ? previousVisit.getEndServiceTime() : (Integer) vehicle.getDepartureTime(); + if (base == null) { + return null; + } + return base + duration; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public TestdataWatchedVisitsVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataWatchedVisitsVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataWatchedVisitsVisit getPreviousVisit() { + return previousVisit; + } + + public void setPreviousVisit(TestdataWatchedVisitsVisit previousVisit) { + this.previousVisit = previousVisit; + } + + public Integer getEndServiceTime() { + return endServiceTime; + } + + public void setEndServiceTime(Integer endServiceTime) { + this.endServiceTime = endServiceTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextConstraintProvider.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextConstraintProvider.java new file mode 100644 index 0000000000..d65c08f1d6 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextConstraintProvider.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +import org.jspecify.annotations.NonNull; + +/** + * Penalizes by the shadow variables, so {@code FULL_ASSERT} catches them if they go stale. + */ +public class TestdataMultiEntityChainNextConstraintProvider implements ConstraintProvider { + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory constraintFactory) { + return new Constraint[] { + constraintFactory.forEachIncludingUnassigned(TestdataMultiEntityChainNextVisit.class) + .filter(visit -> visit.getVehicle() == null) + .penalize(SimpleScore.of(100)) + .asConstraint("Assign all visits"), + + constraintFactory.forEach(TestdataMultiEntityChainNextVehicle.class) + .penalize(SimpleScore.ONE, vehicle -> vehicle.getDeadline() - vehicle.getStartTime()) + .asConstraint("Maximize start time") + }; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextSolution.java new file mode 100644 index 0000000000..ba5d8de361 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextSolution.java @@ -0,0 +1,58 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +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; + +@PlanningSolution +public class TestdataMultiEntityChainNextSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataMultiEntityChainNextSolution.class, + TestdataMultiEntityChainNextVehicle.class, TestdataMultiEntityChainNextVisit.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + @PlanningEntityCollectionProperty + List vehicles; + + @PlanningEntityCollectionProperty + @ValueRangeProvider + List visits; + + @PlanningScore + SimpleScore score; + + public List getVehicles() { + return vehicles; + } + + public void setVehicles(List vehicles) { + this.vehicles = vehicles; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVehicle.java new file mode 100644 index 0000000000..34c5d0f886 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVehicle.java @@ -0,0 +1,101 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * A vehicle whose latest start time is bound by its successor vehicles, + * so a change on a successor's route propagates backwards to this vehicle's visits. + */ +@PlanningEntity +public class TestdataMultiEntityChainNextVehicle extends TestdataObject { + + List nextVehicles = new ArrayList<>(); + int deadline; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + @ShadowVariable(supplierName = "nextStartTimeSupplier") + Integer nextStartTime; + + @ShadowVariable(supplierName = "startTimeSupplier") + Integer startTime; + + public TestdataMultiEntityChainNextVehicle() { + } + + public TestdataMultiEntityChainNextVehicle(String code, int deadline) { + super(code); + this.deadline = deadline; + // A tail vehicle's only source is an empty fact collection, + // so its supplier is never triggered; initialize to the value it would compute. + this.nextStartTime = deadline; + } + + @ShadowSources("nextVehicles[].startTime") + public Integer nextStartTimeSupplier() { + var min = deadline; + for (var nextVehicle : nextVehicles) { + if (nextVehicle.getStartTime() == null) { + return null; + } + min = Math.min(min, nextVehicle.getStartTime()); + } + return min; + } + + @ShadowSources({ "visits[].latestStartTime", "nextStartTime" }) + public Integer startTimeSupplier() { + if (visits.isEmpty()) { + return nextStartTime; + } + return visits.get(0).getLatestStartTime(); + } + + public List getNextVehicles() { + return nextVehicles; + } + + public void setNextVehicles(List nextVehicles) { + this.nextVehicles = nextVehicles; + } + + public int getDeadline() { + return deadline; + } + + public void setDeadline(int deadline) { + this.deadline = deadline; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public Integer getNextStartTime() { + return nextStartTime; + } + + public void setNextStartTime(Integer nextStartTime) { + this.nextStartTime = nextStartTime; + } + + public Integer getStartTime() { + return startTime; + } + + public void setStartTime(Integer startTime) { + this.startTime = startTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVisit.java new file mode 100644 index 0000000000..de7698383c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVisit.java @@ -0,0 +1,79 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_next; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +@PlanningEntity +public class TestdataMultiEntityChainNextVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataMultiEntityChainNextVehicle vehicle; + + @NextElementShadowVariable(sourceVariableName = "visits") + TestdataMultiEntityChainNextVisit nextVisit; + + int duration = 1; + + @ShadowVariable(supplierName = "latestStartTimeSupplier") + Integer latestStartTime; + + public TestdataMultiEntityChainNextVisit() { + } + + public TestdataMultiEntityChainNextVisit(String code) { + super(code); + } + + public TestdataMultiEntityChainNextVisit(String code, int duration) { + super(code); + this.duration = duration; + } + + @ShadowSources({ "vehicle", "vehicle.nextStartTime", "nextVisit", "nextVisit.latestStartTime" }) + public Integer latestStartTimeSupplier() { + if (vehicle == null) { + return null; + } + var base = nextVisit != null ? nextVisit.getLatestStartTime() : vehicle.getNextStartTime(); + if (base == null) { + return null; + } + return base - duration; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public TestdataMultiEntityChainNextVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataMultiEntityChainNextVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataMultiEntityChainNextVisit getNextVisit() { + return nextVisit; + } + + public void setNextVisit(TestdataMultiEntityChainNextVisit nextVisit) { + this.nextVisit = nextVisit; + } + + public Integer getLatestStartTime() { + return latestStartTime; + } + + public void setLatestStartTime(Integer latestStartTime) { + this.latestStartTime = latestStartTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java new file mode 100644 index 0000000000..4afebd72d0 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java @@ -0,0 +1,69 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * A declarative entity class that does not own the list variable. + */ +@PlanningEntity +public class TestdataNonOwnerDepot extends TestdataObject { + + List otherDepots = new ArrayList<>(); + int baseTime; + + @ShadowVariable(supplierName = "openTimeSupplier") + Integer openTime; + + public TestdataNonOwnerDepot() { + } + + public TestdataNonOwnerDepot(String code, int baseTime) { + super(code); + this.baseTime = baseTime; + // A depot without other depots has an empty fact collection as its only source, + // so its supplier is never triggered; initialize to the value it would compute. + this.openTime = baseTime; + } + + @ShadowSources("otherDepots[].openTime") + public Integer openTimeSupplier() { + var max = baseTime; + for (var otherDepot : otherDepots) { + if (otherDepot.getOpenTime() == null) { + return null; + } + max = Math.max(max, otherDepot.getOpenTime()); + } + return max; + } + + public List getOtherDepots() { + return otherDepots; + } + + public void setOtherDepots(List otherDepots) { + this.otherDepots = otherDepots; + } + + public int getBaseTime() { + return baseTime; + } + + public void setBaseTime(int baseTime) { + this.baseTime = baseTime; + } + + public Integer getOpenTime() { + return openTime; + } + + public void setOpenTime(Integer openTime) { + this.openTime = openTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java new file mode 100644 index 0000000000..b787b9242a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java @@ -0,0 +1,69 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +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; + +@PlanningSolution +public class TestdataNonOwnerSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataNonOwnerSolution.class, + TestdataNonOwnerVehicle.class, TestdataNonOwnerVisit.class, TestdataNonOwnerDepot.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + @PlanningEntityCollectionProperty + List vehicles; + + @PlanningEntityCollectionProperty + @ValueRangeProvider + List visits; + + @PlanningEntityCollectionProperty + List depots; + + @PlanningScore + SimpleScore score; + + public List getVehicles() { + return vehicles; + } + + public void setVehicles(List vehicles) { + this.vehicles = vehicles; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } + + public List getDepots() { + return depots; + } + + public void setDepots(List depots) { + this.depots = depots; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java new file mode 100644 index 0000000000..c6ecee2321 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java @@ -0,0 +1,45 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +/** + * Owns the list variable but has no declarative shadow variables, + * so the declarative entity classes are the visit and the depot. + */ +@PlanningEntity +public class TestdataNonOwnerVehicle extends TestdataObject { + + int departureTime; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + public TestdataNonOwnerVehicle() { + } + + public TestdataNonOwnerVehicle(String code, int departureTime) { + super(code); + this.departureTime = departureTime; + } + + public int getDepartureTime() { + return departureTime; + } + + public void setDepartureTime(int departureTime) { + this.departureTime = departureTime; + } + + public List getVisits() { + return visits; + } + + public void setVisits(List visits) { + this.visits = visits; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java new file mode 100644 index 0000000000..71d5f7f865 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java @@ -0,0 +1,75 @@ +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowSources; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.testdomain.TestdataObject; + +@PlanningEntity +public class TestdataNonOwnerVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataNonOwnerVehicle vehicle; + + @PreviousElementShadowVariable(sourceVariableName = "visits") + TestdataNonOwnerVisit previousVisit; + + int duration = 1; + + @ShadowVariable(supplierName = "endServiceTimeSupplier") + Integer endServiceTime; + + public TestdataNonOwnerVisit() { + } + + public TestdataNonOwnerVisit(String code, int duration) { + super(code); + this.duration = duration; + } + + @ShadowSources({ "vehicle", "previousVisit", "previousVisit.endServiceTime" }) + public Integer endServiceTimeSupplier() { + if (vehicle == null) { + return null; + } + var base = previousVisit != null ? previousVisit.getEndServiceTime() : (Integer) vehicle.getDepartureTime(); + if (base == null) { + return null; + } + return base + duration; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public TestdataNonOwnerVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataNonOwnerVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataNonOwnerVisit getPreviousVisit() { + return previousVisit; + } + + public void setPreviousVisit(TestdataNonOwnerVisit previousVisit) { + this.previousVisit = previousVisit; + } + + public Integer getEndServiceTime() { + return endServiceTime; + } + + public void setEndServiceTime(Integer endServiceTime) { + this.endServiceTime = endServiceTime; + } +}