Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a0d90a2
feat: add plumbing for indictment support
Christopher-Chianelli Jul 17, 2026
dac65ef
chore: remove creating a new tuple for `ifExists` indictments
Christopher-Chianelli Jul 20, 2026
bd643db
chore: use a Map to store extra indicted objects from ifExists
Christopher-Chianelli Jul 20, 2026
84d5815
chore: make UniConstraintStream tests aware of indictments
Christopher-Chianelli Jul 21, 2026
b67e780
fix: remove old indicted objects from original group in GroupNode
Christopher-Chianelli Jul 21, 2026
57b24b1
chore: move indictment code for IfExists to IndictmentSource
Christopher-Chianelli Jul 21, 2026
83b2569
chore: track support in indictment, not tuple
Christopher-Chianelli Jul 21, 2026
478bcd2
chore: make getIndictedObjects() return null when indictments disable…
Christopher-Chianelli Jul 21, 2026
246608f
test: add tests for BiConstraintStream indictments
Christopher-Chianelli Jul 21, 2026
c6b5034
test: make TriConstraintStream tests indictment aware
Christopher-Chianelli Jul 21, 2026
e720515
test: Make BavetQuadConstraintStreamTest aware of indictments
Christopher-Chianelli Jul 21, 2026
8714603
test: make AdvanceGroupByTest aware of indictments
Christopher-Chianelli Jul 22, 2026
15031ef
test: make BavetRegressionTest aware of indictments
Christopher-Chianelli Jul 22, 2026
4dfea10
fix: Propagate updates to indictments in ifExists even if left side d…
Christopher-Chianelli Jul 22, 2026
70a6eff
fix: update left indicted set even if its key not changed for indexed…
Christopher-Chianelli Jul 22, 2026
04622f2
test: make TriPrecompute test indictment aware
Christopher-Chianelli Jul 22, 2026
a1b0cb5
test: make QuadPrecompute tests indictment aware
Christopher-Chianelli Jul 22, 2026
b438f8b
chore: merge fixes
Christopher-Chianelli Jul 28, 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
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 on lines 75 to +76

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cases like this would benefit from inheriting the indictment source at construction time; the mutability aspect will probably be necessary in some places (haven't yet read the entire thing), but I think the default pattern here should not happen post-construction.

bag.append(created);
propagationQueue.insert(created);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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 @@ -110,6 +111,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 @@ -115,6 +116,20 @@
counter.countRight++;
}

protected void incrementCounterRightUpdatingIndictment(ExistsCounter<LeftTuple_> counter, UniTuple<Right_> rightTuple) {
IndictmentSource.addSupport(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 @@ -126,6 +141,20 @@
} // Else do not even propagate an update
}

protected void decrementCounterRightUpdatingIndictment(ExistsCounter<LeftTuple_> counter, UniTuple<Right_> rightTuple) {
IndictmentSource.removeSupport(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. No-op when !isFiltering.
// Walk safety: removeFromRight only touches right-side links, so leftNext is stable across the call.
Expand Down Expand Up @@ -164,11 +193,20 @@
// Walk safety: removeFromLeft only touches left-side links, so rightNext is stable across the call.
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 @@ -196,13 +234,17 @@
// which happens when the right input's node sits in a higher layer than the left input's,
// so the left's inserts and updates are delivered before the right's retracts are.
// Skipping is safe, as the pending retract will not have a tracker to clear for this pair.
IndictmentSource.removeSupport(getId(), counter.getTuple(), rightTuple);
return;
}
if (testFiltering(counter.leftTuple, rightTuple)) {
counter.countRight++;
IndictmentSource.addSupport(getId(), counter.getTuple(), rightTuple);
var tracker = new FilteringTracker<>(counter, rightTuple);
linkLeft(tracker);
linkRight(tracker);
} else {
IndictmentSource.removeSupport(getId(), counter.getTuple(), rightTuple);
}
}

Expand Down Expand Up @@ -247,13 +289,17 @@
// The left tuple can be inactive here because its node sits in a higher layer than the right's:
// the right's inserts and updates are delivered before the left's retracts are.
// The mirror case is possible too, see updateCounterLeft(...).
IndictmentSource.removeSupport(getId(), counter.getTuple(), rightTuple);
return;
}
if (testFiltering(leftTuple, rightTuple)) {
incrementCounterRight(counter);
IndictmentSource.addSupport(getId(), counter.getTuple(), rightTuple);
var tracker = new FilteringTracker<>(counter, rightTuple);
linkLeft(tracker);
linkRight(tracker);
} else {
IndictmentSource.removeSupport(getId(), counter.getTuple(), rightTuple);
}
}

Expand All @@ -266,6 +312,21 @@
}
}

