Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8351135
feat: add plumbing for indictment support
Christopher-Chianelli Jul 17, 2026
8a54863
chore: remove creating a new tuple for `ifExists` indictments
Christopher-Chianelli Jul 20, 2026
c0f24dd
chore: use a Map to store extra indicted objects from ifExists
Christopher-Chianelli Jul 20, 2026
dcc45cf
chore: make UniConstraintStream tests aware of indictments
Christopher-Chianelli Jul 21, 2026
271cdf1
fix: remove old indicted objects from original group in GroupNode
Christopher-Chianelli Jul 21, 2026
2cf2be9
chore: move indictment code for IfExists to IndictmentSource
Christopher-Chianelli Jul 21, 2026
46d3960
chore: track support in indictment, not tuple
Christopher-Chianelli Jul 21, 2026
244462c
chore: make getIndictedObjects() return null when indictments disable…
Christopher-Chianelli Jul 21, 2026
3864218
test: add tests for BiConstraintStream indictments
Christopher-Chianelli Jul 21, 2026
fedde0d
test: make TriConstraintStream tests indictment aware
Christopher-Chianelli Jul 21, 2026
46bb0f4
test: Make BavetQuadConstraintStreamTest aware of indictments
Christopher-Chianelli Jul 21, 2026
b68f7af
test: make AdvanceGroupByTest aware of indictments
Christopher-Chianelli Jul 22, 2026
0ef72f7
test: make BavetRegressionTest aware of indictments
Christopher-Chianelli Jul 22, 2026
9a07e31
fix: Propagate updates to indictments in ifExists even if left side d…
Christopher-Chianelli Jul 22, 2026
e4f096f
fix: update left indicted set even if its key not changed for indexed…
Christopher-Chianelli Jul 22, 2026
7a85df1
test: make TriPrecompute test indictment aware
Christopher-Chianelli Jul 22, 2026
ddcaefb
test: make QuadPrecompute tests indictment aware
Christopher-Chianelli Jul 22, 2026
0381238
chore: merge fixes
Christopher-Chianelli Jul 28, 2026
235c351
chore: review comments
Christopher-Chianelli Jul 30, 2026
2759a79
chore: merge fixes
Christopher-Chianelli Aug 10, 2026
5333b08
chore: add preview feature to enable solving with indictments enabled
Christopher-Chianelli Sep 22, 2026
ec29271
chore: make regression tests aware of indictments
Christopher-Chianelli Sep 16, 2026
d5c7f93
chore: add IndictmentAnalysis API to ScoreAnalysis
Christopher-Chianelli Sep 17, 2026
4a13b29
chore: remove ConstraintMatchPolicy changes
Christopher-Chianelli Sep 17, 2026
835d50d
chore: update revapi
Christopher-Chianelli Sep 17, 2026
01695d0
chore: review comments
Christopher-Chianelli Sep 22, 2026
69ed896
chore: move to new API
Christopher-Chianelli Sep 23, 2026
03ca38f
chore: review comments
Christopher-Chianelli Sep 23, 2026
c542057
docs: fix Javadocs
Christopher-Chianelli Sep 24, 2026
5263dcb
docs: add indictments to docs
Christopher-Chianelli Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/src/build/revapi-differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@
"code": "java.method.addedToInterface",
"new": "method <Result_> Result_ ai.timefold.solver.core.api.solver.phase.PhaseCommandContext<Solution_>::executeTemporarilyAndCalculateScore(ai.timefold.solver.core.preview.api.move.Move<Solution_>, java.util.function.Function<Solution_, Result_>, java.util.function.Function<Solution_, Result_>)",
"justification": "PhaseCommandContext is only ever supplied by the solver to PhaseCommand.changeWorkingSolution(context); it is not implemented by user code."
},
{
"ignore": true,
"code": "java.method.addedToInterface",
"new": "method java.util.SequencedMap<java.lang.String, java.util.List<ai.timefold.solver.core.api.score.analysis.IndictmentAnalysis<Score_>>> ai.timefold.solver.core.api.score.analysis.ScoreAnalysis<Score_ extends ai.timefold.solver.core.api.score.Score<Score_>>::indictmentMap()",
"justification": "Add support for Indictments in ScoreAnalysis; it is not implemented by user code."
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package ai.timefold.solver.core.api.score.analysis;

import org.jspecify.annotations.NullMarked;

/**
* An optional interface a class can implement to customize how it appears
* when indicted in a {@link ScoreAnalysis}.
* <p>
* When not implemented, the class' canonical name is used as the key in {@link ScoreAnalysis#indictmentMap()}}
* and {@link IndictmentAnalysis#indictee()} is the same instance.
* <p>
* The primary use case for implementing {@link Indictable} is if you have a separate
* solver and rest model, and don't want to expose your solver types in your REST API.
* <p>
* Note: {@link ScoreAnalysis} in general and indictments in particular
* are exclusive to Timefold Solver Enterprise Edition.
*/
@NullMarked
public interface Indictable {
Comment thread
triceo marked this conversation as resolved.

/**
* Returns the indictment class id used by {@link ScoreAnalysis#indictmentMap()}
* when a class does not implement {@link Indictable}.
*
* @param clazz The type of class
* @return the key into {@link ScoreAnalysis#indictmentMap()} to get indicted objects of that type
*/
static String getIndictmentClassId(Class<?> clazz) {
if (Indictable.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("""
The class (%s) implements %s so its indictment map key cannot be determined statically.
Maybe check (%s) implementation of getIndictmentClassId()?""".formatted(clazz.getCanonicalName(),
Indictable.class.getSimpleName(), clazz.getCanonicalName()));
}
return clazz.getCanonicalName();
}

/**
* An identifier that uniquely identifies the type returned by
* {@link #getIndictedObject()}.
*
* @return a string that can be used to identify the class of {@link #getIndictedObject()}
*/
String getIndictmentClassId();

/**
* Optional method. It is used to calculate the {@link IndictmentAnalysis#indictee()}
* when this object is indicted. If not implemented, it will return this object.
*
* @return the object that {@link IndictmentAnalysis#indictee()} should return
*/
default Object getIndictedObject() {
return this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package ai.timefold.solver.core.api.score.analysis;

import java.util.List;

import ai.timefold.solver.core.api.score.Score;

/**
* Note: {@link ScoreAnalysis} is exclusive to Timefold Solver Enterprise Edition.
*
* @param <Score_>
* @see ScoreAnalysis Description of score analysis and the purpose of this class.
*/
public interface IndictmentAnalysis<Score_ extends Score<Score_>> {
Comment thread
Christopher-Chianelli marked this conversation as resolved.
/**
* The object that was indicted by constraints.
* Its type corresponds to the containing entry's key in {@link ScoreAnalysis#indictmentMap()}.
*
* @return the indicted object
*/
Object indictee();
Comment thread
triceo marked this conversation as resolved.

/**
* The sum of score impacts of each constraint match the {@link #indictee()}
* is involved with.
*
* @return the total score impact of the indicted object
*/
Score_ score();

/**
* A list of {@link MatchAnalysis} for each constraint match the
* {@link #indictee()} is involved with, sorted by absolute score impact
*
* @return a list of matches the indictee is involved with, sorted by absolute score impact
*/
List<MatchAnalysis<Score_>> matches();
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.timefold.solver.core.api.score.analysis;

import java.util.Collection;
import java.util.List;
import java.util.SequencedMap;

import ai.timefold.solver.core.api.score.Score;
Expand Down Expand Up @@ -154,6 +155,17 @@ public interface ScoreAnalysis<Score_ extends Score<Score_>> {
*/
Collection<ConstraintAnalysis<Score_>> constraintAnalyses();

/**
* Returns a map from indicted classes to a list of {@link IndictmentAnalysis} that contributed to at least one constraint
* match in the {@link ScoreAnalysis}. By default, the key is the canonical name of the class, but can be controlled
* by implementing {@link Indictable}. The lists are sorted by the absolute value of their total score contribution, in
* descending order.
*
* @return a map from indicted class to a list of {@link IndictmentAnalysis} linking planning entities and problem facts
* to their impact in this {@link ScoreAnalysis}
*/
SequencedMap<String, List<IndictmentAnalysis<Score_>>> indictmentMap();

/**
* Returns a diagnostic text that explains the solution through the {@link ConstraintAnalysis} API to identify which
* constraints cause that score quality.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,31 @@ public enum ScoreAnalysisFetchPolicy {

/**
* {@link ScoreAnalysis} is fully initialized.
* All included {@link ConstraintAnalysis} objects include full {@link ConstraintAnalysis#matches() match analysis}.
* All included {@link ConstraintAnalysis} objects include full {@link ConstraintAnalysis#matches() match analysis},
* and {@link ScoreAnalysis#indictmentMap()} will be populated.
Comment thread
triceo marked this conversation as resolved.
*/
FETCH_ALL,
/**
* {@link ConstraintAnalysis} included in {@link ScoreAnalysis}
* provides neither {@link ConstraintAnalysis#matches() match analysis}
* nor {@link ConstraintAnalysis#matchCount() match count}.
* Additionally, {@link ScoreAnalysis#indictmentMap()} will be empty.
* This is useful for performance reasons when the match analysis is not needed.
*/
FETCH_SHALLOW,
/**
* {@link ConstraintAnalysis} included in {@link ScoreAnalysis}
* does not provide {@link ConstraintAnalysis#matches() match analysis},
* but does provide {@link ConstraintAnalysis#matchCount() match count}.
* Additionally, {@link ScoreAnalysis#indictmentMap()} will be empty.
* This is useful when there are too many matches to send over the wire
* or meaningfully present to users.
*/
FETCH_MATCH_COUNT

FETCH_MATCH_COUNT,
/**
* {@link ScoreAnalysis} is initialized except for indictments.
* All included {@link ConstraintAnalysis} objects include full {@link ConstraintAnalysis#matches() match analysis},
* {@link ScoreAnalysis#indictmentMap()} will be empty.
*/
FETCH_JUSTIFICATIONS
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package ai.timefold.solver.core.api.solver;

import static ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy.FETCH_ALL;
import static ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy.FETCH_JUSTIFICATIONS;
import static ai.timefold.solver.core.api.solver.SolutionUpdatePolicy.UPDATE_ALL;

import java.util.List;
Expand Down Expand Up @@ -118,10 +118,10 @@ static <Solution_> void updateShadowVariables(Solution_ solution) {

/**
* As defined by {@link #analyze(Object, ScoreAnalysisFetchPolicy, SolutionUpdatePolicy)},
* using {@link SolutionUpdatePolicy#UPDATE_ALL} and {@link ScoreAnalysisFetchPolicy#FETCH_ALL}.
* using {@link SolutionUpdatePolicy#UPDATE_ALL} and {@link ScoreAnalysisFetchPolicy#FETCH_JUSTIFICATIONS}.
*/
default ScoreAnalysis<Score_> analyze(Solution_ solution) {
return analyze(solution, FETCH_ALL, UPDATE_ALL);
return analyze(solution, FETCH_JUSTIFICATIONS, UPDATE_ALL);
}

/**
Expand Down Expand Up @@ -179,12 +179,12 @@ ScoreAnalysis<Score_> analyze(Solution_ solution, ScoreAnalysisFetchPolicy fetch

/**
* As defined by {@link #recommendAssignment(Object, Object, Function, ScoreAnalysisFetchPolicy)},
* with {@link ScoreAnalysisFetchPolicy#FETCH_ALL}.
* with {@link ScoreAnalysisFetchPolicy#FETCH_JUSTIFICATIONS}.
*/
default <EntityOrElement_, Proposition_> List<RecommendedAssignment<Proposition_, Score_>> recommendAssignment(
Solution_ solution, EntityOrElement_ evaluatedEntityOrElement,
Function<EntityOrElement_, @Nullable Proposition_> propositionFunction) {
return recommendAssignment(solution, evaluatedEntityOrElement, propositionFunction, FETCH_ALL);
return recommendAssignment(solution, evaluatedEntityOrElement, propositionFunction, FETCH_JUSTIFICATIONS);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ protected boolean canProduceTuples() {
@Override
public final void insertLeft(LeftTuple_ tuple) {
var outTuple = getOutTupleFromLeft(tuple);
outTuple.setIndictmentSource(tuple.getIndictmentSource());
tuple.setStore(leftSourceTupleCloneStoreIndex, outTuple);
propagationQueue.insert(outTuple);
}
Expand Down Expand Up @@ -109,6 +110,7 @@ public final void retractLeft(LeftTuple_ tuple) {
@Override
public final void insertRight(RightTuple_ tuple) {
var outTuple = getOutTupleFromRight(tuple);
outTuple.setIndictmentSource(tuple.getIndictmentSource());
tuple.setStore(rightSourceTupleCloneStoreIndex, outTuple);
propagationQueue.insert(outTuple);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ private void addTuple(InTuple_ originalTuple, FlattenedItem_ item,
var reuse = bag.reuseOrAdvance();
if (reuse == null) {
var created = createTuple(originalTuple, bag.value);
created.setIndictmentSource(originalTuple.getIndictmentSource());
Comment thread
triceo marked this conversation as resolved.
bag.append(created);
propagationQueue.insert(created);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import ai.timefold.solver.core.impl.bavet.common.tuple.Tuple;
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle;
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState;
import ai.timefold.solver.core.impl.bavet.common.tuple.indictment.IndictmentSource;

public abstract class AbstractGroupNode<InTuple_ extends Tuple, OutTuple_ extends Tuple, GroupKey_, ResultContainer_, Result_>
extends AbstractSingleInputNode<InTuple_> {
Expand Down Expand Up @@ -111,6 +112,7 @@ private void createTuple(InTuple_ tuple, GroupKey_ userSuppliedKey) {
}
tuple.setStore(groupStoreIndex, group);
var outTuple = group.getTuple();
outTuple.setIndictmentSource(IndictmentSource.aggregating(tuple, outTuple));
switch (outTuple.getState()) {
case CREATING, UPDATING -> {
// Already in the correct state.
Expand Down Expand Up @@ -190,6 +192,8 @@ public final void update(InTuple_ tuple) {
if (sameKey) {
updateGroup(tuple, oldGroup);
} else {
var oldOutTuple = oldGroup.getTuple();
oldOutTuple.setIndictmentSource(IndictmentSource.removeFromAggregate(tuple, oldOutTuple));
if (hasCollector) {
groupRetract(tuple);
}
Expand Down Expand Up @@ -274,6 +278,8 @@ public final void retract(InTuple_ tuple) {
// No fail fast if null because we don't track which tuples made it through the filter predicate(s)
return;
}
var oldOutTuple = group.getTuple();
oldOutTuple.setIndictmentSource(IndictmentSource.removeFromAggregate(tuple, oldOutTuple));
if (hasCollector) {
groupRetract(tuple);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleLifecycle;
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleState;
import ai.timefold.solver.core.impl.bavet.common.tuple.UniTuple;
import ai.timefold.solver.core.impl.bavet.common.tuple.indictment.IndictmentSource;

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
Expand Down Expand Up @@ -135,6 +136,20 @@ protected void incrementCounterRight(ExistsCounter<LeftTuple_> counter) {
counter.countRight++;
}

protected void incrementCounterRightUpdatingIndictment(ExistsCounter<LeftTuple_> counter, UniTuple<Right_> rightTuple) {
IndictmentSource.addCorroborator(getId(), counter.getTuple(), rightTuple);
if (counter.countRight == 0) {
if (shouldExist) {
doInsertCounter(counter);
} else {
doRetractCounter(counter);
}
} else if (shouldExist) {
doUpdateCounter(counter);
}
counter.countRight++;
}

protected void decrementCounterRight(ExistsCounter<LeftTuple_> counter) {
counter.countRight--;
if (counter.countRight == 0) {
Expand All @@ -146,6 +161,20 @@ protected void decrementCounterRight(ExistsCounter<LeftTuple_> counter) {
} // Else do not even propagate an update
}

protected void decrementCounterRightUpdatingIndictment(ExistsCounter<LeftTuple_> counter, UniTuple<Right_> rightTuple) {
IndictmentSource.removeCorroborator(getId(), counter.getTuple(), rightTuple);
counter.countRight--;
if (counter.countRight == 0) {
if (shouldExist) {
doRetractCounter(counter);
} else {
doInsertCounter(counter);
}
} else if (shouldExist) {
doUpdateCounter(counter);
}
}

/**
* Clears the left tracker list rooted at leftTuple's inputStoreIndexLeftTrackerList slot,
* cross-removing each tracker from its right tuple's hidden list.
Expand Down Expand Up @@ -194,11 +223,20 @@ private void removeRight(FilteringTracker<LeftTuple_> tracker) {
*/
protected void clearRightTrackerList(UniTuple<Right_> rightTuple) {
FilteringTracker<LeftTuple_> tracker = rightTuple.removeStore(inputStoreIndexRightTrackerList);
while (tracker != null) {
var next = tracker.rightNext;
decrementCounterRight(tracker.counter);
removeLeft(tracker);
tracker = next;
if (rightTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
while (tracker != null) {
var next = tracker.rightNext;
decrementCounterRight(tracker.counter);
removeLeft(tracker);
tracker = next;
}
} else {
while (tracker != null) {
var next = tracker.rightNext;
decrementCounterRightUpdatingIndictment(tracker.counter, rightTuple);
removeLeft(tracker);
tracker = next;
}
}
}

Expand Down Expand Up @@ -233,9 +271,12 @@ protected void updateCounterLeft(ExistsCounter<LeftTuple_> counter, UniTuple<Rig
}
if (testFiltering(counter.leftTuple, rightTuple)) {
counter.countRight++;
IndictmentSource.addCorroborator(getId(), counter.getTuple(), rightTuple);
var tracker = new FilteringTracker<>(counter, rightTuple);
linkLeft(tracker);
linkRight(tracker);
} else {
IndictmentSource.removeCorroborator(getId(), counter.getTuple(), rightTuple);
}
}

Expand Down Expand Up @@ -298,9 +339,12 @@ protected void updateCounterRight(ExistsCounter<LeftTuple_> counter, UniTuple<Ri
}
if (testFiltering(leftTuple, rightTuple)) {
incrementCounterRight(counter);
IndictmentSource.addCorroborator(getId(), counter.getTuple(), rightTuple);
var tracker = new FilteringTracker<>(counter, rightTuple);
linkLeft(tracker);
linkRight(tracker);
} else {
IndictmentSource.removeCorroborator(getId(), counter.getTuple(), rightTuple);
}
}

Expand All @@ -325,6 +369,15 @@ private void doRetractCounter(ExistsCounter<LeftTuple_> counter) {
}
}

private void doUpdateCounter(ExistsCounter<LeftTuple_> counter) {
switch (counter.state) {
case DYING, OK, UPDATING, CREATING -> propagationQueue.update(counter);
case DEAD, ABORTING -> propagationQueue.insert(counter);
default -> throw new IllegalStateException("Impossible state: the counter (%s) has an impossible insert state (%s)."
.formatted(counter, counter.state));
}
}

@Override
protected boolean canProduceTuples() {
// The left input must produce tuples no matter what,
Expand Down
Loading
Loading