From 43984ee728315f682c62fce766170b412d048485 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Tue, 18 Aug 2026 16:22:05 -0400 Subject: [PATCH 1/9] chore: rename InnerScore.isInvalid to InnerScore.isStructuallyFlawed Also add a fail-fast when executing a move that produced a structurally flawed solution. --- .../api/solver/phase/PhaseCommandContext.java | 26 ++++++++++++++ .../decider/acceptor/AbstractAcceptor.java | 2 +- .../solver/core/impl/move/MoveDirector.java | 36 ++++++++++++++++--- .../impl/phase/custom/DefaultCustomPhase.java | 2 +- .../custom/DefaultPhaseCommandContext.java | 18 ++++++++++ .../score/director/AbstractScoreDirector.java | 1 + .../core/impl/score/director/InnerScore.java | 6 +++- .../score/director/InnerScoreDirector.java | 5 +++ .../impl/solver/DefaultSolutionManager.java | 2 +- .../solver/recaller/BestSolutionRecaller.java | 4 +-- .../DefaultExhaustiveSearchPhaseTest.java | 2 ++ .../list/SelectorBasedListAssignMoveTest.java | 1 + .../list/SelectorBasedListSwapMoveTest.java | 1 + .../core/impl/solver/DefaultSolverTest.java | 6 ++-- 14 files changed, 99 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java index 88c17a551a2..617c44dac4e 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java @@ -53,6 +53,7 @@ public interface PhaseCommandContext * without recalculating the score for performance reasons. * * @param move the move to execute + * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. */ void execute(Move move); @@ -62,6 +63,7 @@ public interface PhaseCommandContext * * @param move the move to execute * @return the new score of the working solution after executing the move + * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. */ > Score_ executeAndCalculateScore(Move move); @@ -74,10 +76,24 @@ public interface PhaseCommandContext * @param temporarySolutionConsumer the consumer to execute with the temporarily modified solution; * this solution must not be modified any further. * @return the result of the consumer + * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. */ @Nullable Result_ executeTemporarily(Move move, Function temporarySolutionConsumer); + /** + * As defined by {@link #executeTemporarily(Move, Function)}, + * except having a separate consumer to handle solutions with a negative {@link Score#structuralScore()}. + * + * @param move the move to execute temporarily + * @param temporarySolutionConsumer the consumer to execute with the temporarily modified solution; + * this solution must not be modified any further. + * @return the result of the consumer + */ + @Nullable Result_ executeTemporarilyHandlingStructurallyFlawedSolutions(Move move, + Function temporarySolutionConsumer, + Function structurallyFlawedSolutionConsumer); + /** * Executes the given move temporarily and returns the score of the temporarily modified solution. * The working solution is reverted to its original state after the consumer has been executed, @@ -97,10 +113,20 @@ public interface PhaseCommandContext /** * As defined by {@link #executeTemporarily(Move, Function)}, * with the guarantee of a fresh score at the end of the method's invocation. + * + * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. */ @Nullable Result_ executeTemporarilyAndCalculateScore(Move move, Function temporarySolutionConsumer); + /** + * As defined by {@link #executeTemporarilyHandlingStructurallyFlawedSolutions(Move, Function, Function)}, + * with the guarantee of a fresh score at the end of the method's invocation. + */ + @Nullable Result_ executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(Move move, + Function temporarySolutionConsumer, + Function structurallyFlawedSolutionConsumer); + @Override @Nullable T lookUpWorkingObject(@Nullable T problemFactOrPlanningEntity); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AbstractAcceptor.java b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AbstractAcceptor.java index 05f1d8a29da..af170318ce9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AbstractAcceptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/localsearch/decider/acceptor/AbstractAcceptor.java @@ -21,7 +21,7 @@ public abstract class AbstractAcceptor extends LocalSearchPhaseLifecy // ************************************************************************ @Override public final boolean isAccepted(LocalSearchMoveScope moveScope) { - if (moveScope.getScore().isInvalid()) { + if (moveScope.getScore().isStructurallyFlawed()) { return false; } return isStructurallyValidSolutionAccepted(moveScope); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java index bb5192533df..8cc1fc9b140 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java @@ -410,16 +410,24 @@ public final void execute(Move move) { * to ensure the score is up to date. */ public final void execute(Move move, boolean guaranteeFreshScore) { - move.execute(this); - externalScoreDirector.updateShadowVariables(); + executeAllowingStructurallyFlawedSolutions(move); + if (!backingScoreDirector.isLastVariableUpdateSuccessful()) { + throw new IllegalArgumentException("The move (%s) caused the solution to become structurally flawed." + .formatted(move)); + } if (guaranteeFreshScore) { backingScoreDirector.calculateScore(); } } + void executeAllowingStructurallyFlawedSolutions(Move move) { + move.execute(this); + externalScoreDirector.updateShadowVariables(); + } + public final InnerScore executeTemporary(Move move) { var ephemeralMoveDirector = ephemeral(); - ephemeralMoveDirector.execute(move); + ephemeralMoveDirector.executeAllowingStructurallyFlawedSolutions(move); var score = backingScoreDirector.calculateScore(); ephemeralMoveDirector.close(); // This undoes the move. return score; @@ -428,7 +436,7 @@ public final InnerScore executeTemporary(Move move) { public @Nullable Result_ executeTemporary(Move move, TemporaryMovePostprocessor postprocessor) { try (var ephemeralMoveDirector = ephemeral()) { - ephemeralMoveDirector.execute(move); + ephemeralMoveDirector.executeAllowingStructurallyFlawedSolutions(move); var score = backingScoreDirector.calculateScore(); return postprocessor.apply(score, ephemeralMoveDirector.createUndoMove()); } @@ -447,6 +455,26 @@ public final InnerScore executeTemporary(Move move) { return result; } + public @Nullable Result_ executeTemporaryHandlingStructurallyFlawedSolutions(Move move, + Function postprocessor, + Function flawedSolutionProcessor, + boolean guaranteeFreshScore) { + var ephemeralMoveDirector = ephemeral(); + ephemeralMoveDirector.executeAllowingStructurallyFlawedSolutions(move); + var score = backingScoreDirector.calculateScore(); + Result_ result; + if (score.isStructurallyFlawed()) { + result = flawedSolutionProcessor.apply(backingScoreDirector.getWorkingSolution()); + } else { + result = postprocessor.apply(backingScoreDirector.getWorkingSolution()); + } + ephemeralMoveDirector.close(); // This undoes the move. + if (guaranteeFreshScore) { + backingScoreDirector.calculateScore(); + } + return result; + } + @Override public final Value_ getValue(PlanningVariableMetaModel variableMetaModel, Entity_ entity) { 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 bc907dfb0dc..462cb62c8e8 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,7 +88,7 @@ private void doStep(CustomStepScope stepScope, PhaseCommand phaseTermination.isPhaseTerminated(stepScope.getPhaseScope())); customPhaseCommand.changeWorkingSolution(commandContext); calculateWorkingStepScore(stepScope, customPhaseCommand); - if (stepScope.getScore().isInvalid()) { + if (stepScope.getScore().isStructurallyFlawed()) { throw new IllegalStateException("The custom phase command (%s) resulted in an inconsistent solution." .formatted(customPhaseCommand)); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java index a9758c55102..fc005982c7b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java @@ -61,6 +61,15 @@ public > Score_ executeAndCalculateScore(Move Result_ executeTemporarilyHandlingStructurallyFlawedSolutions(Move move, + Function temporarySolutionConsumer, + Function structurallyFlawedSolutionConsumer) { + return moveDirector.executeTemporaryHandlingStructurallyFlawedSolutions(move, + temporarySolutionConsumer, structurallyFlawedSolutionConsumer, + false); + } + @Override public > Score_ executeTemporarily(Move move) { Score_ score = executeTemporarily(move, solution -> moveDirector.getScoreDirector() @@ -75,6 +84,15 @@ public > Score_ executeTemporarily(Move return moveDirector.executeTemporary(move, temporarySolutionConsumer, true); } + @Override + public @Nullable Result_ executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions( + Move move, Function temporarySolutionConsumer, + Function structurallyFlawedSolutionConsumer) { + return moveDirector.executeTemporaryHandlingStructurallyFlawedSolutions(move, + temporarySolutionConsumer, structurallyFlawedSolutionConsumer, + true); + } + @Override public > Score_ executeTemporarilyAndCalculateScore(Move move) { Score_ score = executeTemporarilyAndCalculateScore(move, solution -> moveDirector.getScoreDirector() 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 726cd506319..34bbc5739c3 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 @@ -495,6 +495,7 @@ public void updateShadowVariables() { lastVariableUpdateSuccessful = shadowVariableSupport.updateShadowVariables(); } + @Override public boolean isLastVariableUpdateSuccessful() { return lastVariableUpdateSuccessful; } 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 df1f8e9543d..e1c8874a77e 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 @@ -40,13 +40,17 @@ public static > InnerScore withUnassignedCo throw new IllegalArgumentException("The unassignedCount (%d) must be >= 0." .formatted(unassignedCount)); } + if (raw.structuralScore() > 0) { + throw new IllegalArgumentException("The structuralScore (%d) must be <= 0." + .formatted(raw.structuralScore())); + } } public boolean isFullyAssigned() { return unassignedCount == 0; } - public boolean isInvalid() { + public boolean isStructurallyFlawed() { return raw.structuralScore() < 0; } 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 e6aa8bee560..e94ac21b4da 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 @@ -331,6 +331,11 @@ default void forceTriggerVariableListeners() { forceUpdateShadowVariables(); } + /** + * @return true if the last {@link #updateShadowVariables()} was successful, false otherwise. + */ + boolean isLastVariableUpdateSuccessful(); + /** * A derived score director is created from a root score director. * The derived score director can be used to create separate* instances for use cases like multithreaded solving. 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 7de1351f60a..3ae773060d1 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 @@ -85,7 +85,7 @@ private Result_ callScoreDirector(String feature, Solution_ solution, } if (solutionUpdatePolicy.isScoreUpdateEnabled()) { var score = scoreDirector.calculateScore(); - if (score.isInvalid()) { + if (score.isStructurallyFlawed()) { var inconsistentEntities = scoreDirector.computeInconsistentEntities(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } 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 48ebf629772..0b6ced886f1 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 @@ -55,11 +55,11 @@ public void solvingStarted(SolverScope solverScope) { var scoreDirector = solverScope.getScoreDirector(); @SuppressWarnings("rawtypes") InnerScore innerScore = scoreDirector.calculateScore(); - if (innerScore.isInvalid()) { + if (innerScore.isStructurallyFlawed()) { LOGGER.warn("The initial solution passed to the solver is inconsistent. Unassigning involved entities."); scoreDirector.unassignInconsistentEntities(); innerScore = scoreDirector.calculateScore(); - if (innerScore.isInvalid()) { + if (innerScore.isStructurallyFlawed()) { // If there were a fixed dependency loop, the shadow variable session would fail fast before here throw new IllegalStateException( "Impossible state: The initial solution passed to the solver is inconsistent even after unassigning involved entities."); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseTest.java b/core/src/test/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseTest.java index 15ed56b7fc6..d0429c1aa86 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/exhaustivesearch/DefaultExhaustiveSearchPhaseTest.java @@ -60,6 +60,7 @@ void restoreWorkingSolutionForBasicVariable() { var workingSolution = new TestdataSolution(); when(phaseScope.getWorkingSolution()).thenReturn(workingSolution); InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); + when(scoreDirector.isLastVariableUpdateSuccessful()).thenReturn(true); var moveDirector = new MoveDirector<>(scoreDirector); doAnswer(invocation -> { var move = (Move) invocation.getArgument(0); @@ -120,6 +121,7 @@ void restoreWorkingSolutionForListVariable() { var workingSolution = new TestdataSolution(); when(phaseScope.getWorkingSolution()).thenReturn(workingSolution); InnerScoreDirector scoreDirector = mock(InnerScoreDirector.class); + when(scoreDirector.isLastVariableUpdateSuccessful()).thenReturn(true); var moveDirector = new MoveDirector<>(scoreDirector); doAnswer(invocation -> { var move = (Move) invocation.getArgument(0); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListAssignMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListAssignMoveTest.java index 2f65d656b90..3b32239a137 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListAssignMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListAssignMoveTest.java @@ -40,6 +40,7 @@ class SelectorBasedListAssignMoveTest { void setUp() { when(innerScoreDirector.getSolutionDescriptor()) .thenReturn(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + when(innerScoreDirector.isLastVariableUpdateSuccessful()).thenReturn(true); when(otherInnerScoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListSwapMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListSwapMoveTest.java index 27d80f3cc83..b833bac8eba 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListSwapMoveTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/SelectorBasedListSwapMoveTest.java @@ -42,6 +42,7 @@ class SelectorBasedListSwapMoveTest { @BeforeEach void setUp() { + when(innerScoreDirector.isLastVariableUpdateSuccessful()).thenReturn(true); when(otherInnerScoreDirector.getValueRangeManager()).thenReturn(valueRangeManager); } 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 83c4c1f9d7e..5ebcdbdcc4a 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 @@ -1556,7 +1556,7 @@ void solveWhenIgnoringInconsistentSolutionsThrowsIfInconsistentEntityPinned() { } @Test - void solveCustomPhaseReturnsInconsistent() { + void solveCustomPhaseReturnsStructurallyFlawed() { // Solver config var solverConfig = PlannerTestUtils.buildSolverConfig( TestdataDependencyNoInconsistentFieldSolution.class, TestdataDependencyNoInconsistentFieldEntity.class, @@ -1600,8 +1600,8 @@ void solveCustomPhaseReturnsInconsistent() { problem.setValues(values); assertThatCode(() -> PlannerTestUtils.solve(solverConfig, problem, false)) - .isInstanceOf(IllegalStateException.class) - .hasMessageContainingAll("The custom phase command", "resulted in an inconsistent solution."); + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContainingAll("The move ", "caused the solution to become structurally flawed."); } @Test From b50b721324504e8c58c77df2ad45e37fb3fdae19 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Wed, 19 Aug 2026 15:57:23 -0400 Subject: [PATCH 2/9] chore: add test for DefaultPhaseCommandContext --- .../DefaultPhaseCommandContextTest.java | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java diff --git a/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java b/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java new file mode 100644 index 00000000000..b3e60f50e45 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java @@ -0,0 +1,115 @@ +package ai.timefold.solver.core.impl.phase.custom; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.function.Function; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.impl.move.MoveDirector; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.preview.api.move.Move; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class DefaultPhaseCommandContextTest { + + Move move; + DefaultPhaseCommandContext commandContext; + InnerScoreDirector scoreDirector; + Function solutionConsumer; + Function flawedSolutionConsumer; + + private static final String SUCCESS = "success"; + private static final String ERROR = "error"; + + @BeforeEach + void setUp() { + var solution = TestdataSolution.generateSolution(3, 2); + move = mock(Move.class); + scoreDirector = mock(InnerScoreDirector.class); + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + + commandContext = new DefaultPhaseCommandContext<>(new MoveDirector<>(scoreDirector), () -> false); + + solutionConsumer = Mockito.mock(Function.class); + when(solutionConsumer.apply(any())).thenReturn(SUCCESS); + + flawedSolutionConsumer = Mockito.mock(Function.class); + when(flawedSolutionConsumer.apply(any())).thenReturn(ERROR); + } + + @Test + void executeTemporarilyHandlingStructurallyFlawedSolutions_moveDoesNotFlawSolution() { + when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.of(0))); + + var result = commandContext + .executeTemporarilyHandlingStructurallyFlawedSolutions(move, + solutionConsumer, + flawedSolutionConsumer); + + assertThat(result).isEqualTo(SUCCESS); + verify(solutionConsumer, times(1)).apply(any()); + verify(flawedSolutionConsumer, never()).apply(any()); + verify(move, times(1)).execute(any(MoveDirector.class)); + verify(scoreDirector, times(1)).calculateScore(); + } + + @Test + void executeTemporarilyHandlingStructurallyFlawedSolutions_moveFlawsSolution() { + when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(new SimpleScore(-1, 0))); + + var result = commandContext + .executeTemporarilyHandlingStructurallyFlawedSolutions(move, + solutionConsumer, + flawedSolutionConsumer); + + assertThat(result).isEqualTo(ERROR); + verify(solutionConsumer, never()).apply(any()); + verify(flawedSolutionConsumer, times(1)).apply(any()); + verify(move, times(1)).execute(any(MoveDirector.class)); + verify(scoreDirector, times(1)).calculateScore(); + } + + @Test + void executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions_moveDoesNotFlawSolution() { + when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.of(0))); + + var result = commandContext + .executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(move, + solutionConsumer, + flawedSolutionConsumer); + + assertThat(result).isEqualTo(SUCCESS); + verify(solutionConsumer, times(1)).apply(any()); + verify(flawedSolutionConsumer, never()).apply(any()); + verify(move, times(1)).execute(any(MoveDirector.class)); + verify(scoreDirector, times(2)).calculateScore(); + } + + @Test + void executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions_moveFlawsSolution() { + when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(new SimpleScore(-1, 0))); + + var result = commandContext + .executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(move, + solutionConsumer, + flawedSolutionConsumer); + + assertThat(result).isEqualTo(ERROR); + verify(solutionConsumer, never()).apply(any()); + verify(flawedSolutionConsumer, times(1)).apply(any()); + verify(move, times(1)).execute(any(MoveDirector.class)); + verify(scoreDirector, times(2)).calculateScore(); + } + +} From 358de72bb56b331ec821366af9e868bea90272bb Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Thu, 20 Aug 2026 10:44:14 -0400 Subject: [PATCH 3/9] docs: document changes relating to structural score --- .../pages/constraints-and-score/overview.adoc | 26 ++++++++++++++++++ .../understanding-the-score.adoc | 8 ++++++ .../modeling-planning-problems.adoc | 27 ++++++++++++++++++- .../optimization-algorithms/overview.adoc | 17 ++++++++++++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/overview.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/overview.adoc index ec56c890404..b85e7d3ae9a 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/overview.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/overview.adoc @@ -165,6 +165,32 @@ such as xref:constraints-and-score/overview.adoc#hardSoftScore[HardSoftScore] and xref:constraints-and-score/overview.adoc#hardMediumSoftScore[HardMediumSoftScore]. +[#structuralScore] +=== Structural score + +All `Score` implementations have an implicit xref:constraints-and-score/overview.adoc#structuralScore[structural] component that outranks every score level. +It is `0` if the solution is structurally sound and negative if the solution is structurally flawed. +A solution is structurally flawed when some of its xref:domain-modeling/modeling-planning-problems.adoc#shadowVariable[shadow variables] form a dependency loop and cannot be calculated +(see xref:domain-modeling/modeling-planning-problems.adoc#detectingInconsistencies[Detecting Inconsistencies in Shadow Variables]). + +Because the structural component is compared before every other level, +a score with a negative structural component is worse than any score with a `0` structural component, +no matter how many score constraints are broken. +It therefore behaves like a super hard score level, +and a score with a negative structural component is not xref:#scoreLevel[feasible]. + +During solving, the structural component is always `0`, +because Timefold Solver never accepts a structurally flawed solution. +A negative structural component only appears on solutions that were modified outside of a solver step, +such as a solution modified by a xref:optimization-algorithms/overview.adoc#customSolverPhase[custom phase] +or a solution passed to `Solver.solve(...)`. + +When it is not `0`, the structural component is printed as the leading `structural` level of the score string, +for example ``-1structural/0hard/-5soft``. +When it is `0`, it is omitted, +so a structurally sound score is printed as usual, for example ``0hard/-5soft``. + + [#paretoScoring] === Pareto scoring (AKA multi-objective optimization scoring) diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc index 0e87ec040ca..9b239a33e4d 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc @@ -91,6 +91,14 @@ For performance reasons and especially with large datasets that you'll later nee In that case, use `ScoreAnalysisFetchPolicy.FETCH_MATCH_COUNT` instead of the default `ScoreAnalysisFetchPolicy.FETCH_ALL` when calling `SolutionManager.analyze(...)`. ==== +[NOTE] +==== +`SolutionManager.analyze(...)` requires a structurally sound solution. +If the solution has a negative xref:constraints-and-score/overview.adoc#structuralScore[structural score], +the method fails fast and throws an `InconsistentSolutionException` +instead of returning a `ScoreAnalysis`. +==== + It is also possible to print the score summary: [tabs] diff --git a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc index 7d93934aa28..8e5333096a8 100644 --- a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc +++ b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc @@ -2035,7 +2035,32 @@ For example, if variable `a` depends on `b` and `b` depends on `a`, both are in - It depends on another variable that is inconsistent. For example, if `c` depends on `a`, and `a` is in a loop with `b`, then `c` is also considered part of the loop. -When a declarative shadow variable is inconsistent, it will be set to `null`. +When a declarative shadow variable is inconsistent, the solver's reaction depends on whether any entity in the model declares a `@ShadowVariablesInconsistent` field. +If at least one entity declares such a field, the inconsistent shadow variables are set to `null` +and the `@ShadowVariablesInconsistent` field of every involved entity is set to `true`. +If no entity declares such a field, the shadow variable update fails instead +and the solution's score receives a negative xref:constraints-and-score/overview.adoc#structuralScore[structural score], +which marks the solution as structurally flawed. +Models that relied on inconsistent shadow variables being silently set to `null` +must therefore declare a `@ShadowVariablesInconsistent` field to keep that behavior. + +[NOTE] +==== +The xref:constraints-and-score/overview.adoc#structuralScore[structural score] is a component of every `Score` +that is `0` for structurally sound solutions and negative for structurally flawed ones. +A structurally flawed score is worse than any structurally sound score, +so a structurally flawed solution is never accepted during solving. + +When a structurally flawed solution is passed to `SolutionManager.update(...)` or `SolutionManager.analyze(...)`, +these methods fail fast and throw an `InconsistentSolutionException`, +which exposes the solution and the entities involved in the inconsistency. + +When a structurally flawed solution is passed to `Solver.solve`, +the `Solver` logs a warning and unassigns the involved planning entities before solving. +If the inconsistency cannot be resolved this way, +for example because an involved entity is xref:domain-modeling/modeling-planning-problems.adoc#pinnedPlanningEntities[pinned], +the `Solver` throws an `Exception`. +==== To detect whether an entity has inconsistent shadow variables, annotate a boolean field with `@ShadowVariablesInconsistent`. The solver will set this field to true if the entity has any inconsistent shadow variables. diff --git a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc index 12d15e532e6..ccfd57660a1 100644 --- a/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc +++ b/docs/src/modules/ROOT/pages/optimization-algorithms/overview.adoc @@ -879,6 +879,13 @@ As defined above, but the executed move is immediately undone. The provided `Function` is executed while the move is still applied, so that the user can perform arbitrary calculations on the working solution with the move applied. The return value of the provided `Function` is returned by this method. +`Object executeTemporarilyHandlingStructurallyFlawedSolutions(Move, Function, Function)`:: +As `executeTemporarily(Move, Function)`, but the executed move is allowed to result in a +xref:constraints-and-score/overview.adoc#structuralScore[structurally flawed] solution. +If the temporarily modified solution is structurally flawed, +the second provided `Function` is executed with it instead of the first one. +`executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(Move, Function, Function)` +is the counterpart that guarantees a fresh score at the end of the method's invocation. `boolean isPhaseTerminated()`:: Returns `true` if the `PhaseCommand` should terminate. `Object lookUpWorkingObject(Object externalObject)`:: @@ -892,6 +899,16 @@ to avoid corrupting the `Solver`. For performance reasons, these methods will not compute a fresh score before it finishes. If you want a fresh score computed, these methods have counterparts, `executeAndCalculateScore` and `executeTemporarilyAndCalculateScore`. +If a `Move` causes the working solution to become +xref:constraints-and-score/overview.adoc#structuralScore[structurally flawed], +the `execute`, `executeAndCalculateScore`, `executeTemporarily` and `executeTemporarilyAndCalculateScore` methods +fail fast and throw an `IllegalArgumentException`. +If a `PhaseCommand` leaves the working solution structurally flawed when it returns, +the `Solver` throws an `IllegalStateException`. +To inspect a structurally flawed solution instead of failing fast, +use the `executeTemporarilyHandlingStructurallyFlawedSolutions` or +`executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions` methods described above. + Long-running commands may want to periodically check `isPhaseTerminated` and when it returns `true`, terminate the command by returning. The solver will only terminate after the command returns. From dd5283fb73d3ae0a04a68f947211e8b31bff212e Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Fri, 21 Aug 2026 15:02:20 -0400 Subject: [PATCH 4/9] chore: return a ScoreAnalysis on a structurally flawed solution instead of raising an exception --- .../api/score/analysis/ScoreAnalysis.java | 15 ++++++ .../analysis/StructuralFlawAnalysis.java | 17 ++++++ .../TimefoldSolverEnterpriseService.java | 7 ++- .../variable/ShadowVariableSupport.java | 6 +-- .../DefaultShadowVariableSessionFactory.java | 24 ++++++--- .../BendableBigDecimalScoreDefinition.java | 5 ++ .../definition/BendableScoreDefinition.java | 5 ++ ...rdMediumSoftBigDecimalScoreDefinition.java | 5 ++ .../HardMediumSoftScoreDefinition.java | 5 ++ .../HardSoftBigDecimalScoreDefinition.java | 5 ++ .../definition/HardSoftScoreDefinition.java | 5 ++ .../score/definition/ScoreDefinition.java | 7 +++ .../SimpleBigDecimalScoreDefinition.java | 5 ++ .../definition/SimpleScoreDefinition.java | 5 ++ .../score/director/AbstractScoreDirector.java | 13 ++++- .../test/AbstractConstraintAssertion.java | 5 +- .../impl/solver/DefaultSolutionManager.java | 54 ++++++++++++++----- 17 files changed, 162 insertions(+), 26 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/ScoreAnalysis.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/ScoreAnalysis.java index 08af3488674..fb8a9e48113 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/ScoreAnalysis.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/ScoreAnalysis.java @@ -86,6 +86,21 @@ public interface ScoreAnalysis> { */ boolean isSolutionInitialized(); + /** + * Indicates whether the solution was structurally flawed at the time of analysis. + * + * @return isSolutionStructurallyFlawed true if the solution was structurally flawed at the time of analysis. + */ + boolean isSolutionStructurallyFlawed(); + + /** + * Returns an analysis of the structural flaws of a structurally flawed solution. + * + * @return null if the solution was not structurally flawed at the time of analysis. + */ + @Nullable + StructuralFlawAnalysis getStructuralFlawAnalysis(); + /** * Performs a lookup on {@link #constraintMap()}. * Equivalent to {@code constraintMap().get(constraintRef)}. diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java new file mode 100644 index 00000000000..dc7f5553fdc --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.core.api.score.analysis; + +import java.util.Collection; + +import org.jspecify.annotations.NullMarked; + +/** + * Represents a breakdown of the structural flaws of a solution. + */ +@NullMarked +public interface StructuralFlawAnalysis { + /** + * Return a collection of {@link ai.timefold.solver.core.api.domain.entity.PlanningEntity} + * that have inconsistent shadow variables. + */ + Collection getInconsistentEntities(); +} 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 afb07167d03..ded6092e75b 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 @@ -1,6 +1,7 @@ package ai.timefold.solver.core.enterprise; import java.lang.reflect.InvocationTargetException; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -44,6 +45,7 @@ import ai.timefold.solver.core.impl.neighborhood.MoveRepository; import ai.timefold.solver.core.impl.partitionedsearch.PartitionedSearchPhase; import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; +import ai.timefold.solver.core.impl.score.definition.ScoreDefinition; import ai.timefold.solver.core.impl.score.director.InnerScore; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; import ai.timefold.solver.core.impl.solver.DefaultSolverFactory; @@ -226,7 +228,10 @@ DestinationSelector applyNearbySelection(DestinationSelec InnerConstraintProfiler buildConstraintProfiler(); > ScoreAnalysis analyze(InnerScore state, - Map> constraintMatchTotalMap, ScoreAnalysisFetchPolicy fetchPolicy); + Map> constraintMatchTotalMap, + Collection inconsistentEntities, + @Nullable ScoreDefinition scoreDefinition, + ScoreAnalysisFetchPolicy fetchPolicy); PlanningSolutionDiff solutionDiff(PlanningSolutionMetaModel metaModel, Solution_ oldSolution, Solution_ newSolution); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java index 61201c6d8d6..266fac0a81a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java @@ -306,7 +306,8 @@ public void resetWorkingSolution() { shadowVariableGraphCreator); shadowVariableSession = shadowVariableSessionFactory.forSolution(consistencyTracker, - scoreDirector.getWorkingSolution()); + scoreDirector.getWorkingSolution(), + scoreDirector.ignoreInconsistentSolutions()); } } @@ -425,8 +426,7 @@ public boolean updateShadowVariables() { public Collection getInconsistentEntities() { if (shadowVariableSession == null) { - throw new IllegalStateException( - "Impossible state: The shadowVariableSession is null. A solution without shadow variables cannot be inconsistent."); + return Collections.emptyList(); } return shadowVariableSession.getInconsistentEntities(); } 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 d8f033546dd..280a40e567d 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 @@ -78,27 +78,34 @@ public int hashCode() { public record GraphDescriptor(ConsistencyTracker consistencyTracker, SolutionDescriptor solutionDescriptor, + boolean ignoreInconsistentSolutions, VariableReferenceGraphBuilder variableReferenceGraphBuilder, Object[] entities, IntFunction graphCreator) { public boolean ignoreInconsistentSolutions() { - return !solutionDescriptor.hasAnyShadowVariablesInconsistentMember(); + return ignoreInconsistentSolutions; } public GraphDescriptor(SolutionDescriptor solutionDescriptor, ChangedVariableNotifier changedVariableNotifier, Object... entities) { - this(new ConsistencyTracker<>(), solutionDescriptor, new VariableReferenceGraphBuilder<>(changedVariableNotifier), + this(new ConsistencyTracker<>(), solutionDescriptor, !solutionDescriptor.hasAnyShadowVariablesInconsistentMember(), + new VariableReferenceGraphBuilder<>(changedVariableNotifier), entities, DefaultTopologicalOrderGraph::new); } public GraphDescriptor withGraphCreator(IntFunction graphCreator) { - return new GraphDescriptor<>(consistencyTracker, solutionDescriptor, + return new GraphDescriptor<>(consistencyTracker, solutionDescriptor, ignoreInconsistentSolutions, variableReferenceGraphBuilder, entities, graphCreator); } public GraphDescriptor withConsistencyTracker(ConsistencyTracker consistencyTracker) { - return new GraphDescriptor<>(consistencyTracker, solutionDescriptor, + return new GraphDescriptor<>(consistencyTracker, solutionDescriptor, ignoreInconsistentSolutions, + variableReferenceGraphBuilder, entities, graphCreator); + } + + public GraphDescriptor withIgnoreInconsistentSolutions(boolean ignoreInconsistentSolutions) { + return new GraphDescriptor<>(consistencyTracker, solutionDescriptor, ignoreInconsistentSolutions, variableReferenceGraphBuilder, entities, graphCreator); } @@ -741,18 +748,21 @@ private static void createFixedVariableRelationEdges( } public DefaultShadowVariableSession forSolution(ConsistencyTracker consistencyTracker, - Solution_ solution) { + Solution_ solution, + boolean ignoreInconsistentSolutions) { 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) + .withIgnoreInconsistentSolutions(ignoreInconsistentSolutions)); return new DefaultShadowVariableSession<>(graph); } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableBigDecimalScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableBigDecimalScoreDefinition.java index 5c731c641d9..b6f90f12e47 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableBigDecimalScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableBigDecimalScoreDefinition.java @@ -28,6 +28,11 @@ public BendableBigDecimalScore getStructurallyFlawedScore() { return new BendableBigDecimalScore(-1L, zero.hardScores(), zero.softScores()); } + @Override + public BendableBigDecimalScore getStructurallyFlawedScore(BendableBigDecimalScore score) { + return new BendableBigDecimalScore(-1L, score.hardScores(), score.softScores()); + } + @Override public BendableBigDecimalScore getZeroScore() { return BendableBigDecimalScore.zero(hardLevelsSize, softLevelsSize); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableScoreDefinition.java index a490d16933c..ebfa56f73fa 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/BendableScoreDefinition.java @@ -28,6 +28,11 @@ public BendableScore getStructurallyFlawedScore() { return new BendableScore(-1L, zero.hardScores(), zero.softScores()); } + @Override + public BendableScore getStructurallyFlawedScore(BendableScore score) { + return new BendableScore(-1L, score.hardScores(), score.softScores()); + } + @Override public BendableScore getZeroScore() { return BendableScore.zero(hardLevelsSize, softLevelsSize); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftBigDecimalScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftBigDecimalScoreDefinition.java index fc126fd4ce2..815282b70b5 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftBigDecimalScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftBigDecimalScoreDefinition.java @@ -39,6 +39,11 @@ public HardMediumSoftBigDecimalScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public HardMediumSoftBigDecimalScore getStructurallyFlawedScore(HardMediumSoftBigDecimalScore score) { + return new HardMediumSoftBigDecimalScore(-1L, score.hardScore(), score.mediumScore(), score.softScore()); + } + @Override public HardMediumSoftBigDecimalScore getZeroScore() { return HardMediumSoftBigDecimalScore.ZERO; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftScoreDefinition.java index d347d84562f..edbf117af3d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardMediumSoftScoreDefinition.java @@ -38,6 +38,11 @@ public HardMediumSoftScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public HardMediumSoftScore getStructurallyFlawedScore(HardMediumSoftScore score) { + return new HardMediumSoftScore(-1L, score.hardScore(), score.mediumScore(), score.softScore()); + } + @Override public HardMediumSoftScore getZeroScore() { return HardMediumSoftScore.ZERO; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftBigDecimalScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftBigDecimalScoreDefinition.java index 65ca06bac6d..fe2f72982f7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftBigDecimalScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftBigDecimalScoreDefinition.java @@ -39,6 +39,11 @@ public HardSoftBigDecimalScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public HardSoftBigDecimalScore getStructurallyFlawedScore(HardSoftBigDecimalScore score) { + return new HardSoftBigDecimalScore(-1L, score.hardScore(), score.softScore()); + } + @Override public HardSoftBigDecimalScore getZeroScore() { return HardSoftBigDecimalScore.ZERO; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftScoreDefinition.java index f915a04ba2d..e4ed03f3875 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/HardSoftScoreDefinition.java @@ -38,6 +38,11 @@ public HardSoftScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public HardSoftScore getStructurallyFlawedScore(HardSoftScore score) { + return new HardSoftScore(-1L, score.hardScore(), score.softScore()); + } + @Override public HardSoftScore getZeroScore() { return HardSoftScore.ZERO; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/ScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/ScoreDefinition.java index ceb90528f11..f47107b9e36 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/ScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/ScoreDefinition.java @@ -57,6 +57,13 @@ public interface ScoreDefinition> { */ Score_ getStructurallyFlawedScore(); + /** + * The same score as the argument, except with a negative structural score + * + * @return never null + */ + Score_ getStructurallyFlawedScore(Score_ score); + /** * The score that represents zero. * diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleBigDecimalScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleBigDecimalScoreDefinition.java index 0c6914c54bc..f37d816492f 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleBigDecimalScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleBigDecimalScoreDefinition.java @@ -38,6 +38,11 @@ public SimpleBigDecimalScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public SimpleBigDecimalScore getStructurallyFlawedScore(SimpleBigDecimalScore score) { + return new SimpleBigDecimalScore(-1L, score.score()); + } + @Override public SimpleBigDecimalScore getZeroScore() { return SimpleBigDecimalScore.ZERO; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleScoreDefinition.java b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleScoreDefinition.java index b0c176da583..675a859127d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleScoreDefinition.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/definition/SimpleScoreDefinition.java @@ -33,6 +33,11 @@ public SimpleScore getStructurallyFlawedScore() { return STRUCTURALLY_FLAWED_SCORE; } + @Override + public SimpleScore getStructurallyFlawedScore(SimpleScore score) { + return new SimpleScore(-1L, score.score()); + } + @Override public SimpleScore getZeroScore() { return SimpleScore.ZERO; 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 34bbc5739c3..967e19d845b 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 @@ -111,7 +111,8 @@ protected AbstractScoreDirector(AbstractScoreDirectorBuilder(solutionDescriptor); this.shadowVariableSupport = ShadowVariableSupport.create(this); this.shadowVariableSupport.linkShadowVariables(); @@ -527,6 +528,7 @@ public InnerScoreDirector createChildThreadScoreDirector(Chil var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() .withLookUpEnabled(lookUpEnabled) .withConstraintMatchPolicy(constraintMatchPolicy) + .withForceAllowInconsistentSolutions(!ignoreInconsistentSolutions) .buildDerived(); // ScoreCalculationCountTermination takes into account previous phases // but the calculationCount of partitions is maxed, not summed. @@ -536,6 +538,7 @@ public InnerScoreDirector createChildThreadScoreDirector(Chil var childThreadScoreDirector = scoreDirectorFactory.createScoreDirectorBuilder() .withLookUpEnabled(true) .withConstraintMatchPolicy(constraintMatchPolicy) + .withForceAllowInconsistentSolutions(!ignoreInconsistentSolutions) .buildDerived(); childThreadScoreDirector.setWorkingSolution(cloneWorkingSolution()); return childThreadScoreDirector; @@ -808,6 +811,7 @@ 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) + .withForceAllowInconsistentSolutions(!ignoreInconsistentSolutions) .buildDerived()) { uncorruptedScoreDirector.setWorkingSolution(workingSolution); var uncorruptedInnerScore = uncorruptedScoreDirector.calculateScore(); @@ -1006,6 +1010,7 @@ public abstract static class AbstractScoreDirectorBuilder build(); /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/test/AbstractConstraintAssertion.java b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/test/AbstractConstraintAssertion.java index db6d6acac3e..64bd4beb130 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/stream/test/AbstractConstraintAssertion.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/stream/test/AbstractConstraintAssertion.java @@ -1,6 +1,7 @@ package ai.timefold.solver.core.impl.score.stream.test; import java.util.Collection; +import java.util.Collections; import java.util.Map; import java.util.TreeMap; @@ -85,7 +86,9 @@ protected String explainScore(InnerScore workingScore, var constraintRef = constraintMatchTotal.getConstraintRef(); constraintAnalyses.put(constraintRef, constraintMatchTotal); } - return s.analyze(workingScore, constraintAnalyses, ScoreAnalysisFetchPolicy.FETCH_ALL) + return s.analyze(workingScore, constraintAnalyses, Collections.emptyList(), + null, + ScoreAnalysisFetchPolicy.FETCH_ALL) .summarize(); }, () -> "Score analysis is only available in Timefold Solver Enterprise Edition."); 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 3ae773060d1..cadd4b2f0db 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 @@ -1,7 +1,10 @@ package ai.timefold.solver.core.impl.solver; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.function.BiFunction; import java.util.function.Function; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; @@ -57,17 +60,21 @@ public Score_ update(Solution_ solution, SolutionUpdatePolicy solutionUpdatePoli .formatted(this.getClass().getSimpleName(), solutionUpdatePolicy)); } return callScoreDirector("Solution update", solution, solutionUpdatePolicy, - s -> s.getSolutionDescriptor().getScore(s.getWorkingSolution()), ConstraintMatchPolicy.DISABLED, false); + (s, inconsistentEntities) -> s.getSolutionDescriptor().getScore(s.getWorkingSolution()), + ConstraintMatchPolicy.DISABLED, false, false); } private Result_ callScoreDirector(String feature, Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy, - Function, Result_> function, ConstraintMatchPolicy constraintMatchPolicy, - boolean cloneSolution) { + BiFunction, Collection, Result_> function, + ConstraintMatchPolicy constraintMatchPolicy, + boolean cloneSolution, boolean handlesStructurallyFlawedSolutions) { var isShadowVariableUpdateEnabled = solutionUpdatePolicy.isShadowVariableUpdateEnabled(); var nonNullSolution = Objects.requireNonNull(solution); try (var scoreDirector = getScoreDirectorFactory().createScoreDirectorBuilder().withLookUpEnabled(cloneSolution) .withConstraintMatchPolicy(constraintMatchPolicy) - .withExpectShadowVariablesInCorrectState(!isShadowVariableUpdateEnabled).build()) { + .withExpectShadowVariablesInCorrectState(!isShadowVariableUpdateEnabled) + .withForceAllowInconsistentSolutions(handlesStructurallyFlawedSolutions) + .build()) { nonNullSolution = cloneSolution ? scoreDirector.cloneSolution(nonNullSolution) : nonNullSolution; if (isShadowVariableUpdateEnabled) { scoreDirector.setWorkingSolution(nonNullSolution); @@ -83,17 +90,36 @@ private Result_ callScoreDirector(String feature, Solution_ solution, Requested constraint matching but score director doesn't support it. Maybe use Constraint Streams instead of Easy or Incremental score calculator?"""); } + + // if handlesStructurallyFlawedSolutions is true, then the score can never be structurally flawed + // and all variable updates will be successful + Collection inconsistentEntities = null; if (solutionUpdatePolicy.isScoreUpdateEnabled()) { var score = scoreDirector.calculateScore(); if (score.isStructurallyFlawed()) { - var inconsistentEntities = scoreDirector.computeInconsistentEntities(); + inconsistentEntities = scoreDirector.computeInconsistentEntities(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } + if (handlesStructurallyFlawedSolutions) { + inconsistentEntities = scoreDirector.computeInconsistentEntities(); + if (!inconsistentEntities.isEmpty()) { + scoreDirector.getSolutionDescriptor().setScore( + scoreDirector.getWorkingSolution(), + scoreDirector.getScoreDefinition().getStructurallyFlawedScore() + .add(score.raw())); + } + } } else if (!scoreDirector.isLastVariableUpdateSuccessful()) { - var inconsistentEntities = scoreDirector.computeInconsistentEntities(); + inconsistentEntities = scoreDirector.computeInconsistentEntities(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } - return function.apply(scoreDirector); + + if (inconsistentEntities == null) { + inconsistentEntities = (handlesStructurallyFlawedSolutions) ? scoreDirector.computeInconsistentEntities() + : Collections.emptyList(); + } + + return function.apply(scoreDirector, inconsistentEntities); } } @@ -122,9 +148,10 @@ public ScoreAnalysis analyze(Solution_ solution, ScoreAnalysisFetchPolic TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.SCORE_ANALYSIS); var currentScore = (Score_) scoreDirectorFactory.getSolutionDescriptor().getScore(solution); var analysis = callScoreDirector("Score analysis", solution, solutionUpdatePolicy, - scoreDirector -> enterpriseService.analyze(scoreDirector.calculateScore(), - scoreDirector.getConstraintMatchTotalMap(), fetchPolicy), - ConstraintMatchPolicy.match(fetchPolicy), false); + (scoreDirector, inconsistentEntities) -> enterpriseService.analyze(scoreDirector.calculateScore(), + scoreDirector.getConstraintMatchTotalMap(), inconsistentEntities, + scoreDirector.getScoreDefinition(), fetchPolicy), + ConstraintMatchPolicy.match(fetchPolicy), false, true); assertFreshScore(solution, currentScore, analysis.score(), solutionUpdatePolicy); return analysis; } @@ -144,9 +171,10 @@ public List> recommendAssignment var enterpriseService = TimefoldSolverEnterpriseService.loadOrFail(TimefoldSolverEnterpriseService.Feature.RECOMMENDATIONS); return callScoreDirector("Recommended assignment", - solution, SolutionUpdatePolicy.UPDATE_ALL, enterpriseService.buildRecommender(solverFactory, solution, - evaluatedEntityOrElement, propositionFunction, fetchPolicy), - ConstraintMatchPolicy.match(fetchPolicy), true); + solution, SolutionUpdatePolicy.UPDATE_ALL, + (scoreDirector, inconsistentEntities) -> enterpriseService.buildRecommender(solverFactory, solution, + evaluatedEntityOrElement, propositionFunction, fetchPolicy).apply((InnerScoreDirector) scoreDirector), + ConstraintMatchPolicy.match(fetchPolicy), true, false); } } From 188f1012e5f011818d1154aefa319046259c127d Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Fri, 21 Aug 2026 15:19:25 -0400 Subject: [PATCH 5/9] docs: update docs --- .../constraints-and-score/understanding-the-score.adoc | 7 +++---- .../pages/domain-modeling/modeling-planning-problems.adoc | 7 +++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc index 9b239a33e4d..236208df682 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc @@ -93,10 +93,9 @@ In that case, use `ScoreAnalysisFetchPolicy.FETCH_MATCH_COUNT` instead of the de [NOTE] ==== -`SolutionManager.analyze(...)` requires a structurally sound solution. -If the solution has a negative xref:constraints-and-score/overview.adoc#structuralScore[structural score], -the method fails fast and throws an `InconsistentSolutionException` -instead of returning a `ScoreAnalysis`. +The score analysis of structurally flawed solutions has a structural flaw analysis, +which can be used to get inconsistent entities. +Constraint analyses are still available for structurally flawed solutions, but will exclude any matches sourced from an inconsistent entity. ==== It is also possible to print the score summary: diff --git a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc index 8e5333096a8..d046eff5ea9 100644 --- a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc +++ b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc @@ -2051,9 +2051,12 @@ that is `0` for structurally sound solutions and negative for structurally flawe A structurally flawed score is worse than any structurally sound score, so a structurally flawed solution is never accepted during solving. -When a structurally flawed solution is passed to `SolutionManager.update(...)` or `SolutionManager.analyze(...)`, -these methods fail fast and throw an `InconsistentSolutionException`, +When a structurally flawed solution is passed to `SolutionManager.update(...)`, +the method fails fast and throws an `InconsistentSolutionException`, which exposes the solution and the entities involved in the inconsistency. +When passed to `SolutionManager.analyze(...)`, +the returned xref:constraints-and-score/understanding-the-score.adoc[Score analysis] +has a structural flaw analysis that can be used to get the inconsistent entities. When a structurally flawed solution is passed to `Solver.solve`, the `Solver` logs a warning and unassigns the involved planning entities before solving. From db4867e9f1e7bb7cadc3f76787c75413d4214048 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Thu, 27 Aug 2026 17:45:05 -0400 Subject: [PATCH 6/9] chore: review comments --- .../InconsistentSolutionException.java | 14 ++++---- .../score/analysis/EntityVariablePair.java | 17 ++++++++++ .../score/analysis/LoopedVariableInfo.java | 31 +++++++++++++++++ .../analysis/StructuralFlawAnalysis.java | 8 ++--- .../api/solver/phase/PhaseCommandContext.java | 33 +++++++++++++++---- .../TimefoldSolverEnterpriseService.java | 8 +++-- .../variable/ShadowVariableSupport.java | 5 +-- .../DefaultShadowVariableSession.java | 7 ++-- .../DefaultVariableReferenceGraph.java | 14 +++++--- .../EmptyVariableReferenceGraph.java | 5 +-- .../FixedVariableReferenceGraph.java | 6 ++-- ...rectionalParentVariableReferenceGraph.java | 4 +-- .../declarative/VariableReferenceGraph.java | 4 +-- .../custom/DefaultPhaseCommandContext.java | 4 +-- .../score/director/AbstractScoreDirector.java | 13 +++++--- .../score/director/InnerScoreDirector.java | 6 +++- .../impl/solver/DefaultSolutionManager.java | 14 ++++---- .../core/api/solver/SolutionManagerTest.java | 27 +++++++++++++-- .../DefaultPhaseCommandContextTest.java | 8 ++--- 19 files changed, 171 insertions(+), 57 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/api/score/analysis/EntityVariablePair.java create mode 100644 core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.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 index ef45228b4b8..389c5425be1 100644 --- 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 @@ -1,19 +1,20 @@ package ai.timefold.solver.core.api.domain.variable; -import java.util.Collection; import java.util.List; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; + import org.jspecify.annotations.NullMarked; @NullMarked public class InconsistentSolutionException extends RuntimeException { private final Object solution; - private final Collection involvedEntityCollection; + private final List inconsistentGroups; - public InconsistentSolutionException(String feature, Object solution, Collection involvedEntityCollection) { + public InconsistentSolutionException(String feature, Object solution, List inconsistentGroups) { super("The solution (%s) is inconsistent. %s requires a consistent solution.".formatted(solution, feature)); this.solution = solution; - this.involvedEntityCollection = involvedEntityCollection; + this.inconsistentGroups = inconsistentGroups; } @SuppressWarnings("unchecked") @@ -21,8 +22,7 @@ public T getSolution() { return (T) solution; } - @SuppressWarnings("unchecked") - public List getInvolvedEntityCollection() { - return (List) involvedEntityCollection; + public List getInconsistentGroups() { + return inconsistentGroups; } } diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/EntityVariablePair.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/EntityVariablePair.java new file mode 100644 index 00000000000..0c6fe3fab08 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/EntityVariablePair.java @@ -0,0 +1,17 @@ +package ai.timefold.solver.core.api.score.analysis; + +import org.jspecify.annotations.NullMarked; + +/** + * A pair of an entity and a variable on it. + * + * @param entity The entity. + * @param variableName The variable on the entity. + */ +@NullMarked +public record EntityVariablePair(Object entity, String variableName) { + @Override + public String toString() { + return "%s.%s".formatted(entity, variableName); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java new file mode 100644 index 00000000000..bb93de1ece9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java @@ -0,0 +1,31 @@ +package ai.timefold.solver.core.api.score.analysis; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.jspecify.annotations.NullMarked; + +/** + * A set of entity-variable pairs that form a cycle. + * + * @param involvedVariableSet + */ +@NullMarked +public record LoopedVariableInfo(Set involvedVariableSet) { + /** + * Get the set of involved entities in the cycle + */ + @SuppressWarnings("unchecked") + public Set getEntitySet() { + return (Set) involvedVariableSet.stream() + .map(EntityVariablePair::entity) + .collect(Collectors.toSet()); + } + + @Override + public String toString() { + return involvedVariableSet.stream() + .map(EntityVariablePair::toString) + .collect(Collectors.joining(", ", "[", "]")); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java index dc7f5553fdc..72c7d29d9ca 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java @@ -1,6 +1,6 @@ package ai.timefold.solver.core.api.score.analysis; -import java.util.Collection; +import java.util.List; import org.jspecify.annotations.NullMarked; @@ -10,8 +10,8 @@ @NullMarked public interface StructuralFlawAnalysis { /** - * Return a collection of {@link ai.timefold.solver.core.api.domain.entity.PlanningEntity} - * that have inconsistent shadow variables. + * Return a list of independent {@link LoopedVariableInfo} + * that form cycles and thus cause inconsistencies in the solution. */ - Collection getInconsistentEntities(); + List getInconsistentGroups(); } diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java index 617c44dac4e..348f10c7c87 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java @@ -53,7 +53,10 @@ public interface PhaseCommandContext * without recalculating the score for performance reasons. * * @param move the move to execute - * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. + * @throws IllegalArgumentException if the move causes the solution to have a negative + * {@link Score#structuralScore()}. If you are unsure if a move will result in a structural + * solution, use {@link #executeTemporarily(Move)} to check + * if a move results in a structural flawed solution before executing it. */ void execute(Move move); @@ -63,7 +66,10 @@ public interface PhaseCommandContext * * @param move the move to execute * @return the new score of the working solution after executing the move - * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. + * @throws IllegalArgumentException if the move causes the solution to have a negative + * {@link Score#structuralScore()}. If you are unsure if a move will result in a structural + * solution, use {@link #executeTemporarily(Move)} to check + * if a move results in a structural flawed solution before executing it. */ > Score_ executeAndCalculateScore(Move move); @@ -77,6 +83,8 @@ public interface PhaseCommandContext * this solution must not be modified any further. * @return the result of the consumer * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. + * Use {@link #executeTemporarily(Move, Function, Function)} instead, + * where structurally flawed solutions are handled by a separate consumer. */ @Nullable Result_ executeTemporarily(Move move, Function temporarySolutionConsumer); @@ -86,11 +94,13 @@ public interface PhaseCommandContext * except having a separate consumer to handle solutions with a negative {@link Score#structuralScore()}. * * @param move the move to execute temporarily - * @param temporarySolutionConsumer the consumer to execute with the temporarily modified solution; + * @param temporarySolutionConsumer the consumer to execute with the temporarily modified structurally valid solution; * this solution must not be modified any further. + * @param structurallyFlawedSolutionConsumer the consumer that is called when a move results in a structurally flawed + * solution. This solution must not be modified any further. * @return the result of the consumer */ - @Nullable Result_ executeTemporarilyHandlingStructurallyFlawedSolutions(Move move, + @Nullable Result_ executeTemporarily(Move move, Function temporarySolutionConsumer, Function structurallyFlawedSolutionConsumer); @@ -113,17 +123,26 @@ public interface PhaseCommandContext /** * As defined by {@link #executeTemporarily(Move, Function)}, * with the guarantee of a fresh score at the end of the method's invocation. - * + * + * @param move the move to execute temporarily + * @param temporarySolutionConsumer the consumer to execute with the temporarily modified solution; + * this solution must not be modified any further. * @throws IllegalArgumentException if the move causes the solution to have a negative {@link Score#structuralScore()}. */ @Nullable Result_ executeTemporarilyAndCalculateScore(Move move, Function temporarySolutionConsumer); /** - * As defined by {@link #executeTemporarilyHandlingStructurallyFlawedSolutions(Move, Function, Function)}, + * As defined by {@link #executeTemporarily(Move, Function, Function)}, * with the guarantee of a fresh score at the end of the method's invocation. + * + * @param move the move to execute temporarily + * @param temporarySolutionConsumer the consumer to execute with the temporarily modified structurally valid solution; + * this solution must not be modified any further. + * @param structurallyFlawedSolutionConsumer the consumer that is called when a move results in a structurally flawed + * solution. This solution must not be modified any further. */ - @Nullable Result_ executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(Move move, + @Nullable Result_ executeTemporarilyAndCalculateScore(Move move, Function temporarySolutionConsumer, Function structurallyFlawedSolutionConsumer); 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 ded6092e75b..734ecceda44 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 @@ -1,7 +1,6 @@ package ai.timefold.solver.core.enterprise; import java.lang.reflect.InvocationTargetException; -import java.util.Collection; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -9,6 +8,7 @@ import java.util.function.Supplier; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.api.score.stream.ConstraintRef; @@ -227,9 +227,13 @@ DestinationSelector applyNearbySelection(DestinationSelec InnerConstraintProfiler buildConstraintProfiler(); + /** + * @param inconsistentEntities the entities that are inconsistent + * @param scoreDefinition can be null if inconsistentEntities is known to be empty + */ > ScoreAnalysis analyze(InnerScore state, Map> constraintMatchTotalMap, - Collection inconsistentEntities, + List inconsistentEntities, @Nullable ScoreDefinition scoreDefinition, ScoreAnalysisFetchPolicy fetchPolicy); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java index 266fac0a81a..f04cf81272a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java @@ -12,6 +12,7 @@ import java.util.function.IntFunction; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.cascade.CascadingUpdateShadowVariableDescriptor; @@ -424,11 +425,11 @@ public boolean updateShadowVariables() { return true; } - public Collection getInconsistentEntities() { + public List getInconsistentGroups() { if (shadowVariableSession == null) { return Collections.emptyList(); } - return shadowVariableSession.getInconsistentEntities(); + return shadowVariableSession.getInconsistentGroups(); } /** 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 2391448d13a..135cf9bba32 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,7 +1,8 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; -import java.util.Collection; +import java.util.List; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.supply.Supply; @@ -53,7 +54,7 @@ public boolean updateVariables() { return graph.updateChanged(); } - public Collection getInconsistentEntities() { - return graph.getInconsistentEntities(); + public List getInconsistentGroups() { + return graph.getInconsistentGroups(); } } 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 765a027f111..c499b6af67c 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 @@ -2,12 +2,14 @@ import java.util.ArrayList; import java.util.BitSet; -import java.util.Collection; import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.function.IntFunction; +import ai.timefold.solver.core.api.score.analysis.EntityVariablePair; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; + import org.jspecify.annotations.NonNull; final class DefaultVariableReferenceGraph extends AbstractVariableReferenceGraph { @@ -75,17 +77,21 @@ public void setUnknownInconsistencyValues() { } @Override - public Collection getInconsistentEntities() { - var out = new LinkedHashSet<>(); + public List getInconsistentGroups() { + var out = new ArrayList(); var graphTrackingInconsistentEntities = new DefaultTopologicalOrderGraph(this.nodeTopologicalOrders.length); graph.forEachEdge(graphTrackingInconsistentEntities::addEdge); graphTrackingInconsistentEntities.commitChanges(new BitSet()); var loopedComponentList = graphTrackingInconsistentEntities.getLoopedComponentList(); for (var loopedComponent : loopedComponentList) { + var entityVariablePairs = new LinkedHashSet(loopedComponent.size()); for (var nodeId : loopedComponent) { var node = this.nodeList.get(nodeId); - out.add(node.entity()); + for (var variable : node.variableReferences()) { + entityVariablePairs.add(new EntityVariablePair(node.entity(), variable.id().name())); + } } + out.add(new LoopedVariableInfo(entityVariablePairs)); } return 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 e70e3104189..0c33e7188f9 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,8 +1,9 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; -import java.util.Collection; import java.util.Collections; +import java.util.List; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; final class EmptyVariableReferenceGraph implements VariableReferenceGraph { @@ -26,7 +27,7 @@ public void afterVariableChanged(VariableMetaModel variableReference, O } @Override - public Collection getInconsistentEntities() { + public List getInconsistentGroups() { return Collections.emptyList(); } 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 0a6c9ffc138..18e055dfe2e 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,13 +1,15 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; import java.util.BitSet; -import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.PriorityQueue; import java.util.Spliterators; import java.util.function.IntFunction; import java.util.stream.StreamSupport; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; + import org.jspecify.annotations.NonNull; public final class FixedVariableReferenceGraph @@ -110,7 +112,7 @@ boolean innerUpdateChanged() { } @Override - public Collection getInconsistentEntities() { + public List getInconsistentGroups() { 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 91ca1e29b49..84229826b30 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,7 +2,6 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; @@ -12,6 +11,7 @@ import java.util.Set; import java.util.function.UnaryOperator; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public final class SingleDirectionalParentVariableReferenceGraph implements VariableReferenceGraph { @@ -139,7 +139,7 @@ public void afterVariableChanged(VariableMetaModel variableReference, O } @Override - public Collection getInconsistentEntities() { + public List getInconsistentGroups() { 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 3d776f63be8..b27b8598e59 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,8 +1,8 @@ package ai.timefold.solver.core.impl.domain.variable.declarative; -import java.util.Collection; import java.util.List; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public sealed interface VariableReferenceGraph @@ -71,5 +71,5 @@ default void afterListVariableChanged(VariableMetaModel variableReferen // Most graphs do not have edges that depend on a list variable's contents. } - Collection getInconsistentEntities(); + List getInconsistentGroups(); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java index fc005982c7b..82606aec547 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContext.java @@ -62,7 +62,7 @@ public > Score_ executeAndCalculateScore(Move Result_ executeTemporarilyHandlingStructurallyFlawedSolutions(Move move, + public @Nullable Result_ executeTemporarily(Move move, Function temporarySolutionConsumer, Function structurallyFlawedSolutionConsumer) { return moveDirector.executeTemporaryHandlingStructurallyFlawedSolutions(move, @@ -85,7 +85,7 @@ public > Score_ executeTemporarily(Move } @Override - public @Nullable Result_ executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions( + public @Nullable Result_ executeTemporarilyAndCalculateScore( Move move, Function temporarySolutionConsumer, Function structurallyFlawedSolutionConsumer) { return moveDirector.executeTemporaryHandlingStructurallyFlawedSolutions(move, 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 967e19d845b..8799e1dfc0b 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 @@ -2,10 +2,10 @@ import static java.util.Objects.requireNonNull; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -15,6 +15,7 @@ import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; import ai.timefold.solver.core.api.domain.variable.ShadowVariable; import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.api.solver.change.ProblemChange; import ai.timefold.solver.core.api.solver.change.ProblemChangeDirector; import ai.timefold.solver.core.config.solver.EnvironmentMode; @@ -346,12 +347,16 @@ protected void afterSetWorkingSolution() { // Do nothing } - public Collection computeInconsistentEntities() { - return shadowVariableSupport.getInconsistentEntities(); + public List computeInconsistentGroups() { + return shadowVariableSupport.getInconsistentGroups(); } public void unassignInconsistentEntities() { - var inconsistentEntities = computeInconsistentEntities(); + var inconsistentCycles = computeInconsistentGroups(); + var inconsistentEntities = new LinkedHashSet<>(); + for (var inconsistentCycle : inconsistentCycles) { + inconsistentEntities.addAll(inconsistentCycle.getEntitySet()); + } if (listVariableStateSupply != null) { var listVariableDescriptor = listVariableStateSupply.getSourceVariableDescriptor(); var listElementClass = listVariableStateSupply.getSourceVariableDescriptor().getElementType(); 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 e94ac21b4da..1c9a174a09e 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 @@ -5,6 +5,7 @@ import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowVariablesInconsistent; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.stream.Constraint; import ai.timefold.solver.core.api.score.stream.ConstraintRef; @@ -332,7 +333,10 @@ default void forceTriggerVariableListeners() { } /** - * @return true if the last {@link #updateShadowVariables()} was successful, false otherwise. + * @return true if the last {@link #updateShadowVariables()} did not result in a structurally flawed solutions, + * false otherwise. + *