private void doUpdateCounter(ExistsCounter<LeftTuple_> counter) {
switch (counter.state) {
case CREATING, UPDATING -> {
// Do nothing
}
case OK -> {

Check warning on line 320 in core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant block.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ-qhZfyF5FOG_1Hn5H-&open=AZ-qhZfyF5FOG_1Hn5H-&pullRequest=2528
propagationQueue.update(counter);
}
case DYING, DEAD, ABORTING -> {

Check warning on line 323 in core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIfExistsNode.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this redundant block.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ-qhZfyF5FOG_1Hn5H_&open=AZ-qhZfyF5FOG_1Hn5H_&pullRequest=2528
throw new IllegalStateException("Impossible state: The counter (%s) has an impossible retract state (%s)."
.formatted(counter, counter.state));
}
}
}

private void doRetractCounter(ExistsCounter<LeftTuple_> counter) {
switch (counter.state) {
case CREATING -> // Kill it before it propagates.
Expand Down Expand Up @@ -323,4 +384,4 @@

}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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.UniTuple;
import ai.timefold.solver.core.impl.bavet.common.tuple.indictment.IndictmentSource;
import ai.timefold.solver.core.impl.util.ListEntry;

import org.jspecify.annotations.Nullable;
Expand Down Expand Up @@ -99,6 +100,12 @@
leftTuple.setStore(inputStoreIndexLeftCounterEntry, counterEntry);
if (!isFiltering) {
counter.countRight = rightSize(leftTuple, compositeKey);
if (leftTuple.getIndictmentSource() != IndictmentSource.DISABLED) {
leftTuple.getIndictmentSource().getSupportForNodeId(getId()).clear();
forEachRightFromLeft(leftTuple, compositeKey, rightTuple -> {

Check warning on line 105 in core/src/main/java/ai/timefold/solver/core/impl/bavet/common/AbstractIndexedIfExistsNode.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove useless curly braces around statement

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AZ-Naxeg1MbjmFmOP9Fq&open=AZ-Naxeg1MbjmFmOP9Fq&pullRequest=2528
IndictmentSource.addSupport(getId(), leftTuple, rightTuple);
});
}
} else {
// Trackers link themselves into the left tuple's inputStoreIndexLeftTrackerList slot.
// No list object is needed; the slot starts null and the first tracker becomes the head.
Expand Down Expand Up @@ -203,7 +210,12 @@

private void updateCounterLeft(UniTuple<Right_> rightTuple, Object compositeKey) {
if (!isFiltering) {
forEachLeftCounter(rightTuple, compositeKey, this::incrementCounterRight);
if (rightTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
forEachLeftCounter(rightTuple, compositeKey, this::incrementCounterRight);
} else {
forEachLeftCounter(rightTuple, compositeKey,
counter -> incrementCounterRightUpdatingIndictment(counter, rightTuple));
}
} else {
// Trackers link themselves into the right tuple's inputStoreIndexRightTrackerList slot.
// No list object is needed; the slot starts null and the first tracker becomes the head.
Expand Down Expand Up @@ -265,7 +277,12 @@
bucket.removeRight(compositeKey, entry);
fusedEqualIndex.removeBucketIfEmpty(compositeKey, bucket);
if (!isFiltering) {
bucket.forEachLeft(compositeKey, this::decrementCounterRight);
if (rightTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
bucket.forEachLeft(compositeKey, this::decrementCounterRight);
} else {
bucket.forEachLeft(compositeKey,
leftTuple -> decrementCounterRightUpdatingIndictment(leftTuple, rightTuple));
}
} else {
clearRightTrackerList(rightTuple);
}
Expand Down Expand Up @@ -342,4 +359,4 @@
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import ai.timefold.solver.core.impl.bavet.common.tuple.TupleList;
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.Nullable;

Expand Down Expand Up @@ -110,6 +111,7 @@ private void insertOutTupleIfActiveFiltered(LeftTuple_ leftTuple, UniTuple<Right

private void insertOutTuple(LeftTuple_ leftTuple, UniTuple<Right_> rightTuple) {
var outTuple = createOutTuple(leftTuple, rightTuple);
outTuple.setIndictmentSource(IndictmentSource.joining(leftTuple, rightTuple));
TupleList<OutTuple_> outTupleListLeft = leftTuple.getStore(inputStoreIndexLeftOutTupleList);
outTupleListLeft.add(outTuple);
outTuple.setStore(outputStoreIndexLeftOutTupleList, outTupleListLeft);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public final void insert(InTuple_ tuple) {
+ ") was already added in the tupleStore.");
}
var outTuple = map(tuple);
outTuple.setIndictmentSource(tuple.getIndictmentSource());
tuple.setStore(inputStoreIndex, outTuple);
propagationQueue.insert(outTuple);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public abstract class AbstractNodeBuildHelper<Stream_ extends BavetStream> {

private final Set<Stream_> activeStreamSet;
private final Map<AbstractNode, Stream_> nodeCreatorMap;
private final Map<Stream_, AbstractNode> streamToCreatedNodeMap;
private final Map<Stream_, TupleLifecycle<? extends Tuple>> tupleLifecycleMap;
private final Map<Stream_, Integer> storeIndexMap;

Expand All @@ -34,6 +35,7 @@ protected AbstractNodeBuildHelper(Set<Stream_> activeStreamSet) {
this.activeStreamSet = activeStreamSet;
int activeStreamSetSize = activeStreamSet.size();
this.nodeCreatorMap = HashMap.newHashMap(Math.max(16, activeStreamSetSize));
this.streamToCreatedNodeMap = HashMap.newHashMap(Math.max(16, activeStreamSetSize));
this.tupleLifecycleMap = HashMap.newHashMap(Math.max(16, activeStreamSetSize));
this.storeIndexMap = HashMap.newHashMap(Math.max(16, activeStreamSetSize / 2));
this.reversedNodeList = new ArrayList<>(activeStreamSetSize);
Expand All @@ -51,6 +53,7 @@ public void addNode(AbstractNode node, Stream_ creator, @Nullable Stream_ parent
reversedNodeList.add(node);
node.addLocationSet(creator.getLocationSet());
nodeCreatorMap.put(node, creator);
streamToCreatedNodeMap.put(creator, node);
if (!(node instanceof AbstractRootNode<?>)) {
if (parent == null) {
throw new IllegalStateException("Impossible state: The node (%s) has no parent (%s).".formatted(node, parent));
Expand All @@ -63,6 +66,7 @@ public void addNode(AbstractNode node, Stream_ creator, Stream_ leftParent, Stre
reversedNodeList.add(node);
node.addLocationSet(creator.getLocationSet());
nodeCreatorMap.put(node, creator);
streamToCreatedNodeMap.put(creator, node);
putInsertUpdateRetract(leftParent, TupleLifecycle.ofLeft((LeftTupleLifecycle<? extends Tuple>) node));
putInsertUpdateRetract(rightParent, TupleLifecycle.ofRight((RightTupleLifecycle<? extends Tuple>) node));
}
Expand Down Expand Up @@ -143,6 +147,24 @@ public Stream_ getNodeCreatingStream(AbstractNode node) {
return nodeCreatorMap.get(node);
}

public List<AbstractNode> getParentNodeList(Stream_ stream) {
var out = new ArrayList<AbstractNode>();
while (stream != null) {
var node = streamToCreatedNodeMap.get(stream);
if (node != null) {
out.add(node);
}
if (stream instanceof BavetStreamBinaryOperation<?> binaryOperation) {
out.addAll(getParentNodeList((Stream_) binaryOperation.getLeftParent()));
out.addAll(getParentNodeList((Stream_) binaryOperation.getRightParent()));
return out;
} else {
stream = stream.getParent();
}
}
return out;
}

public AbstractNode findParentNode(Stream_ childNodeCreator) {
if (childNodeCreator == null) { // We've recursed to the bottom without finding a parent node.
throw new IllegalStateException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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.UniTuple;
import ai.timefold.solver.core.impl.bavet.common.tuple.indictment.IndictmentSource;
import ai.timefold.solver.core.impl.util.ElementAwareLinkedList;

/**
Expand Down Expand Up @@ -103,7 +104,11 @@ public final void insertRight(UniTuple<Right_> rightTuple) {
}
rightTuple.setStore(inputStoreIndexRightEntry, rightTupleList.add(rightTuple));
if (!isFiltering) {
counterList.forEach(this::incrementCounterRight);
if (rightTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
counterList.forEach(this::incrementCounterRight);
} else {
counterList.forEach(counter -> incrementCounterRightUpdatingIndictment(counter, rightTuple));
}
} else {
// Trackers link themselves into the right tuple's inputStoreIndexRightTrackerList slot.
// No list object is needed; the slot starts null and the first tracker becomes the head.
Expand Down Expand Up @@ -138,10 +143,14 @@ public final void retractRight(UniTuple<Right_> rightTuple) {
}
rightEntry.remove();
if (!isFiltering) {
counterList.forEach(this::decrementCounterRight);
if (rightTuple.getIndictmentSource() == IndictmentSource.DISABLED) {
counterList.forEach(this::decrementCounterRight);
} else {
counterList.forEach(counter -> decrementCounterRightUpdatingIndictment(counter, rightTuple));
}
} else {
clearRightTrackerList(rightTuple);
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void setState(TupleState state) {

@Override
public String toString() {
return "Counter(" + leftTuple + ")";
return "Counter(%s)".formatted(leftTuple);
}

}
Loading
Loading