From 7ad4ac95e029c9ab5125db6ac17fa51b57ead6c4 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Thu, 18 Jun 2026 17:18:53 -0400 Subject: [PATCH] perf: add ability to ignore inconsistent solutions --- .../core/config/solver/PreviewFeature.java | 1 + .../TimefoldSolverEnterpriseService.java | 2 +- .../variable/ShadowVariableUpdateHelper.java | 8 +- .../AbstractVariableReferenceGraph.java | 7 +- .../declarative/ConsistencyTracker.java | 10 ++- .../DefaultShadowVariableSession.java | 4 +- .../DefaultShadowVariableSessionFactory.java | 36 +++++---- .../DefaultTopologicalOrderGraph.java | 5 +- .../DefaultVariableReferenceGraph.java | 18 +++-- .../EmptyVariableReferenceGraph.java | 3 +- .../FixedVariableReferenceGraph.java | 5 +- ...rectionalParentVariableReferenceGraph.java | 3 +- .../declarative/TopologicalOrderGraph.java | 4 +- .../declarative/VariableReferenceGraph.java | 4 +- .../VariableReferenceGraphBuilder.java | 5 +- .../support/VariableListenerSupport.java | 35 +++++--- .../decider/acceptor/CompositeAcceptor.java | 3 + .../greatdeluge/GreatDelugeAcceptor.java | 3 + .../hillclimbing/HillClimbingAcceptor.java | 3 + .../DiversifiedLateAcceptanceAcceptor.java | 3 + .../LateAcceptanceAcceptor.java | 3 + .../SimulatedAnnealingAcceptor.java | 4 + .../StepCountingHillClimbingAcceptor.java | 3 + .../acceptor/tabu/AbstractTabuAcceptor.java | 4 + .../impl/move/MoveTesterScoreDirector.java | 2 +- .../score/director/AbstractScoreDirector.java | 50 ++++++++++-- .../core/impl/score/director/InnerScore.java | 8 ++ .../score/director/InnerScoreDirector.java | 2 + .../director/easy/EasyScoreDirector.java | 2 +- .../incremental/IncrementalScoreDirector.java | 2 +- .../BavetConstraintStreamScoreDirector.java | 3 +- ...tConstraintStreamScoreDirectorFactory.java | 2 +- .../impl/solver/DefaultSolverFactory.java | 3 + .../solver/recaller/BestSolutionRecaller.java | 5 ++ core/src/main/resources/solver.xsd | 2 + .../composite/CompositeAcceptorTest.java | 6 +- .../core/impl/solver/DefaultSolverTest.java | 81 +++++++++++++++++++ .../src/main/resources/benchmark.xsd | 3 + 38 files changed, 281 insertions(+), 66 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java b/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java index 1629f6970eb..848a8b4e810 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/PreviewFeature.java @@ -21,6 +21,7 @@ public enum PreviewFeature { DIVERSIFIED_LATE_ACCEPTANCE, PLANNING_SOLUTION_DIFF, + IGNORE_INCONSISTENT_SOLUTIONS, /** * Unlike other preview features, Neighborhoods are an active research project. * It is intended to simplify the creation of custom moves, eventually replacing move selectors. diff --git a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 24431ceb65b..cbd033110fe 100644 --- a/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -151,7 +151,7 @@ static T loadOrDefault(Function builder, } } - TopologicalOrderGraph buildTopologyGraph(int size); + TopologicalOrderGraph buildTopologyGraph(int size, boolean ignoreInconsistentSolutions); /** * Will create new classes that apply node-sharing to the given {@link ConstraintProvider}. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java index cb6ff541105..6ac92a31383 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java @@ -106,7 +106,7 @@ public void updateShadowVariables(Class solutionClass, Object... enti .formatted(missingShadowVariableTypeList)); } // No solution, we trigger all supported events manually - var session = InternalShadowVariableSession.build(solutionDescriptor, entities); + var session = InternalShadowVariableSession.build(solutionDescriptor, false, entities); // Update all built-in shadow variables var listVariableDescriptor = solutionDescriptor.getListVariableDescriptor(); if (listVariableDescriptor == null) { @@ -123,12 +123,14 @@ private record InternalShadowVariableSession(SolutionDescriptor InternalShadowVariableSession build( SolutionDescriptor solutionDescriptor, + boolean ignoreInconsistentSolutions, Object... entities) { return new InternalShadowVariableSession<>(solutionDescriptor, DefaultShadowVariableSessionFactory.buildGraph( new DefaultShadowVariableSessionFactory.GraphDescriptor<>(solutionDescriptor, ChangedVariableNotifier.empty(), entities) - .assertingNoReferencedMissingEntities())); + .assertingNoReferencedMissingEntities(), + ignoreInconsistentSolutions)); } /** @@ -331,7 +333,7 @@ public void setWorkingSolutionWithoutUpdatingShadows(Solution_ workingSolution) } @Override - public InnerScore calculateScore() { + public InnerScore innerCalculateScore() { throw new UnsupportedOperationException(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/AbstractVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/AbstractVariableReferenceGraph.java index 39da87a0247..709c62e708f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/AbstractVariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/AbstractVariableReferenceGraph.java @@ -79,7 +79,7 @@ public abstract sealed class AbstractVariableReferenceGraph changed); @Override - public final void updateChanged() { + public final boolean updateChanged() { isUpdating = true; - innerUpdateChanged(); + var success = innerUpdateChanged(); isUpdating = false; + return success; } private BaseTopologicalOrderGraph.NodeTopologicalOrder[] buildNodeTopologicalOrderArray(BaseTopologicalOrderGraph graph, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ConsistencyTracker.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ConsistencyTracker.java index d303f92d661..157b6f33308 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ConsistencyTracker.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/ConsistencyTracker.java @@ -23,9 +23,11 @@ private ConsistencyTracker(boolean isFrozen) { } public static ConsistencyTracker frozen(SolutionDescriptor solutionDescriptor, + boolean ignoreInconsistentSolutions, Object[] entityOrFacts) { var out = new ConsistencyTracker(true); - out.setUnknownConsistencyFromEntityShadowVariablesInconsistent(solutionDescriptor, entityOrFacts); + out.setUnknownConsistencyFromEntityShadowVariablesInconsistent(solutionDescriptor, ignoreInconsistentSolutions, + entityOrFacts); return out; } @@ -46,6 +48,7 @@ public static ConsistencyTracker frozen(SolutionDescripto * (regardless of its actual consistency in the graph). */ void setUnknownConsistencyFromEntityShadowVariablesInconsistent(SolutionDescriptor solutionDescriptor, + boolean ignoreInconsistentSolutions, Object[] entityOrFacts) { // Not private so DefaultVariableReferenceGraph javadoc can reference it. var entities = Arrays.stream(entityOrFacts) .filter(maybeEntity -> solutionDescriptor.hasEntityDescriptor(maybeEntity.getClass())) @@ -60,7 +63,8 @@ void setUnknownConsistencyFromEntityShadowVariablesInconsistent(SolutionDescript new DefaultShadowVariableSessionFactory.GraphDescriptor<>(solutionDescriptor, ChangedVariableNotifier.empty(), entities) .withConsistencyTracker(this) - .assertingNoReferencedMissingEntities()); + .assertingNoReferencedMissingEntities(), + ignoreInconsistentSolutions); // Graph will either be DefaultVariableReferenceGraph or EmptyVariableReferenceGraph // If it is empty, we don't need to do anything. @@ -71,7 +75,7 @@ void setUnknownConsistencyFromEntityShadowVariablesInconsistent(SolutionDescript /** * If true, consistency and shadow variables are frozen and should not be updated. - * ConstraintVerifier creates a frozen instance via {@link #frozen(SolutionDescriptor, Object[])}. + * ConstraintVerifier creates a frozen instance via {@link #frozen(SolutionDescriptor, boolean, Object[])}. * * @return true if consistency and shadow variables are frozen and should not be updated */ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSession.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSession.java index 32961759be4..51f1315a3b3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSession.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSession.java @@ -34,7 +34,7 @@ public void afterVariableChanged(VariableMetaModel variableMeta entity); } - public void updateVariables() { - graph.updateChanged(); + public boolean updateVariables() { + return graph.updateChanged(); } } 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 7da950ad1e2..6445776f0d5 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 @@ -162,29 +162,31 @@ public ChangedVariableNotifier changedVariableNotifier() { } } - public static VariableReferenceGraph buildGraph(GraphDescriptor graphDescriptor) { + public static VariableReferenceGraph buildGraph(GraphDescriptor graphDescriptor, + boolean ignoreInconsistentSolutions) { var graphStructureAndDirection = GraphStructure.determineGraphStructure(graphDescriptor.solutionDescriptor(), graphDescriptor.entities()); LOGGER.trace("Shadow variable graph structure: {}", graphStructureAndDirection); - return buildGraphForStructureAndDirection(graphStructureAndDirection, graphDescriptor); + return buildGraphForStructureAndDirection(graphStructureAndDirection, graphDescriptor, ignoreInconsistentSolutions); } static VariableReferenceGraph buildGraphForStructureAndDirection( - GraphStructure.GraphStructureAndDirection graphStructureAndDirection, GraphDescriptor graphDescriptor) { + GraphStructure.GraphStructureAndDirection graphStructureAndDirection, GraphDescriptor graphDescriptor, + boolean ignoreInconsistentSolutions) { return switch (graphStructureAndDirection.structure()) { case EMPTY -> EmptyVariableReferenceGraph.INSTANCE; case SINGLE_DIRECTIONAL_PARENT -> { var scoreDirector = graphDescriptor.variableReferenceGraphBuilder().changedVariableNotifier.innerScoreDirector(); if (scoreDirector == null) { - yield buildArbitraryGraph(graphDescriptor); + yield buildArbitraryGraph(graphDescriptor, ignoreInconsistentSolutions); } yield buildSingleDirectionalParentGraph(graphDescriptor, graphStructureAndDirection); } case ARBITRARY_SINGLE_ENTITY_AT_MOST_ONE_DIRECTIONAL_PARENT_TYPE -> - buildArbitrarySingleEntityGraph(graphDescriptor); + buildArbitrarySingleEntityGraph(graphDescriptor, ignoreInconsistentSolutions); case NO_DYNAMIC_EDGES, ARBITRARY -> - buildArbitraryGraph(graphDescriptor); + buildArbitraryGraph(graphDescriptor, ignoreInconsistentSolutions); }; } @@ -280,7 +282,8 @@ yield new TopologicalSorter(listStateSupply::getPreviousElement, }; } - private static VariableReferenceGraph buildArbitraryGraph(GraphDescriptor graphDescriptor) { + private static VariableReferenceGraph buildArbitraryGraph(GraphDescriptor graphDescriptor, + boolean ignoreInconsistentSolutions) { var declarativeShadowVariableDescriptors = graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(); var variableIdToUpdater = EntityVariableUpdaterLookup. entityIndependentLookup(); @@ -294,13 +297,14 @@ private static VariableReferenceGraph buildArbitraryGraph(GraphDescr graphDescriptor, declarativeShadowVariableDescriptors, variableIdToUpdater); return buildVariableReferenceGraph(graphDescriptor, declarativeShadowVariableDescriptors, - declarativeShadowVariableToAliasMap); + declarativeShadowVariableToAliasMap, ignoreInconsistentSolutions); } private static VariableReferenceGraph buildVariableReferenceGraph( GraphDescriptor graphDescriptor, List> declarativeShadowVariableDescriptors, - Map, Set> declarativeShadowVariableToAliasMap) { + Map, Set> declarativeShadowVariableToAliasMap, + boolean ignoreInconsistentSolutions) { // Create variable processors for each declarative shadow variable descriptor for (var declarativeShadowVariable : declarativeShadowVariableDescriptors) { var fromVariableId = declarativeShadowVariable.getVariableMetaModel(); @@ -315,7 +319,8 @@ private static VariableReferenceGraph buildVariableReferenceGraph( // Create the fixed edges in the graph createFixedVariableRelationEdges(graphDescriptor.variableReferenceGraphBuilder(), graphDescriptor.entities(), declarativeShadowVariableDescriptors); - return graphDescriptor.variableReferenceGraphBuilder().build(graphDescriptor.graphCreator()); + return graphDescriptor.variableReferenceGraphBuilder().build(graphDescriptor.graphCreator(), + ignoreInconsistentSolutions); } private record GroupVariableUpdaterInfo( @@ -443,7 +448,7 @@ public List> getUpdatersForEntityVariable(Object } private static VariableReferenceGraph buildArbitrarySingleEntityGraph( - GraphDescriptor graphDescriptor) { + GraphDescriptor graphDescriptor, boolean ignoreInconsistentSolutions) { var declarativeShadowVariableDescriptors = graphDescriptor.solutionDescriptor().getDeclarativeShadowVariableDescriptors(); // Use a dependent lookup; if an entity does not use groups, then all variables can share the same node. @@ -475,7 +480,7 @@ private static VariableReferenceGraph buildArbitrarySingleEntityGrap (entity, declarativeShadowVariable, variableId) -> variableIdToGroupedUpdater.get(variableId) .getUpdatersForEntityVariable(entity, declarativeShadowVariable)); return buildVariableReferenceGraph(graphDescriptor, declarativeShadowVariableDescriptors, - declarativeShadowVariableToAliasMap); + declarativeShadowVariableToAliasMap, ignoreInconsistentSolutions); } private static Map, Set> createGraphNodes( @@ -691,18 +696,21 @@ private static void createFixedVariableRelationEdges( } public DefaultShadowVariableSession forSolution(ConsistencyTracker consistencyTracker, + boolean ignoreInconsistentSolutions, Solution_ solution) { var entities = new ArrayList<>(); solutionDescriptor.visitAllEntities(solution, entities::add); - return forEntities(consistencyTracker, entities.toArray()); + return forEntities(consistencyTracker, ignoreInconsistentSolutions, entities.toArray()); } public DefaultShadowVariableSession forEntities(ConsistencyTracker consistencyTracker, + boolean ignoreInconsistentSolutions, Object... entities) { var graph = buildGraph( new GraphDescriptor<>(solutionDescriptor, ChangedVariableNotifier.of(scoreDirector), entities) .withConsistencyTracker(consistencyTracker) - .withGraphCreator(graphCreator)); + .withGraphCreator(graphCreator), + ignoreInconsistentSolutions); return new DefaultShadowVariableSession<>(graph); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultTopologicalOrderGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultTopologicalOrderGraph.java index 22dd43ac561..9d425f1d44e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultTopologicalOrderGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultTopologicalOrderGraph.java @@ -117,7 +117,7 @@ public int getTopologicalOrder(int node) { } @Override - public void commitChanges(BitSet changed) { + public boolean commitChanges(BitSet changed) { var index = new MutableInt(1); var stackIndex = new MutableInt(0); var size = forwardEdges.length; @@ -126,6 +126,7 @@ public void commitChanges(BitSet changed) { var lowMap = new int[size]; var onStackSet = new boolean[size]; var components = new ArrayList(); + var anyLooped = false; componentMap.clear(); for (var node = 0; node < size; node++) { @@ -139,6 +140,7 @@ public void commitChanges(BitSet changed) { var component = components.get(i); var componentSize = component.cardinality(); var isComponentLooped = componentSize != 1; + anyLooped |= isComponentLooped; var componentNodes = new ArrayList(componentSize); for (var node = component.nextSetBit(0); node >= 0; node = component.nextSetBit(node + 1)) { nodeIdToTopologicalOrderMap[node] = ordIndex; @@ -159,6 +161,7 @@ public void commitChanges(BitSet changed) { } } } + return anyLooped; } private void strongConnect(int node, MutableInt index, MutableInt stackIndex, int[] stack, diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultVariableReferenceGraph.java index 45fed5549cf..645d6ca3ba4 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultVariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultVariableReferenceGraph.java @@ -11,11 +11,13 @@ final class DefaultVariableReferenceGraph extends AbstractVariableReferenceGraph { // These structures are mutable. private final AffectedEntitiesUpdater affectedEntitiesUpdater; + private final boolean ignoreInconsistentSolutions; public DefaultVariableReferenceGraph(VariableReferenceGraphBuilder outerGraph, - IntFunction graphCreator) { + IntFunction graphCreator, + boolean ignoreInconsistentSolutions) { super(outerGraph, graphCreator); - + this.ignoreInconsistentSolutions = ignoreInconsistentSolutions; var entityToVariableReferenceMap = new IdentityHashMap>>(); for (var instance : nodeList) { if (instance.groupEntityIds() == null) { @@ -49,12 +51,16 @@ void markChanged(@NonNull GraphNode node) { } @Override - void innerUpdateChanged() { + boolean innerUpdateChanged() { if (changeTracker.isEmpty()) { - return; + return true; + } + if (graph.commitChanges(changeTracker) && ignoreInconsistentSolutions) { + return false; + } else { + affectedEntitiesUpdater.accept(changeTracker); + return true; } - graph.commitChanges(changeTracker); - affectedEntitiesUpdater.accept(changeTracker); } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/EmptyVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/EmptyVariableReferenceGraph.java index 8a6bae7576f..53d2b1a281f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/EmptyVariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/EmptyVariableReferenceGraph.java @@ -7,8 +7,9 @@ final class EmptyVariableReferenceGraph implements VariableReferenceGraph { public static final EmptyVariableReferenceGraph INSTANCE = new EmptyVariableReferenceGraph(); @Override - public void updateChanged() { + public boolean updateChanged() { // No need to do anything. + return true; } @Override diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/FixedVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/FixedVariableReferenceGraph.java index da59a3c148f..3999e11a60f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/FixedVariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/FixedVariableReferenceGraph.java @@ -74,13 +74,13 @@ void markChanged(@NonNull GraphNode node) { } @Override - void innerUpdateChanged() { + boolean innerUpdateChanged() { BitSet visited; if (!changeTracker.isEmpty()) { visited = new BitSet(nodeList.size()); visited.set(changeTracker.peek().nodeId()); } else { - return; + return true; } // NOTE: This assumes the user did not add any fixed loops to @@ -104,5 +104,6 @@ void innerUpdateChanged() { } } isChanged.clear(); + return true; } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/SingleDirectionalParentVariableReferenceGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/SingleDirectionalParentVariableReferenceGraph.java index 41804b68375..52385256312 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/SingleDirectionalParentVariableReferenceGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/SingleDirectionalParentVariableReferenceGraph.java @@ -80,7 +80,7 @@ public SingleDirectionalParentVariableReferenceGraph( } @Override - public void updateChanged() { + public boolean updateChanged() { isUpdating = true; changedEntities.sort(topologicalOrderComparator); for (var changedEntity : changedEntities) { @@ -94,6 +94,7 @@ public void updateChanged() { isUpdating = false; changedEntities.clear(); keyToLastProcessedObject.clear(); + return true; } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/TopologicalOrderGraph.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/TopologicalOrderGraph.java index e5feccb74dc..1437ef52b6a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/TopologicalOrderGraph.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/TopologicalOrderGraph.java @@ -9,8 +9,10 @@ public interface TopologicalOrderGraph extends BaseTopologicalOrderGraph { * Called when all edge modifications are queued. * After this method returns, {@link #getTopologicalOrder(int)} * must be accurate for every node in the graph. + * + * @return true if the graph has loops, false otherwise */ - void commitChanges(BitSet changed); + boolean commitChanges(BitSet changed); /** * Called on graph creation to supply metadata about the graph nodes. 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 0fdd0467b5c..fe9ad2d68c8 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 @@ -13,8 +13,10 @@ public sealed interface VariableReferenceGraph * Called after all {@link ai.timefold.solver.core.impl.domain.variable.VariableListener} are * triggered. Declarative {@link ai.timefold.solver.core.api.domain.variable.ShadowVariable} * are guaranteed to be the last variables to update. + * + * @return true if the update successful; false otherwise */ - void updateChanged(); + boolean updateChanged(); /** * Called before the variable corresponding to the {@link VariableMetaModel} on the given entity changes. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraphBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraphBuilder.java index 5213ea4f6e5..55025c8886c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraphBuilder.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/VariableReferenceGraphBuilder.java @@ -116,7 +116,8 @@ public void addAfterProcessor(GraphChangeType graphChangeType, VariableMetaModel .add(consumer); } - public VariableReferenceGraph build(IntFunction graphCreator) { + public VariableReferenceGraph build(IntFunction graphCreator, + boolean ignoreInconsistentSolutions) { assertNoFixedLoops(); if (nodeList.isEmpty()) { return EmptyVariableReferenceGraph.INSTANCE; @@ -124,7 +125,7 @@ public VariableReferenceGraph build(IntFunction graphCrea if (isGraphFixed) { return new FixedVariableReferenceGraph<>(this, graphCreator); } - return new DefaultVariableReferenceGraph<>(this, graphCreator); + return new DefaultVariableReferenceGraph<>(this, graphCreator, ignoreInconsistentSolutions); } public @NonNull GraphNode lookupOrError(VariableMetaModel variableId, Object entity) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java index b6b998e4c04..169ef89e658 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java @@ -57,7 +57,8 @@ public final class VariableListenerSupport implements SupplyManager { public static VariableListenerSupport create(InnerScoreDirector scoreDirector) { return new VariableListenerSupport<>(scoreDirector, new NotifiableRegistry<>(scoreDirector.getSolutionDescriptor()), - TimefoldSolverEnterpriseService.loadOrDefault(service -> service::buildTopologyGraph, + TimefoldSolverEnterpriseService.loadOrDefault( + service -> size -> service.buildTopologyGraph(size, scoreDirector.ignoreInconsistentSolutions()), () -> DefaultTopologicalOrderGraph::new)); } @@ -74,6 +75,7 @@ public static VariableListenerSupport create(InnerScoreDi private final IntFunction shadowVariableGraphCreator; private boolean notificationQueuesAreEmpty = true; + private boolean updateSuccessful = true; private int nextGlobalOrder = 0; @Nullable private DefaultShadowVariableSession shadowVariableSession = null; @@ -253,7 +255,9 @@ public void resetWorkingSolution() { scoreDirector, shadowVariableGraphCreator); shadowVariableSession = - shadowVariableSessionFactory.forSolution(consistencyTracker, scoreDirector.getWorkingSolution()); + shadowVariableSessionFactory.forSolution(consistencyTracker, + scoreDirector.ignoreInconsistentSolutions(), + scoreDirector.getWorkingSolution()); } } @@ -332,12 +336,12 @@ public void afterListVariableChanged(ListVariableDescriptor variableD return scoreDirector; } - public void triggerVariableListenersInNotificationQueues() { + public boolean triggerVariableListenersInNotificationQueues() { if (notificationQueuesAreEmpty) { // Shortcut in case the trigger is called multiple times in a row, // without any notifications inbetween. // This is better than trying to ensure that the situation never ever occurs. - return; + return updateSuccessful; } for (var notifiable : notifiableRegistry.getAll()) { notifiable.triggerAllNotifications(); @@ -352,15 +356,22 @@ public void triggerVariableListenersInNotificationQueues() { listVariableChangedNotificationList.clear(); } if (shadowVariableSession != null) { - shadowVariableSession.updateVariables(); - // Some internal variable listeners (such as those used - // to check for solution corruption) might have a declarative - // shadow variable as a source and need to be triggered here. - for (var notifiable : notifiableRegistry.getAll()) { - notifiable.triggerAllNotifications(); + if (shadowVariableSession.updateVariables()) { + // Some internal variable listeners (such as those used + // to check for solution corruption) might have a declarative + // shadow variable as a source and need to be triggered here. + for (var notifiable : notifiableRegistry.getAll()) { + notifiable.triggerAllNotifications(); + } + } else { + updateSuccessful = false; + notificationQueuesAreEmpty = true; + return false; } } + updateSuccessful = true; notificationQueuesAreEmpty = true; + return true; } /** @@ -439,9 +450,9 @@ private void cascadeUnassignedValues( * * @param workingSolution working solution */ - public void forceTriggerAllVariableListeners(Solution_ workingSolution) { + public boolean forceTriggerAllVariableListeners(Solution_ workingSolution) { scoreDirector.getSolutionDescriptor().visitAllEntities(workingSolution, this::simulateGenuineVariableChange); - triggerVariableListenersInNotificationQueues(); + return triggerVariableListenersInNotificationQueues(); } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/CompositeAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/CompositeAcceptor.java index 6e9567059de..3cc27f1d20a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/CompositeAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/CompositeAcceptor.java @@ -52,6 +52,9 @@ public void stepStarted(LocalSearchStepScope stepScope) { @Override public boolean isAccepted(LocalSearchMoveScope moveScope) { + if (moveScope.getScore().isInvalid()) { + return false; + } for (Acceptor acceptor : acceptorList) { boolean accepted = acceptor.isAccepted(moveScope); if (!accepted) { diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/greatdeluge/GreatDelugeAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/greatdeluge/GreatDelugeAcceptor.java index d58feb18829..a6cd2d70a95 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/greatdeluge/GreatDelugeAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/greatdeluge/GreatDelugeAcceptor.java @@ -63,6 +63,9 @@ public void phaseEnded(LocalSearchPhaseScope phaseScope) { @SuppressWarnings({ "unchecked", "rawtypes" }) @Override public boolean isAccepted(LocalSearchMoveScope moveScope) { + if (moveScope.getScore().isInvalid()) { + return false; + } var moveScore = moveScope.getScore().raw(); if (moveScore.compareTo(currentWaterLevel) >= 0) { return true; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/hillclimbing/HillClimbingAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/hillclimbing/HillClimbingAcceptor.java index 651a18db767..dd2b05964a7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/hillclimbing/HillClimbingAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/hillclimbing/HillClimbingAcceptor.java @@ -10,6 +10,9 @@ public class HillClimbingAcceptor extends AbstractAcceptor @Override public boolean isAccepted(LocalSearchMoveScope moveScope) { InnerScore moveScore = moveScope.getScore(); + if (moveScore.isInvalid()) { + return false; + } InnerScore lastStepScore = moveScope.getStepScope().getPhaseScope().getLastCompletedStepScope().getScore(); return moveScore.compareTo(lastStepScore) >= 0; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/DiversifiedLateAcceptanceAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/DiversifiedLateAcceptanceAcceptor.java index 642af444936..c3b3428e1d7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/DiversifiedLateAcceptanceAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/DiversifiedLateAcceptanceAcceptor.java @@ -53,6 +53,9 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { // The acceptance and replacement strategies are based on the work: // Diversified Late Acceptance Search by M. Namazi, C. Sanderson, M. A. H. Newton, M. M. A. Polash, and A. Sattar var moveScore = moveScope.getScore(); + if (moveScore.isInvalid()) { + return false; + } var current = (InnerScore) moveScope.getStepScope().getPhaseScope() .getLastCompletedStepScope().getScore(); var previous = current; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/LateAcceptanceAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/LateAcceptanceAcceptor.java index 61e93d46ee2..db6e741ad5e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/LateAcceptanceAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/lateacceptance/LateAcceptanceAcceptor.java @@ -50,6 +50,9 @@ private void validate() { @Override public boolean isAccepted(LocalSearchMoveScope moveScope) { var moveScore = (InnerScore) moveScope.getScore(); + if (moveScore.isInvalid()) { + return false; + } var lateScore = getPreviousScore(lateScoreIndex); if (moveScore.compareTo(lateScore) >= 0) { return true; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java index a8833327aed..024bebd735b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/simulatedannealing/SimulatedAnnealingAcceptor.java @@ -57,6 +57,10 @@ public boolean isAccepted(LocalSearchMoveScope moveScope) { // Guaranteed local search; no need for InnerScore. Score lastStepScore = phaseScope.getLastCompletedStepScope().getScore().raw(); Score moveScore = moveScope.getScore().raw(); + var thisStepInvalid = moveScope.getScore().isInvalid(); + if (thisStepInvalid) { + return false; + } if (moveScore.compareTo(lastStepScore) >= 0) { return true; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/stepcountinghillclimbing/StepCountingHillClimbingAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/stepcountinghillclimbing/StepCountingHillClimbingAcceptor.java index 39b47236c36..5903f9424af 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/stepcountinghillclimbing/StepCountingHillClimbingAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/stepcountinghillclimbing/StepCountingHillClimbingAcceptor.java @@ -45,6 +45,9 @@ public void phaseStarted(LocalSearchPhaseScope phaseScope) { public boolean isAccepted(LocalSearchMoveScope moveScope) { InnerScore lastStepScore = moveScope.getStepScope().getPhaseScope().getLastCompletedStepScope().getScore(); InnerScore moveScore = moveScope.getScore(); + if (moveScore.isInvalid()) { + return false; + } if (moveScore.compareTo(lastStepScore) >= 0) { return true; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java index a3d3f7f269b..acf67deb603 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/tabu/AbstractTabuAcceptor.java @@ -124,6 +124,10 @@ private static IllegalStateException createHashcodeStabilityViolationException(O @Override public boolean isAccepted(LocalSearchMoveScope moveScope) { + var thisStepInvalid = moveScope.getScore().isInvalid(); + if (thisStepInvalid) { + return false; + } var maximumTabuStepIndex = locateMaximumTabuStepIndex(moveScope); if (maximumTabuStepIndex < 0) { // The move isn't tabu at all diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java index 675f60b01c9..c33fbe1dbdb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveTesterScoreDirector.java @@ -26,7 +26,7 @@ public void setWorkingSolutionWithoutUpdatingShadows(Solution_ workingSolution) } @Override - public InnerScore calculateScore() { + public InnerScore innerCalculateScore() { return InnerScore.fullyAssigned(scoreDirectorFactory.getScoreDefinition().getZeroScore()); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java index 710456be2b9..c0434f8581d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/AbstractScoreDirector.java @@ -75,6 +75,7 @@ public abstract class AbstractScoreDirector variableDescriptorCache; protected final VariableListenerSupport variableListenerSupport; private final @Nullable SolutionTracker solutionTracker; // Null when tracking disabled. @@ -95,6 +96,7 @@ public abstract class AbstractScoreDirector(solutionDescriptor); this.variableListenerSupport = VariableListenerSupport.create(this); this.variableListenerSupport.linkVariableListeners(); @@ -170,6 +173,11 @@ public boolean expectShadowVariablesInCorrectState() { return expectShadowVariablesInCorrectState; } + @Override + public boolean ignoreInconsistentSolutions() { + return ignoreInconsistentSolutions; + } + @Override public Solution_ getWorkingSolution() { return workingSolution; @@ -235,6 +243,19 @@ public NeighborhoodNotifier getNeighborhoodNotifier() { // Complex methods // ************************************************************************ + public abstract InnerScore innerCalculateScore(); + + @Override + public final InnerScore calculateScore() { + if (lastVariableUpdateWasSuccessful) { + return innerCalculateScore(); + } else { + var invalidScore = InnerScore.invalid(getScoreDefinition().getZeroScore()); + getSolutionDescriptor().setScore(workingSolution, invalidScore.raw()); + return invalidScore; + } + } + /** * Note: resetting the working solution does NOT substitute the calls to before/after methods of * the {@link ProblemChangeDirector} during {@link ProblemChange problem changes}, @@ -413,7 +434,7 @@ public Solution_ cloneSolution(Solution_ originalSolution) { @Override public void triggerVariableListeners() { - variableListenerSupport.triggerVariableListenersInNotificationQueues(); + lastVariableUpdateWasSuccessful = variableListenerSupport.triggerVariableListenersInNotificationQueues(); } /** @@ -428,7 +449,7 @@ protected void clearVariableListenerEvents() { @Override public void forceTriggerVariableListeners() { - variableListenerSupport.forceTriggerAllVariableListeners(getWorkingSolution()); + lastVariableUpdateWasSuccessful = variableListenerSupport.forceTriggerAllVariableListeners(getWorkingSolution()); } protected void setCalculatedScore(Score_ score) { @@ -440,15 +461,21 @@ protected void setCalculatedScore(Score_ score) { public InnerScoreDirector createChildThreadScoreDirector(ChildThreadType childThreadType) { // Most score directors don't need derived status; CS will override this. if (childThreadType == ChildThreadType.PART_THREAD) { - var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(lookUpEnabled) - .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() + .withLookUpEnabled(lookUpEnabled) + .withConstraintMatchPolicy(constraintMatchPolicy) + .withIgnoreInconsistentSolutions(ignoreInconsistentSolutions) + .buildDerived(); // ScoreCalculationCountTermination takes into account previous phases // but the calculationCount of partitions is maxed, not summed. childThreadScoreDirector.calculationCount = calculationCount; return childThreadScoreDirector; } else if (childThreadType == ChildThreadType.MOVE_THREAD) { - var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder().withLookUpEnabled(true) - .withConstraintMatchPolicy(constraintMatchPolicy).buildDerived(); + var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() + .withLookUpEnabled(true) + .withConstraintMatchPolicy(constraintMatchPolicy) + .withIgnoreInconsistentSolutions(ignoreInconsistentSolutions) + .buildDerived(); childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); return childThreadScoreDirector; } else { @@ -719,7 +746,9 @@ private void assertScoreFromScratch(InnerScore innerScore, Object comple } // Most score directors don't need derived status; CS will override this. try (var uncorruptedScoreDirector = assertionScoreDirectorFactory.createScoreDirectorBuilder() - .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).buildDerived()) { + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .withIgnoreInconsistentSolutions(ignoreInconsistentSolutions) + .buildDerived()) { uncorruptedScoreDirector.setWorkingSolution(workingSolution); var uncorruptedInnerScore = uncorruptedScoreDirector.calculateScore(); if (!innerScore.equals(uncorruptedInnerScore)) { @@ -917,6 +946,7 @@ public abstract static class AbstractScoreDirectorBuilder build(); /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScore.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScore.java index 3bf634e3902..bb811fa11ce 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScore.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScore.java @@ -34,6 +34,10 @@ public static > InnerScore withUnassignedCo return new InnerScore<>(score, unassignedCount); } + public static > InnerScore invalid(Score_ zeroScore) { + return new InnerScore<>(zeroScore, Integer.MAX_VALUE); + } + public InnerScore { Objects.requireNonNull(raw); if (unassignedCount < 0) { @@ -46,6 +50,10 @@ public boolean isFullyAssigned() { return unassignedCount == 0; } + public boolean isInvalid() { + return unassignedCount == Integer.MAX_VALUE; + } + @Override public int compareTo(InnerScore other) { var uninitializedCountComparison = Integer.compare(unassignedCount, other.unassignedCount); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index 300153e72f8..a1e8d1f733e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java @@ -168,6 +168,8 @@ default InnerScore executeTemporaryMove(Move move, boolean as */ boolean expectShadowVariablesInCorrectState(); + boolean ignoreInconsistentSolutions(); + /** * @return never null */ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java index f23921cea6b..db0f77fc703 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/easy/EasyScoreDirector.java @@ -44,7 +44,7 @@ public EasyScoreCalculator getEasyScoreCalculator() { } @Override - public InnerScore calculateScore() { + public InnerScore innerCalculateScore() { variableListenerSupport.assertNotificationQueuesAreEmpty(); var score = easyScoreCalculator.calculateScore(workingSolution); setCalculatedScore(score); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java index 1df721dd19c..2024d12a4cb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -79,7 +79,7 @@ private void resetWorkingSolutionAndMaps(Solution_ workingSolution) { } @Override - public InnerScore calculateScore() { + public InnerScore innerCalculateScore() { variableListenerSupport.assertNotificationQueuesAreEmpty(); var score = Objects.requireNonNull(incrementalScoreCalculator.calculateScore(), () -> "The incrementalScoreCalculator (%s) must return a non-null score in the method calculateScore()." diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java index 8cfba1587fe..a5d489dd4f6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java @@ -71,6 +71,7 @@ public void updateConsistencyFromSolution(Solution_ solution) { solutionDescriptor.visitAllEntities(solution, entityList::add); variableListenerSupport.setConsistencyTracker(ConsistencyTracker.frozen( getSolutionDescriptor(), + ignoreInconsistentSolutions(), entityList.toArray())); } @@ -89,7 +90,7 @@ protected void afterSetWorkingSolution() { } @Override - public InnerScore calculateScore() { + public InnerScore innerCalculateScore() { variableListenerSupport.assertNotificationQueuesAreEmpty(); var score = session.calculateScore(); setCalculatedScore(score); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java index f596146042e..05a34ff91ef 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirectorFactory.java @@ -85,7 +85,7 @@ public BavetConstraintSession newSession(Solution_ workingSolution, @Override public AbstractScoreInliner fireAndForget(Object... facts) { - var consistencyTracker = ConsistencyTracker.frozen(solutionDescriptor, facts); + var consistencyTracker = ConsistencyTracker.frozen(solutionDescriptor, false, facts); var session = newSession(null, consistencyTracker, ConstraintMatchPolicy.ENABLED, true); Arrays.stream(facts).forEach(session::insert); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 8d841424c32..7ea8f5968cf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -125,6 +125,9 @@ public Solver buildSolver(SolverConfigOverride configOverride) { } var castScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() .withLookUpEnabled(true) // Custom phases and problem changes may rely on lookups. + .withIgnoreInconsistentSolutions(Objects.requireNonNullElse( + solverConfig.getEnablePreviewFeatureSet(), Collections.emptySet()) + .contains(PreviewFeature.IGNORE_INCONSISTENT_SOLUTIONS)) .withConstraintMatchPolicy( constraintMatchEnabled ? ConstraintMatchPolicy.ENABLED : ConstraintMatchPolicy.DISABLED) .build(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java index 91f645962b5..b7c2afb10a7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java @@ -50,6 +50,11 @@ public void solvingStarted(SolverScope solverScope) { // Starting bestSolution is already set by Solver.solve(Solution) var scoreDirector = solverScope.getScoreDirector(); InnerScore innerScore = scoreDirector.calculateScore(); + if (innerScore.isInvalid()) { + throw new IllegalStateException( + "The initial solution passed to the solver (%s) is invalid because it has dependency loops." + .formatted(solverScope.getWorkingSolution())); + } var score = innerScore.raw(); solverScope.setBestScore(innerScore); solverScope.setBestSolutionTimeMillis(solverScope.getClock().millis()); diff --git a/core/src/main/resources/solver.xsd b/core/src/main/resources/solver.xsd index a5d10b20c96..6338557303a 100644 --- a/core/src/main/resources/solver.xsd +++ b/core/src/main/resources/solver.xsd @@ -1461,6 +1461,8 @@ + + diff --git a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/composite/CompositeAcceptorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/composite/CompositeAcceptorTest.java index 483dcf8db82..e45236b9120 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/composite/CompositeAcceptorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/composite/CompositeAcceptorTest.java @@ -8,11 +8,13 @@ import java.util.ArrayList; +import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.Acceptor; import ai.timefold.solver.core.impl.localsearch.decider.acceptor.CompositeAcceptor; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchMoveScope; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchPhaseScope; import ai.timefold.solver.core.impl.localsearch.scope.LocalSearchStepScope; +import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.solver.scope.SolverScope; import ai.timefold.solver.core.testdomain.TestdataSolution; @@ -66,6 +68,8 @@ private boolean isCompositeAccepted(boolean... childAccepts) { acceptorList.add(acceptor); } var acceptor = new CompositeAcceptor<>(acceptorList); - return acceptor.isAccepted(mock(LocalSearchMoveScope.class)); + var moveScope = mock(LocalSearchMoveScope.class); + when(moveScope.getScore()).thenReturn(InnerScore.fullyAssigned(new SimpleScore(0))); + return acceptor.isAccepted(moveScope); } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java index 644dbe07429..f46fdedad5b 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/solver/DefaultSolverTest.java @@ -1449,6 +1449,87 @@ void solveStaleDeclarativeShadows() { assertThat(solution.getScore()).isEqualTo(HardSoftScore.of(0, -240)); } + @Test + void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() { + // Solver config + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataConcurrentSolution.class, TestdataConcurrentEntity.class, TestdataConcurrentValue.class) + .withEasyScoreCalculatorClass(null) + .withConstraintProviderClass(TestdataConcurrentConstraintProvider.class) + .withPreviewFeature(PreviewFeature.IGNORE_INCONSISTENT_SOLUTIONS); + + var e1 = new TestdataConcurrentEntity("e1"); + var e2 = new TestdataConcurrentEntity("e2"); + + var a1 = new TestdataConcurrentValue("a1"); + var a2 = new TestdataConcurrentValue("a2"); + var b1 = new TestdataConcurrentValue("b1"); + var b2 = new TestdataConcurrentValue("b2"); + + a1.setConcurrentValueGroup(List.of(a1, a2)); + a2.setConcurrentValueGroup(List.of(a1, a2)); + + b1.setConcurrentValueGroup(List.of(b1, b2)); + b2.setConcurrentValueGroup(List.of(b1, b2)); + + e1.setValues(List.of(a1, b1)); + e2.setValues(List.of(b2, a2)); + + var entities = List.of(e1, e2); + var values = List.of(a1, a2, b1, b2); + + var problem = new TestdataConcurrentSolution(); + + problem.setEntities(entities); + problem.setValues(values); + + assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem)).isInstanceOf(IllegalStateException.class) + .hasMessageContainingAll("The initial solution passed to the solver", + "is invalid because it has dependency loops"); + } + + @Test + void solveIgnoreInconsistent() { + // Solver config + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataConcurrentSolution.class, TestdataConcurrentEntity.class, TestdataConcurrentValue.class) + .withEasyScoreCalculatorClass(null) + .withConstraintProviderClass(TestdataConcurrentConstraintProvider.class); + + var e1 = new TestdataConcurrentEntity("e1"); + var e2 = new TestdataConcurrentEntity("e2"); + + var a1 = new TestdataConcurrentValue("a1"); + var a2 = new TestdataConcurrentValue("a2"); + var b1 = new TestdataConcurrentValue("b1"); + var b2 = new TestdataConcurrentValue("b2"); + + a1.setConcurrentValueGroup(List.of(a1, a2)); + a2.setConcurrentValueGroup(List.of(a1, a2)); + + b1.setConcurrentValueGroup(List.of(b1, b2)); + b2.setConcurrentValueGroup(List.of(b1, b2)); + + e1.setValues(List.of(a1, b2)); + e2.setValues(List.of(a2, b1)); + + var entities = List.of(e1, e2); + var values = List.of(a1, a2, b1, b2); + + var problem = new TestdataConcurrentSolution(); + + problem.setEntities(entities); + problem.setValues(values); + + var solution = PlannerTestUtils.solve(solverConfig, problem); + + assertThat(solution.getEntities().getFirst().getValues()).map(TestdataConcurrentValue::getId).containsExactly("a1", + "b2"); + assertThat(solution.getEntities().get(1).getValues()).map(TestdataConcurrentValue::getId).containsExactly("a2", "b1"); + + assertThat(solution.getScore()).isEqualTo(HardSoftScore.of(0, -240)); + } + private static List> generateMovesForMixedModel() { // Local Search var allMoveSelectionConfigList = new ArrayList>(); diff --git a/tools/benchmark/src/main/resources/benchmark.xsd b/tools/benchmark/src/main/resources/benchmark.xsd index 87ef47a9121..f1723ce2e16 100644 --- a/tools/benchmark/src/main/resources/benchmark.xsd +++ b/tools/benchmark/src/main/resources/benchmark.xsd @@ -2420,6 +2420,9 @@ + + +