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 00000000000..96039b27321 --- /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/domain/variable/declarative/DefaultShadowVariableSession.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DefaultShadowVariableSession.java index 51f1315a3b3..c66fdb04b0b 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 7a49187bca5..ece50ea84bd 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 53d2b1a281f..440a20685c6 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 3999e11a60f..6014c36ed31 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 52385256312..8714ebb4404 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 fe9ad2d68c8..759bb53bee2 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 169ef89e658..ee2bd1e01cb 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/phase/custom/DefaultCustomPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java index 3496d7ac328..bc907dfb0dc 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/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 c0434f8581d..4ae7ddcfad9 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,60 @@ protected void afterSetWorkingSolution() { // Do nothing } + public List getInconsistentEntities() { + return variableListenerSupport.getInconsistentEntities(); + } + + public void unassignInconsistentEntities() { + var inconsistentEntities = 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)); + + 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); + 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)); + } + 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); + afterVariableChanged(genuineVariableDescriptor, inconsistentEntity); + } + triggerVariableListeners(); + } + @Override public void setMoveRepository(@Nullable MoveRepository moveRepository) { if (this.moveRepository == moveRepository) { // Prevent double initialization @@ -437,6 +491,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/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java index a1e8d1f733e..a2c0e88160a 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/DefaultSolutionManager.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java index 74d8e95bf02..25ec5453f52 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; @@ -51,20 +52,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 +86,14 @@ 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()) { + var inconsistentEntities = scoreDirector.getInconsistentEntities(); + throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); + } + } else if (!scoreDirector.isLastVariableUpdateWasSuccessful()) { + var inconsistentEntities = scoreDirector.getInconsistentEntities(); + throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } return function.apply(scoreDirector); } @@ -112,7 +123,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 +145,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/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 b7c2afb10a7..24ea4a3e321 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,21 @@ 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/api/solver/SolutionManagerTest.java b/core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java index d2405c4b11d..d5b31dd39cc 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,10 +4,12 @@ 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; +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; @@ -23,6 +25,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 +119,32 @@ 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(InconsistentSolutionException.class) + .hasMessageContainingAll("The 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) { softly.assertThat(current.getIndex()).isNull(); softly.assertThat(current.getEntity()).isNull(); @@ -149,6 +181,32 @@ 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)) + .isInstanceOf(InconsistentSolutionException.class) + .hasMessageContainingAll("The solution (", + "is inconsistent", "Solution update", "requires a consistent solution") + .hasFieldOrPropertyWithValue("solution", inconsistentSolution) + .hasFieldOrPropertyWithValue("involvedEntityList", List.of(valueA1, valueA2)); + } + @ParameterizedTest @EnumSource(SolutionManagerSource.class) void updateOnlyScore(SolutionManagerSource solutionManagerSource) { 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 9e99243594a..1c6af881e28 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,65 @@ void solveStaleDeclarativeShadows() { } @Test - void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() { + void solveWhenIgnoringInconsistentSolutionsUnassignsIfInitialSolutionInconsistent() { + // Solver config + var solverConfig = PlannerTestUtils.buildSolverConfig( + TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, + TestdataDependencyNoInconsistentFieldValue.class) + .withEasyScoreCalculatorClass(null) + .withConstraintProviderClass(TestdataDependencyNoInconsistentFieldConstraintProvider.class) + .withPhases(new CustomPhaseConfig() + .withCustomPhaseCommands(command -> { + })); + + 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); + + var solution = PlannerTestUtils.solve(solverConfig, problem, false); + 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()).isEqualTo(sE2); + assertThat(sA1.getPreviousValue()).isNull(); + + assertThat(sA2.getEntity()).isEqualTo(sE2); + assertThat(sA2.getPreviousValue()).isEqualTo(sA1); + + assertThat(sB1.getEntity()).isNull(); + assertThat(sB1.getPreviousValue()).isNull(); + + assertThat(sB2.getEntity()).isNull(); + assertThat(sB2.getPreviousValue()).isNull(); + } + + @Test + void solveWhenIgnoringInconsistentSolutionsThrowsIfInconsistentEntityPinned() { // Solver config var solverConfig = PlannerTestUtils.buildSolverConfig( TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, @@ -1479,15 +1537,65 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInitialSolutionInconsistent() 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 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); - // 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"); + assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContainingAll("The custom phase command", "resulted in an inconsistent solution."); } @Test 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 c9daf36785a..d09643e8aa4 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);