From 1c272937757b8e2951b5ec520946b1ed33b99490 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Thu, 25 Jun 2026 10:14:36 -0400 Subject: [PATCH 1/5] chore: unassign on inconsistent graph --- .../DefaultShadowVariableSession.java | 6 +++ .../DefaultVariableReferenceGraph.java | 18 +++++++++ .../EmptyVariableReferenceGraph.java | 8 ++++ .../FixedVariableReferenceGraph.java | 7 ++++ ...rectionalParentVariableReferenceGraph.java | 6 +++ .../declarative/VariableReferenceGraph.java | 3 ++ .../support/VariableListenerSupport.java | 8 ++++ .../score/director/AbstractScoreDirector.java | 39 +++++++++++++++++++ .../score/director/InnerScoreDirector.java | 3 +- .../solver/recaller/BestSolutionRecaller.java | 16 ++++++-- .../core/impl/solver/DefaultSolverTest.java | 28 ++++++++++--- 11 files changed, 132 insertions(+), 10 deletions(-) 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 51f1315a3b..c66fdb04b0 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 @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; +import java.util.List; + import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.supply.Supply; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; @@ -37,4 +39,8 @@ public void afterVariableChanged(VariableMetaModel variableMeta public boolean updateVariables() { return graph.updateChanged(); } + + public List getInconsistentEntities() { + return graph.getInconsistentEntities(); + } } 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 7a49187bca..ece50ea84b 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 @@ -6,6 +6,8 @@ import java.util.List; import java.util.function.IntFunction; +import ai.timefold.solver.core.impl.util.LinkedIdentityHashSet; + import org.jspecify.annotations.NonNull; final class DefaultVariableReferenceGraph extends AbstractVariableReferenceGraph { @@ -71,4 +73,20 @@ public void setUnknownInconsistencyValues() { graph.commitChanges(changeTracker); affectedEntitiesUpdater.setUnknownInconsistencyValues(); } + + @Override + public List getInconsistentEntities() { + var out = new LinkedIdentityHashSet<>(); + var graphTrackingInconsistentEntities = new DefaultTopologicalOrderGraph(this.nodeTopologicalOrders.length); + graph.forEachEdge(graphTrackingInconsistentEntities::addEdge); + graphTrackingInconsistentEntities.commitChanges(new BitSet()); + var loopedComponentList = graphTrackingInconsistentEntities.getLoopedComponentList(); + for (var loopedComponent : loopedComponentList) { + for (var nodeId : loopedComponent) { + var node = this.nodeList.get(nodeId); + out.add(node.entity()); + } + } + return new ArrayList<>(out); + } } 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 53d2b1a281..440a20685c 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 @@ -1,5 +1,8 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; +import java.util.Collections; +import java.util.List; + import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; final class EmptyVariableReferenceGraph implements VariableReferenceGraph { @@ -22,6 +25,11 @@ public void afterVariableChanged(VariableMetaModel variableReference, O // No need to do anything. } + @Override + public List getInconsistentEntities() { + return Collections.emptyList(); + } + @Override public String toString() { return "{}"; 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 3999e11a60..6014c36ed3 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 @@ -1,6 +1,8 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; import java.util.BitSet; +import java.util.Collections; +import java.util.List; import java.util.PriorityQueue; import java.util.Spliterators; import java.util.function.IntFunction; @@ -106,4 +108,9 @@ boolean innerUpdateChanged() { isChanged.clear(); return true; } + + @Override + public List getInconsistentEntities() { + return Collections.emptyList(); + } } 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 5238525631..8714ebb440 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 @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.IdentityHashMap; @@ -136,4 +137,9 @@ public void afterVariableChanged(VariableMetaModel variableReference, O } } + @Override + public List getInconsistentEntities() { + return Collections.emptyList(); + } + } 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 fe9ad2d68c..759bb53bee 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 @@ -1,5 +1,7 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; +import java.util.List; + import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public sealed interface VariableReferenceGraph @@ -34,4 +36,5 @@ public sealed interface VariableReferenceGraph */ void afterVariableChanged(VariableMetaModel variableReference, Object entity); + List getInconsistentEntities(); } 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 169ef89e65..ee2bd1e01c 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 @@ -374,6 +374,14 @@ public boolean triggerVariableListenersInNotificationQueues() { return true; } + public List getInconsistentEntities() { + if (shadowVariableSession == null) { + throw new IllegalStateException( + "The shadowVariableSession is null. A solution without shadow variables cannot be inconsistent."); + } + return shadowVariableSession.getInconsistentEntities(); + } + /** * Triggers all cascading update shadow variable user-logic. */ 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 c0434f8581..c19f2c9a32 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 @@ -344,6 +344,45 @@ protected void afterSetWorkingSolution() { // Do nothing } + public void unassignInconsistentEntities() { + var inconsistentEntities = variableListenerSupport.getInconsistentEntities(); + if (listVariableStateSupply != null) { + var listVariableDescriptor = listVariableStateSupply.getSourceVariableDescriptor(); + var listElementClass = listVariableStateSupply.getSourceVariableDescriptor().getElementType(); + for (var inconsistentEntity : inconsistentEntities) { + if (listElementClass.isInstance(inconsistentEntity)) { + var inverse = Objects.requireNonNull(listVariableStateSupply.getInverseSingleton(inconsistentEntity)); + int index = Objects.requireNonNull(listVariableStateSupply.getIndex(inconsistentEntity)); + beforeListVariableElementUnassigned(listVariableDescriptor, inconsistentEntity); + beforeListVariableChanged(listVariableDescriptor, inverse, index, index + 1); + listVariableDescriptor.removeElement(inverse, index); + afterListVariableChanged(listVariableDescriptor, inverse, index, index); + afterListVariableElementUnassigned(listVariableDescriptor, inconsistentEntity); + triggerVariableListeners(); + } + // Unassign any normal @PlanningVariable on the entity too + unassignPlainEntity(inconsistentEntity); + } + } else { + for (var inconsistentEntity : inconsistentEntities) { + unassignPlainEntity(inconsistentEntity); + } + } + } + + private void unassignPlainEntity(Object inconsistentEntity) { + var entityDescriptor = getSolutionDescriptor().findEntityDescriptor(inconsistentEntity.getClass()); + if (entityDescriptor == null) { + throw new IllegalStateException("Object (%s) is not an entity but is inconsistent".formatted(inconsistentEntity)); + } + for (var genuineVariableDescriptor : entityDescriptor.getGenuineVariableDescriptorList()) { + beforeVariableChanged(genuineVariableDescriptor, inconsistentEntity); + genuineVariableDescriptor.setValue(inconsistentEntity, null); + afterVariableChanged(genuineVariableDescriptor, inconsistentEntity); + } + triggerVariableListeners(); + } + @Override public void setMoveRepository(@Nullable MoveRepository moveRepository) { if (this.moveRepository == moveRepository) { // Prevent double initialization 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 a1e8d1f733..a2c0e88160 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 @@ -170,6 +170,8 @@ default InnerScore executeTemporaryMove(Move move, boolean as boolean ignoreInconsistentSolutions(); + void unassignInconsistentEntities(); + /** * @return never null */ @@ -363,5 +365,4 @@ default void afterEntityRemoved(Object entity) { void beforeProblemFactRemoved(Object problemFact); void afterProblemFactRemoved(Object problemFact); - } 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 b7c2afb10a..f053294bdc 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 @@ -11,6 +11,9 @@ import ai.timefold.solver.core.impl.solver.event.SolverEventSupport; import ai.timefold.solver.core.impl.solver.scope.SolverScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Remembers the {@link PlanningSolution best solution} that a {@link Solver} encounters. * @@ -18,6 +21,7 @@ */ public class BestSolutionRecaller extends PhaseLifecycleListenerAdapter { + private static final Logger log = LoggerFactory.getLogger(BestSolutionRecaller.class); protected boolean assertInitialScoreFromScratch = false; protected boolean assertShadowVariablesAreNotStale = false; protected boolean assertBestScoreIsUnmodified = false; @@ -44,16 +48,20 @@ public void setSolverEventSupport(SolverEventSupport solverEventSuppo // Worker methods // ************************************************************************ - @SuppressWarnings({ "unchecked", "rawtypes" }) @Override + @SuppressWarnings("unchecked") public void solvingStarted(SolverScope solverScope) { // Starting bestSolution is already set by Solver.solve(Solution) var scoreDirector = solverScope.getScoreDirector(); + @SuppressWarnings("rawtypes") 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())); + log.warn("The initial solution passed to the solver is inconsistent. Unassigning involved entities."); + scoreDirector.unassignInconsistentEntities(); + innerScore = scoreDirector.calculateScore(); + if (innerScore.isInvalid()) { + throw new IllegalStateException("The initial solution passed to the solver is inconsistent even after unassigning involved entities."); + } } var score = innerScore.raw(); solverScope.setBestScore(innerScore); 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 9e99243594..dace6c5a92 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 @@ -1460,7 +1460,9 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, TestdataDependencyNoInconsistentFieldValue.class) .withEasyScoreCalculatorClass(null) - .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class); + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class) + .withPhases(new CustomPhaseConfig() + .withCustomPhaseCommands(command -> {})); var e1 = new TestdataDependencyNoInconsistentFieldEntity("a"); var e2 = new TestdataDependencyNoInconsistentFieldEntity("b"); @@ -1484,10 +1486,26 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() problem.setEntities(entities); problem.setValues(values); - // TODO: Verify the values were unassigned instead - assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem)).isInstanceOf(IllegalStateException.class) - .hasMessageContainingAll("The initial solution passed to the solver", - "is invalid because it has dependency loops"); + var solution = PlannerTestUtils.solve(solverConfig, problem, false); + assertThat(solution.getEntities().getFirst().getValues().isEmpty()); + assertThat(solution.getEntities().getLast().getValues().isEmpty()); + + var sA1 = solution.getValues().get(0); + var sA2 = solution.getValues().get(1); + var sB1 = solution.getValues().get(2); + var sB2 = solution.getValues().get(3); + + assertThat(sA1.getEntity()).isNull(); + assertThat(sA1.getPreviousValue()).isNull(); + + assertThat(sA2.getEntity()).isNull(); + assertThat(sA2.getPreviousValue()).isNull(); + + assertThat(sB1.getEntity()).isNull(); + assertThat(sB1.getPreviousValue()).isNull(); + + assertThat(sB2.getEntity()).isNull(); + assertThat(sB2.getPreviousValue()).isNull(); } @Test From 4ec768f72830a97f5e25c4e6719f8b2a7d3dd7f5 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 29 Jun 2026 15:26:55 -0400 Subject: [PATCH 2/5] chore: throw if pinned entity is inconsistent --- .../score/director/AbstractScoreDirector.java | 11 ++++ .../solver/recaller/BestSolutionRecaller.java | 3 +- .../core/impl/solver/DefaultSolverTest.java | 57 ++++++++++++++++--- ...taDependencyNoInconsistentFieldEntity.java | 12 ++++ 4 files changed, 74 insertions(+), 9 deletions(-) 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 c19f2c9a32..55a575e2a3 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 @@ -353,6 +353,12 @@ public void unassignInconsistentEntities() { if (listElementClass.isInstance(inconsistentEntity)) { var inverse = Objects.requireNonNull(listVariableStateSupply.getInverseSingleton(inconsistentEntity)); int index = Objects.requireNonNull(listVariableStateSupply.getIndex(inconsistentEntity)); + + if (listVariableDescriptor.isElementPinned(Objects.requireNonNull(workingSolution), inverse, index)) { + throw new IllegalStateException( + "Entity (%s) is pinned but is involved in a dependency loop".formatted(inconsistentEntity)); + } + beforeListVariableElementUnassigned(listVariableDescriptor, inconsistentEntity); beforeListVariableChanged(listVariableDescriptor, inverse, index, index + 1); listVariableDescriptor.removeElement(inverse, index); @@ -375,6 +381,11 @@ private void unassignPlainEntity(Object inconsistentEntity) { if (entityDescriptor == null) { throw new IllegalStateException("Object (%s) is not an entity but is inconsistent".formatted(inconsistentEntity)); } + if (entityDescriptor.isGenuine() + && !entityDescriptor.isMovable(Objects.requireNonNull(workingSolution), inconsistentEntity)) { + throw new IllegalStateException( + "Entity (%s) is pinned but is involved in a dependency loop".formatted(inconsistentEntity)); + } for (var genuineVariableDescriptor : entityDescriptor.getGenuineVariableDescriptorList()) { beforeVariableChanged(genuineVariableDescriptor, inconsistentEntity); genuineVariableDescriptor.setValue(inconsistentEntity, null); 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 f053294bdc..24ea4a3e32 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 @@ -60,7 +60,8 @@ public void solvingStarted(SolverScope solverScope) { scoreDirector.unassignInconsistentEntities(); innerScore = scoreDirector.calculateScore(); if (innerScore.isInvalid()) { - throw new IllegalStateException("The initial solution passed to the solver is inconsistent even after unassigning involved entities."); + throw new IllegalStateException( + "The initial solution passed to the solver is inconsistent even after unassigning involved entities."); } } var score = innerScore.raw(); 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 dace6c5a92..e1be4ae669 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 @@ -1454,7 +1454,7 @@ void solveStaleDeclarativeShadows() { } @Test - void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() { + void solveWhenIgnoringInconsistentSolutionsUnassignsIfInitialSolutionInconsistent() { // Solver config var solverConfig = PlannerTestUtils.buildSolverConfig( TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, @@ -1462,7 +1462,8 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() .withEasyScoreCalculatorClass(null) .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class) .withPhases(new CustomPhaseConfig() - .withCustomPhaseCommands(command -> {})); + .withCustomPhaseCommands(command -> { + })); var e1 = new TestdataDependencyNoInconsistentFieldEntity("a"); var e2 = new TestdataDependencyNoInconsistentFieldEntity("b"); @@ -1476,7 +1477,7 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() b2.setDependencies(List.of(b1)); e1.setValues(List.of(b2, b1)); - e2.setValues(List.of(a2, a1)); + e2.setValues(List.of(a1, a2)); var entities = List.of(e1, e2); var values = List.of(a1, a2, b1, b2); @@ -1487,19 +1488,21 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() problem.setValues(values); var solution = PlannerTestUtils.solve(solverConfig, problem, false); - assertThat(solution.getEntities().getFirst().getValues().isEmpty()); - assertThat(solution.getEntities().getLast().getValues().isEmpty()); + assertThat(solution.getEntities().getFirst().getValues()).isEmpty(); + assertThat(solution.getEntities().getLast().getValues()).hasSize(2); + + var sE2 = solution.getEntities().getLast(); var sA1 = solution.getValues().get(0); var sA2 = solution.getValues().get(1); var sB1 = solution.getValues().get(2); var sB2 = solution.getValues().get(3); - assertThat(sA1.getEntity()).isNull(); + assertThat(sA1.getEntity()).isEqualTo(sE2); assertThat(sA1.getPreviousValue()).isNull(); - assertThat(sA2.getEntity()).isNull(); - assertThat(sA2.getPreviousValue()).isNull(); + assertThat(sA2.getEntity()).isEqualTo(sE2); + assertThat(sA2.getPreviousValue()).isEqualTo(sA1); assertThat(sB1.getEntity()).isNull(); assertThat(sB1.getPreviousValue()).isNull(); @@ -1508,6 +1511,44 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() assertThat(sB2.getPreviousValue()).isNull(); } + @Test + void solveWhenIgnoringInconsistentSolutionsThrowsIfInconsistentEntityPinned() { + // Solver config + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, + TestdataDependencyNoInconsistentFieldValue.class) + .withEasyScoreCalculatorClass(null) + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class); + + var e1 = new TestdataDependencyNoInconsistentFieldEntity("a"); + var e2 = new TestdataDependencyNoInconsistentFieldEntity("b"); + + var a1 = new TestdataDependencyNoInconsistentFieldValue("a1"); + var a2 = new TestdataDependencyNoInconsistentFieldValue("a2"); + var b1 = new TestdataDependencyNoInconsistentFieldValue("b1"); + var b2 = new TestdataDependencyNoInconsistentFieldValue("b2"); + + a2.setDependencies(List.of(a1)); + b2.setDependencies(List.of(b1)); + + e1.setValues(List.of(b2, b1)); + e2.setValues(List.of(a2, a1)); + + var entities = List.of(e1, e2); + var values = List.of(a1, a2, b1, b2); + + e1.setPinnedToIndex(1); + + var problem = new TestdataDependencyNoInconsistentFieldSolution(); + + problem.setEntities(entities); + problem.setValues(values); + + assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContainingAll("Entity", "b2", "is pinned but is involved in a dependency loop"); + } + @Test void solveIgnoreInconsistent() { // Solver config diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/no_inconsistent_field/TestdataDependencyNoInconsistentFieldEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/no_inconsistent_field/TestdataDependencyNoInconsistentFieldEntity.java index c9daf36785..d09643e8aa 100644 --- a/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/no_inconsistent_field/TestdataDependencyNoInconsistentFieldEntity.java +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/shadow/no_inconsistent_field/TestdataDependencyNoInconsistentFieldEntity.java @@ -7,6 +7,7 @@ import ai.timefold.solver.core.api.domain.common.PlanningId; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; @PlanningEntity @@ -17,6 +18,9 @@ public class TestdataDependencyNoInconsistentFieldEntity { @PlanningListVariable List values; + @PlanningPinToIndex + int pinnedToIndex; + LocalDateTime startTime; public TestdataDependencyNoInconsistentFieldEntity() { @@ -57,6 +61,14 @@ public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; } + public int getPinnedToIndex() { + return pinnedToIndex; + } + + public void setPinnedToIndex(int pinnedToIndex) { + this.pinnedToIndex = pinnedToIndex; + } + @Override public String toString() { return "TestdataPredecessorEntity{id=%s, values=%s, startTime=%s}".formatted(id, values, startTime); From 42433a9ca6cedadb60d35d00455e33bb1a973908 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 29 Jun 2026 14:42:16 -0400 Subject: [PATCH 3/5] chore: handle inconsistent solutions in update, analyze and recommendations --- .../score/director/AbstractScoreDirector.java | 4 ++ .../impl/solver/DefaultSolutionManager.java | 26 ++++++--- .../core/api/solver/SolutionManagerTest.java | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) 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 55a575e2a3..1751d5bccb 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 @@ -487,6 +487,10 @@ public void triggerVariableListeners() { lastVariableUpdateWasSuccessful = variableListenerSupport.triggerVariableListenersInNotificationQueues(); } + public boolean isLastVariableUpdateWasSuccessful() { + return lastVariableUpdateWasSuccessful; + } + /** * This function clears all listener events that have been generated without triggering any of them. * Using this method requires caution because clearing the event queue can lead to inconsistent states. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java index 74d8e95bf0..cdda9bae0e 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java @@ -51,20 +51,23 @@ public ScoreDirectorFactory getScoreDirectorFactory() { @Override public Score_ update(Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy) { if (solutionUpdatePolicy == SolutionUpdatePolicy.NO_UPDATE) { - throw new IllegalArgumentException("Can not call " + this.getClass().getSimpleName() - + ".update() with this solutionUpdatePolicy (" + solutionUpdatePolicy + ")."); + throw new IllegalArgumentException( + "Can not call %s.update() with this solutionUpdatePolicy (%s)." + .formatted(this.getClass().getSimpleName(), solutionUpdatePolicy)); } - return callScoreDirector(solution, solutionUpdatePolicy, + return callScoreDirector("Solution update", solution, solutionUpdatePolicy, s -> s.getSolutionDescriptor().getScore(s.getWorkingSolution()), ConstraintMatchPolicy.DISABLED, false); } - private Result_ callScoreDirector(Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy, + private Result_ callScoreDirector(String feature, Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy, Function, Result_> function, ConstraintMatchPolicy constraintMatchPolicy, boolean cloneSolution) { var isShadowVariableUpdateEnabled = solutionUpdatePolicy.isShadowVariableUpdateEnabled(); var nonNullSolution = Objects.requireNonNull(solution); + var allowsInconsistent = getScoreDirectorFactory().getSolutionDescriptor().hasAnyShadowVariablesInconsistentMember(); try (var scoreDirector = getScoreDirectorFactory().createScoreDirectorBuilder().withLookUpEnabled(cloneSolution) .withConstraintMatchPolicy(constraintMatchPolicy) + .withIgnoreInconsistentSolutions(!allowsInconsistent) .withExpectShadowVariablesInCorrectState(!isShadowVariableUpdateEnabled).build()) { nonNullSolution = cloneSolution ? scoreDirector.cloneSolution(nonNullSolution) : nonNullSolution; if (isShadowVariableUpdateEnabled) { @@ -82,7 +85,16 @@ private Result_ callScoreDirector(Solution_ solution, SolutionUpdatePo Maybe use Constraint Streams instead of Easy or Incremental score calculator?"""); } if (solutionUpdatePolicy.isScoreUpdateEnabled()) { - scoreDirector.calculateScore(); + var score = scoreDirector.calculateScore(); + if (score.isInvalid()) { + throw new IllegalArgumentException(""" + The solution (%s) is inconsistent. %s requires a consistent solution to be passed. + """.formatted(solution, feature)); + } + } else if (!scoreDirector.isLastVariableUpdateWasSuccessful()) { + throw new IllegalArgumentException(""" + The solution (%s) is inconsistent. %s requires a consistent solution to be passed. + """.formatted(solution, feature)); } return function.apply(scoreDirector); } @@ -112,7 +124,7 @@ public ScoreAnalysis analyze(Solution_ solution, ScoreAnalysisFetchPolic var enterpriseService = TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.SCORE_ANALYSIS); var currentScore = (Score_) scoreDirectorFactory.getSolutionDescriptor().getScore(solution); - var analysis = callScoreDirector(solution, solutionUpdatePolicy, + var analysis = callScoreDirector("Solution analysis", solution, solutionUpdatePolicy, scoreDirector -> enterpriseService.analyze(scoreDirector.calculateScore(), scoreDirector.getConstraintMatchTotalMap(), fetchPolicy), ConstraintMatchPolicy.match(fetchPolicy), false); @@ -134,7 +146,7 @@ public List> recommendAssignment ScoreAnalysisFetchPolicy fetchPolicy) { var enterpriseService = TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.RECOMMENDATIONS); - return callScoreDirector( + return callScoreDirector("Recommended assignment", solution, SolutionUpdatePolicy.UPDATE_ALL, enterpriseService.buildRecommender(solverFactory, solution, evaluatedEntityOrElement, propositionFunction, fetchPolicy), ConstraintMatchPolicy.match(fetchPolicy), true); diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java index d2405c4b11..b83b76933a 100644 --- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import java.time.Duration; import java.util.Arrays; import java.util.List; import java.util.function.Function; @@ -23,6 +24,10 @@ import ai.timefold.solver.core.testdomain.shadow.concurrent.TestdataConcurrentEntity; import ai.timefold.solver.core.testdomain.shadow.concurrent.TestdataConcurrentSolution; import ai.timefold.solver.core.testdomain.shadow.concurrent.TestdataConcurrentValue; +import ai.timefold.solver.core.testdomain.shadow.no_inconsistent_field.TestdataDependencyNoInconsistentFieldConstraintProvider; +import ai.timefold.solver.core.testdomain.shadow.no_inconsistent_field.TestdataDependencyNoInconsistentFieldEntity; +import ai.timefold.solver.core.testdomain.shadow.no_inconsistent_field.TestdataDependencyNoInconsistentFieldSolution; +import ai.timefold.solver.core.testdomain.shadow.no_inconsistent_field.TestdataDependencyNoInconsistentFieldValue; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; @@ -113,6 +118,30 @@ void updateEverythingList(SolutionManagerSource solutionManagerSource) { }); } + @ParameterizedTest + @EnumSource(SolutionManagerSource.class) + void updateInconsistent(SolutionManagerSource solutionManagerSource) { + var entity1 = new TestdataDependencyNoInconsistentFieldEntity("e1"); + var valueA1 = new TestdataDependencyNoInconsistentFieldValue("a1"); + var valueA2 = new TestdataDependencyNoInconsistentFieldValue("a2", Duration.ofHours(1L), List.of(valueA1)); + entity1.setValues(List.of(valueA2, valueA1)); + var inconsistentSolution = + new TestdataDependencyNoInconsistentFieldSolution(List.of(entity1), List.of(valueA1, valueA2)); + + var solverFactory = SolverFactory.create(new SolverConfig() + .withSolutionClass(TestdataDependencyNoInconsistentFieldSolution.class) + .withEntityClasses(TestdataDependencyNoInconsistentFieldEntity.class, + TestdataDependencyNoInconsistentFieldValue.class) + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class)); + var solutionManager = solutionManagerSource.createSolutionManager(solverFactory); + assertThat(solutionManager).isNotNull(); + + assertThatCode(() -> solutionManager.update(inconsistentSolution)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContainingAll("The solution (", + "is inconsistent", "Solution update", "requires a consistent solution"); + } + private void assertShadowedListValueAllNull(SoftAssertions softly, TestdataListValueWithShadowHistory current) { softly.assertThat(current.getIndex()).isNull(); softly.assertThat(current.getEntity()).isNull(); @@ -149,6 +178,30 @@ void updateOnlyShadowVariables(SolutionManagerSource solutionManagerSource) { }); } + @ParameterizedTest + @EnumSource(SolutionManagerSource.class) + void updateOnlyShadowVariablesInconsistent(SolutionManagerSource solutionManagerSource) { + var entity1 = new TestdataDependencyNoInconsistentFieldEntity("e1"); + var valueA1 = new TestdataDependencyNoInconsistentFieldValue("a1"); + var valueA2 = new TestdataDependencyNoInconsistentFieldValue("a2", Duration.ofHours(1L), List.of(valueA1)); + entity1.setValues(List.of(valueA2, valueA1)); + var inconsistentSolution = + new TestdataDependencyNoInconsistentFieldSolution(List.of(entity1), List.of(valueA1, valueA2)); + + var solverFactory = SolverFactory.create(new SolverConfig() + .withSolutionClass(TestdataDependencyNoInconsistentFieldSolution.class) + .withEntityClasses(TestdataDependencyNoInconsistentFieldEntity.class, + TestdataDependencyNoInconsistentFieldValue.class) + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class)); + var solutionManager = solutionManagerSource.createSolutionManager(solverFactory); + assertThat(solutionManager).isNotNull(); + + assertThatCode(() -> solutionManager.update(inconsistentSolution, SolutionUpdatePolicy.UPDATE_SHADOW_VARIABLES_ONLY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContainingAll("The solution (", + "is inconsistent", "Solution update", "requires a consistent solution"); + } + @ParameterizedTest @EnumSource(SolutionManagerSource.class) void updateOnlyScore(SolutionManagerSource solutionManagerSource) { From 3bade17440f4034fc8133187da6e97d9e1ae8592 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 29 Jun 2026 16:41:20 -0400 Subject: [PATCH 4/5] chore: use a custom exception for inconsistent solution --- .../InconsistentSolutionException.java | 27 +++++++++++++++++++ .../score/director/AbstractScoreDirector.java | 6 ++++- .../impl/solver/DefaultSolutionManager.java | 11 ++++---- .../core/api/solver/SolutionManagerTest.java | 15 +++++++---- 4 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/api/domain/variable/InconsistentSolutionException.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/InconsistentSolutionException.java b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/InconsistentSolutionException.java new file mode 100644 index 0000000000..96039b2732 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/InconsistentSolutionException.java @@ -0,0 +1,27 @@ +package ai.timefold.solver.core.api.domain.variable; + +import java.util.List; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class InconsistentSolutionException extends RuntimeException { + private final Object solution; + private final List involvedEntityList; + + public InconsistentSolutionException(String feature, Object solution, List involvedEntityList) { + super("The solution (%s) is inconsistent. %s requires a consistent solution.".formatted(solution, feature)); + this.solution = solution; + this.involvedEntityList = involvedEntityList; + } + + @SuppressWarnings("unchecked") + public T getSolution() { + return (T) solution; + } + + @SuppressWarnings("unchecked") + public List getInvolvedEntityList() { + return (List) involvedEntityList; + } +} 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 1751d5bccb..4ae7ddcfad 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 @@ -344,8 +344,12 @@ protected void afterSetWorkingSolution() { // Do nothing } + public List getInconsistentEntities() { + return variableListenerSupport.getInconsistentEntities(); + } + public void unassignInconsistentEntities() { - var inconsistentEntities = variableListenerSupport.getInconsistentEntities(); + var inconsistentEntities = getInconsistentEntities(); if (listVariableStateSupply != null) { var listVariableDescriptor = listVariableStateSupply.getSourceVariableDescriptor(); var listElementClass = listVariableStateSupply.getSourceVariableDescriptor().getElementType(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java index cdda9bae0e..25ec5453f5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java @@ -5,6 +5,7 @@ import java.util.function.Function; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.InconsistentSolutionException; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; import ai.timefold.solver.core.api.solver.RecommendedAssignment; @@ -87,14 +88,12 @@ private Result_ callScoreDirector(String feature, Solution_ solution, if (solutionUpdatePolicy.isScoreUpdateEnabled()) { var score = scoreDirector.calculateScore(); if (score.isInvalid()) { - throw new IllegalArgumentException(""" - The solution (%s) is inconsistent. %s requires a consistent solution to be passed. - """.formatted(solution, feature)); + var inconsistentEntities = scoreDirector.getInconsistentEntities(); + throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } } else if (!scoreDirector.isLastVariableUpdateWasSuccessful()) { - throw new IllegalArgumentException(""" - The solution (%s) is inconsistent. %s requires a consistent solution to be passed. - """.formatted(solution, feature)); + var inconsistentEntities = scoreDirector.getInconsistentEntities(); + throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } return function.apply(scoreDirector); } diff --git a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java index b83b76933a..d5b31dd39c 100644 --- a/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.function.Function; +import ai.timefold.solver.core.api.domain.variable.InconsistentSolutionException; import ai.timefold.solver.core.api.score.HardSoftScore; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; @@ -137,9 +138,11 @@ void updateInconsistent(SolutionManagerSource solutionManagerSource) { assertThat(solutionManager).isNotNull(); assertThatCode(() -> solutionManager.update(inconsistentSolution)) - .isInstanceOf(IllegalArgumentException.class) + .isInstanceOf(InconsistentSolutionException.class) .hasMessageContainingAll("The solution (", - "is inconsistent", "Solution update", "requires a consistent solution"); + "is inconsistent", "Solution update", "requires a consistent solution") + .hasFieldOrPropertyWithValue("solution", inconsistentSolution) + .hasFieldOrPropertyWithValue("involvedEntityList", List.of(valueA1, valueA2)); } private void assertShadowedListValueAllNull(SoftAssertions softly, TestdataListValueWithShadowHistory current) { @@ -196,10 +199,12 @@ void updateOnlyShadowVariablesInconsistent(SolutionManagerSource solutionManager var solutionManager = solutionManagerSource.createSolutionManager(solverFactory); assertThat(solutionManager).isNotNull(); - assertThatCode(() -> solutionManager.update(inconsistentSolution, SolutionUpdatePolicy.UPDATE_SHADOW_VARIABLES_ONLY)) - .isInstanceOf(IllegalArgumentException.class) + assertThatCode(() -> solutionManager.update(inconsistentSolution)) + .isInstanceOf(InconsistentSolutionException.class) .hasMessageContainingAll("The solution (", - "is inconsistent", "Solution update", "requires a consistent solution"); + "is inconsistent", "Solution update", "requires a consistent solution") + .hasFieldOrPropertyWithValue("solution", inconsistentSolution) + .hasFieldOrPropertyWithValue("involvedEntityList", List.of(valueA1, valueA2)); } @ParameterizedTest From 4d2d22283b68bae6fd1392cc877184408c8cc043 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 29 Jun 2026 16:25:09 -0400 Subject: [PATCH 5/5] chore: throw an exception if a custom phase command returns an inconsistent solution --- .../impl/phase/custom/DefaultCustomPhase.java | 4 ++ .../core/impl/solver/DefaultSolverTest.java | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java index 3496d7ac32..bc907dfb0d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java @@ -88,6 +88,10 @@ private void doStep(CustomStepScope stepScope, PhaseCommand phaseTermination.isPhaseTerminated(stepScope.getPhaseScope())); customPhaseCommand.changeWorkingSolution(commandContext); calculateWorkingStepScore(stepScope, customPhaseCommand); + if (stepScope.getScore().isInvalid()) { + throw new IllegalStateException("The custom phase command (%s) resulted in an inconsistent solution." + .formatted(customPhaseCommand)); + } var solver = stepScope.getPhaseScope().getSolverScope().getSolver(); solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope); } 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 e1be4ae669..1c6af881e2 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 @@ -1549,6 +1549,55 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInconsistentEntityPinned() { .hasMessageContainingAll("Entity", "b2", "is pinned but is involved in a dependency loop"); } + @Test + void solveCustomPhaseReturnsInconsistent() { + // Solver config + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, + TestdataDependencyNoInconsistentFieldValue.class) + .withEasyScoreCalculatorClass(null) + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class) + .withPhases(new CustomPhaseConfig() + .withCustomPhaseCommands(command -> { + var solution = (TestdataDependencyNoInconsistentFieldSolution) command.getWorkingSolution(); + var sE1 = solution.getEntities().getFirst(); + var sB1 = solution.getValues().get(2); + var sB2 = solution.getValues().get(3); + var metaModel = command.getSolutionMetaModel() + .genuineEntity(TestdataDependencyNoInconsistentFieldEntity.class) + .listVariable("values", TestdataDependencyNoInconsistentFieldValue.class); + command.execute(Moves.compose( + Moves.assign(metaModel, sB2, sE1, 0), + Moves.assign(metaModel, sB1, sE1, 1))); + })); + + var e1 = new TestdataDependencyNoInconsistentFieldEntity("a"); + var e2 = new TestdataDependencyNoInconsistentFieldEntity("b"); + + var a1 = new TestdataDependencyNoInconsistentFieldValue("a1"); + var a2 = new TestdataDependencyNoInconsistentFieldValue("a2"); + var b1 = new TestdataDependencyNoInconsistentFieldValue("b1"); + var b2 = new TestdataDependencyNoInconsistentFieldValue("b2"); + + a2.setDependencies(List.of(a1)); + b2.setDependencies(List.of(b1)); + + e1.setValues(List.of(b2, b1)); + e2.setValues(List.of(a1, a2)); + + var entities = List.of(e1, e2); + var values = List.of(a1, a2, b1, b2); + + var problem = new TestdataDependencyNoInconsistentFieldSolution(); + + problem.setEntities(entities); + problem.setValues(values); + + assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContainingAll("The custom phase command", "resulted in an inconsistent solution."); + } + @Test void solveIgnoreInconsistent() { // Solver config