@@ -41,6 +41,14 @@ public final class RecordAndReplayPropagator<Tuple_ extends Tuple> implements Pr
4141 private final Supplier <BavetPrecomputeBuildHelper <Tuple_ >> precomputeBuildHelperSupplier ;
4242 private final UnaryOperator <Tuple_ > internalTupleToOutputTupleMapper ;
4343 private final Map <Object , List <Tuple_ >> objectToOutputTuplesMap ;
44+ /**
45+ * Output tuples which depend only on problem facts.
46+ * Unlike entity-derived output, these are not stored per-source
47+ * (facts never update, so they need no replay),
48+ * but they must still be delivered downstream exactly once.
49+ * Retained between recalculations so they can be retracted on cache invalidation.
50+ */
51+ private final List <Tuple_ > factOutputTupleList = new ArrayList <>();
4452 private final Set <Object > alreadyUpdatingSet = Collections .newSetFromMap (new IdentityHashMap <>());
4553 private final Map <Class <?>, Boolean > objectClassToIsEntitySourceClassMap ;
4654
@@ -189,13 +197,19 @@ private void retractIfPresent(Tuple_ tuple) {
189197 }
190198
191199 private void invalidateCache () {
192- objectToOutputTuplesMap .values ().stream ().flatMap (List ::stream ).forEach (this ::retractIfPresent );
200+ objectToOutputTuplesMap .values ()
201+ .stream ()
202+ .flatMap (List ::stream )
203+ .forEach (this ::retractIfPresent );
193204 objectToOutputTuplesMap .clear ();
205+ factOutputTupleList .forEach (this ::retractIfPresent );
206+ factOutputTupleList .clear ();
194207 }
195208
196209 private void recalculateTuples (NodeNetwork internalNodeNetwork , Map <Class <?>, List <BavetRootNode <?>>> classToRootNodeList ,
197210 RecordingTupleLifecycle <Tuple_ > recordingTupleLifecycle ) {
198- var internalTupleToOutputTupleMap = new IdentityHashMap <Tuple_ , Tuple_ >(seenEntitySet .size ());
211+ var internalTupleToOutputTupleMap =
212+ new IdentityHashMap <Tuple_ , Tuple_ >(seenEntitySet .size () + seenFactSet .size ());
199213 for (var invalidated : seenEntitySet ) {
200214 var mappedTuples = new ArrayList <Tuple_ >();
201215 try (var unusedActiveRecordingLifecycle = recordingTupleLifecycle .recordInto (
@@ -212,7 +226,28 @@ private void recalculateTuples(NodeNetwork internalNodeNetwork, Map<Class<?>, Li
212226 objectToOutputTuplesMap .put (invalidated , mappedTuples );
213227 }
214228 }
215- objectToOutputTuplesMap .values ().stream ().flatMap (List ::stream ).forEach (this ::insertIfAbsent );
229+ objectToOutputTuplesMap .values ()
230+ .stream ()
231+ .flatMap (List ::stream )
232+ .forEach (this ::insertIfAbsent );
233+ // Output tuples derived purely from facts are not re-emitted by any entity update above,
234+ // so they would never be delivered.
235+ // Facts never update during solving,
236+ // so a single recording pass over all facts is enough to capture every fact-dependent output tuple exactly once.
237+ // Tuples also derived from an entity were already delivered above;
238+ // insertIfAbsent skips them via tuple-state deduplication.
239+ if (!seenFactSet .isEmpty ()) {
240+ try (var unusedActiveRecordingLifecycle =
241+ recordingTupleLifecycle .recordInto (new TupleRecorder <>(factOutputTupleList ,
242+ internalTupleToOutputTupleMapper , internalTupleToOutputTupleMap ))) {
243+ for (var fact : seenFactSet ) {
244+ classToRootNodeList .get (fact .getClass ())
245+ .forEach (node -> ((BavetRootNode <Object >) node ).update (fact ));
246+ }
247+ internalNodeNetwork .settle ();
248+ }
249+ factOutputTupleList .forEach (this ::insertIfAbsent );
250+ }
216251 }
217252
218253}
0 commit comments