Skip to main content

xlog_runtime/executor/
recursive.rs

1//! Recursive SCC execution using semi-naive fixpoint iteration.
2
3use std::collections::{BTreeSet, HashMap, HashSet};
4use std::time::Instant;
5
6use xlog_core::{RelId, Result, Schema, XlogError};
7use xlog_cuda::{CudaBuffer, CudaKernelProvider};
8use xlog_ir::{ExecutionPlan, RirNode, Stratum};
9
10use crate::profiler::Profiler;
11
12use super::delta::DeltaRelationTracker;
13use super::Executor;
14
15impl Executor {
16    /// Maximum iterations for fixpoint computation to prevent infinite loops
17    const MAX_FIXPOINT_ITERATIONS: usize = 1000;
18
19    /// Union a batch of same-head contributions in one multiway pass,
20    /// recording a single profiled "union" op for the whole batch.
21    ///
22    /// Takes the provider and profiler as explicit arguments (instead of
23    /// `&mut self`) so call sites can hold `self.store` borrows across the
24    /// call; the field borrows stay disjoint.
25    fn union_batch_profiled(
26        provider: &CudaKernelProvider,
27        profiler: &mut Profiler,
28        inputs: &[&CudaBuffer],
29    ) -> Result<CudaBuffer> {
30        let union_input: u64 = inputs.iter().map(|b| b.num_rows()).sum();
31        let start = profiler.start_op();
32        let merged = provider.union_many_gpu(inputs)?;
33        if let Some(start) = start {
34            let mem = provider.memory().allocated_bytes();
35            profiler.record_op("union", union_input, merged.num_rows(), start, mem);
36            profiler.record_peak_memory(mem);
37        }
38        Ok(merged)
39    }
40
41    /// For a `MultiWayJoin` or `ChainJoin` body, try the specialized WCOJ
42    /// dispatchers first; on decline, fall back to the embedded fallback
43    /// subtree via `execute_node`. For any other RIR variant, defer to
44    /// `execute_node` directly.
45    ///
46    /// Used at two sites in the recursive engine: the seeding pass, where
47    /// stable rules and recursive rules get their initial dispatch on the full
48    /// body, and the per-variant loop, where each recursive scan with a
49    /// non-empty delta is rewritten to its delta `RelId` for one dispatch.
50    /// Multi-recursive bodies, including distinct recursive predicates and
51    /// same-predicate self-recursive bodies, reach a `MultiWayJoin` here after
52    /// the promoter admits bodies with more than one recursive scan; the
53    /// per-variant rewrite loop builds one variant per recursive occurrence
54    /// with a non-empty delta and dispatches each via this helper.
55    ///
56    /// Counter semantics: `wcoj_*_dispatch_count` increments once per
57    /// successful WCOJ kernel result: once per recursive rule, iteration, and
58    /// variant. Non-recursive dispatch sites increment once per rule per call.
59    fn execute_wcoj_or_fallback_node(&mut self, node: &RirNode) -> Result<CudaBuffer> {
60        if let RirNode::ChainJoin { .. } = node {
61            if let Some(buf) = self.try_dispatch_chain_on_body(node)? {
62                return Ok(buf);
63            }
64            return self.execute_node(node);
65        }
66        if let RirNode::MultiWayJoin { .. } = node {
67            // Triangle, 4-cycle, then K-clique. A body cannot
68            // match more than one paper-derived shape (different
69            // atom counts). The dispatcher's own gate handles
70            // env-var / config / adaptive decisions; this site is
71            // purely structural.
72            if let Some(buf) = self.try_dispatch_wcoj_triangle_on_body(node)? {
73                return Ok(buf);
74            }
75            if let Some(buf) = self.try_dispatch_wcoj_4cycle_on_body(node)? {
76                return Ok(buf);
77            }
78            // Recursive clique bodies use the same launch-local metadata
79            // builders as non-recursive K-clique dispatch, so rewritten
80            // semi-naive variants are eligible here too.
81            if let Some(buf) = self.try_dispatch_wcoj_clique5_on_body(node)? {
82                return Ok(buf);
83            }
84            if let Some(buf) = self.try_dispatch_wcoj_clique6_on_body(node)? {
85                return Ok(buf);
86            }
87            if let Some(buf) = self.try_dispatch_wcoj_clique7_on_body(node)? {
88                return Ok(buf);
89            }
90            if let Some(buf) = self.try_dispatch_wcoj_clique8_on_body(node)? {
91                return Ok(buf);
92            }
93            // Generalized Free Join dispatch for every multiway shape the
94            // dedicated dispatchers declined. The hook re-checks dedicated
95            // shapes structurally, so it only fires on general bodies.
96            if let Some(buf) = self.try_dispatch_free_join(node)? {
97                return Ok(buf);
98            }
99        }
100        self.execute_node(node)
101    }
102
103    fn refresh_kclique_edge_metadata_after_merge(
104        &mut self,
105        rules: &[xlog_ir::CompiledRule],
106        pred: &str,
107    ) {
108        let start = Instant::now();
109        let affected_rules = rules
110            .iter()
111            .filter(|rule| self.kclique_body_mentions_pred(&rule.body, pred))
112            .count() as u64;
113        self.record_kclique_histogram_refresh_time(start, affected_rules);
114    }
115
116    fn record_kclique_histogram_refresh_time(&mut self, start: Instant, affected_rules: u64) {
117        if affected_rules == 0 {
118            return;
119        }
120        self.kclique_histogram_refresh_count = self
121            .kclique_histogram_refresh_count
122            .saturating_add(affected_rules);
123        self.kclique_histogram_refresh_nanos = self
124            .kclique_histogram_refresh_nanos
125            .saturating_add(start.elapsed().as_nanos());
126    }
127
128    fn kclique_body_mentions_pred(&self, node: &RirNode, pred: &str) -> bool {
129        let RirNode::MultiWayJoin {
130            inputs, var_order, ..
131        } = node
132        else {
133            return false;
134        };
135        let Some(order) = var_order.as_ref().and_then(|order| order.kclique.as_ref()) else {
136            return false;
137        };
138        if !matches!(order.k, 5..=8) {
139            return false;
140        }
141        inputs.iter().any(|input| {
142            let RirNode::Scan { rel } = input else {
143                return false;
144            };
145            self.rel_names.get(rel).is_some_and(|name| name == pred)
146        })
147    }
148
149    /// Stub: always returns an error directing callers to use `execute_plan` instead.
150    pub fn execute_stratum(&mut self, _stratum: &Stratum) -> Result<()> {
151        Err(XlogError::Execution(
152            "execute_stratum cannot be called directly; use execute_plan instead which provides \
153             the required rules_by_scc context"
154                .to_string(),
155        ))
156    }
157
158    /// Execute all rules in a non-recursive strongly connected component once.
159    ///
160    /// Each contiguous run of same-head rules is merged into the store with
161    /// one multiway union instead of one union per rule, so many-rule heads
162    /// stay linear in their total rows. Flushing on every head switch (not
163    /// once per SCC group) preserves rule-order dataflow: promoter-generated
164    /// helper rules share an SCC group with their consumer, so a helper's
165    /// head must be installed before the next rule reads it.
166    pub fn execute_non_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
167        let mut pending_head: Option<&str> = None;
168        let mut pending: Vec<CudaBuffer> = Vec::new();
169        for rule in rules {
170            if pending_head != Some(rule.head.as_str()) {
171                if let Some(head) = pending_head.take() {
172                    let batch = std::mem::take(&mut pending);
173                    self.install_plain_head_batch(head, batch)?;
174                }
175                pending_head = Some(rule.head.as_str());
176            } else if !pending.is_empty() && self.body_reads_own_head(rule) {
177                // A same-head rule that reads its own head (a non-monotone
178                // singleton SCC, admitted under the probabilistic profile)
179                // must observe prior same-head contributions exactly as the
180                // sequential per-rule path did: flush before evaluating it.
181                let batch = std::mem::take(&mut pending);
182                self.install_plain_head_batch(&rule.head, batch)?;
183            }
184            let result = self.execute_node(&rule.body)?;
185            pending.push(result);
186        }
187        if let Some(head) = pending_head {
188            self.install_plain_head_batch(head, pending)?;
189        }
190        Ok(())
191    }
192
193    /// Whether the rule's body reads its own head relation in a way that is
194    /// sensitive to same-pass installs. A body that IS a bare scan of the
195    /// head (`h :- h`, the planner's identity/carry rule for fact
196    /// predicates) is exempt: its scan result is always a subset of the
197    /// final merged relation, so batching cannot change the outcome. Any
198    /// other self-reading shape (negation, joins, or projections over the
199    /// head — possible only for non-monotone singleton SCCs admitted under
200    /// the probabilistic profile) must flush for sequential parity.
201    fn body_reads_own_head(&self, rule: &xlog_ir::CompiledRule) -> bool {
202        if let RirNode::Scan { rel } = &rule.body {
203            if self.get_rel_name(*rel).is_some_and(|n| n == rule.head) {
204                return false;
205            }
206        }
207        let mut scans = Vec::new();
208        Self::collect_scan_rels(&rule.body, &mut scans);
209        scans
210            .into_iter()
211            .any(|rel| self.get_rel_name(rel).is_some_and(|n| n == rule.head))
212    }
213
214    /// Install one head's batched results, skipping empty contributions.
215    /// Mirrors the pre-batching per-rule behavior: an existing relation is
216    /// left untouched when every contribution is empty, and a lone fresh
217    /// non-empty result records a single-input union (internally one dedup)
218    /// like the dispatched install path, so `--stats` accounting stays
219    /// uniform across both installers. The store is only mutated after the
220    /// merge succeeds, so a failed union leaves the existing relation
221    /// intact.
222    fn install_plain_head_batch(&mut self, head: &str, results: Vec<CudaBuffer>) -> Result<()> {
223        let non_empty: Vec<&CudaBuffer> = results.iter().filter(|r| !r.is_empty()).collect();
224
225        // An existing empty relation (e.g. a pre-seeded schema buffer)
226        // carries no rows to merge, so it takes the fresh-install path,
227        // matching the dispatched installer.
228        let existing = self.store.get(head).filter(|buf| !buf.is_empty());
229        if let Some(existing) = existing {
230            if non_empty.is_empty() {
231                // No new rows for this head: leave the relation untouched.
232                return Ok(());
233            }
234            let mut union_inputs = Vec::with_capacity(non_empty.len() + 1);
235            union_inputs.push(existing);
236            union_inputs.extend(non_empty);
237            let merged =
238                Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
239            self.store_put(head, merged);
240        } else if non_empty.is_empty() {
241            // All contributions are empty: an absent head gets an empty
242            // relation with the result schema; an existing (empty) head is
243            // left untouched.
244            if self.store.get(head).is_none() {
245                let first = results.into_iter().next().ok_or_else(|| {
246                    XlogError::Execution(format!("No results collected for head {}", head))
247                })?;
248                self.store_put(head, first);
249            }
250        } else {
251            let merged =
252                Self::union_batch_profiled(&self.provider, &mut self.profiler, &non_empty)?;
253            self.store_put(head, merged);
254        }
255        Ok(())
256    }
257
258    /// Install one head's batched non-recursive results with a single
259    /// profiled multiway union. Each entry's flag records whether the
260    /// route's output is already sorted+deduped, so a lone fresh WCOJ
261    /// result installs without a redundant dedup pass, mirroring the
262    /// per-route install behavior. The store is only mutated after the
263    /// merge succeeds, so a failed union leaves the existing relation
264    /// intact.
265    fn install_dispatched_head_batch(
266        &mut self,
267        head: &str,
268        results: Vec<(CudaBuffer, bool)>,
269    ) -> Result<()> {
270        // Union with existing result if the predicate already has rows. An
271        // existing empty relation (e.g. a pre-seeded schema buffer)
272        // contributes nothing, so it takes the fresh-install path — which
273        // is what lets a lone already-deduped WCOJ result skip the
274        // redundant dedup in production runs.
275        let existing = self.store.get(head).filter(|buf| !buf.is_empty());
276        if let Some(existing) = existing {
277            let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(results.len() + 1);
278            union_inputs.push(existing);
279            union_inputs.extend(results.iter().map(|(buf, _)| buf));
280            let merged =
281                Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
282            self.store_put(head, merged);
283        } else if results.len() == 1 {
284            let (result, already_deduped) = results.into_iter().next().expect("len checked");
285            if already_deduped || result.is_empty() {
286                self.store_put(head, result);
287            } else {
288                // Set semantics for a lone raw result: a single-input
289                // multiway union (internally one dedup), recorded as a
290                // "union" op like the union-with-empty install it replaces.
291                let merged =
292                    Self::union_batch_profiled(&self.provider, &mut self.profiler, &[&result])?;
293                self.store_put(head, merged);
294            }
295        } else {
296            let union_inputs: Vec<&CudaBuffer> = results.iter().map(|(buf, _)| buf).collect();
297            let merged =
298                Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
299            self.store_put(head, merged);
300        }
301        Ok(())
302    }
303
304    /// Execute a stratum (internal implementation)
305    ///
306    /// Processes all SCCs in the stratum by executing their rules.
307    /// For recursive SCCs, uses semi-naive fixpoint iteration.
308    pub(super) fn execute_stratum_impl(
309        &mut self,
310        stratum: &Stratum,
311        plan: &ExecutionPlan,
312    ) -> Result<()> {
313        // Process each SCC in the stratum
314        for &scc_id in &stratum.sccs {
315            // Get rules for this SCC
316            if let Some(rules) = plan.rules_by_scc.get(scc_id as usize) {
317                // Get SCC metadata
318                let scc = plan.sccs.get(scc_id as usize);
319                let is_recursive = scc.map(|s| s.is_recursive).unwrap_or(false);
320
321                if is_recursive {
322                    // Recursive SCC: use semi-naive fixpoint iteration. The
323                    // recursive engine invokes WCOJ dispatch via
324                    // `execute_wcoj_or_fallback_node` on both the seeding
325                    // pass and per-variant evaluation when the promoted body
326                    // shape is eligible.
327                    self.execute_recursive_scc(rules)?;
328                } else {
329                    // Non-recursive SCC: execute rules once, merging each
330                    // contiguous run of same-head results with one multiway
331                    // union instead of one union per rule, so many-rule heads
332                    // stay linear in their total rows. Flushing on every head
333                    // switch (not once per SCC group) preserves rule-order
334                    // dataflow: promoter-generated helper rules share an SCC
335                    // group with their consumer, so a helper's head must be
336                    // installed before the next rule dispatches against it.
337                    let mut pending_head: Option<&str> = None;
338                    let mut pending: Vec<(CudaBuffer, bool)> = Vec::new();
339                    for rule in rules {
340                        if pending_head != Some(rule.head.as_str()) {
341                            if let Some(head) = pending_head.take() {
342                                let batch = std::mem::take(&mut pending);
343                                self.install_dispatched_head_batch(head, batch)?;
344                            }
345                            pending_head = Some(rule.head.as_str());
346                        } else if !pending.is_empty() && self.body_reads_own_head(rule) {
347                            // A same-head rule that reads its own head (a
348                            // non-monotone singleton SCC, admitted under the
349                            // probabilistic profile) must observe prior
350                            // same-head contributions exactly as the
351                            // sequential per-rule path did: flush before
352                            // evaluating it.
353                            let batch = std::mem::take(&mut pending);
354                            self.install_dispatched_head_batch(&rule.head, batch)?;
355                        }
356
357                        // Route two-atom ChainJoin bodies before the
358                        // triangle/4-cycle/KC attempts. The dispatcher
359                        // silently declines on non-chain bodies or when
360                        // the env gate disables the route.
361                        let entry = if let Some(chain_result) =
362                            self.try_dispatch_chain_on_body(&rule.body)?
363                        {
364                            (chain_result, false)
365                        }
366                        // WCOJ triangle dispatch, gated by runtime configuration.
367                        // Try to short-circuit the rule via the GPU
368                        // 3-way kernel. On Some(_), record the result
369                        // and skip the binary-join path for this rule.
370                        // On None (gate off, shape mismatch, missing
371                        // input, kernel error), fall through silently.
372                        // See `wcoj_dispatch::try_dispatch_wcoj_triangle`
373                        // for the full match contract. WCOJ output is
374                        // already sorted+deduped, so a lone fresh
375                        // install needs no dedup pass.
376                        else if let Some(wcoj_result) = self.try_dispatch_wcoj_triangle(rule)? {
377                            (wcoj_result, true)
378                        }
379                        // WCOJ 4-cycle dispatch.
380                        // Same pattern as triangle. Order is a doc
381                        // anchor — a body cannot match both shapes
382                        // (different atom counts), so triangle's
383                        // earlier attempt always returns None on a
384                        // 4-cycle body and vice versa.
385                        else if let Some(wcoj_result) = self.try_dispatch_wcoj_4cycle(rule)? {
386                            (wcoj_result, true)
387                        }
388                        // K-clique dispatch for k=5..k=8.
389                        // Same shape-gated default-dispatch
390                        // pattern as triangle / 4-cycle; silent
391                        // fallback to MultiWayJoin.fallback on
392                        // dispatcher decline or kernel error.
393                        else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique5(rule)? {
394                            (wcoj_result, true)
395                        } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique6(rule)? {
396                            (wcoj_result, true)
397                        } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique7(rule)? {
398                            (wcoj_result, true)
399                        } else if let Some(wcoj_result) = self.try_dispatch_wcoj_clique8(rule)? {
400                            (wcoj_result, true)
401                        }
402                        // Generalized Free Join dispatch for every multiway
403                        // shape the dedicated dispatchers above declined. The
404                        // dispatcher re-checks those shapes structurally, so
405                        // it only fires on general bodies. Unlike the
406                        // dedicated kernels, the frontier engine emits one row
407                        // per derivation path, so its output still needs the
408                        // dedup the per-head merge (or the fresh-install
409                        // dedup) provides.
410                        else if let Some(fj_result) = self.try_dispatch_free_join(&rule.body)? {
411                            (fj_result, false)
412                        } else {
413                            // When WCOJ dispatch declines on a `MultiWayJoin`
414                            // body (gate off, kernel error, adaptive score below
415                            // threshold, ...), execute the embedded `fallback`,
416                            // the post-optimizer binary-join tree the promoter
417                            // captured. `execute_node`'s `MultiWayJoin` arm is the
418                            // defensive safety net; explicit destructuring here
419                            // keeps the intent visible at the dispatch site.
420                            let body_to_execute = match &rule.body {
421                                xlog_ir::RirNode::MultiWayJoin { fallback, .. }
422                                | xlog_ir::RirNode::ChainJoin { fallback, .. } => fallback.as_ref(),
423                                other => other,
424                            };
425                            (self.execute_node(body_to_execute)?, false)
426                        };
427
428                        pending.push(entry);
429                    }
430                    if let Some(head) = pending_head {
431                        self.install_dispatched_head_batch(head, pending)?;
432                    }
433                }
434            }
435        }
436
437        Ok(())
438    }
439
440    /// Execute a recursive SCC using semi-naive fixpoint iteration
441    ///
442    /// The algorithm:
443    /// 1. Execute all rules once to get initial result
444    /// 2. Track which relations changed (delta)
445    /// 3. Re-execute rules, using delta from previous iteration
446    /// 4. Repeat until no changes (fixpoint reached)
447    pub fn execute_recursive_scc(&mut self, rules: &[xlog_ir::CompiledRule]) -> Result<()> {
448        // Reset the per-iteration stats trace at SCC entry so tests see a
449        // fresh trace per invocation. Gated on the `recursive-stats-trace`
450        // feature; default OFF.
451        #[cfg(feature = "recursive-stats-trace")]
452        {
453            self.last_recursive_stats_trace.entries.clear();
454        }
455        // Identify SCC predicates from rule heads (these are the recursive IDBs).
456        let mut recursive_pred_names: BTreeSet<String> = BTreeSet::new();
457        let mut schema_by_pred: HashMap<String, Schema> = HashMap::new();
458        for rule in rules {
459            recursive_pred_names.insert(rule.head.clone());
460            if rule.meta.schema.arity() > 0 {
461                schema_by_pred
462                    .entry(rule.head.clone())
463                    .or_insert_with(|| rule.meta.schema.clone());
464            }
465        }
466        let recursive_pred_lookup: HashSet<String> = recursive_pred_names.iter().cloned().collect();
467        let recursive_preds: Vec<String> = recursive_pred_names.into_iter().collect();
468
469        // Ensure all recursive predicates exist in the store so scans never fail
470        // due to evaluation order (mutual recursion can reference an as-yet-empty relation).
471        for pred in &recursive_preds {
472            if !self.store.contains(pred) {
473                let schema = schema_by_pred
474                    .get(pred)
475                    .cloned()
476                    .or_else(|| self.store.get(pred).map(|b| b.schema().clone()))
477                    .ok_or_else(|| {
478                        XlogError::Execution(format!(
479                            "Missing schema for recursive predicate {}",
480                            pred
481                        ))
482                    })?;
483                let empty = self.create_empty_buffer(schema)?;
484                self.store_put(pred, empty);
485            }
486        }
487
488        // Create per-predicate delta relations (distinct RelIds) so semi-naive evaluation
489        // can target a single recursive Scan occurrence without overriding *all* scans of
490        // that predicate in a rule (required for self-joins like p(X,Y), p(Y,Z)).
491        let mut next_rel_id = self
492            .rel_names
493            .keys()
494            .map(|r| r.0)
495            .max()
496            .unwrap_or(0)
497            .saturating_add(1);
498
499        let mut delta_tracker = DeltaRelationTracker::new();
500        for pred in &recursive_preds {
501            let rel_id = RelId(next_rel_id);
502            next_rel_id = next_rel_id.saturating_add(1);
503            let name = format!("__delta_{}_{}", pred, rel_id.0);
504            self.register_relation(rel_id, &name);
505            delta_tracker.insert(pred.clone(), rel_id, name);
506        }
507
508        // Execute all rules once against the current store to seed initial results.
509        // Accumulate per-head before mutating the store to avoid order dependence.
510        //
511        // Route through `execute_wcoj_or_fallback_node` so promoted
512        // MultiWayJoin bodies for stable and linear-recursive triangles or
513        // 4-cycles get a chance at WCOJ dispatch on the seeding pass. Stable
514        // rules with zero recursive scans only run here, so without this hook
515        // they would never see a kernel.
516        let mut derived_initial: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
517        for rule in rules {
518            let result = self.execute_wcoj_or_fallback_node(&rule.body)?;
519            derived_initial
520                .entry(rule.head.clone())
521                .or_default()
522                .push(result);
523        }
524
525        // Initialize delta from the newly-derived tuples only.
526        //
527        // This supports incremental maintenance: if the SCC is executed again after EDB inserts,
528        // the delta relations start with only the *new* tuples, not a full rescan of the current
529        // fixed point.
530        for pred in &recursive_preds {
531            // Read the prior full relation in place: the store is only
532            // mutated after the merge succeeds, so a failed union leaves the
533            // relation (and its version counter) intact.
534            let full_old = self
535                .store
536                .get(pred)
537                .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
538
539            let derived = derived_initial.remove(pred).unwrap_or_default();
540
541            // One multiway union per head: the prior full relation and every
542            // same-head seed contribution are concatenated, sorted, and
543            // deduplicated in a single pass instead of one union per rule.
544            let mut union_inputs: Vec<&CudaBuffer> = Vec::with_capacity(derived.len() + 1);
545            union_inputs.push(full_old);
546            union_inputs.extend(derived.iter());
547            let full_new =
548                Self::union_batch_profiled(&self.provider, &mut self.profiler, &union_inputs)?;
549            drop(union_inputs);
550            drop(derived);
551
552            let delta_name = delta_tracker.delta_name(pred)?;
553
554            let full_old_rows = self.buffer_row_count(full_old)?;
555            let full_new_rows = self.buffer_row_count(&full_new)?;
556            let delta_initial = if full_new_rows == 0 {
557                self.create_empty_buffer(full_new.schema().clone())?
558            } else if full_old_rows == 0 {
559                self.clone_buffer(&full_new)?
560            } else {
561                let diff_input = full_new.num_rows() + full_old.num_rows();
562                let start = self.profiler.start_op();
563                let diffed = self.provider.diff_gpu(&full_new, full_old)?;
564                if let Some(start) = start {
565                    let mem = self.provider.memory().allocated_bytes();
566                    self.profiler
567                        .record_op("diff", diff_input, diffed.num_rows(), start, mem);
568                    self.profiler.record_peak_memory(mem);
569                }
570                diffed
571            };
572
573            // Seed-iteration cardinality refresh. Capture the actual
574            // `delta_initial` row count before the `store_put` move; after the
575            // move, the buffer is gone.
576            let delta_initial_rows = self.buffer_row_count(&delta_initial)? as u64;
577            let seed_full_rows = full_new_rows as u64;
578            // Pre-resolve rel_id lookups before the &mut self stats
579            // borrow below.
580            let full_rel_opt = self.name_to_rel_id(pred);
581            let delta_rel = delta_tracker.delta_rel_id(pred)?;
582
583            self.store_put(pred, full_new);
584            self.store_put(delta_name, delta_initial);
585
586            // Stats updates fire whether or not WCOJ ran on the seed
587            // pass. update_cardinality is a no-op for unregistered
588            // rel_ids (defensive: tests that don't register an IDB
589            // head get a no-op for the full_rel write).
590            if let Some(full_rel) = full_rel_opt {
591                self.stats.update_cardinality(full_rel, seed_full_rows);
592            }
593            self.stats.update_cardinality(delta_rel, delta_initial_rows);
594
595            // Seed stats trace entry, gated on `recursive-stats-trace`.
596            #[cfg(feature = "recursive-stats-trace")]
597            self.last_recursive_stats_trace
598                .entries
599                .push(super::RecursiveStatsTraceEntry {
600                    iteration: 0,
601                    pred: pred.clone(),
602                    full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
603                    delta_rel,
604                    full_rows: seed_full_rows,
605                    delta_rows: delta_initial_rows,
606                    phase: super::RecursiveStatsPhase::Seed,
607                    binary_est_for_variant: None,
608                });
609        }
610
611        // Iterate until no new tuples are produced.
612        let mut reached_fixpoint = false;
613        let max_iterations = self.config.max_iterations as usize;
614        let mut iteration_count = 0usize;
615        // D3 — per-fixpoint dispatch context for the factorized delta
616        // (domain bounds + normalized EDB statics are cached across
617        // iterations).
618        let mut fd_ctx = super::wcoj_dispatch::FactorizedDeltaCtx::default();
619        for _iteration in 0..max_iterations {
620            iteration_count += 1;
621            // Compute delta_new_raw per head by evaluating each rule once per recursive Scan occurrence.
622            // Contributions are collected unmerged; the per-head finalize
623            // below unions each head's batch in one multiway pass instead of
624            // one union per rule.
625            let mut delta_new_raw_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
626            // D3 — factorized novel sets per head: already diffed
627            // against the stable relation and full-row deduped at
628            // dispatch time. Kept separate from the raw accumulator so
629            // all-factorized heads can skip the legacy diff entirely.
630            let mut delta_novel_by_head: HashMap<String, Vec<CudaBuffer>> = HashMap::new();
631
632            for rule in rules {
633                let mut scans = Vec::new();
634                Self::collect_scan_rels(&rule.body, &mut scans);
635
636                // Build a list of (rel_id, occurrence_idx, pred_name) for recursive scans.
637                let mut seen: HashMap<RelId, usize> = HashMap::new();
638                let mut variants: Vec<(RelId, usize, String)> = Vec::new();
639                for rel_id in scans {
640                    let pred_name = match self.get_rel_name(rel_id) {
641                        Some(n) => n.to_string(),
642                        None => continue,
643                    };
644                    if !recursive_pred_lookup.contains(&pred_name) {
645                        continue;
646                    }
647
648                    // Skip variants where the delta for this predicate is empty.
649                    let delta_name = match delta_tracker.get(&pred_name) {
650                        Some((_rel_id, name)) => name.as_str(),
651                        None => continue,
652                    };
653                    let delta_is_empty = match self.store.get(delta_name) {
654                        Some(delta) => self.buffer_row_count(delta)? == 0,
655                        None => true,
656                    };
657                    if delta_is_empty {
658                        continue;
659                    }
660
661                    let occ = seen.entry(rel_id).or_insert(0);
662                    variants.push((rel_id, *occ, pred_name));
663                    *occ += 1;
664                }
665
666                if variants.is_empty() {
667                    // Base rule: it can only contribute on the first seeding pass.
668                    continue;
669                }
670
671                let mut rule_delta_raw: Vec<CudaBuffer> = Vec::new();
672                let mut rule_delta_novel: Vec<CudaBuffer> = Vec::new();
673                for (rel_id, occ, pred_name) in variants {
674                    let delta_rel_id = delta_tracker.delta_rel_id(&pred_name)?;
675
676                    let variant_node =
677                        Self::rewrite_scan_nth(&rule.body, rel_id, occ, delta_rel_id).ok_or_else(
678                            || {
679                                XlogError::Execution(format!(
680                                    "Failed to rewrite rule body for predicate {}",
681                                    pred_name
682                                ))
683                            },
684                        )?;
685
686                    // Try the factorized delta pipeline first: a qualifying
687                    // ChainJoin variant returns the novel set directly
688                    // (already diffed against the head's stable relation and
689                    // deduped). Declines are silent and fall through to the
690                    // legacy path.
691                    if let Some(novel) = self.try_dispatch_factorized_delta(
692                        &variant_node,
693                        delta_rel_id,
694                        &rule.head,
695                        &recursive_pred_lookup,
696                        &mut fd_ctx,
697                    )? {
698                        rule_delta_novel.push(novel);
699                        continue;
700                    }
701
702                    // Try WCOJ on the rewritten variant body before falling
703                    // back to the binary-join walker.
704                    // For a linear-recursive triangle/4-cycle, the
705                    // variant has one Scan's RelId swapped to its
706                    // delta — the kernel reads from the delta store
707                    // entry transparently, no special-case dispatch
708                    // logic needed.
709                    let out = self.execute_wcoj_or_fallback_node(&variant_node)?;
710                    rule_delta_raw.push(out);
711                }
712
713                // D3 — a rule with BOTH factorized and legacy variant
714                // outputs folds its novel rows into the raw batch
715                // (the legacy diff is a no-op on novel rows, so this is
716                // sound); an all-factorized rule keeps its novel rows on
717                // the diff-free track.
718                if !rule_delta_raw.is_empty() {
719                    rule_delta_raw.append(&mut rule_delta_novel);
720                    delta_new_raw_by_head
721                        .entry(rule.head.clone())
722                        .or_default()
723                        .append(&mut rule_delta_raw);
724                } else if !rule_delta_novel.is_empty() {
725                    delta_novel_by_head
726                        .entry(rule.head.clone())
727                        .or_default()
728                        .append(&mut rule_delta_novel);
729                }
730            }
731
732            // Finalize delta_new per head: delta_new = dedup(delta_raw - full).
733            delta_tracker.begin_iteration();
734
735            for pred in &recursive_preds {
736                let full = self
737                    .store
738                    .get(pred)
739                    .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
740                // Capture the current full row count for the trace's
741                // `full_rows` field before this iteration's delta relation is
742                // replaced. Gated on `recursive-stats-trace` so production
743                // builds do not compute it.
744                #[cfg(feature = "recursive-stats-trace")]
745                let pre_phase4_full_rows = self.buffer_row_count(full)? as u64;
746
747                let mut raw_bufs = delta_new_raw_by_head.remove(pred).unwrap_or_default();
748                let mut novel_bufs = delta_novel_by_head.remove(pred).unwrap_or_default();
749                // D3 — when a head received both raw and factorized
750                // contributions (different rules), fold the novel rows
751                // into the raw batch before the legacy diff (sound: the
752                // diff is a no-op on novel rows). An all-factorized
753                // head skips the diff entirely — its novel rows are
754                // already diffed and deduped by construction.
755                if !raw_bufs.is_empty() {
756                    raw_bufs.append(&mut novel_bufs);
757                }
758                let delta_new = if !novel_bufs.is_empty() {
759                    if novel_bufs.len() == 1 {
760                        novel_bufs.pop().expect("len checked")
761                    } else {
762                        // Novel sets are deduped per rule, not across rules,
763                        // so a multi-rule head still unions its novel batch.
764                        let union_inputs: Vec<&CudaBuffer> = novel_bufs.iter().collect();
765                        Self::union_batch_profiled(
766                            &self.provider,
767                            &mut self.profiler,
768                            &union_inputs,
769                        )?
770                    }
771                } else if !raw_bufs.is_empty() {
772                    let delta_raw = if raw_bufs.len() == 1 {
773                        raw_bufs.pop().expect("len checked")
774                    } else {
775                        // One multiway union per head instead of one union
776                        // per rule contribution.
777                        let union_inputs: Vec<&CudaBuffer> = raw_bufs.iter().collect();
778                        Self::union_batch_profiled(
779                            &self.provider,
780                            &mut self.profiler,
781                            &union_inputs,
782                        )?
783                    };
784                    drop(raw_bufs);
785                    if self.buffer_row_count(&delta_raw)? == 0 {
786                        self.create_empty_buffer(full.schema().clone())?
787                    } else {
788                        let diff_input = delta_raw.num_rows() + full.num_rows();
789                        let start = self.profiler.start_op();
790                        let diffed = self.provider.diff_gpu(&delta_raw, full)?;
791                        if let Some(start) = start {
792                            let mem = self.provider.memory().allocated_bytes();
793                            self.profiler.record_op(
794                                "diff",
795                                diff_input,
796                                diffed.num_rows(),
797                                start,
798                                mem,
799                            );
800                            self.profiler.record_peak_memory(mem);
801                        }
802                        diffed
803                    }
804                } else {
805                    self.create_empty_buffer(full.schema().clone())?
806                };
807
808                let delta_name = delta_tracker.delta_name(pred)?.to_string();
809                let delta_new_rows = self.buffer_row_count(&delta_new)? as u64;
810                if delta_new_rows != 0 {
811                    delta_tracker.mark_changed();
812                }
813                // Pre-resolve rel_id lookups before the &mut self
814                // store_put + stats update below. `full_rel_opt` is
815                // only used by the trace under the
816                // `recursive-stats-trace` feature.
817                #[cfg(feature = "recursive-stats-trace")]
818                let full_rel_opt = self.name_to_rel_id(pred);
819                let delta_rel = delta_tracker.delta_rel_id(pred)?;
820                self.store_put(&delta_name, delta_new);
821
822                // Refresh the delta relation cardinality after computing this
823                // iteration's delta. The full relation cardinality is not
824                // updated here because the full relation has not changed yet
825                // this iteration; the merge step owns that.
826                self.stats.update_cardinality(delta_rel, delta_new_rows);
827
828                // Delta stats trace entry, gated on `recursive-stats-trace`.
829                // binary_est_for_variant captures the cost model's
830                // first-binary-hop estimate for the linear-recursive fixtures
831                // (`pred == "e1"` rewrites
832                // Scan(e1) → Scan(delta_e1); first hop is
833                // `delta_e1.col1 ⋈ e2.col0`). Populated inline because
834                // delta_rel is unregistered at fixpoint exit, so the
835                // test cannot recompute after `execute_plan` returns.
836                #[cfg(feature = "recursive-stats-trace")]
837                let binary_est_for_variant: Option<u64> = if pred == "e1" {
838                    self.name_to_rel_id("e2").map(|e2_rel| {
839                        self.stats
840                            .estimate_join_cardinality(delta_rel, e2_rel, &[1], &[0])
841                    })
842                } else {
843                    None
844                };
845                #[cfg(feature = "recursive-stats-trace")]
846                self.last_recursive_stats_trace
847                    .entries
848                    .push(super::RecursiveStatsTraceEntry {
849                        iteration: iteration_count,
850                        pred: pred.clone(),
851                        full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
852                        delta_rel,
853                        full_rows: pre_phase4_full_rows,
854                        delta_rows: delta_new_rows,
855                        phase: super::RecursiveStatsPhase::Phase2Delta,
856                        binary_est_for_variant,
857                    });
858            }
859
860            // Fixpoint reached if no deltas produced.
861            if delta_tracker.is_converged() {
862                reached_fixpoint = true;
863                self.profiler.record_iterations(iteration_count);
864                break;
865            }
866
867            // Merge deltas into full relations.
868            for pred in &recursive_preds {
869                let dn = delta_tracker.delta_name(pred)?.to_string();
870                // Read both relations in place: the store is only mutated
871                // after the merge succeeds, so a failed union leaves the
872                // full relation (and its version counter) intact, and the
873                // unchanged delta never needs a remove/re-put round trip.
874                let full_old = self
875                    .store
876                    .get(pred)
877                    .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", pred)))?;
878                let delta = self
879                    .store
880                    .get(&dn)
881                    .ok_or_else(|| XlogError::Execution(format!("Missing relation: {}", dn)))?;
882
883                if self.buffer_row_count(delta)? == 0 {
884                    // Zero-delta short-circuit: full and delta are unchanged
885                    // this iteration. The delta relation record with zero
886                    // rows stands, and the full relation record from the prior
887                    // merge stands. No additional update is needed.
888                    continue;
889                }
890
891                let union_input = full_old.num_rows() + delta.num_rows();
892                let start = self.profiler.start_op();
893                let merged = self.provider.union_gpu(full_old, delta)?;
894                if let Some(start) = start {
895                    let mem = self.provider.memory().allocated_bytes();
896                    self.profiler
897                        .record_op("union", union_input, merged.num_rows(), start, mem);
898                    self.profiler.record_peak_memory(mem);
899                }
900
901                let full_new = merged;
902                // Capture `full_new`'s row count before the `store_put` move
903                // and pre-resolve `full_rel_opt` before the mutable stats
904                // borrow. The delta row count and delta relation id are only
905                // used by the trace under the `recursive-stats-trace` feature.
906                let full_new_rows_phase4 = self.buffer_row_count(&full_new)? as u64;
907                #[cfg(feature = "recursive-stats-trace")]
908                let delta_rows_phase4 = self.buffer_row_count(delta)? as u64;
909                let full_rel_opt = self.name_to_rel_id(pred);
910                #[cfg(feature = "recursive-stats-trace")]
911                let delta_rel = delta_tracker.delta_rel_id(pred)?;
912                self.store_put(pred, full_new);
913
914                // Record the full relation's new cardinality. The delta
915                // relation was already recorded for this iteration.
916                if let Some(full_rel) = full_rel_opt {
917                    self.stats
918                        .update_cardinality(full_rel, full_new_rows_phase4);
919                }
920                self.refresh_kclique_edge_metadata_after_merge(rules, pred);
921
922                // Full-relation stats trace entry, gated on `recursive-stats-trace`.
923                #[cfg(feature = "recursive-stats-trace")]
924                self.last_recursive_stats_trace
925                    .entries
926                    .push(super::RecursiveStatsTraceEntry {
927                        iteration: iteration_count,
928                        pred: pred.clone(),
929                        full_rel: full_rel_opt.unwrap_or(RelId(u32::MAX)),
930                        delta_rel,
931                        full_rows: full_new_rows_phase4,
932                        delta_rows: delta_rows_phase4,
933                        phase: super::RecursiveStatsPhase::Phase4Full,
934                        binary_est_for_variant: None,
935                    });
936            }
937        }
938
939        // Cleanup: remove delta relations from store and relation mapping.
940        for (_pred, (rel_id, delta_name)) in delta_tracker.into_inner() {
941            self.store_remove(&delta_name);
942            self.rel_names.remove(&rel_id);
943            self.name_to_rel.remove(&delta_name);
944            let _ = self.stats.unregister_relation(rel_id);
945        }
946
947        if !reached_fixpoint {
948            // Record iterations even on failure for debugging
949            self.profiler.record_iterations(iteration_count);
950            return Err(XlogError::Execution(format!(
951                "Recursive SCC iteration limit ({}) exceeded",
952                self.config.max_iterations
953            )));
954        }
955
956        Ok(())
957    }
958
959    /// Execute a Fixpoint node using semi-naive evaluation
960    ///
961    /// The semi-naive algorithm avoids redundant computation in recursive queries:
962    ///
963    /// 1. **Initialize:**
964    ///    - Compute base case: `R = base_result`
965    ///    - Set delta to base: `delta = R`
966    ///    - Store both `R` and `delta` in RelationStore
967    ///
968    /// 2. **Iterate until fixpoint:**
969    ///    - Compute new tuples: `delta_new = recursive_result` using current `delta`
970    ///    - Remove already-known tuples: `delta_new = delta_new - R`
971    ///    - If `delta_new` is empty, we have reached fixpoint
972    ///    - Otherwise: `R = R union delta_new`, `delta = delta_new`
973    ///
974    /// 3. **Return:** Final `R`
975    ///
976    /// # Arguments
977    /// * `scc_id` - SCC identifier for logging/debugging
978    /// * `base` - Base case RIR tree (non-recursive facts/rules)
979    /// * `recursive` - Recursive RIR tree (references delta relation)
980    /// * `delta_rel` - RelId for delta relation
981    /// * `full_rel` - RelId for full relation
982    ///
983    /// # Returns
984    /// A CudaBuffer containing the final fixpoint result
985    ///
986    /// # Errors
987    /// Returns an error if iteration limit is exceeded
988    pub(super) fn execute_fixpoint(
989        &mut self,
990        scc_id: u32,
991        base: &RirNode,
992        recursive: &RirNode,
993        delta_rel: RelId,
994        full_rel: RelId,
995    ) -> Result<CudaBuffer> {
996        // Compute base case R = eval(base)
997        let r_initial = self.execute_node(base)?;
998
999        // Handle empty base case using device-resident row count
1000        if self.buffer_row_count(&r_initial)? == 0 {
1001            return Ok(r_initial);
1002        }
1003
1004        // Initialize delta = R (clone the base result)
1005        let delta_initial = self.clone_buffer(&r_initial)?;
1006
1007        // Get relation names for delta and full relations
1008        let delta_name = self.get_or_create_rel_name(delta_rel, &format!("__delta_{}", scc_id));
1009        let full_name = self.get_or_create_rel_name(full_rel, &format!("__full_{}", scc_id));
1010
1011        // Store initial R and delta in relation store
1012        self.store_put(&full_name, r_initial);
1013        self.store_put(&delta_name, delta_initial);
1014
1015        // Iterate until fixpoint
1016        for _iteration in 0..Self::MAX_FIXPOINT_ITERATIONS {
1017            // Evaluate recursive step using current delta
1018            // The recursive RIR tree should reference delta_rel internally
1019            let delta_new_raw = self.execute_node(recursive)?;
1020
1021            // Get current R for set difference
1022            let current_r = self.store.get(&full_name).ok_or_else(|| {
1023                XlogError::Execution(format!(
1024                    "Full relation {} not found during fixpoint iteration",
1025                    full_name
1026                ))
1027            })?;
1028
1029            // Compute delta_new = delta_new_raw - R (remove already-known tuples)
1030            let delta_new = self.provider.diff_gpu(&delta_new_raw, current_r)?;
1031
1032            // Check for fixpoint: if delta_new is empty, we are done
1033            if self.buffer_row_count(&delta_new)? == 0 {
1034                // Fixpoint reached - return final R
1035                let final_r = self.store_remove(&full_name).ok_or_else(|| {
1036                    XlogError::Execution("Full relation lost during fixpoint".to_string())
1037                })?;
1038
1039                // Clean up delta relation
1040                self.store_remove(&delta_name);
1041
1042                return Ok(final_r);
1043            }
1044
1045            // Not at fixpoint yet: R = R union delta_new
1046            let new_r = self.provider.union_gpu(current_r, &delta_new)?;
1047
1048            // Update relations for next iteration
1049            // delta = delta_new (the newly discovered tuples)
1050            self.store_put(&delta_name, delta_new);
1051            self.store_put(&full_name, new_r);
1052        }
1053
1054        // Iteration limit exceeded
1055        Err(XlogError::Execution(format!(
1056            "Fixpoint iteration limit ({}) exceeded for SCC {}",
1057            Self::MAX_FIXPOINT_ITERATIONS,
1058            scc_id
1059        )))
1060    }
1061}