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 extends IncrementalScoreCalculator> 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 extends IncrementalScoreCalculator> 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 extends IncrementalScoreCalculator> 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