diff --git a/.github/workflows/pull_request_secure.yml b/.github/workflows/pull_request_secure.yml index 87ca3e2a18..4d303c2504 100644 --- a/.github/workflows/pull_request_secure.yml +++ b/.github/workflows/pull_request_secure.yml @@ -118,7 +118,7 @@ jobs: working-directory: ./timefold-solver-enterprise shell: bash env: - TIMEFOLD_ENTERPRISE_LICENSE: ${{ secrets.TIMEFOLD_SOLVER_CI_PROD_LICENSE }} + TIMEFOLD_LICENSE: ${{ secrets.TIMEFOLD_SOLVER_CI_PROD_LICENSE }} run: ./mvnw -B clean verify - name: Test Summary uses: test-summary/action@2920bc1b1b377c787227b204af6981e8f41bbef3 diff --git a/.github/workflows/release-changelog-template.md b/.github/workflows/release-changelog-template.md index 7f0632c26f..bdbf32ec72 100644 --- a/.github/workflows/release-changelog-template.md +++ b/.github/workflows/release-changelog-template.md @@ -12,9 +12,8 @@ _Timefold Solver Community Edition_ is an open source project, and you are more than welcome to contribute as well! For more, see [Contributing](https://github.com/TimefoldAI/timefold-solver/blob/main/CONTRIBUTING.adoc). -Should your business need to scale to truly massive data sets or require enterprise-grade support, -check out [_Timefold Solver Enterprise Edition_](https://docs.timefold.ai/timefold-solver/latest/enterprise-edition/enterprise-edition). -Enterprise Edition requires [a license](https://timefold.ai/pricing). +Timefold also offers commercial editions of the solver, which include additional features such as explainability, the ability to scale out to the biggest datasets, and enterprise-grade support. +Find out [which edition is right for you](https://licenses.timefold.ai/). # How to use Timefold Solver diff --git a/README.adoc b/README.adoc index 29367828e3..5971354ecc 100644 --- a/README.adoc +++ b/README.adoc @@ -35,7 +35,7 @@ image:https://img.shields.io/badge/Java-21+-brightgreen.svg?style=for-the-badge[ == Build from source -. Install JDK 21+ and Maven 3.9.11, for example with https://sdkman.io[Sdkman]: +. Install JDK 21+ and Maven 3.9.11+, for example with https://sdkman.io[Sdkman]: + ---- $ sdk install java @@ -61,25 +61,20 @@ $ ./mvnw clean install -Dquickly This is an open source project, and you are more than welcome to contribute! For more, see link:CONTRIBUTING.md[Contributing]. -== Editions -There are two editions of Timefold Solver: +== Editions -- _Timefold Solver Community Edition_ (this repo). -- _Timefold Solver Enterprise Edition_, a https://timefold.ai/pricing[licensed version] of Timefold Solver. +There are three editions of Timefold Solver: -=== Key Features of Timefold Solver Enterprise Edition +- _Timefold Solver Community Edition_, +- _Timefold Solver Plus_ +- and _Timefold Solver Enterprise_. -- **Multi-threaded Solving:** Experience enhanced performance with multi-threaded solving capabilities. -- **Nearby Selection:** Get better solutions quicker, especially with spatial problems. -- **Dedicated Support:** Get direct support from the Timefold team for all your questions and requirements. +The Community Edition (this repo) is open-source and licensed under the Apache-2.0 license. +The latter two are non-open-source commercial offerings and require a Timefold license to run. -=== Licensing and Usage +https://licenses.timefold.ai/[See which edition is right for you.] -Unlike the Apache-2.0 licensed _Community Edition_ in this repo, -the _Enterprise Edition_ is not open source. -If you wish to use the Enterprise Edition in a production environment, -please https://timefold.ai/contact[contact Timefold] to obtain the appropriate license. == Legal notice @@ -91,5 +86,5 @@ which includes copyrights of the original creator, Red Hat Inc., affiliates, and that were all entirely licensed under the Apache-2.0 license. Every source file has been modified. -== Documentation icon libraries +=== Documentation icon libraries - [tabler icons](https://tabler.io/icons) under [MIT license](https://docs.tabler.io/ui/getting-started/license) diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/AnalyzableIncrementalScoreCalculator.java b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/AnalyzableIncrementalScoreCalculator.java index 81e8fd558e..5735acc86a 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/AnalyzableIncrementalScoreCalculator.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/AnalyzableIncrementalScoreCalculator.java @@ -10,9 +10,9 @@ * Used for incremental java {@link Score} calculation with support for {@link ScoreAnalysis} * Any implementation is naturally stateful. *

- * Note: Both incremental score calculation and score analysis are exclusive to Timefold Solver Enterprise Edition. - * They are not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw exceptions at runtime. + * Note: Explainability features are exclusive to Timefold Solver Enterprise Edition. + * Implementing this interface in Community Edition may still bring benefits + * in terms of score corruption analysis. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the score type to go with the solution diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/ConstraintMatchRegistry.java b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/ConstraintMatchRegistry.java index 6aca84f307..665f69210d 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/ConstraintMatchRegistry.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/ConstraintMatchRegistry.java @@ -10,7 +10,6 @@ import org.jspecify.annotations.NullMarked; /** - * * @param * @see AnalyzableIncrementalScoreCalculator Adding explainability to incremental score calculator. */ diff --git a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/IncrementalScoreCalculator.java b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/IncrementalScoreCalculator.java index c006e2ab4d..f49d832d3b 100644 --- a/core/src/main/java/ai/timefold/solver/core/api/score/calculator/IncrementalScoreCalculator.java +++ b/core/src/main/java/ai/timefold/solver/core/api/score/calculator/IncrementalScoreCalculator.java @@ -13,10 +13,6 @@ * This is much faster than {@link EasyScoreCalculator} but requires much more code to implement too. *

* Any implementation is naturally stateful. - *

- * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition. - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw exceptions at runtime. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the score type to go with the solution diff --git a/core/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java b/core/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java index 3f07f491dc..1fcb443c2e 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java @@ -110,39 +110,19 @@ public void setConstraintStreamProfilingEnabled(Boolean constraintStreamProfilin this.constraintStreamProfilingEnabled = constraintStreamProfilingEnabled; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public @Nullable Class getIncrementalScoreCalculatorClass() { return incrementalScoreCalculatorClass; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public void setIncrementalScoreCalculatorClass( @Nullable Class incrementalScoreCalculatorClass) { this.incrementalScoreCalculatorClass = incrementalScoreCalculatorClass; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public @Nullable Map<@NonNull String, @NonNull String> getIncrementalScoreCalculatorCustomProperties() { return incrementalScoreCalculatorCustomProperties; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public void setIncrementalScoreCalculatorCustomProperties( @Nullable Map<@NonNull String, @NonNull String> incrementalScoreCalculatorCustomProperties) { this.incrementalScoreCalculatorCustomProperties = incrementalScoreCalculatorCustomProperties; @@ -206,11 +186,6 @@ public void setAssertionScoreDirectorFactory(@Nullable ScoreDirectorFactoryConfi return this; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public @NonNull ScoreDirectorFactoryConfig withIncrementalScoreCalculatorClass( @NonNull Class incrementalScoreCalculatorClass) { @@ -218,11 +193,6 @@ public void setAssertionScoreDirectorFactory(@Nullable ScoreDirectorFactoryConfi return this; } - /** - * Note: Incremental score calculation is exclusive to Timefold Solver Enterprise Edition - * It is not available in the open-source version of Timefold Solver, - * and attempts to use it without a valid license will throw an exception at runtime. - */ public @NonNull ScoreDirectorFactoryConfig withIncrementalScoreCalculatorCustomProperties( @NonNull Map<@NonNull String, @NonNull String> incrementalScoreCalculatorCustomProperties) { 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 860803d98d..24431ceb65 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 @@ -24,13 +24,11 @@ import ai.timefold.solver.core.config.heuristic.selector.move.generic.list.ListMultistageMoveSelectorConfig; import ai.timefold.solver.core.config.heuristic.selector.value.ValueSelectorConfig; import ai.timefold.solver.core.config.partitionedsearch.PartitionedSearchPhaseConfig; -import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.impl.bavet.common.InnerConstraintProfiler; import ai.timefold.solver.core.impl.constructionheuristic.decider.ConstructionHeuristicDecider; import ai.timefold.solver.core.impl.constructionheuristic.decider.forager.ConstructionHeuristicForager; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.declarative.TopologicalOrderGraph; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector; @@ -46,7 +44,6 @@ 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.director.AbstractScoreDirectorFactory; 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; @@ -68,7 +65,6 @@ final class InstanceCarrier { } String COMMUNITY_NAME = "Timefold Solver Community Edition"; - String COMMUNITY_COORDINATES = "ai.timefold.solver:timefold-solver-core"; String ENTERPRISE_NAME = "Timefold Solver Enterprise Edition"; String ENTERPRISE_COORDINATES = "ai.timefold.solver.enterprise:timefold-solver-enterprise-core"; String DEVELOPMENT_SNAPSHOT = "Development Snapshot"; @@ -118,19 +114,19 @@ static TimefoldSolverEnterpriseService loadOrFail(Feature feature) { return load(); } catch (EnterpriseLicenseException cause) { throw new IllegalStateException(""" - No valid Timefold Enterprise License was found. + No valid Timefold License was found. Please contact Timefold to obtain a valid license, or if you believe that this message was given in error.""", cause); } catch (EnterpriseProductException cause) { throw new IllegalStateException(""" - Valid Timefold Enterprise License was found, but it does not entitle you to run "%s". + Valid Timefold License was found, but it does not entitle you to run "%s". Maybe %s. Please contact Timefold to obtain an applicable license, or if you believe that this message was given in error.""" .formatted(feature.getName(), feature.getWorkaround()), cause); } catch (Exception cause) { throw new IllegalStateException(""" - A feature of Enterprise Edition "%s" was requested but it could not be loaded. + A commercial feature "%s" was requested but it could not be loaded. Maybe add the %s dependency, or %s. Please contact Timefold to obtain an applicable license, or if you believe that this message was given in error.""" @@ -202,10 +198,6 @@ DestinationSelector applyNearbySelection(DestinationSelec InnerConstraintProfiler buildConstraintProfiler(); - > AbstractScoreDirectorFactory - buildIncrementalScoreDirectorFactory(ScoreDirectorFactoryConfig config, - SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode); - > ScoreAnalysis analyze(InnerScore state, Map> constraintMatchTotalMap, ScoreAnalysisFetchPolicy fetchPolicy); @@ -227,9 +219,7 @@ enum Feature { "remove multistageMoveSelector and/or listMultistageMoveSelector from the solver configuration"), CONSTRAINT_PROFILING("Constraint profiling", "remove constraintStreamProfilingEnabled from the solver configuration"), SCORE_ANALYSIS("Score analysis", "do not use SolutionManager's analyze() method"), - RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"), - INCREMENTAL_SCORE_CALCULATOR("Incremental score calculator", - "remove incrementalScoreCalculatorClass and incrementalScoreCalculatorCustomProperties from the solver configuration"); + RECOMMENDATIONS("Recommendations", "do not use SolutionManager's recommendAssignment() method"); private final String name; private final String workaround; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java index af46f85a62..08f3c3aa54 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactory.java @@ -6,9 +6,9 @@ import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel; import ai.timefold.solver.core.config.solver.EnvironmentMode; -import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.easy.EasyScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend; @@ -62,10 +62,7 @@ public ScoreDirectorFactory buildScoreDirectorFactory(Environ if (config.getEasyScoreCalculatorClass() != null) { return EasyScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); } else if (config.getIncrementalScoreCalculatorClass() != null) { - var timefoldSolverEnterpriseService = TimefoldSolverEnterpriseService - .loadOrFail(TimefoldSolverEnterpriseService.Feature.INCREMENTAL_SCORE_CALCULATOR); - return timefoldSolverEnterpriseService.buildIncrementalScoreDirectorFactory(config, solutionDescriptor, - environmentMode); + return IncrementalScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); } else if (config.getConstraintProviderClass() != null) { return BavetConstraintStreamScoreDirectorFactory.buildScoreDirectorFactory(solutionDescriptor, config, environmentMode); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java new file mode 100644 index 0000000000..1df721dd19 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -0,0 +1,298 @@ +package ai.timefold.solver.core.impl.score.director.incremental; + +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.calculator.AnalyzableIncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistration; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistry; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.stream.ConstraintJustification; +import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +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.score.constraint.ConstraintMatch; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchTotal; +import ai.timefold.solver.core.impl.score.director.AbstractScoreDirector; +import ai.timefold.solver.core.impl.score.director.InnerScore; +import ai.timefold.solver.core.impl.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.util.MutableReference; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Incremental java implementation of {@link ScoreDirector}, which only recalculates the {@link Score} + * of the part of the {@link PlanningSolution working solution} that changed, + * instead of the going through the entire {@link PlanningSolution}. This is incremental calculation, which is fast. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the score type to go with the solution + * @see ScoreDirector + */ +@NullMarked +public final class IncrementalScoreDirector> + extends AbstractScoreDirector> + implements ConstraintMatchRegistry { + + private final IncrementalScoreCalculator incrementalScoreCalculator; + private final boolean constraintMatchEnabled; + private Score_ totalScore; + private final SortedMap> constraintMatchTotalMap = new TreeMap<>(); + + private IncrementalScoreDirector(Builder builder) { + super(builder); + this.incrementalScoreCalculator = Objects.requireNonNull(builder.incrementalScoreCalculator, + "The incrementalScoreCalculator must not be null."); + this.constraintMatchEnabled = getConstraintMatchPolicy().isEnabled(); + this.totalScore = getScoreDefinition().getZeroScore(); + if (incrementalScoreCalculator instanceof AnalyzableIncrementalScoreCalculator analyzableIncrementalScoreCalculator + && constraintMatchEnabled) { + analyzableIncrementalScoreCalculator.enableConstraintMatch(this); + } + } + + public IncrementalScoreCalculator getIncrementalScoreCalculator() { + return incrementalScoreCalculator; + } + + // ************************************************************************ + // Complex methods + // ************************************************************************ + + @Override + public void setWorkingSolutionWithoutUpdatingShadows(Solution_ workingSolution) { + super.setWorkingSolutionWithoutUpdatingShadows(workingSolution, null); + resetWorkingSolutionAndMaps(workingSolution); + } + + private void resetWorkingSolutionAndMaps(Solution_ workingSolution) { + constraintMatchTotalMap.clear(); + totalScore = getScoreDefinition().getZeroScore(); + incrementalScoreCalculator.resetWorkingSolution(workingSolution); + } + + @Override + public InnerScore calculateScore() { + variableListenerSupport.assertNotificationQueuesAreEmpty(); + var score = Objects.requireNonNull(incrementalScoreCalculator.calculateScore(), + () -> "The incrementalScoreCalculator (%s) must return a non-null score in the method calculateScore()." + .formatted(incrementalScoreCalculator)); + setCalculatedScore(score); + return new InnerScore<>(score, -getWorkingInitScore()); + } + + @Override + public Map> getConstraintMatchTotalMap() { + if (!constraintMatchPolicy.isEnabled()) { + throw new IllegalStateException("When constraint matching (" + constraintMatchPolicy + + ") is disabled in the constructor, this method should not be called."); + } + // Notice that we don't trigger the variable listeners + return constraintMatchTotalMap; + } + + @Override + public boolean requiresFlushing() { + return true; // Incremental may decide to keep events for delayed processing. + } + + // ************************************************************************ + // Entity/variable add/change/remove methods + // ************************************************************************ + + @Override + public void afterEntityAdded(EntityDescriptor entityDescriptor, Object entity) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterEntityAdded(entityDescriptor, entity); + } + + @Override + public void beforeVariableChanged(VariableDescriptor variableDescriptor, Object entity) { + incrementalScoreCalculator.beforeVariableChanged(entity, variableDescriptor.getVariableName()); + super.beforeVariableChanged(variableDescriptor, entity); + } + + @Override + public void afterVariableChanged(VariableDescriptor variableDescriptor, Object entity) { + incrementalScoreCalculator.afterVariableChanged(entity, variableDescriptor.getVariableName()); + super.afterVariableChanged(variableDescriptor, entity); + } + + @Override + public void beforeListVariableElementAssigned(ListVariableDescriptor variableDescriptor, Object element) { + incrementalScoreCalculator.beforeListVariableElementAssigned(variableDescriptor.getVariableName(), element); + super.beforeListVariableElementAssigned(variableDescriptor, element); + } + + @Override + public void afterListVariableElementAssigned(ListVariableDescriptor variableDescriptor, Object element) { + incrementalScoreCalculator.afterListVariableElementAssigned(variableDescriptor.getVariableName(), element); + super.afterListVariableElementAssigned(variableDescriptor, element); + } + + @Override + public void beforeListVariableElementUnassigned(ListVariableDescriptor variableDescriptor, Object element) { + incrementalScoreCalculator.beforeListVariableElementUnassigned(variableDescriptor.getVariableName(), element); + super.beforeListVariableElementUnassigned(variableDescriptor, element); + } + + @Override + public void afterListVariableElementUnassigned(ListVariableDescriptor variableDescriptor, Object element) { + incrementalScoreCalculator.afterListVariableElementUnassigned(variableDescriptor.getVariableName(), element); + super.afterListVariableElementUnassigned(variableDescriptor, element); + } + + @Override + public void beforeListVariableChanged(ListVariableDescriptor variableDescriptor, Object entity, int fromIndex, + int toIndex) { + incrementalScoreCalculator.beforeListVariableChanged(entity, variableDescriptor.getVariableName(), fromIndex, toIndex); + super.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); + } + + @Override + public void afterListVariableChanged(ListVariableDescriptor variableDescriptor, Object entity, int fromIndex, + int toIndex) { + incrementalScoreCalculator.afterListVariableChanged(entity, variableDescriptor.getVariableName(), fromIndex, toIndex); + super.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); + } + + @Override + public void afterEntityRemoved(EntityDescriptor entityDescriptor, Object entity) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterEntityRemoved(entityDescriptor, entity); + } + + // ************************************************************************ + // Problem fact add/change/remove methods + // ************************************************************************ + + @Override + public void beforeProblemFactAdded(Object problemFact) { + super.beforeProblemFactAdded(problemFact); + } + + @Override + public void afterProblemFactAdded(Object problemFact) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterProblemFactAdded(problemFact); + } + + @Override + public void afterProblemPropertyChanged(Object problemFactOrEntity) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterProblemPropertyChanged(problemFactOrEntity); + } + + @Override + public void afterProblemFactRemoved(Object problemFact) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterProblemFactRemoved(problemFact); + } + + @Override + public ConstraintMatchRegistration registerConstraintMatch(ConstraintRef constraintRef, Score_ score, + ConstraintJustification justification) { + if (!constraintMatchEnabled) { + throw new IllegalStateException( + "Cannot register constraint match (%s) when constraint matching (%s) is disabled in the constructor." + .formatted(constraintRef, constraintMatchPolicy)); + } + var total = constraintMatchTotalMap.get(constraintRef); + if (total == null) { + total = new ConstraintMatchTotal<>(constraintRef, score.zero()); + constraintMatchTotalMap.put(constraintRef, total); + } + + var match = total.addConstraintMatch(justification, score); + totalScore = totalScore.add(score); + var effectiveTotal = total; + var canceled = new MutableReference<>(false); + return new DefaultConstraintMatchRegistration<>(match, () -> { + if (Objects.requireNonNullElse(canceled.getValue(), false)) { + throw new IllegalStateException("Constraint match (%s) can only be canceled once." + .formatted(match)); + } + canceled.setValue(true); + totalScore = totalScore.subtract(score); + effectiveTotal.removeConstraintMatch(match); + }); + } + + @Override + public Score_ totalScore() { + return totalScore; + } + + private record DefaultConstraintMatchRegistration>( + ConstraintMatch constraintMatch, + Runnable undo) + implements + ConstraintMatchRegistration { + + @Override + public ConstraintRef constraintRef() { + return constraintMatch.getConstraintRef(); + } + + @Override + public Score_ score() { + return constraintMatch.getScore(); + } + + @Override + public ConstraintJustification justification() { + return Objects.requireNonNull(constraintMatch.getJustification()); + } + + @Override + public void cancel() { + undo.run(); + } + } + + @NullMarked + public static final class Builder> + extends + AbstractScoreDirectorBuilder, Builder> { + + private @Nullable IncrementalScoreCalculator incrementalScoreCalculator; + + public Builder(IncrementalScoreDirectorFactory scoreDirectorFactory) { + super(scoreDirectorFactory); + } + + public Builder + withIncrementalScoreCalculator(IncrementalScoreCalculator incrementalScoreCalculator) { + this.incrementalScoreCalculator = incrementalScoreCalculator; + return withConstraintMatchPolicy(constraintMatchPolicy); // Ensure the policy is correct for the calculator. + } + + @Override + public Builder withConstraintMatchPolicy(ConstraintMatchPolicy constraintMatchPolicy) { + return super.withConstraintMatchPolicy(determineCorrectPolicy(constraintMatchPolicy, incrementalScoreCalculator)); + } + + @Override + public IncrementalScoreDirector build() { + return new IncrementalScoreDirector<>(this); + } + + private static ConstraintMatchPolicy determineCorrectPolicy(ConstraintMatchPolicy constraintMatchPolicy, + @Nullable IncrementalScoreCalculator incrementalScoreCalculator) { + if (incrementalScoreCalculator == null) { + return ConstraintMatchPolicy.DISABLED; + } + return incrementalScoreCalculator instanceof AnalyzableIncrementalScoreCalculator + ? constraintMatchPolicy + : ConstraintMatchPolicy.DISABLED; + } + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java new file mode 100644 index 0000000000..76f52d77e7 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java @@ -0,0 +1,62 @@ +package ai.timefold.solver.core.impl.score.director.incremental; + +import java.util.function.Supplier; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; + +/** + * Incremental implementation of {@link ScoreDirectorFactory}. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + * @param the score type to go with the solution + * @see IncrementalScoreDirector + * @see ScoreDirectorFactory + */ +public final class IncrementalScoreDirectorFactory> + extends AbstractScoreDirectorFactory> { + + public static > IncrementalScoreDirectorFactory + buildScoreDirectorFactory(SolutionDescriptor solutionDescriptor, ScoreDirectorFactoryConfig config, + EnvironmentMode environmentMode) { + if (!IncrementalScoreCalculator.class.isAssignableFrom(config.getIncrementalScoreCalculatorClass())) { + throw new IllegalArgumentException("The incrementalScoreCalculatorClass (%s) does not implement %s." + .formatted(config.getIncrementalScoreCalculatorClass(), IncrementalScoreCalculator.class.getSimpleName())); + } + return new IncrementalScoreDirectorFactory<>(solutionDescriptor, () -> { + var incrementalScoreCalculator = (IncrementalScoreCalculator) ConfigUtils.newInstance(config, + "incrementalScoreCalculatorClass", (Class) config.getIncrementalScoreCalculatorClass()); + ConfigUtils.applyCustomProperties(incrementalScoreCalculator, "incrementalScoreCalculatorClass", + config.getIncrementalScoreCalculatorCustomProperties(), "incrementalScoreCalculatorCustomProperties"); + return incrementalScoreCalculator; + }, environmentMode); + } + + private final Supplier> incrementalScoreCalculatorSupplier; + + public IncrementalScoreDirectorFactory(SolutionDescriptor solutionDescriptor, + Supplier> incrementalScoreCalculatorSupplier, + EnvironmentMode environmentMode) { + super(solutionDescriptor, environmentMode); + this.incrementalScoreCalculatorSupplier = incrementalScoreCalculatorSupplier; + } + + @Override + public IncrementalScoreDirector.Builder createScoreDirectorBuilder() { + return new IncrementalScoreDirector.Builder<>(this) + .withIncrementalScoreCalculator(incrementalScoreCalculatorSupplier.get()); + } + + @Override + public IncrementalScoreDirector buildScoreDirector() { + return createScoreDirectorBuilder().build(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java index 2313530f99..1f2076cfe1 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/ScoreDirectorFactoryFactoryTest.java @@ -3,17 +3,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import java.util.HashMap; + import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; import ai.timefold.solver.core.api.score.stream.Constraint; import ai.timefold.solver.core.api.score.stream.ConstraintFactory; import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.score.director.incremental.IncrementalScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.stream.BavetConstraintStreamScoreDirectorFactory; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.Test; class ScoreDirectorFactoryFactoryTest { @@ -86,4 +91,97 @@ public static class TestdataConstraintProvider implements ConstraintProvider { } } + @Test + void incrementalMultipleScoreCalculations_throwsException() { + var config = new ScoreDirectorFactoryConfig() + .withConstraintProviderClass(ai.timefold.solver.core.testdomain.TestdataConstraintProvider.class) + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> buildTestdataScoreDirectoryFactory(config)) + .withMessageContaining("scoreDirectorFactory") + .withMessageContaining("together"); + } + + @Test + void incrementalScoreCalculatorWithCustomProperties() { + var config = new ScoreDirectorFactoryConfig(); + config.setIncrementalScoreCalculatorClass( + TestCustomPropertiesIncrementalScoreCalculator.class); + var customProperties = new HashMap(); + customProperties.put("stringProperty", "string 1"); + customProperties.put("intProperty", "7"); + config.setIncrementalScoreCalculatorCustomProperties(customProperties); + + var scoreDirectorFactory = + (IncrementalScoreDirectorFactory) buildTestdataScoreDirectoryFactory(config); + try (var scoreDirector = scoreDirectorFactory.buildScoreDirector()) { + var scoreCalculator = + (TestCustomPropertiesIncrementalScoreCalculator) scoreDirector.getIncrementalScoreCalculator(); + assertThat(scoreCalculator.getStringProperty()).isEqualTo("string 1"); + assertThat(scoreCalculator.getIntProperty()).isEqualTo(7); + } + } + + @Test + void buildWithAssertionScoreDirectorFactory() { + var assertionScoreDirectorConfig = new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class); + var config = new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestCustomPropertiesIncrementalScoreCalculator.class) + .withAssertionScoreDirectorFactory(assertionScoreDirectorConfig); + + var scoreDirectorFactory = + (AbstractScoreDirectorFactory) buildTestdataScoreDirectoryFactory(config, + EnvironmentMode.STEP_ASSERT); + + var assertionScoreDirectorFactory = + (IncrementalScoreDirectorFactory) scoreDirectorFactory + .getAssertionScoreDirectorFactory(); + try (var assertionScoreDirector = assertionScoreDirectorFactory.buildScoreDirector()) { + var assertionScoreCalculator = assertionScoreDirector.getIncrementalScoreCalculator(); + assertThat(assertionScoreCalculator).isExactlyInstanceOf(TestCustomPropertiesIncrementalScoreCalculator.class); + } + } + + @NullMarked + public static class TestCustomPropertiesIncrementalScoreCalculator + implements IncrementalScoreCalculator { + + private String stringProperty; + private int intProperty; + + public String getStringProperty() { + return stringProperty; + } + + public void setStringProperty(String stringProperty) { + this.stringProperty = stringProperty; + } + + public int getIntProperty() { + return intProperty; + } + + public void setIntProperty(int intProperty) { + this.intProperty = intProperty; + } + + @Override + public void resetWorkingSolution(TestdataSolution workingSolution) { + + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.ZERO; + } + } + } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java new file mode 100644 index 0000000000..3aa3c4b50f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java @@ -0,0 +1,158 @@ +package ai.timefold.solver.core.impl.score.director.incremental; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.AnalyzableIncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistry; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorSemanticsTest; +import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactory; +import ai.timefold.solver.core.impl.score.director.ScoreDirectorFactoryFactory; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.constraintweightoverrides.TestdataConstraintWeightOverridesSolution; +import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.TestdataPinnedListSolution; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListEntity; +import ai.timefold.solver.core.testdomain.list.pinned.index.TestdataPinnedWithIndexListSolution; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +final class IncrementalScoreDirectorSemanticsTest extends AbstractScoreDirectorSemanticsTest { + + @Override + protected ScoreDirectorFactory + buildScoreDirectorFactoryWithConstraintConfiguration( + SolutionDescriptor solutionDescriptor) { + var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestdataConstraintWeightOverridesIncrementalScoreCalculator.class); + var scoreDirectorFactoryFactory = + new ScoreDirectorFactoryFactory( + scoreDirectorFactoryConfig); + return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + } + + @Override + protected ScoreDirectorFactory buildScoreDirectorFactoryWithListVariableEntityPin( + SolutionDescriptor solutionDescriptor) { + var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestdataPinnedListIncrementalScoreCalculator.class); + var scoreDirectorFactoryFactory = + new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + } + + @Override + protected ScoreDirectorFactory + buildScoreDirectorFactoryWithListVariablePinIndex( + SolutionDescriptor solutionDescriptor) { + var scoreDirectorFactoryConfig = new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(TestdataPinnedWithIndexListIncrementalScoreCalculator.class); + var scoreDirectorFactoryFactory = + new ScoreDirectorFactoryFactory(scoreDirectorFactoryConfig); + return scoreDirectorFactoryFactory.buildScoreDirectorFactory(EnvironmentMode.PHASE_ASSERT, solutionDescriptor); + } + + @NullMarked + public static class TestdataPinnedListIncrementalScoreCalculator + implements IncrementalScoreCalculator { + + private List entityList = Collections.emptyList(); + + @Override + public void resetWorkingSolution(TestdataPinnedListSolution workingSolution) { + this.entityList = new ArrayList<>(workingSolution.getEntityList()); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + // No need to do anything. + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + // No need to do anything. + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-entityList.size()); + } + } + + @NullMarked + public static class TestdataPinnedWithIndexListIncrementalScoreCalculator + implements IncrementalScoreCalculator { + + private List entityList = Collections.emptyList(); + + @Override + public void resetWorkingSolution(TestdataPinnedWithIndexListSolution workingSolution) { + this.entityList = new ArrayList<>(workingSolution.getEntityList()); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + // No need to do anything. + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + // No need to do anything. + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-entityList.size()); + } + } + + @NullMarked + public static class TestdataConstraintWeightOverridesIncrementalScoreCalculator + implements AnalyzableIncrementalScoreCalculator { + + private @Nullable TestdataConstraintWeightOverridesSolution workingSolution; + private @Nullable ConstraintMatchRegistry constraintMatchRegistry; + private List entityList = Collections.emptyList(); + + @Override + public void resetWorkingSolution(TestdataConstraintWeightOverridesSolution workingSolution) { + this.workingSolution = workingSolution; + this.entityList = new ArrayList<>(workingSolution.getEntityList()); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + throw new UnsupportedOperationException(); // Will not be called. + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + throw new UnsupportedOperationException(); // Will not be called. + } + + @Override + public SimpleScore calculateScore() { + var overrides = workingSolution.getConstraintWeightOverrides(); + var firstWeight = overrides.getConstraintWeight("First weight"); + var score = SimpleScore.of(entityList.size()); + if (constraintMatchRegistry != null && firstWeight != null) { + constraintMatchRegistry.registerConstraintMatch(ConstraintRef.of("First weight"), score); + return score.multiply(firstWeight.score()); + } + return score; + } + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java new file mode 100644 index 0000000000..e5f0d0e1ab --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java @@ -0,0 +1,374 @@ +package ai.timefold.solver.core.impl.score.director.incremental; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.AnalyzableIncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistration; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistry; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.stream.ConstraintRef; +import ai.timefold.solver.core.api.score.stream.DefaultConstraintJustification; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy; +import ai.timefold.solver.core.impl.score.definition.SimpleScoreDefinition; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class IncrementalScoreDirectorTest { + + private static final ConstraintRef CONSTRAINT_A = ConstraintRef.of("constraintA"); + private static final ConstraintRef CONSTRAINT_B = ConstraintRef.of("constraintB"); + + @Test + void illegalStateExceptionThrownWhenConstraintMatchNotEnabled() { + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { + scoreDirector.setWorkingSolution(new Object()); + assertThatIllegalStateException().isThrownBy(scoreDirector::getConstraintMatchTotalMap) + .withMessageContaining(ConstraintMatchPolicy.DISABLED.name()); + } + } + + @Test + void constraintMatchTotalsNeverNull() { + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(true)) + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { + scoreDirector.setWorkingSolution(new Object()); + assertThat(scoreDirector.getConstraintMatchTotalMap()).isNotNull(); + } + } + + @Test + void constraintMatchIsNotEnabledWhenScoreCalculatorNotConstraintMatchAware() { + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)) + .withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED).build()) { + assertThat(scoreDirector.getConstraintMatchPolicy()).isEqualTo(ConstraintMatchPolicy.DISABLED); + } + } + + @SuppressWarnings("unchecked") + private IncrementalScoreDirectorFactory mockIncrementalScoreDirectorFactory() { + IncrementalScoreDirectorFactory factory = mock(IncrementalScoreDirectorFactory.class); + when(factory.getScoreDefinition()).thenReturn(new SimpleScoreDefinition()); + SolutionDescriptor solutionDescriptor = mock(SolutionDescriptor.class); + when(factory.getSolutionDescriptor()).thenReturn(solutionDescriptor); + return factory; + } + + @SuppressWarnings("unchecked") + private IncrementalScoreCalculator mockIncrementalScoreCalculator(boolean constraintMatchAware) { + if (constraintMatchAware) { + return mock(AnalyzableIncrementalScoreCalculator.class); + } else { + return mock(IncrementalScoreCalculator.class); + } + } + + @Nested + class Justifications { + + @Test + void registerConstraintMatchThrowsWhenConstraintMatchingDisabled() { + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(mockIncrementalScoreCalculator(false)).build()) { + scoreDirector.setWorkingSolution(new Object()); + assertThatIllegalStateException() + .isThrownBy(() -> scoreDirector.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-1), + DefaultConstraintJustification.of(SimpleScore.of(-1)))) + .withMessageContaining(ConstraintMatchPolicy.DISABLED.name()); + } + } + + @Test + void registerConstraintMatchUpdatesTotalScoreAndMap() { + var calculator = new RegistryCapturingCalculator(SimpleScore.of(-5)); + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-5)); + var matchTotalMap = scoreDirector.getConstraintMatchTotalMap(); + assertThat(matchTotalMap).containsKey(CONSTRAINT_A); + assertThat(matchTotalMap.get(CONSTRAINT_A).getScore()).isEqualTo(SimpleScore.of(-5)); + assertThat(matchTotalMap.get(CONSTRAINT_A).getConstraintMatchSet()).hasSize(1); + } + } + + @Test + void cancelRegistrationUpdatesTotalScoreAndMap() { + var registration = new ConstraintMatchRegistration[1]; + var calculator = new AnalyzableIncrementalScoreCalculator() { + + private ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + registration[0] = constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-3), + DefaultConstraintJustification.of(SimpleScore.of(-3))); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-3); + } + }; + + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-3)); + + registration[0].cancel(); + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.ZERO); + assertThat(scoreDirector.getConstraintMatchTotalMap().get(CONSTRAINT_A).getConstraintMatchSet()).isEmpty(); + } + } + + @Test + void cancelRegistrationTwiceThrows() { + var registration = new ConstraintMatchRegistration[1]; + var calculator = new AnalyzableIncrementalScoreCalculator() { + + private ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + registration[0] = constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-1), + DefaultConstraintJustification.of(SimpleScore.of(-1))); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-1); + } + }; + + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + registration[0].cancel(); + assertThatIllegalStateException().isThrownBy(registration[0]::cancel) + .withMessageContaining("canceled once"); + } + } + + @Test + void multipleConstraintMatchesAcrossDifferentConstraints() { + var calculator = new AnalyzableIncrementalScoreCalculator() { + + private ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-2), + DefaultConstraintJustification.of(SimpleScore.of(-2))); + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-3), + DefaultConstraintJustification.of(SimpleScore.of(-3))); + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_B, SimpleScore.of(-5), + DefaultConstraintJustification.of(SimpleScore.of(-5))); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-10); + } + }; + + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-10)); + var matchTotalMap = scoreDirector.getConstraintMatchTotalMap(); + assertThat(matchTotalMap).containsKeys(CONSTRAINT_A, CONSTRAINT_B); + assertThat(matchTotalMap.get(CONSTRAINT_A).getConstraintMatchSet()).hasSize(2); + assertThat(matchTotalMap.get(CONSTRAINT_A).getScore()).isEqualTo(SimpleScore.of(-5)); + assertThat(matchTotalMap.get(CONSTRAINT_B).getConstraintMatchSet()).hasSize(1); + assertThat(matchTotalMap.get(CONSTRAINT_B).getScore()).isEqualTo(SimpleScore.of(-5)); + } + } + + @Test + void resetClearsConstraintMatchMapAndResetsScore() { + var callCount = new int[1]; + var calculator = new AnalyzableIncrementalScoreCalculator() { + + private ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + callCount[0]++; + // First reset: register a match; subsequent resets: register nothing. + if (callCount[0] == 1) { + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-7), + DefaultConstraintJustification.of(SimpleScore.of(-7))); + } + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.ZERO; + } + }; + + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.of(-7)); + assertThat(scoreDirector.getConstraintMatchTotalMap()).containsKey(CONSTRAINT_A); + + // Trigger a second reset via setWorkingSolution. + scoreDirector.setWorkingSolution(new Object()); + assertThat(scoreDirector.totalScore()).isEqualTo(SimpleScore.ZERO); + assertThat(scoreDirector.getConstraintMatchTotalMap()).doesNotContainKey(CONSTRAINT_A); + } + } + + @Test + void registrationConstraintRefAndScoreAreAccessible() { + var registration = new ConstraintMatchRegistration[1]; + var calculator = new AnalyzableIncrementalScoreCalculator() { + + private ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + var justification = DefaultConstraintJustification.of(SimpleScore.of(-4)); + registration[0] = + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, SimpleScore.of(-4), justification); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(-4); + } + }; + + try (var scoreDirector = new IncrementalScoreDirector.Builder<>(mockIncrementalScoreDirectorFactory()) + .withIncrementalScoreCalculator(calculator).withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED) + .build()) { + scoreDirector.setWorkingSolution(new Object()); + + assertThat(registration[0].constraintRef()).isEqualTo(CONSTRAINT_A); + assertThat(registration[0].score()).isEqualTo(SimpleScore.of(-4)); + assertThat(registration[0].justification()).isNotNull(); + } + } + + @NullMarked + private static class RegistryCapturingCalculator + implements AnalyzableIncrementalScoreCalculator { + + private final SimpleScore scoreToRegister; + private @Nullable ConstraintMatchRegistry constraintMatchRegistry; + + public RegistryCapturingCalculator(SimpleScore scoreToRegister) { + this.scoreToRegister = scoreToRegister; + } + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + + @Override + public void resetWorkingSolution(Object workingSolution) { + constraintMatchRegistry.registerConstraintMatch(CONSTRAINT_A, scoreToRegister, + DefaultConstraintJustification.of(scoreToRegister)); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + } + + @Override + public SimpleScore calculateScore() { + return scoreToRegister; + } + } + + } +} 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 1791b3a2c1..3cb55c1b60 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 @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.fail; import static org.assertj.core.api.SoftAssertions.assertSoftly; @@ -11,6 +12,7 @@ import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Objects; import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -21,7 +23,11 @@ import ai.timefold.solver.core.api.score.HardSoftScore; import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.AnalyzableIncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.ConstraintMatchRegistry; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; +import ai.timefold.solver.core.api.score.stream.ConstraintRef; import ai.timefold.solver.core.api.solver.SolutionManager; import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.api.solver.phase.PhaseCommand; @@ -144,6 +150,7 @@ import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.extension.ExtendWith; @@ -2198,4 +2205,143 @@ protected void execute(VariableDescriptorAwareScoreDirector create(solverConfig); + var solver = solverFactory.buildSolver(); + + var solution = new TestdataSolution("s1"); + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + solution.setValueList(List.of(value1, value2)); + var entity1 = new TestdataEntity("e1"); + entity1.setValue(value1); + var entity2 = new TestdataEntity("e2"); + entity2.setValue(value2); + solution.setEntityList(List.of(entity1, entity2)); + + var bestSolution = solver.solve(solution); + assertThat(bestSolution).isNotNull(); + assertThat(bestSolution.getScore()).isEqualTo(SimpleScore.of(2)); + } + + @Test + void solveCorruptedIncrementalUninitialized() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(CorruptedIncrementalScoreCalculator.class)); + + var solution = new TestdataSolution("s1"); + solution.setValueList(Arrays.asList(new TestdataValue("v1"), new TestdataValue("v2"))); + solution.setEntityList(Arrays.asList(new TestdataEntity("e1"), new TestdataEntity("e2"))); + + assertThatThrownBy(() -> PlannerTestUtils.solve(solverConfig, solution, false)) + .hasMessageContaining("Score corruption") + .hasMessageContaining("workingScore") + .hasMessageContaining("uncorruptedScore") + .hasMessageContaining("Score corruption analysis:"); + } + + @Test + void solveCorruptedIncrementalInitialized() { + var solverConfig = PlannerTestUtils.buildSolverConfig(TestdataSolution.class, TestdataEntity.class) + .withEnvironmentMode(EnvironmentMode.FULL_ASSERT) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withIncrementalScoreCalculatorClass(CorruptedIncrementalScoreCalculator.class)); + var solverFactory = SolverFactory. create(solverConfig); + var solver = solverFactory.buildSolver(); + + var solution = new TestdataSolution("s1"); + var value1 = new TestdataValue("v1"); + var value2 = new TestdataValue("v2"); + solution.setValueList(List.of(value1, value2)); + var entity1 = new TestdataEntity("e1"); + entity1.setValue(value1); + var entity2 = new TestdataEntity("e2"); + entity2.setValue(value2); + solution.setEntityList(List.of(entity1, entity2)); + + assertThatThrownBy(() -> solver.solve(solution)) + .hasMessageContaining("Score corruption") + .hasMessageContaining("workingScore") + .hasMessageContaining("uncorruptedScore") + .hasMessageContaining("Score corruption analysis:"); + } + + @NullMarked + public static class CorruptedIncrementalScoreCalculator + implements AnalyzableIncrementalScoreCalculator { + + private @Nullable ConstraintMatchRegistry constraintMatchRegistry; + + @Override + public void resetWorkingSolution(TestdataSolution workingSolution) { + Objects.requireNonNull(constraintMatchRegistry) + .registerConstraintMatch(ConstraintRef.of("b"), SimpleScore.of(1)); + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + // Ignore + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + // Ignore + } + + @Override + public SimpleScore calculateScore() { + var random = new Random(); + return SimpleScore.of(random.nextInt(1000)); + } + + @Override + public void enableConstraintMatch(ConstraintMatchRegistry constraintMatchRegistry) { + this.constraintMatchRegistry = constraintMatchRegistry; + } + } + + @NullMarked + public static class TestdataIncrementalScoreCalculator + implements IncrementalScoreCalculator { + + private int initializedEntityCount; + + @Override + public void resetWorkingSolution(TestdataSolution workingSolution) { + initializedEntityCount = 0; + for (var entity : workingSolution.getEntityList()) { + if (entity.getValue() != null) { + initializedEntityCount++; + } + } + } + + @Override + public void beforeVariableChanged(Object entity, String variableName) { + if (((TestdataEntity) entity).getValue() != null) { + initializedEntityCount--; + } + } + + @Override + public void afterVariableChanged(Object entity, String variableName) { + if (((TestdataEntity) entity).getValue() != null) { + initializedEntityCount++; + } + } + + @Override + public SimpleScore calculateScore() { + return SimpleScore.of(initializedEntityCount); + } + } + } diff --git a/docs/src/modules/ROOT/nav.adoc b/docs/src/modules/ROOT/nav.adoc index 91d199c2d9..3ce6829d17 100644 --- a/docs/src/modules/ROOT/nav.adoc +++ b/docs/src/modules/ROOT/nav.adoc @@ -47,6 +47,5 @@ ** xref:using-timefold-solver/running-the-solver.adoc#multithreadedIncrementalSolving[Multithreaded solving] ** xref:using-timefold-solver/running-the-solver.adoc#partitionedSearch[Partitioned search] ** xref:constraints-and-score/performance.adoc#constraintProfiling[Constraint profiling] -** xref:constraints-and-score/score-calculation.adoc#incrementalScoreCalculation[Incremental score calculation] ** xref:commercial-editions/multistage-moves.adoc[leveloffset=+1] ** xref:using-timefold-solver/running-the-solver.adoc#throttlingBestSolutionEvents[Throttling best solution events] diff --git a/docs/src/modules/ROOT/pages/commercial-editions/commercial-editions.adoc b/docs/src/modules/ROOT/pages/commercial-editions/commercial-editions.adoc index 0afb29529b..cd9ddda00a 100644 --- a/docs/src/modules/ROOT/pages/commercial-editions/commercial-editions.adoc +++ b/docs/src/modules/ROOT/pages/commercial-editions/commercial-editions.adoc @@ -44,9 +44,6 @@ xref:commercial-editions/installation.adoc#solverObtainLicenseKey[See: How to ob | xref:constraints-and-score/performance.adoc#constraintProfiling[Constraint profiling] | | ✓ -| xref:constraints-and-score/score-calculation.adoc#incrementalScoreCalculation[Incremental score calculation] -| | ✓ - | xref:commercial-editions/multistage-moves.adoc[Multistage moves] | | ✓ diff --git a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc index 48e6c69afa..bc6eb69127 100644 --- a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc +++ b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc @@ -13,23 +13,22 @@ A correctly configured and active license key is required in order for our comme [#solverObtainLicenseKey] === Obtaining a license key -Contact us through our https://timefold.ai/talk-to-us[webform]. +Generate your license key using https://licenses.timefold.ai/[Timefold License Manager]. -[NOTE] -==== Take care not to leak the license file, for example by committing it to a public repository, logging the license file content in logs, or sharing it with unauthorized parties. -==== + +Consult https://timefold.ai/terms[Terms of Use] for details on license key usage and restrictions. [#solverSetupLicenseKey] === Set up your license key -After obtaining your license file (.pem) you can introduce it to your project by one of the following methods: +After obtaining your license file you can introduce it to your project by one of the following methods: -* Store the license's PEM string in the `TIMEFOLD_ENTERPRISE_LICENSE` environment variable. -* Store the absolute path to the file in `TIMEFOLD_ENTERPRISE_LICENSE_PATH` environment variable. -* Place the file in the user's home directory. -* Place the file in the root of your application classpath. +* Store the license's PEM string (the contents of the license file) in the `TIMEFOLD_LICENSE` environment variable. +* Store the absolute path to the file in `TIMEFOLD_LICENSE_PATH` environment variable. +* Name the file `timefold-license.pem` and place it in the user's home directory. Take care that the file name matches exactly, including letter case. +* Name the file `timefold-license.pem` and place it in the root of your application classpath. Take care that the file name matches exactly, including letter case. [#enterpriseArtifacts] == Use the enterprise artifacts diff --git a/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc b/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc index 59d4415356..f5d316ac8a 100644 --- a/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc +++ b/docs/src/modules/ROOT/pages/constraints-and-score/score-calculation.adoc @@ -2053,8 +2053,6 @@ add the `easyScoreCalculatorCustomProperties` element and use xref:using-timefol [#incrementalScoreCalculation] === Incremental score calculation -include::../commercial-editions/_only-enterprise.adoc[] - A way to implement your score calculation incrementally in Java. Advantages:: @@ -2135,6 +2133,8 @@ add the `incrementalScoreCalculatorCustomProperties` element and use xref:using- [#analyzableIncrementalScoreCalculator] ==== Incremental score calculator and score analysis +include::../commercial-editions/_only-plus-and-enterprise.adoc[] + To add support for xref:constraints-and-score/understanding-the-score.adoc[score analysis], implement the `AnalyzableIncrementalScoreCalculator` interface instead of `IncrementalScoreCalculator`: diff --git a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc index d06b1c8a56..6ea1f7fb95 100644 --- a/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc +++ b/docs/src/modules/ROOT/pages/upgrading-timefold-solver/upgrade-from-v1.adoc @@ -319,8 +319,17 @@ The following `ScoreExplanation` methods have no direct replacement and must be * `getSummary()` — use `ScoreAnalysis.summarize()` instead * `getConstraintMatchTotalMap()` — use `ScoreAnalysis.constraintAnalyses()` instead -Add the Timefold Solver Enterprise dependency to your project to use `ScoreAnalysis`. -There is no automated migration recipe for `ScoreExplanation`; all changes must be made manually. +`IncrementalScoreCalculator` has also seen changes in this area. +`ConstraintMatchAwareIncrementalScoreCalculator` was removed. +The replacement `AnalyzableIncrementalScoreCalculator` is provided by the Plus and Enterprise editions. + +Instead of overriding methods to return constraint matches, implement the `enableConstraintMatch(ConstraintMatchRegistry)` method. +Use `ConstraintMatchRegistry.registerConstraintMatch(ConstraintRef, score, justification)` to register each match; +it returns a `ConstraintMatchRegistration` whose `cancel()` method removes the match when the assignment changes. + +To use `ScoreAnalysis` or `AnalyzableIncrementalScoreCalculator`, +xref:commercial-editions/installation.adoc[install either of the commercial editions]. +There is no automated migration recipe for these changes; all changes must be made manually. ==== ''' @@ -335,23 +344,6 @@ If you use Jackson serialization, register `TimefoldEnterpriseJacksonModule` in ''' -.icon:exclamation-triangle[role=red] Incremental score calculator no longer open-source -[%collapsible%open] -==== -`IncrementalScoreCalculator` has moved to Timefold Solver Enterprise Edition. -`ConstraintMatchAwareIncrementalScoreCalculator` was removed. -The replacement `AnalyzableIncrementalScoreCalculator` is provided by the Enterprise Edition. - -Instead of overriding methods to return constraint matches, implement the `enableConstraintMatch(ConstraintMatchRegistry)` method. -Use `ConstraintMatchRegistry.registerConstraintMatch(ConstraintRef, score, justification)` to register each match; -it returns a `ConstraintMatchRegistration` whose `cancel()` method removes the match when the assignment changes. - -Add the Timefold Solver Enterprise dependency and rewrite the implementation body manually; -there is no automated migration recipe for this change. -==== - -''' - .icon:exclamation-triangle[role=red] `Move` interface refactored [%collapsible%open] ====