diff --git a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/RecordAndReplayPropagator.java b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/RecordAndReplayPropagator.java index 81c56d24a3a..51e436b51ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/RecordAndReplayPropagator.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/bavet/common/RecordAndReplayPropagator.java @@ -41,6 +41,14 @@ public final class RecordAndReplayPropagator implements Pr private final Supplier> precomputeBuildHelperSupplier; private final UnaryOperator internalTupleToOutputTupleMapper; private final Map> objectToOutputTuplesMap; + /** + * Output tuples which depend only on problem facts. + * Unlike entity-derived output, these are not stored per-source + * (facts never update, so they need no replay), + * but they must still be delivered downstream exactly once. + * Retained between recalculations so they can be retracted on cache invalidation. + */ + private final List factOutputTupleList = new ArrayList<>(); private final Set alreadyUpdatingSet = Collections.newSetFromMap(new IdentityHashMap<>()); private final Map, Boolean> objectClassToIsEntitySourceClassMap; @@ -189,13 +197,19 @@ private void retractIfPresent(Tuple_ tuple) { } private void invalidateCache() { - objectToOutputTuplesMap.values().stream().flatMap(List::stream).forEach(this::retractIfPresent); + objectToOutputTuplesMap.values() + .stream() + .flatMap(List::stream) + .forEach(this::retractIfPresent); objectToOutputTuplesMap.clear(); + factOutputTupleList.forEach(this::retractIfPresent); + factOutputTupleList.clear(); } private void recalculateTuples(NodeNetwork internalNodeNetwork, Map, List>> classToRootNodeList, RecordingTupleLifecycle recordingTupleLifecycle) { - var internalTupleToOutputTupleMap = new IdentityHashMap(seenEntitySet.size()); + var internalTupleToOutputTupleMap = + new IdentityHashMap(seenEntitySet.size() + seenFactSet.size()); for (var invalidated : seenEntitySet) { var mappedTuples = new ArrayList(); try (var unusedActiveRecordingLifecycle = recordingTupleLifecycle.recordInto( @@ -212,7 +226,28 @@ private void recalculateTuples(NodeNetwork internalNodeNetwork, Map, Li objectToOutputTuplesMap.put(invalidated, mappedTuples); } } - objectToOutputTuplesMap.values().stream().flatMap(List::stream).forEach(this::insertIfAbsent); + objectToOutputTuplesMap.values() + .stream() + .flatMap(List::stream) + .forEach(this::insertIfAbsent); + // Output tuples derived purely from facts are not re-emitted by any entity update above, + // so they would never be delivered. + // Facts never update during solving, + // so a single recording pass over all facts is enough to capture every fact-dependent output tuple exactly once. + // Tuples also derived from an entity were already delivered above; + // insertIfAbsent skips them via tuple-state deduplication. + if (!seenFactSet.isEmpty()) { + try (var unusedActiveRecordingLifecycle = + recordingTupleLifecycle.recordInto(new TupleRecorder<>(factOutputTupleList, + internalTupleToOutputTupleMapper, internalTupleToOutputTupleMap))) { + for (var fact : seenFactSet) { + classToRootNodeList.get(fact.getClass()) + .forEach(node -> ((BavetRootNode) node).update(fact)); + } + internalNodeNetwork.settle(); + } + factOutputTupleList.forEach(this::insertIfAbsent); + } } } \ No newline at end of file diff --git a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamPrecomputeTest.java b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamPrecomputeTest.java index f75db61baa3..65b3f749e48 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamPrecomputeTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/score/stream/common/uni/AbstractUniConstraintStreamPrecomputeTest.java @@ -33,6 +33,40 @@ protected AbstractUniConstraintStreamPrecomputeTest(ConstraintStreamImplSupport super(implSupport); } + /** + * A precompute that simply enumerates a problem-fact class must emit one tuple per fact. + * Removing a problem fact must re-derive the fact-only output + * and retract the removed fact's tuple. + * Exercises the cache-invalidation path for fact-derived output. + */ + @TestTemplate + void forEachUnfiltered_fact() { + var solution = TestdataLavishSolution.generateEmptySolution(); + var value1 = new TestdataLavishValue(); + var value2 = new TestdataLavishValue(); + var value3 = new TestdataLavishValue(); + solution.getValueList().addAll(List.of(value1, value2, value3)); + + var scoreDirector = buildScoreDirector( + factory -> factory.precompute(pf -> pf.forEachUnfiltered(TestdataLavishValue.class)) + .penalize(SimpleScore.ONE) + .asConstraint(TEST_CONSTRAINT_ID)); + + scoreDirector.setWorkingSolution(solution); + assertScore(scoreDirector, + assertMatch(value1), + assertMatch(value2), + assertMatch(value3)); + + scoreDirector.beforeProblemFactRemoved(value3); + solution.getValueList().remove(value3); + scoreDirector.afterProblemFactRemoved(value3); + + assertScore(scoreDirector, + assertMatch(value1), + assertMatch(value2)); + } + @Override @TestTemplate public void filter_0_changed() {