From 09664be3396aa16cbf2f966533952eda4186dd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Mon, 20 Apr 2026 09:52:08 +0200 Subject: [PATCH 01/11] docs: reference the new license manager --- .github/workflows/release-changelog-template.md | 2 +- README.adoc | 4 ++-- .../modules/ROOT/pages/commercial-editions/installation.adoc | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-changelog-template.md b/.github/workflows/release-changelog-template.md index 7f0632c26f..cdd06ec6e7 100644 --- a/.github/workflows/release-changelog-template.md +++ b/.github/workflows/release-changelog-template.md @@ -14,7 +14,7 @@ For more, see [Contributing](https://github.com/TimefoldAI/timefold-solver/blob/ 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). +Enterprise Edition requires [a license](https://licenses.timefold.ai/). # How to use Timefold Solver diff --git a/README.adoc b/README.adoc index 29367828e3..e8dc22d085 100644 --- a/README.adoc +++ b/README.adoc @@ -66,7 +66,7 @@ For more, see link:CONTRIBUTING.md[Contributing]. There are two editions of Timefold Solver: - _Timefold Solver Community Edition_ (this repo). -- _Timefold Solver Enterprise Edition_, a https://timefold.ai/pricing[licensed version] of Timefold Solver. +- _Timefold Solver Enterprise Edition_, a https://licenses.timefold.ai/[licensed version] of Timefold Solver. === Key Features of Timefold Solver Enterprise Edition @@ -79,7 +79,7 @@ There are two editions of Timefold Solver: 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. +https://licenses.timefold.ai/[obtain the appropriate license]. == Legal notice diff --git a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc index 48e6c69afa..9264acd8cc 100644 --- a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc +++ b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc @@ -13,7 +13,7 @@ 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] ==== From 2ec68e94f6ce58879c26844717de3f408551ddf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 08:15:51 +0200 Subject: [PATCH 02/11] chore: move incremental back to community --- .../AnalyzableIncrementalScoreCalculator.java | 7 +- .../calculator/ConstraintMatchRegistry.java | 4 + .../IncrementalScoreCalculator.java | 4 - .../director/ScoreDirectorFactoryConfig.java | 30 -- .../TimefoldSolverEnterpriseService.java | 11 +- .../director/ScoreDirectorFactoryFactory.java | 7 +- .../incremental/IncrementalScoreDirector.java | 318 ++++++++++++++++++ .../IncrementalScoreDirectorFactory.java | 62 ++++ .../ScoreDirectorFactoryFactoryTest.java | 99 ++++++ .../core/impl/solver/DefaultSolverTest.java | 146 ++++++++ docs/src/modules/ROOT/nav.adoc | 1 - .../commercial-editions.adoc | 3 - .../score-calculation.adoc | 4 +- .../upgrade-from-v1.adoc | 29 +- 14 files changed, 649 insertions(+), 76 deletions(-) create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorFactory.java 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..66aa0335a9 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. + * Note: Explainability features 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. + * and using this interface will have no effect there. * * @param the solution type, the class with the {@link PlanningSolution} annotation * @param the score type to go with the solution @@ -25,6 +25,9 @@ public interface AnalyzableIncrementalScoreCalculator + * Has no effect in Timefold Solver Community Edition, + * as score analysis is only available in Enterprise Edition. * * @param constraintMatchRegistry use for registering constraint matches */ 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..a3c3312acd 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,6 +10,10 @@ import org.jspecify.annotations.NullMarked; /** + *

+ * Note: Explainability features are exclusive to Timefold Solver Enterprise Edition. + * They are not available in the open-source version of Timefold Solver, + * and using this class will have no effect there. * * @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..3ae23ab6e0 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; @@ -202,10 +199,6 @@ DestinationSelector applyNearbySelection(DestinationSelec InnerConstraintProfiler buildConstraintProfiler(); - > AbstractScoreDirectorFactory - buildIncrementalScoreDirectorFactory(ScoreDirectorFactoryConfig config, - SolutionDescriptor solutionDescriptor, EnvironmentMode environmentMode); - > ScoreAnalysis analyze(InnerScore state, Map> constraintMatchTotalMap, ScoreAnalysisFetchPolicy fetchPolicy); @@ -227,9 +220,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..0f750e375c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -0,0 +1,318 @@ +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 beforeEntityAdded(EntityDescriptor entityDescriptor, Object entity) { + super.beforeEntityAdded(entityDescriptor, entity); + } + + @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 beforeEntityRemoved(EntityDescriptor entityDescriptor, Object entity) { + super.beforeEntityRemoved(entityDescriptor, entity); + } + + @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 beforeProblemPropertyChanged(Object problemFactOrEntity) { + super.beforeProblemPropertyChanged(problemFactOrEntity); + } + + @Override + public void afterProblemPropertyChanged(Object problemFactOrEntity) { + resetWorkingSolutionAndMaps(workingSolution); + super.afterProblemPropertyChanged(problemFactOrEntity); + } + + @Override + public void beforeProblemFactRemoved(Object problemFact) { + super.beforeProblemFactRemoved(problemFact); + } + + @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..644e4f3feb 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,23 @@ 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.TestdataConstraintProvider; 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 +92,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/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/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..d4058c70eb 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,16 @@ 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 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 to your project to use `ScoreAnalysis` or `AnalyzableIncrementalScoreCalculator`. +There is no automated migration recipe for these changes; all changes must be made manually. ==== ''' @@ -335,23 +343,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] ==== From ea19284b606bb605aa7fda5a36cc81cfee3a3fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 08:26:42 +0200 Subject: [PATCH 03/11] chore: it's just "timefold license" now --- .../core/enterprise/TimefoldSolverEnterpriseService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3ae23ab6e0..f4920d0826 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 @@ -115,19 +115,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.""" From 3b5107adb123cd684bee89179bd421d0165b8c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 09:06:23 +0200 Subject: [PATCH 04/11] chore: license naming changes --- .github/workflows/pull_request_secure.yml | 2 +- .../ROOT/pages/commercial-editions/installation.adoc | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc index 9264acd8cc..1701fc8fd1 100644 --- a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc +++ b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc @@ -24,12 +24,12 @@ logging the license file content in logs, or sharing it with unauthorized partie [#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 From 8be3c1d2f9418327cd26c029d24cf41d74bd8e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 09:24:30 +0200 Subject: [PATCH 05/11] chore: adjust README --- README.adoc | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/README.adoc b/README.adoc index e8dc22d085..8296991433 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://licenses.timefold.ai/[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, -https://licenses.timefold.ai/[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) From ca894019dd274c4c89e405a38e04e2cdf9902f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 09:52:18 +0200 Subject: [PATCH 06/11] review --- README.adoc | 2 +- .../calculator/AnalyzableIncrementalScoreCalculator.java | 4 ++-- .../core/api/score/calculator/ConstraintMatchRegistry.java | 5 ----- .../pages/upgrading-timefold-solver/upgrade-from-v1.adoc | 5 +++-- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/README.adoc b/README.adoc index 8296991433..5971354ecc 100644 --- a/README.adoc +++ b/README.adoc @@ -68,7 +68,7 @@ There are three editions of Timefold Solver: - _Timefold Solver Community Edition_, - _Timefold Solver Plus_ -- and _Timefold Solver Enterprise. +- and _Timefold Solver Enterprise_. 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. 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 66aa0335a9..ae11688283 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 @@ -11,8 +11,8 @@ * Any implementation is naturally stateful. *

* Note: Explainability features are exclusive to Timefold Solver Enterprise Edition. - * They are not available in the open-source version of Timefold Solver, - * and using this interface will have no effect there. + * 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 a3c3312acd..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,11 +10,6 @@ import org.jspecify.annotations.NullMarked; /** - *

- * Note: Explainability features are exclusive to Timefold Solver Enterprise Edition. - * They are not available in the open-source version of Timefold Solver, - * and using this class will have no effect there. - * * @param * @see AnalyzableIncrementalScoreCalculator Adding explainability to incremental score calculator. */ 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 d4058c70eb..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 @@ -321,13 +321,14 @@ The following `ScoreExplanation` methods have no direct replacement and must be `IncrementalScoreCalculator` has also seen changes in this area. `ConstraintMatchAwareIncrementalScoreCalculator` was removed. -The replacement `AnalyzableIncrementalScoreCalculator` is provided by the Enterprise Edition. +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. -Add the Timefold Solver Enterprise dependency to your project to use `ScoreAnalysis` or `AnalyzableIncrementalScoreCalculator`. +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. ==== From 41e0335d439453b13d886c26e71ab5ce1b4bf08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 12:16:50 +0200 Subject: [PATCH 07/11] Better release template. --- .github/workflows/release-changelog-template.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-changelog-template.md b/.github/workflows/release-changelog-template.md index cdd06ec6e7..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://licenses.timefold.ai/). +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 From 4d99bdb9f0d8d213cf6eae9ebbbccca94ceec8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 13:15:17 +0200 Subject: [PATCH 08/11] Remove version check --- .../score/calculator/AnalyzableIncrementalScoreCalculator.java | 3 --- .../core/enterprise/TimefoldSolverEnterpriseService.java | 1 - .../impl/score/director/ScoreDirectorFactoryFactoryTest.java | 1 - 3 files changed, 5 deletions(-) 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 ae11688283..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 @@ -25,9 +25,6 @@ public interface AnalyzableIncrementalScoreCalculator - * Has no effect in Timefold Solver Community Edition, - * as score analysis is only available in Enterprise Edition. * * @param constraintMatchRegistry use for registering constraint matches */ 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 f4920d0826..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 @@ -65,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"; 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 644e4f3feb..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 @@ -15,7 +15,6 @@ 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.TestdataConstraintProvider; import ai.timefold.solver.core.testdomain.TestdataSolution; import org.jspecify.annotations.NonNull; From 017238fef5d0ef87bb1ec0cf397cc7117fca91d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 15:11:36 +0200 Subject: [PATCH 09/11] Terms of use --- .../modules/ROOT/pages/commercial-editions/installation.adoc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc index 1701fc8fd1..bc6eb69127 100644 --- a/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc +++ b/docs/src/modules/ROOT/pages/commercial-editions/installation.adoc @@ -15,11 +15,10 @@ A correctly configured and active license key is required in order for our comme 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 From e835fd7cbef79792fb2e25bc4668d505562b7747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 16:22:37 +0200 Subject: [PATCH 10/11] Add omitted tests --- ...IncrementalScoreDirectorSemanticsTest.java | 158 ++++++++ .../IncrementalScoreDirectorTest.java | 374 ++++++++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorSemanticsTest.java create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirectorTest.java 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..2237fc327e --- /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; + } + } + + } +} From 561ac552760d9629f334d44672decf8056651400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Petrovick=C3=BD?= Date: Tue, 21 Apr 2026 16:46:32 +0200 Subject: [PATCH 11/11] Sonar --- .../incremental/IncrementalScoreDirector.java | 20 ------------------- .../IncrementalScoreDirectorTest.java | 2 +- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java index 0f750e375c..1df721dd19 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/incremental/IncrementalScoreDirector.java @@ -107,11 +107,6 @@ public boolean requiresFlushing() { // Entity/variable add/change/remove methods // ************************************************************************ - @Override - public void beforeEntityAdded(EntityDescriptor entityDescriptor, Object entity) { - super.beforeEntityAdded(entityDescriptor, entity); - } - @Override public void afterEntityAdded(EntityDescriptor entityDescriptor, Object entity) { resetWorkingSolutionAndMaps(workingSolution); @@ -168,11 +163,6 @@ public void afterListVariableChanged(ListVariableDescriptor variableD super.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex); } - @Override - public void beforeEntityRemoved(EntityDescriptor entityDescriptor, Object entity) { - super.beforeEntityRemoved(entityDescriptor, entity); - } - @Override public void afterEntityRemoved(EntityDescriptor entityDescriptor, Object entity) { resetWorkingSolutionAndMaps(workingSolution); @@ -194,22 +184,12 @@ public void afterProblemFactAdded(Object problemFact) { super.afterProblemFactAdded(problemFact); } - @Override - public void beforeProblemPropertyChanged(Object problemFactOrEntity) { - super.beforeProblemPropertyChanged(problemFactOrEntity); - } - @Override public void afterProblemPropertyChanged(Object problemFactOrEntity) { resetWorkingSolutionAndMaps(workingSolution); super.afterProblemPropertyChanged(problemFactOrEntity); } - @Override - public void beforeProblemFactRemoved(Object problemFact) { - super.beforeProblemFactRemoved(problemFact); - } - @Override public void afterProblemFactRemoved(Object problemFact) { resetWorkingSolutionAndMaps(workingSolution); 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 index 2237fc327e..e5f0d0e1ab 100644 --- 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 @@ -185,7 +185,7 @@ public SimpleScore calculateScore() { .build()) { scoreDirector.setWorkingSolution(new Object()); registration[0].cancel(); - assertThatIllegalStateException().isThrownBy(() -> registration[0].cancel()) + assertThatIllegalStateException().isThrownBy(registration[0]::cancel) .withMessageContaining("canceled once"); } }