From 91a652cb8f0e218711290e2e7a9cae5e1fd17bcf Mon Sep 17 00:00:00 2001 From: fodzal Date: Tue, 28 Jul 2026 13:13:05 +0200 Subject: [PATCH 1/3] perf: update planning list variable elements by a cascade instead of the graph When a model's list elements only read their chain and, through their inverse, pre-chain declarative variables of their own list entity, and the other entities only reach the elements through the list variable itself, the elements are excluded from the variable reference graph. The graph then only covers the remaining entities and is built by the existing machinery, unchanged and with its full generality; without per-element edges it often has no dynamic edges at all and stays fixed. The elements are updated by a cascade that walks each dirty entity's list from the earliest dirty element, marking the entity's post-chain variables changed when an element changed. Updates alternate the cascade and the graph until neither has work left; an entity whose pre-chain variables just changed is deferred while other entities still need walking, so every element settles in a single computation. This removes the per-element graph nodes, edges and topological order maintenance, which dominate the graph cost in vehicle routing models where elements far outnumber the other entities. Co-Authored-By: Claude Fable 5 --- .../DefaultShadowVariableSessionFactory.java | 163 ++++++++- .../variable/declarative/GraphStructure.java | 232 ++++++++++++- ...tElementCascadeVariableReferenceGraph.java | 322 ++++++++++++++++++ .../declarative/VariableReferenceGraph.java | 3 +- .../DeclarativeShadowVariableAssertions.java | 55 +++ .../declarative/GraphStructureTest.java | 118 +++++++ ...entCascadeFactChainShadowVariableTest.java | 141 ++++++++ ...tElementCascadeNextShadowVariableTest.java | 104 ++++++ .../ListElementCascadeShadowVariableTest.java | 212 ++++++++++++ ...mentCascadeVariableReferenceGraphTest.java | 108 ++++++ ...ataMultiEntityChainConstraintProvider.java | 27 ++ .../TestdataMultiEntityChainSolution.java | 58 ++++ .../TestdataMultiEntityChainVehicle.java | 119 +++++++ .../TestdataMultiEntityChainVisit.java | 122 +++++++ .../TestdataFactChainConstraintProvider.java | 27 ++ .../TestdataFactChainSolution.java | 58 ++++ .../TestdataFactChainVehicle.java | 119 +++++++ .../TestdataFactChainVisit.java | 90 +++++ .../TestdataElementFactSolution.java | 58 ++++ .../TestdataElementFactVehicle.java | 72 ++++ .../TestdataElementFactVisit.java | 84 +++++ .../TestdataNonOwnerDepot.java | 69 ++++ .../TestdataNonOwnerSolution.java | 69 ++++ .../TestdataNonOwnerVehicle.java | 45 +++ .../TestdataNonOwnerVisit.java | 75 ++++ .../TestdataWatchedVisitsSolution.java | 58 ++++ .../TestdataWatchedVisitsVehicle.java | 98 ++++++ .../TestdataWatchedVisitsVisit.java | 75 ++++ ...ultiEntityChainNextConstraintProvider.java | 27 ++ .../TestdataMultiEntityChainNextSolution.java | 58 ++++ .../TestdataMultiEntityChainNextVehicle.java | 101 ++++++ .../TestdataMultiEntityChainNextVisit.java | 79 +++++ 32 files changed, 3030 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraph.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableAssertions.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeFactChainShadowVariableTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeNextShadowVariableTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeShadowVariableTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeVariableReferenceGraphTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainConstraintProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain/TestdataMultiEntityChainVisit.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainConstraintProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fact/TestdataFactChainVisit.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerDepot.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataWatchedVisitsVisit.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextConstraintProvider.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextSolution.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVehicle.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_next/TestdataMultiEntityChainNextVisit.java 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..c44a9976f6 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,107 @@ 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()); + + // The inner graph covers everything but the elements, built by the normal machinery; + // its structure was determined on the non-element descriptors. + 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 +392,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 +410,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 +431,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 +568,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 +605,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 +659,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 +748,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 +763,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 +832,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 +850,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..c31de57948 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,179 @@ 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 listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); + if (listVariableDescriptor == null) { + return null; + } + // 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..1c14b6fe96 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,34 @@ 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.TestdataElementFactSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataElementFactVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataElementFactVisit; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerDepot; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerSolution; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerVehicle; +import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerVisit; +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.simple_list.TestdataDeclarativeSimpleListSolution; import ai.timefold.solver.core.testdomain.shadow.simple_list.TestdataDeclarativeSimpleListValue; @@ -132,9 +154,105 @@ 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 multiEntityChainWithElementFactSource() { + var vehicle = new TestdataElementFactVehicle("A", 0); + var visit1 = new TestdataElementFactVisit("v1", 1); + var visit2 = new TestdataElementFactVisit("v2", 1); + visit1.setBuddy(visit2); + visit2.setBuddy(visit1); + assertThat(GraphStructure.determineGraphStructure( + TestdataElementFactSolution.buildSolutionDescriptor(), vehicle, visit1, visit2)) + .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..8de798f537 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeFactChainShadowVariableTest.java @@ -0,0 +1,141 @@ +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_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 cyclicVehicleFactsFailFast() { + var vehicleA = new TestdataFactChainVehicle("A", 0); + var vehicleB = new TestdataFactChainVehicle("B", 0); + vehicleA.setPreviousVehicle(vehicleB); + vehicleB.setPreviousVehicle(vehicleA); + + var solution = new TestdataFactChainSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB)); + solution.setVisits(List.of(new TestdataFactChainVisit("v1"))); + + assertThatCode(() -> MoveTester.build(TestdataFactChainSolution.buildMetaModel()).using(solution)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fixed dependency loops"); + } + + @Test + void solveWithFullAssertFromUninitializedSolution() { + assertShadowsAreAtFixedPoint(solve(generateSolution(false))); + } + + @Test + void solveWithFullAssertFromInitializedSolution() { + assertShadowsAreAtFixedPoint(solve(generateSolution(true))); + } + + private static TestdataFactChainSolution solve(TestdataFactChainSolution problem) { + return solveWithFullAssert(TestdataFactChainSolution.class, + TestdataFactChainConstraintProvider.class, problem, + TestdataFactChainVehicle.class, TestdataFactChainVisit.class); + } + + private static TestdataFactChainSolution generateSolution(boolean initialized) { + 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))); + } + if (initialized) { + vehicles.get(0).setVisits(new ArrayList<>(visits.subList(0, 4))); + vehicles.get(1).setVisits(new ArrayList<>(visits.subList(4, 6))); + } + 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..e52e144398 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeNextShadowVariableTest.java @@ -0,0 +1,104 @@ +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_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 changeOnSuccessorVehiclePropagatesBackwards() { + var x1 = new TestdataMultiEntityChainNextVisit("x1"); + var x2 = new TestdataMultiEntityChainNextVisit("x2"); + var x3 = new TestdataMultiEntityChainNextVisit("x3"); + var y1 = new TestdataMultiEntityChainNextVisit("y1"); + var y2 = new TestdataMultiEntityChainNextVisit("y2"); + var y3 = new TestdataMultiEntityChainNextVisit("y3"); // Initially unassigned. + + var vehicleA = new TestdataMultiEntityChainNextVehicle("A", 100); + var vehicleB = new TestdataMultiEntityChainNextVehicle("B", 100); + vehicleA.getNextVehicles().add(vehicleB); + vehicleA.setVisits(new ArrayList<>(List.of(x1, x2, x3))); + vehicleB.setVisits(new ArrayList<>(List.of(y1, y2))); + + var solution = new TestdataMultiEntityChainNextSolution(); + solution.setVehicles(List.of(vehicleA, vehicleB)); + solution.setVisits(List.of(x1, x2, x3, y1, y2, y3)); + + var solutionMetaModel = TestdataMultiEntityChainNextSolution.buildMetaModel(); + var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataMultiEntityChainNextVehicle.class) + .listVariable("visits", TestdataMultiEntityChainNextVisit.class); + + var context = MoveTester.build(solutionMetaModel).using(solution); + // B = [y1, y2] starts at 98; A is bound by B -> [x1, x2, x3] starts at 95. + assertThat(vehicleB.getStartTime()).isEqualTo(98); + assertThat(vehicleA.getNextStartTime()).isEqualTo(98); + assertThat(x3.getLatestStartTime()).isEqualTo(97); + assertThat(x2.getLatestStartTime()).isEqualTo(96); + assertThat(x1.getLatestStartTime()).isEqualTo(95); + assertThat(vehicleA.getStartTime()).isEqualTo(95); + + // Prepending y3 to B shifts A's whole route backwards. + context.execute(Moves.assign(listVariableMetaModel, y3, vehicleB, 0)); + assertThat(vehicleB.getStartTime()).isEqualTo(97); + assertThat(vehicleA.getNextStartTime()).isEqualTo(97); + assertThat(x3.getLatestStartTime()).isEqualTo(96); + assertThat(x2.getLatestStartTime()).isEqualTo(95); + assertThat(x1.getLatestStartTime()).isEqualTo(94); + assertThat(vehicleA.getStartTime()).isEqualTo(94); + assertShadowsAreAtFixedPoint(solution); + + // Removing y3 restores the original times. + context.execute(Moves.unassign(listVariableMetaModel, vehicleB, 0)); + assertThat(y3.getLatestStartTime()).isNull(); + assertThat(vehicleB.getStartTime()).isEqualTo(98); + assertThat(vehicleA.getNextStartTime()).isEqualTo(98); + assertThat(x1.getLatestStartTime()).isEqualTo(95); + assertThat(vehicleA.getStartTime()).isEqualTo(95); + assertShadowsAreAtFixedPoint(solution); + } + + @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..f08996910e --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/declarative/ListElementCascadeShadowVariableTest.java @@ -0,0 +1,212 @@ +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, false))); + } + + @Test + void solveWithFullAssertFromInitializedSolution() { + assertShadowsAreAtFixedPoint(solve(generateSolution(true, false))); + } + + @Test + void solveWithFullAssertWithRebasingElements() { + assertShadowsAreAtFixedPoint(solve(generateSolution(false, true))); + } + + private static TestdataMultiEntityChainSolution solve(TestdataMultiEntityChainSolution problem) { + return solveWithFullAssert(TestdataMultiEntityChainSolution.class, + TestdataMultiEntityChainConstraintProvider.class, problem, + TestdataMultiEntityChainVehicle.class, TestdataMultiEntityChainVisit.class); + } + + private static TestdataMultiEntityChainSolution generateSolution(boolean initialized, 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)); + } + if (initialized) { + vehicles.get(0).setVisits(new ArrayList<>(visits.subList(0, 4))); + vehicles.get(1).setVisits(new ArrayList<>(visits.subList(4, 6))); + } + 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/TestdataElementFactSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java new file mode 100644 index 0000000000..1d10c75d94 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.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 TestdataElementFactSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataElementFactSolution.class, + TestdataElementFactVehicle.class, TestdataElementFactVisit.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/TestdataElementFactVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java new file mode 100644 index 0000000000..c612ab2661 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java @@ -0,0 +1,72 @@ +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 sourcing a declarative variable through a fact of its list variable's elements, + * which may belong to another vehicle's elements, + * so the multi-entity chained graph cannot represent it. + */ +@PlanningEntity +public class TestdataElementFactVehicle extends TestdataObject { + + int departureTime; + + @PlanningListVariable(allowsUnassignedValues = true) + List visits = new ArrayList<>(); + + @ShadowVariable(supplierName = "buddyEndTimeSupplier") + Integer buddyEndTime; + + public TestdataElementFactVehicle() { + } + + public TestdataElementFactVehicle(String code, int departureTime) { + super(code); + this.departureTime = departureTime; + } + + @ShadowSources("visits[].buddy.endServiceTime") + public Integer buddyEndTimeSupplier() { + var max = departureTime; + for (var visit : visits) { + var buddyEndServiceTime = visit.getBuddy().getEndServiceTime(); + if (buddyEndServiceTime == null) { + return null; + } + max = Math.max(max, buddyEndServiceTime); + } + return max; + } + + 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 getBuddyEndTime() { + return buddyEndTime; + } + + public void setBuddyEndTime(Integer buddyEndTime) { + this.buddyEndTime = buddyEndTime; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java new file mode 100644 index 0000000000..c4175082fd --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java @@ -0,0 +1,84 @@ +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 TestdataElementFactVisit extends TestdataObject { + + @InverseRelationShadowVariable(sourceVariableName = "visits") + TestdataElementFactVehicle vehicle; + + @PreviousElementShadowVariable(sourceVariableName = "visits") + TestdataElementFactVisit previousVisit; + + TestdataElementFactVisit buddy; + int duration = 1; + + @ShadowVariable(supplierName = "endServiceTimeSupplier") + Integer endServiceTime; + + public TestdataElementFactVisit() { + } + + public TestdataElementFactVisit(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 TestdataElementFactVisit getBuddy() { + return buddy; + } + + public void setBuddy(TestdataElementFactVisit buddy) { + this.buddy = buddy; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public TestdataElementFactVehicle getVehicle() { + return vehicle; + } + + public void setVehicle(TestdataElementFactVehicle vehicle) { + this.vehicle = vehicle; + } + + public TestdataElementFactVisit getPreviousVisit() { + return previousVisit; + } + + public void setPreviousVisit(TestdataElementFactVisit 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_fallback/TestdataNonOwnerDepot.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerDepot.java new file mode 100644 index 0000000000..9180bc1bd1 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerDepot.java @@ -0,0 +1,69 @@ +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.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_fallback/TestdataNonOwnerSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java new file mode 100644 index 0000000000..c025c9ebd0 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java @@ -0,0 +1,69 @@ +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 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_fallback/TestdataNonOwnerVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java new file mode 100644 index 0000000000..b827d81fff --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java @@ -0,0 +1,45 @@ +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.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_fallback/TestdataNonOwnerVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.java new file mode 100644 index 0000000000..fb608f1064 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.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 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; + } +} 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; + } +} From 9eec01fc4e490b9f906eece1abbf9eb4eb7343cb Mon Sep 17 00:00:00 2001 From: fodzal Date: Thu, 30 Jul 2026 23:24:44 +0200 Subject: [PATCH 2/3] ensure (again) only one list variable in model --- .../domain/solution/descriptor/SolutionDescriptor.java | 4 ++++ .../impl/domain/variable/declarative/GraphStructure.java | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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/GraphStructure.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/GraphStructure.java index c31de57948..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 @@ -205,10 +205,15 @@ private record ListElementCascadeAndDirection(ListElementCascade elementCascade, private static @Nullable ListElementCascadeAndDirection determineListElementCascade( SolutionDescriptor solutionDescriptor, List> declarativeShadowVariableDescriptors) { - var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); - if (listVariableDescriptor == null) { + 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; From 2cb7ae9fbf30d08f1f3b81d7fd6e81fa201cc048 Mon Sep 17 00:00:00 2001 From: fodzal Date: Fri, 31 Jul 2026 00:04:10 +0200 Subject: [PATCH 3/3] simplify comments + reduce number of tests --- .../DefaultShadowVariableSessionFactory.java | 2 - .../declarative/GraphStructureTest.java | 24 +----- ...entCascadeFactChainShadowVariableTest.java | 40 ++------- ...tElementCascadeNextShadowVariableTest.java | 56 ------------- .../ListElementCascadeShadowVariableTest.java | 15 +--- .../TestdataElementFactSolution.java | 58 ------------- .../TestdataElementFactVehicle.java | 72 ---------------- .../TestdataElementFactVisit.java | 84 ------------------- .../TestdataNonOwnerDepot.java | 2 +- .../TestdataNonOwnerSolution.java | 2 +- .../TestdataNonOwnerVehicle.java | 2 +- .../TestdataNonOwnerVisit.java | 2 +- 12 files changed, 16 insertions(+), 343 deletions(-) delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java delete mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java rename core/src/test/java/ai/timefold/solver/core/testdomain/shadow/{multi_entity_chain_fallback => multi_entity_chain_non_owner}/TestdataNonOwnerDepot.java (99%) rename core/src/test/java/ai/timefold/solver/core/testdomain/shadow/{multi_entity_chain_fallback => multi_entity_chain_non_owner}/TestdataNonOwnerSolution.java (99%) rename core/src/test/java/ai/timefold/solver/core/testdomain/shadow/{multi_entity_chain_fallback => multi_entity_chain_non_owner}/TestdataNonOwnerVehicle.java (98%) rename core/src/test/java/ai/timefold/solver/core/testdomain/shadow/{multi_entity_chain_fallback => multi_entity_chain_non_owner}/TestdataNonOwnerVisit.java (99%) 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 c44a9976f6..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 @@ -261,8 +261,6 @@ static VariableReferenceGraph buildListElementCascadeGraph( }, changedVariableNotifier.innerScoreDirector()); - // The inner graph covers everything but the elements, built by the normal machinery; - // its structure was determined on the non-element descriptors. var innerGraphDescriptor = new GraphDescriptor<>(graphDescriptor.consistencyTracker(), solutionDescriptor, new VariableReferenceGraphBuilder<>(flaggingNotifier), graphDescriptor.entities(), graphDescriptor.graphCreator()); 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 1c14b6fe96..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 @@ -35,19 +35,16 @@ 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.TestdataElementFactSolution; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataElementFactVehicle; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataElementFactVisit; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerDepot; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerSolution; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerVehicle; -import ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback.TestdataNonOwnerVisit; 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; @@ -225,19 +222,6 @@ void multiEntityChainWithFactCollectionOfElements() { .hasFieldOrPropertyWithValue("elementCascade", null); } - @Test - void multiEntityChainWithElementFactSource() { - var vehicle = new TestdataElementFactVehicle("A", 0); - var visit1 = new TestdataElementFactVisit("v1", 1); - var visit2 = new TestdataElementFactVisit("v2", 1); - visit1.setBuddy(visit2); - visit2.setBuddy(visit1); - assertThat(GraphStructure.determineGraphStructure( - TestdataElementFactSolution.buildSolutionDescriptor(), vehicle, visit1, visit2)) - .hasFieldOrPropertyWithValue("structure", ARBITRARY) - .hasFieldOrPropertyWithValue("elementCascade", null); - } - @Test void multiEntityChainWithNonListOwnerEntity() { var vehicle = new TestdataNonOwnerVehicle("A", 0); 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 index 8de798f537..f30c97d97e 100644 --- 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 @@ -2,7 +2,6 @@ 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; @@ -79,38 +78,13 @@ void changeOnPredecessorVehiclePropagatesThroughFactChain() { } @Test - void cyclicVehicleFactsFailFast() { - var vehicleA = new TestdataFactChainVehicle("A", 0); - var vehicleB = new TestdataFactChainVehicle("B", 0); - vehicleA.setPreviousVehicle(vehicleB); - vehicleB.setPreviousVehicle(vehicleA); - - var solution = new TestdataFactChainSolution(); - solution.setVehicles(List.of(vehicleA, vehicleB)); - solution.setVisits(List.of(new TestdataFactChainVisit("v1"))); - - assertThatCode(() -> MoveTester.build(TestdataFactChainSolution.buildMetaModel()).using(solution)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("fixed dependency loops"); + void solveFactChainedModelWithFullAssert() { + assertShadowsAreAtFixedPoint(solveWithFullAssert(TestdataFactChainSolution.class, + TestdataFactChainConstraintProvider.class, generateSolution(), + TestdataFactChainVehicle.class, TestdataFactChainVisit.class)); } - @Test - void solveWithFullAssertFromUninitializedSolution() { - assertShadowsAreAtFixedPoint(solve(generateSolution(false))); - } - - @Test - void solveWithFullAssertFromInitializedSolution() { - assertShadowsAreAtFixedPoint(solve(generateSolution(true))); - } - - private static TestdataFactChainSolution solve(TestdataFactChainSolution problem) { - return solveWithFullAssert(TestdataFactChainSolution.class, - TestdataFactChainConstraintProvider.class, problem, - TestdataFactChainVehicle.class, TestdataFactChainVisit.class); - } - - private static TestdataFactChainSolution generateSolution(boolean initialized) { + private static TestdataFactChainSolution generateSolution() { var vehicles = new ArrayList(); for (var i = 0; i < 3; i++) { vehicles.add(new TestdataFactChainVehicle("vehicle" + i, i)); @@ -122,10 +96,6 @@ private static TestdataFactChainSolution generateSolution(boolean initialized) { for (var i = 0; i < 6; i++) { visits.add(new TestdataFactChainVisit("visit" + i, 1 + (i % 3))); } - if (initialized) { - vehicles.get(0).setVisits(new ArrayList<>(visits.subList(0, 4))); - vehicles.get(1).setVisits(new ArrayList<>(visits.subList(4, 6))); - } var solution = new TestdataFactChainSolution(); solution.setVehicles(vehicles); solution.setVisits(visits); 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 index e52e144398..1e14993e45 100644 --- 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 @@ -1,13 +1,9 @@ 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_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; @@ -22,58 +18,6 @@ */ class ListElementCascadeNextShadowVariableTest { - @Test - void changeOnSuccessorVehiclePropagatesBackwards() { - var x1 = new TestdataMultiEntityChainNextVisit("x1"); - var x2 = new TestdataMultiEntityChainNextVisit("x2"); - var x3 = new TestdataMultiEntityChainNextVisit("x3"); - var y1 = new TestdataMultiEntityChainNextVisit("y1"); - var y2 = new TestdataMultiEntityChainNextVisit("y2"); - var y3 = new TestdataMultiEntityChainNextVisit("y3"); // Initially unassigned. - - var vehicleA = new TestdataMultiEntityChainNextVehicle("A", 100); - var vehicleB = new TestdataMultiEntityChainNextVehicle("B", 100); - vehicleA.getNextVehicles().add(vehicleB); - vehicleA.setVisits(new ArrayList<>(List.of(x1, x2, x3))); - vehicleB.setVisits(new ArrayList<>(List.of(y1, y2))); - - var solution = new TestdataMultiEntityChainNextSolution(); - solution.setVehicles(List.of(vehicleA, vehicleB)); - solution.setVisits(List.of(x1, x2, x3, y1, y2, y3)); - - var solutionMetaModel = TestdataMultiEntityChainNextSolution.buildMetaModel(); - var listVariableMetaModel = solutionMetaModel.genuineEntity(TestdataMultiEntityChainNextVehicle.class) - .listVariable("visits", TestdataMultiEntityChainNextVisit.class); - - var context = MoveTester.build(solutionMetaModel).using(solution); - // B = [y1, y2] starts at 98; A is bound by B -> [x1, x2, x3] starts at 95. - assertThat(vehicleB.getStartTime()).isEqualTo(98); - assertThat(vehicleA.getNextStartTime()).isEqualTo(98); - assertThat(x3.getLatestStartTime()).isEqualTo(97); - assertThat(x2.getLatestStartTime()).isEqualTo(96); - assertThat(x1.getLatestStartTime()).isEqualTo(95); - assertThat(vehicleA.getStartTime()).isEqualTo(95); - - // Prepending y3 to B shifts A's whole route backwards. - context.execute(Moves.assign(listVariableMetaModel, y3, vehicleB, 0)); - assertThat(vehicleB.getStartTime()).isEqualTo(97); - assertThat(vehicleA.getNextStartTime()).isEqualTo(97); - assertThat(x3.getLatestStartTime()).isEqualTo(96); - assertThat(x2.getLatestStartTime()).isEqualTo(95); - assertThat(x1.getLatestStartTime()).isEqualTo(94); - assertThat(vehicleA.getStartTime()).isEqualTo(94); - assertShadowsAreAtFixedPoint(solution); - - // Removing y3 restores the original times. - context.execute(Moves.unassign(listVariableMetaModel, vehicleB, 0)); - assertThat(y3.getLatestStartTime()).isNull(); - assertThat(vehicleB.getStartTime()).isEqualTo(98); - assertThat(vehicleA.getNextStartTime()).isEqualTo(98); - assertThat(x1.getLatestStartTime()).isEqualTo(95); - assertThat(vehicleA.getStartTime()).isEqualTo(95); - assertShadowsAreAtFixedPoint(solution); - } - @Test void solveNextDirectionalModelWithFullAssert() { var vehicles = new ArrayList(); 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 index f08996910e..59f04681c8 100644 --- 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 @@ -162,17 +162,12 @@ void cyclicVehicleFactsFailFast() { @Test void solveWithFullAssertFromUninitializedSolution() { - assertShadowsAreAtFixedPoint(solve(generateSolution(false, false))); - } - - @Test - void solveWithFullAssertFromInitializedSolution() { - assertShadowsAreAtFixedPoint(solve(generateSolution(true, false))); + assertShadowsAreAtFixedPoint(solve(generateSolution(false))); } @Test void solveWithFullAssertWithRebasingElements() { - assertShadowsAreAtFixedPoint(solve(generateSolution(false, true))); + assertShadowsAreAtFixedPoint(solve(generateSolution(true))); } private static TestdataMultiEntityChainSolution solve(TestdataMultiEntityChainSolution problem) { @@ -181,7 +176,7 @@ private static TestdataMultiEntityChainSolution solve(TestdataMultiEntityChainSo TestdataMultiEntityChainVehicle.class, TestdataMultiEntityChainVisit.class); } - private static TestdataMultiEntityChainSolution generateSolution(boolean initialized, boolean alternateRebasing) { + private static TestdataMultiEntityChainSolution generateSolution(boolean alternateRebasing) { var vehicles = new ArrayList(); for (var i = 0; i < 3; i++) { vehicles.add(new TestdataMultiEntityChainVehicle("vehicle" + i, i)); @@ -194,10 +189,6 @@ private static TestdataMultiEntityChainSolution generateSolution(boolean initial visits.add(new TestdataMultiEntityChainVisit("visit" + i, 1 + (i % 3), !alternateRebasing || i % 2 == 0)); } - if (initialized) { - vehicles.get(0).setVisits(new ArrayList<>(visits.subList(0, 4))); - vehicles.get(1).setVisits(new ArrayList<>(visits.subList(4, 6))); - } var solution = new TestdataMultiEntityChainSolution(); solution.setVehicles(vehicles); solution.setVisits(visits); diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java deleted file mode 100644 index 1d10c75d94..0000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactSolution.java +++ /dev/null @@ -1,58 +0,0 @@ -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 TestdataElementFactSolution { - - public static SolutionDescriptor buildSolutionDescriptor() { - return SolutionDescriptor.buildSolutionDescriptor(TestdataElementFactSolution.class, - TestdataElementFactVehicle.class, TestdataElementFactVisit.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/TestdataElementFactVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java deleted file mode 100644 index c612ab2661..0000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVehicle.java +++ /dev/null @@ -1,72 +0,0 @@ -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 sourcing a declarative variable through a fact of its list variable's elements, - * which may belong to another vehicle's elements, - * so the multi-entity chained graph cannot represent it. - */ -@PlanningEntity -public class TestdataElementFactVehicle extends TestdataObject { - - int departureTime; - - @PlanningListVariable(allowsUnassignedValues = true) - List visits = new ArrayList<>(); - - @ShadowVariable(supplierName = "buddyEndTimeSupplier") - Integer buddyEndTime; - - public TestdataElementFactVehicle() { - } - - public TestdataElementFactVehicle(String code, int departureTime) { - super(code); - this.departureTime = departureTime; - } - - @ShadowSources("visits[].buddy.endServiceTime") - public Integer buddyEndTimeSupplier() { - var max = departureTime; - for (var visit : visits) { - var buddyEndServiceTime = visit.getBuddy().getEndServiceTime(); - if (buddyEndServiceTime == null) { - return null; - } - max = Math.max(max, buddyEndServiceTime); - } - return max; - } - - 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 getBuddyEndTime() { - return buddyEndTime; - } - - public void setBuddyEndTime(Integer buddyEndTime) { - this.buddyEndTime = buddyEndTime; - } -} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java deleted file mode 100644 index c4175082fd..0000000000 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataElementFactVisit.java +++ /dev/null @@ -1,84 +0,0 @@ -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 TestdataElementFactVisit extends TestdataObject { - - @InverseRelationShadowVariable(sourceVariableName = "visits") - TestdataElementFactVehicle vehicle; - - @PreviousElementShadowVariable(sourceVariableName = "visits") - TestdataElementFactVisit previousVisit; - - TestdataElementFactVisit buddy; - int duration = 1; - - @ShadowVariable(supplierName = "endServiceTimeSupplier") - Integer endServiceTime; - - public TestdataElementFactVisit() { - } - - public TestdataElementFactVisit(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 TestdataElementFactVisit getBuddy() { - return buddy; - } - - public void setBuddy(TestdataElementFactVisit buddy) { - this.buddy = buddy; - } - - public int getDuration() { - return duration; - } - - public void setDuration(int duration) { - this.duration = duration; - } - - public TestdataElementFactVehicle getVehicle() { - return vehicle; - } - - public void setVehicle(TestdataElementFactVehicle vehicle) { - this.vehicle = vehicle; - } - - public TestdataElementFactVisit getPreviousVisit() { - return previousVisit; - } - - public void setPreviousVisit(TestdataElementFactVisit 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_fallback/TestdataNonOwnerDepot.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java similarity index 99% rename from core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerDepot.java rename to core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java index 9180bc1bd1..4afebd72d0 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerDepot.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerDepot.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; import java.util.ArrayList; import java.util.List; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java similarity index 99% rename from core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java rename to core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java index c025c9ebd0..b787b9242a 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerSolution.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerSolution.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; import java.util.List; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java similarity index 98% rename from core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java rename to core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java index b827d81fff..c6ecee2321 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVehicle.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVehicle.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; +package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_non_owner; import java.util.ArrayList; import java.util.List; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java similarity index 99% rename from core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.java rename to core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java index fb608f1064..71d5f7f865 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_fallback/TestdataNonOwnerVisit.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/multi_entity_chain_non_owner/TestdataNonOwnerVisit.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.core.testdomain.shadow.multi_entity_chain_fallback; +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;