+ * Note: Planning models with {@link ShadowVariablesInconsistent} will always result in successful updates. */ boolean isLastVariableUpdateSuccessful(); 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 cadd4b2f0db..c3d8fde78aa 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 @@ -1,6 +1,5 @@ package ai.timefold.solver.core.impl.solver; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -10,6 +9,7 @@ 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.LoopedVariableInfo; import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; import ai.timefold.solver.core.api.solver.RecommendedAssignment; import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy; @@ -65,7 +65,7 @@ public Score_ update(Solution_ solution, SolutionUpdatePolicy solutionUpdatePoli } private Result_ callScoreDirector(String feature, Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy, - BiFunction, Collection, Result_> function, + BiFunction, List, Result_> function, ConstraintMatchPolicy constraintMatchPolicy, boolean cloneSolution, boolean handlesStructurallyFlawedSolutions) { var isShadowVariableUpdateEnabled = solutionUpdatePolicy.isShadowVariableUpdateEnabled(); @@ -93,15 +93,15 @@ private Result_ callScoreDirector(String feature, Solution_ solution, // if handlesStructurallyFlawedSolutions is true, then the score can never be structurally flawed // and all variable updates will be successful - Collection inconsistentEntities = null; + List inconsistentEntities = null; if (solutionUpdatePolicy.isScoreUpdateEnabled()) { var score = scoreDirector.calculateScore(); if (score.isStructurallyFlawed()) { - inconsistentEntities = scoreDirector.computeInconsistentEntities(); + inconsistentEntities = scoreDirector.computeInconsistentGroups(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } if (handlesStructurallyFlawedSolutions) { - inconsistentEntities = scoreDirector.computeInconsistentEntities(); + inconsistentEntities = scoreDirector.computeInconsistentGroups(); if (!inconsistentEntities.isEmpty()) { scoreDirector.getSolutionDescriptor().setScore( scoreDirector.getWorkingSolution(), @@ -110,12 +110,12 @@ private Result_ callScoreDirector(String feature, Solution_ solution, } } } else if (!scoreDirector.isLastVariableUpdateSuccessful()) { - inconsistentEntities = scoreDirector.computeInconsistentEntities(); + inconsistentEntities = scoreDirector.computeInconsistentGroups(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } if (inconsistentEntities == null) { - inconsistentEntities = (handlesStructurallyFlawedSolutions) ? scoreDirector.computeInconsistentEntities() + inconsistentEntities = (handlesStructurallyFlawedSolutions) ? scoreDirector.computeInconsistentGroups() : Collections.emptyList(); } 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 d5a26b664b8..c35aeb45f7d 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 @@ -13,6 +13,7 @@ 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.api.score.analysis.EntityVariablePair; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.testdomain.list.shadowhistory.TestdataListEntityWithShadowHistory; @@ -143,7 +144,18 @@ void updateInconsistent(SolutionManagerSource solutionManagerSource) { .hasMessageContainingAll("The solution (", "is inconsistent", "Solution update", "requires a consistent solution") .hasFieldOrPropertyWithValue("solution", inconsistentSolution) - .hasFieldOrPropertyWithValue("involvedEntityCollection", Set.of(valueA1, valueA2)); + .matches(exception -> { + var inconsistentGroups = ((InconsistentSolutionException) exception).getInconsistentGroups(); + if (inconsistentGroups.size() != 1) { + return false; + } + return inconsistentGroups.getFirst().involvedVariableSet() + .equals(Set.of( + new EntityVariablePair(valueA1, "startTime"), + new EntityVariablePair(valueA1, "endTime"), + new EntityVariablePair(valueA2, "startTime"), + new EntityVariablePair(valueA2, "endTime"))); + }); } private void assertShadowedListValueAllNull(SoftAssertions softly, TestdataListValueWithShadowHistory current) { @@ -205,7 +217,18 @@ void updateOnlyShadowVariablesInconsistent(SolutionManagerSource solutionManager .hasMessageContainingAll("The solution (", "is inconsistent", "Solution update", "requires a consistent solution") .hasFieldOrPropertyWithValue("solution", inconsistentSolution) - .hasFieldOrPropertyWithValue("involvedEntityCollection", Set.of(valueA1, valueA2)); + .matches(exception -> { + var inconsistentGroups = ((InconsistentSolutionException) exception).getInconsistentGroups(); + if (inconsistentGroups.size() != 1) { + return false; + } + return inconsistentGroups.getFirst().involvedVariableSet() + .equals(Set.of( + new EntityVariablePair(valueA1, "startTime"), + new EntityVariablePair(valueA1, "endTime"), + new EntityVariablePair(valueA2, "startTime"), + new EntityVariablePair(valueA2, "endTime"))); + }); } @ParameterizedTest diff --git a/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java b/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java index b3e60f50e45..19ec93d09a2 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/phase/custom/DefaultPhaseCommandContextTest.java @@ -53,7 +53,7 @@ void executeTemporarilyHandlingStructurallyFlawedSolutions_moveDoesNotFlawSoluti when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.of(0))); var result = commandContext - .executeTemporarilyHandlingStructurallyFlawedSolutions(move, + .executeTemporarily(move, solutionConsumer, flawedSolutionConsumer); @@ -69,7 +69,7 @@ void executeTemporarilyHandlingStructurallyFlawedSolutions_moveFlawsSolution() { when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(new SimpleScore(-1, 0))); var result = commandContext - .executeTemporarilyHandlingStructurallyFlawedSolutions(move, + .executeTemporarily(move, solutionConsumer, flawedSolutionConsumer); @@ -85,7 +85,7 @@ void executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions_move when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(SimpleScore.of(0))); var result = commandContext - .executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(move, + .executeTemporarilyAndCalculateScore(move, solutionConsumer, flawedSolutionConsumer); @@ -101,7 +101,7 @@ void executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions_move when(scoreDirector.calculateScore()).thenReturn(InnerScore.fullyAssigned(new SimpleScore(-1, 0))); var result = commandContext - .executeTemporarilyAndCalculateScoreHandlingStructurallyFlawedSolutions(move, + .executeTemporarilyAndCalculateScore(move, solutionConsumer, flawedSolutionConsumer); From 95bfe002d49adc00ead7f622679b8ddc87f0a604 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Thu, 27 Aug 2026 19:21:07 -0400 Subject: [PATCH 7/9] docs: update docs --- .../api/domain/variable/ShadowVariablesInconsistent.java | 6 ++++++ .../constraints-and-score/understanding-the-score.adoc | 5 +---- .../pages/domain-modeling/modeling-planning-problems.adoc | 7 +++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java index d66fa9d47a8..5a848b23b7d 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java @@ -9,6 +9,7 @@ import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.score.stream.Constraint; /** @@ -129,8 +130,13 @@ * Do not use a {@link ShadowVariablesInconsistent} property in a method annotated with {@link ShadowSources}. * {@link ShadowSources} marked methods do not need to check {@link ShadowVariablesInconsistent} properties, * since they are only called if all their dependencies are consistent. + * + * @deprecated The introduction of {@link Score#structuralScore()} removed the need for this annotation. + * If you currently have this annotation on a property, you are encouraged to remove + * it to have simpler constraints and faster solve speeds. */ @Target({ METHOD, FIELD }) @Retention(RUNTIME) +@Deprecated(since = "2.6.0") public @interface ShadowVariablesInconsistent { } diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc index 236208df682..8dac6b805ad 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/understanding-the-score.adoc @@ -91,12 +91,9 @@ For performance reasons and especially with large datasets that you'll later nee In that case, use `ScoreAnalysisFetchPolicy.FETCH_MATCH_COUNT` instead of the default `ScoreAnalysisFetchPolicy.FETCH_ALL` when calling `SolutionManager.analyze(...)`. ==== -[NOTE] -==== -The score analysis of structurally flawed solutions has a structural flaw analysis, +NOTE: The score analysis of structurally flawed solutions has a structural flaw analysis, which can be used to get inconsistent entities. Constraint analyses are still available for structurally flawed solutions, but will exclude any matches sourced from an inconsistent entity. -==== It is also possible to print the score summary: diff --git a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc index d046eff5ea9..37a59fa0056 100644 --- a/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc +++ b/docs/src/modules/ROOT/pages/domain-modeling/modeling-planning-problems.adoc @@ -2065,9 +2065,16 @@ for example because an involved entity is xref:domain-modeling/modeling-planning the `Solver` throws an `Exception`. ==== +If no entity have a `@ShadowVariablesInconsistent` property, you do not need to handle inconsistencies in any constraints, since the Solver will not calculate the score for any structurally flawed solutions. + + +==== Deprecated: Per entity inconsistency tracking + To detect whether an entity has inconsistent shadow variables, annotate a boolean field with `@ShadowVariablesInconsistent`. The solver will set this field to true if the entity has any inconsistent shadow variables. +IMPORTANT: If you use `@ShadowVariablesInconsistent`, you would need to handle inconsistent entities in your constraints, and have a slower solve speed. It is recommended to avoid that annotation, and to remove it if you currently have it. + [tabs] ==== Java:: From 840734450d173960b570663b1f32a8fa2239a17d Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 31 Aug 2026 16:31:02 -0400 Subject: [PATCH 8/9] chore: review comments --- .../variable/InconsistentSolutionException.java | 12 ++++++------ .../variable/ShadowVariablesInconsistent.java | 2 +- .../api/score/analysis/StructuralFlawAnalysis.java | 4 ++-- .../{LoopedVariableInfo.java => VariableLoop.java} | 2 +- .../core/api/solver/phase/PhaseCommandContext.java | 4 ++-- .../TimefoldSolverEnterpriseService.java | 8 ++++---- .../domain/variable/ShadowVariableSupport.java | 6 +++--- .../declarative/DefaultShadowVariableSession.java | 6 +++--- .../declarative/DefaultVariableReferenceGraph.java | 8 ++++---- .../declarative/EmptyVariableReferenceGraph.java | 4 ++-- .../declarative/FixedVariableReferenceGraph.java | 4 ++-- ...gleDirectionalParentVariableReferenceGraph.java | 4 ++-- .../declarative/VariableReferenceGraph.java | 4 ++-- .../impl/score/director/AbstractScoreDirector.java | 8 ++++---- .../core/impl/solver/DefaultSolutionManager.java | 14 +++++++------- .../core/api/solver/SolutionManagerTest.java | 4 ++-- 16 files changed, 47 insertions(+), 47 deletions(-) rename core/src/main/java/ai/timefold/solver/core/api/score/analysis/{LoopedVariableInfo.java => VariableLoop.java} (90%) 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 index 389c5425be1..e9b9ddf7664 100644 --- 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 @@ -2,19 +2,19 @@ import java.util.List; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import org.jspecify.annotations.NullMarked; @NullMarked public class InconsistentSolutionException extends RuntimeException { private final Object solution; - private final List inconsistentGroups; + private final List variableLoops; - public InconsistentSolutionException(String feature, Object solution, List inconsistentGroups) { + public InconsistentSolutionException(String feature, Object solution, List variableLoops) { super("The solution (%s) is inconsistent. %s requires a consistent solution.".formatted(solution, feature)); this.solution = solution; - this.inconsistentGroups = inconsistentGroups; + this.variableLoops = variableLoops; } @SuppressWarnings("unchecked") @@ -22,7 +22,7 @@ public T getSolution() { return (T) solution; } - public List getInconsistentGroups() { - return inconsistentGroups; + public List getVariableLoops() { + return variableLoops; } } diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java index 5a848b23b7d..e1827cbc0ab 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/ShadowVariablesInconsistent.java @@ -137,6 +137,6 @@ */ @Target({ METHOD, FIELD }) @Retention(RUNTIME) -@Deprecated(since = "2.6.0") +@Deprecated(since = "2.7.0") public @interface ShadowVariablesInconsistent { } diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java index 72c7d29d9ca..c9c83c698bb 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/StructuralFlawAnalysis.java @@ -10,8 +10,8 @@ @NullMarked public interface StructuralFlawAnalysis { /** - * Return a list of independent {@link LoopedVariableInfo} + * Return a list of independent {@link VariableLoop} * that form cycles and thus cause inconsistencies in the solution. */ - List getInconsistentGroups(); + List getVariableLoops(); } diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/VariableLoop.java similarity index 90% rename from core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java rename to core/src/main/java/ai/timefold/solver/core/api/score/analysis/VariableLoop.java index bb93de1ece9..c50ef105e83 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/analysis/LoopedVariableInfo.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/analysis/VariableLoop.java @@ -11,7 +11,7 @@ * @param involvedVariableSet */ @NullMarked -public record LoopedVariableInfo(Set involvedVariableSet) { +public record VariableLoop(Set involvedVariableSet) { /** * Get the set of involved entities in the cycle */ diff --git a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java index 348f10c7c87..c05209b4228 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java +++ b/core/src/main/java/ai/timefold/solver/core/api/solver/phase/PhaseCommandContext.java @@ -54,7 +54,7 @@ public interface PhaseCommandContext * * @param move the move to execute * @throws IllegalArgumentException if the move causes the solution to have a negative - * {@link Score#structuralScore()}. If you are unsure if a move will result in a structural + * {@link Score#structuralScore()}. If you are unsure if a move will result in a structurally valid * solution, use {@link #executeTemporarily(Move)} to check * if a move results in a structural flawed solution before executing it. */ @@ -67,7 +67,7 @@ public interface PhaseCommandContext * @param move the move to execute * @return the new score of the working solution after executing the move * @throws IllegalArgumentException if the move causes the solution to have a negative - * {@link Score#structuralScore()}. If you are unsure if a move will result in a structural + * {@link Score#structuralScore()}. If you are unsure if a move will result in a structurally valid * solution, use {@link #executeTemporarily(Move)} to check * if a move results in a structural flawed solution before executing it. */ 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 734ecceda44..0b15608643c 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 @@ -8,8 +8,8 @@ import java.util.function.Supplier; import ai.timefold.solver.core.api.score.Score; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.api.score.stream.ConstraintRef; import ai.timefold.solver.core.api.solver.RecommendedAssignment; @@ -228,12 +228,12 @@ DestinationSelector applyNearbySelection(DestinationSelec InnerConstraintProfiler buildConstraintProfiler(); /** - * @param inconsistentEntities the entities that are inconsistent - * @param scoreDefinition can be null if inconsistentEntities is known to be empty + * @param variableLoops the variable loops in the solution + * @param scoreDefinition can be null if variableLoops is known to be empty */ > ScoreAnalysis analyze(InnerScore state, Map> constraintMatchTotalMap, - List inconsistentEntities, + List variableLoops, @Nullable ScoreDefinition scoreDefinition, ScoreAnalysisFetchPolicy fetchPolicy); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java index f04cf81272a..d24f1e540b7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableSupport.java @@ -12,7 +12,7 @@ import java.util.function.IntFunction; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.cascade.CascadingUpdateShadowVariableDescriptor; @@ -425,11 +425,11 @@ public boolean updateShadowVariables() { return true; } - public List getInconsistentGroups() { + public List getVariableLoops() { if (shadowVariableSession == null) { return Collections.emptyList(); } - return shadowVariableSession.getInconsistentGroups(); + return shadowVariableSession.getVariableLoops(); } /** 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 135cf9bba32..b0d70f97ab4 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 @@ -2,7 +2,7 @@ import java.util.List; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.supply.Supply; @@ -54,7 +54,7 @@ public boolean updateVariables() { return graph.updateChanged(); } - public List getInconsistentGroups() { - return graph.getInconsistentGroups(); + public List getVariableLoops() { + return graph.getVariableLoops(); } } 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 c499b6af67c..1fad12e748d 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 @@ -8,7 +8,7 @@ import java.util.function.IntFunction; import ai.timefold.solver.core.api.score.analysis.EntityVariablePair; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import org.jspecify.annotations.NonNull; @@ -77,8 +77,8 @@ public void setUnknownInconsistencyValues() { } @Override - public List getInconsistentGroups() { - var out = new ArrayList(); + public List getVariableLoops() { + var out = new ArrayList(); var graphTrackingInconsistentEntities = new DefaultTopologicalOrderGraph(this.nodeTopologicalOrders.length); graph.forEachEdge(graphTrackingInconsistentEntities::addEdge); graphTrackingInconsistentEntities.commitChanges(new BitSet()); @@ -91,7 +91,7 @@ public List getInconsistentGroups() { entityVariablePairs.add(new EntityVariablePair(node.entity(), variable.id().name())); } } - out.add(new LoopedVariableInfo(entityVariablePairs)); + out.add(new VariableLoop(entityVariablePairs)); } return 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 0c33e7188f9..b858b3fa7ff 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 @@ -3,7 +3,7 @@ import java.util.Collections; import java.util.List; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; final class EmptyVariableReferenceGraph implements VariableReferenceGraph { @@ -27,7 +27,7 @@ public void afterVariableChanged(VariableMetaModel variableReference, O } @Override - public List getInconsistentGroups() { + public List getVariableLoops() { return Collections.emptyList(); } 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 18e055dfe2e..1b7ebda029b 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 @@ -8,7 +8,7 @@ import java.util.function.IntFunction; import java.util.stream.StreamSupport; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import org.jspecify.annotations.NonNull; @@ -112,7 +112,7 @@ boolean innerUpdateChanged() { } @Override - public List getInconsistentGroups() { + public List getVariableLoops() { 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 84229826b30..7848deadf17 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 @@ -11,7 +11,7 @@ import java.util.Set; import java.util.function.UnaryOperator; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public final class SingleDirectionalParentVariableReferenceGraph implements VariableReferenceGraph { @@ -139,7 +139,7 @@ public void afterVariableChanged(VariableMetaModel variableReference, O } @Override - public List getInconsistentGroups() { + public List getVariableLoops() { 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 b27b8598e59..395cac43b5e 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 @@ -2,7 +2,7 @@ import java.util.List; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel; public sealed interface VariableReferenceGraph @@ -71,5 +71,5 @@ default void afterListVariableChanged(VariableMetaModel variableReferen // Most graphs do not have edges that depend on a list variable's contents. } - List getInconsistentGroups(); + List getVariableLoops(); } 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 8799e1dfc0b..3e13fffeb90 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 @@ -15,7 +15,7 @@ import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; import ai.timefold.solver.core.api.domain.variable.ShadowVariable; import ai.timefold.solver.core.api.score.Score; -import ai.timefold.solver.core.api.score.analysis.LoopedVariableInfo; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.api.solver.change.ProblemChange; import ai.timefold.solver.core.api.solver.change.ProblemChangeDirector; import ai.timefold.solver.core.config.solver.EnvironmentMode; @@ -347,12 +347,12 @@ protected void afterSetWorkingSolution() { // Do nothing } - public List computeInconsistentGroups() { - return shadowVariableSupport.getInconsistentGroups(); + public List computeVariableLoops() { + return shadowVariableSupport.getVariableLoops(); } public void unassignInconsistentEntities() { - var inconsistentCycles = computeInconsistentGroups(); + var inconsistentCycles = computeVariableLoops(); var inconsistentEntities = new LinkedHashSet<>(); for (var inconsistentCycle : inconsistentCycles) { inconsistentEntities.addAll(inconsistentCycle.getEntitySet()); 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 c3d8fde78aa..86565476684 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 @@ -9,8 +9,8 @@ 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.LoopedVariableInfo; import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis; +import ai.timefold.solver.core.api.score.analysis.VariableLoop; import ai.timefold.solver.core.api.solver.RecommendedAssignment; import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy; import ai.timefold.solver.core.api.solver.SolutionManager; @@ -65,7 +65,7 @@ public Score_ update(Solution_ solution, SolutionUpdatePolicy solutionUpdatePoli } private Result_ callScoreDirector(String feature, Solution_ solution, SolutionUpdatePolicy solutionUpdatePolicy, - BiFunction, List, Result_> function, + BiFunction, List, Result_> function, ConstraintMatchPolicy constraintMatchPolicy, boolean cloneSolution, boolean handlesStructurallyFlawedSolutions) { var isShadowVariableUpdateEnabled = solutionUpdatePolicy.isShadowVariableUpdateEnabled(); @@ -93,15 +93,15 @@ private Result_ callScoreDirector(String feature, Solution_ solution, // if handlesStructurallyFlawedSolutions is true, then the score can never be structurally flawed // and all variable updates will be successful - List inconsistentEntities = null; + List inconsistentEntities = null; if (solutionUpdatePolicy.isScoreUpdateEnabled()) { var score = scoreDirector.calculateScore(); if (score.isStructurallyFlawed()) { - inconsistentEntities = scoreDirector.computeInconsistentGroups(); + inconsistentEntities = scoreDirector.computeVariableLoops(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } if (handlesStructurallyFlawedSolutions) { - inconsistentEntities = scoreDirector.computeInconsistentGroups(); + inconsistentEntities = scoreDirector.computeVariableLoops(); if (!inconsistentEntities.isEmpty()) { scoreDirector.getSolutionDescriptor().setScore( scoreDirector.getWorkingSolution(), @@ -110,12 +110,12 @@ private Result_ callScoreDirector(String feature, Solution_ solution, } } } else if (!scoreDirector.isLastVariableUpdateSuccessful()) { - inconsistentEntities = scoreDirector.computeInconsistentGroups(); + inconsistentEntities = scoreDirector.computeVariableLoops(); throw new InconsistentSolutionException(feature, nonNullSolution, inconsistentEntities); } if (inconsistentEntities == null) { - inconsistentEntities = (handlesStructurallyFlawedSolutions) ? scoreDirector.computeInconsistentGroups() + inconsistentEntities = (handlesStructurallyFlawedSolutions) ? scoreDirector.computeVariableLoops() : Collections.emptyList(); } 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 c35aeb45f7d..09c90886edc 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 @@ -145,7 +145,7 @@ void updateInconsistent(SolutionManagerSource solutionManagerSource) { "is inconsistent", "Solution update", "requires a consistent solution") .hasFieldOrPropertyWithValue("solution", inconsistentSolution) .matches(exception -> { - var inconsistentGroups = ((InconsistentSolutionException) exception).getInconsistentGroups(); + var inconsistentGroups = ((InconsistentSolutionException) exception).getVariableLoops(); if (inconsistentGroups.size() != 1) { return false; } @@ -218,7 +218,7 @@ void updateOnlyShadowVariablesInconsistent(SolutionManagerSource solutionManager "is inconsistent", "Solution update", "requires a consistent solution") .hasFieldOrPropertyWithValue("solution", inconsistentSolution) .matches(exception -> { - var inconsistentGroups = ((InconsistentSolutionException) exception).getInconsistentGroups(); + var inconsistentGroups = ((InconsistentSolutionException) exception).getVariableLoops(); if (inconsistentGroups.size() != 1) { return false; } From 8ac4b3646f73a6f39999455ee0f050a668cc775c Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 31 Aug 2026 16:36:08 -0400 Subject: [PATCH 9/9] chore: make InconsistentSolutionException final --- .../core/api/domain/variable/InconsistentSolutionException.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e9b9ddf7664..5cb80cbab98 100644 --- 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 @@ -7,7 +7,7 @@ import org.jspecify.annotations.NullMarked; @NullMarked -public class InconsistentSolutionException extends RuntimeException { +public final class InconsistentSolutionException extends RuntimeException { private final Object solution; private final List variableLoops;