Skip to main content

xlog_runtime/executor/
mod.rs

1//! Query executor for RIR nodes
2//!
3//! The executor interprets RIR (Relational IR) nodes using the CUDA kernel provider
4//! to execute GPU-accelerated relational operations.
5
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, OnceLock};
8
9#[cfg(test)]
10use xlog_core::ScalarType;
11use xlog_core::{RelId, Result, RuntimeConfig, Schema, XlogError};
12use xlog_cuda::memory::TrackedCudaSlice;
13use xlog_cuda::{CudaBuffer, CudaKernelProvider};
14#[cfg(test)]
15use xlog_ir::{CompareOp, ConstValue, Stratum};
16use xlog_ir::{ExecutionPlan, Expr, JoinType, ProjectExpr, RirNode};
17use xlog_stats::{StatsManager, StatsSnapshot};
18
19use crate::ilp_registry::{IlpRegistry, IlpTaggedResult};
20use crate::profiler::{ExecutionStats, Profiler};
21use crate::RelationStore;
22
23mod delta;
24mod epistemic_workspace;
25mod expression;
26mod join_cache;
27mod node_dispatch;
28mod recursive;
29mod rewrite;
30mod wcoj_cost_model;
31mod wcoj_dispatch;
32#[cfg(feature = "wcoj-phase-timing")]
33pub mod wcoj_phase_timing;
34pub use epistemic_workspace::{
35    EpistemicGpuBatchExecutionResult, EpistemicGpuBatchExecutionTrace,
36    EpistemicGpuCandidateGenerationTrace, EpistemicGpuCandidateValidationTrace,
37    EpistemicGpuConstraintValidationTrace, EpistemicGpuConstraintWorldViewValidationTrace,
38    EpistemicGpuExecutionResult, EpistemicGpuFinalResultMaterializationTrace,
39    EpistemicGpuFinalResultTransferTrace, EpistemicGpuFinalTupleMaterializationTrace,
40    EpistemicGpuKernelTimingTrace, EpistemicGpuMaterializationTrace,
41    EpistemicGpuModelMembershipSource, EpistemicGpuModelMembershipTrace,
42    EpistemicGpuPreparedExecution, EpistemicGpuPropagationTrace, EpistemicGpuProviderIdentity,
43    EpistemicGpuRejectionReason, EpistemicGpuRuntimeCounters, EpistemicGpuRuntimePreflight,
44    EpistemicGpuRuntimeTrace, EpistemicGpuRuntimeWcojCertification,
45    EpistemicGpuTransferBudgetTrace, EpistemicGpuWorkspace, EpistemicGpuWorkspaceCapacities,
46    EpistemicGpuWorkspaceLayout, EpistemicGpuWorkspaceResetTrace,
47    EpistemicGpuWorldViewValidationTrace,
48};
49use join_cache::JoinIndexCache;
50pub use join_cache::JoinIndexCacheStats;
51
52/// Incremental update for a base relation.
53pub struct RelationDelta {
54    /// Tuples to insert (if any).
55    pub insert: Option<CudaBuffer>,
56    /// Tuples to delete (if any).
57    pub delete: Option<CudaBuffer>,
58}
59
60impl RelationDelta {
61    /// Create a new incremental update.
62    pub fn new(insert: Option<CudaBuffer>, delete: Option<CudaBuffer>) -> Self {
63        Self { insert, delete }
64    }
65}
66
67/// Runtime summary for a delta recomputation pass.
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct DeltaRecomputeStats {
70    /// Number of base relations changed by the delta map.
71    pub changed_relations: usize,
72    /// True when at least one relation supplied delete rows.
73    pub has_deletes: bool,
74    /// Number of SCCs whose dependency closure was affected.
75    pub affected_sccs: usize,
76    /// Number of affected SCCs that were cleared and fully recomputed.
77    pub recomputed_sccs: usize,
78    /// Number of affected SCCs updated without clearing prior output.
79    pub incremental_sccs: usize,
80}
81
82/// Runtime common subexpression elimination telemetry.
83#[derive(Clone, Debug, Default, PartialEq, Eq)]
84pub struct CommonSubexpressionStats {
85    /// Number of safe subplan cache hits.
86    pub hits: u64,
87    /// Number of safe subplans evaluated and inserted into the cache.
88    pub misses: u64,
89    /// Number of subplans rejected because they cross an unsafe boundary.
90    pub unsafe_rejections: u64,
91    /// Rejection reason labels observed during the current executor lifetime.
92    pub rejection_reasons: Vec<String>,
93}
94
95/// Runtime join observation used by adaptive re-optimization decisions.
96#[derive(Clone, Debug, PartialEq)]
97pub struct AdaptiveJoinObservation {
98    /// Left relation ID observed at the join boundary.
99    pub left_rel: RelId,
100    /// Right relation ID observed at the join boundary.
101    pub right_rel: RelId,
102    /// Estimated output rows before the join executed.
103    pub estimated_output_rows: u64,
104    /// Actual output rows observed after the join executed.
105    pub actual_output_rows: u64,
106    /// Absolute row-count delta between estimate and observation.
107    pub cardinality_delta_abs: u64,
108    /// Estimated selectivity before execution.
109    pub estimated_selectivity: f64,
110    /// Actual selectivity observed after execution.
111    pub actual_selectivity: f64,
112    /// Absolute selectivity delta.
113    pub selectivity_delta_abs: f64,
114    /// Runtime heat for the left relation.
115    pub left_heat: f32,
116    /// Runtime heat for the right relation.
117    pub right_heat: f32,
118    /// Absolute heat delta between the join inputs.
119    pub heat_delta_abs: f32,
120    /// Multiplicative mis-plan ratio, always at least 1.0.
121    pub misplan_ratio: f64,
122}
123
124/// Deterministic adaptive re-optimization decision action.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum AdaptiveReoptimizationAction {
127    /// Adaptive re-optimization is explicitly disabled.
128    Disabled,
129    /// Telemetry did not cross the deterministic adaptation threshold.
130    Skipped,
131    /// Telemetry crossed the threshold and the candidate should be attempted.
132    AttemptCandidate,
133    /// Candidate output matched the baseline and was adopted.
134    Adopted,
135    /// Candidate failed or diverged and the baseline snapshot was restored.
136    RolledBack,
137}
138
139/// Typed adaptive re-optimization decision.
140#[derive(Clone, Debug, PartialEq)]
141pub struct AdaptiveReoptimizationDecision {
142    /// Decision action.
143    pub action: AdaptiveReoptimizationAction,
144    /// Stable reason label.
145    pub reason: String,
146    /// Maximum observed mis-plan ratio used by the decision.
147    pub max_misplan_ratio: f64,
148    /// Minimum threshold required to attempt a candidate.
149    pub min_misplan_ratio: f64,
150}
151
152/// Typed adaptive re-optimization diagnostic kind.
153#[derive(Clone, Copy, Debug, PartialEq, Eq)]
154pub enum AdaptiveReoptimizationDiagnosticKind {
155    /// Candidate execution returned an error.
156    CandidateExecutionFailed,
157    /// Candidate completed but did not produce baseline-equivalent outputs.
158    CandidateOutputMismatch,
159    /// Baseline relation snapshot was restored after an adverse candidate.
160    RollbackRestoredBaseline,
161}
162
163/// Typed adaptive re-optimization diagnostic.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct AdaptiveReoptimizationDiagnostic {
166    /// Diagnostic kind.
167    pub kind: AdaptiveReoptimizationDiagnosticKind,
168    /// Stable reason label or error text.
169    pub message: String,
170}
171
172/// Runtime adaptive re-optimization telemetry.
173#[derive(Clone, Debug, Default, PartialEq)]
174pub struct AdaptiveReoptimizationStats {
175    /// Number of adaptive entry-point invocations.
176    pub invocations: u64,
177    /// Number of invocations skipped because the feature was disabled.
178    pub disabled: u64,
179    /// Number of enabled invocations that did not attempt a candidate.
180    pub skipped: u64,
181    /// Number of candidate plans adopted.
182    pub adopted: u64,
183    /// Number of candidate plans rolled back.
184    pub rolled_back: u64,
185    /// Last deterministic decision.
186    pub last_decision: Option<AdaptiveReoptimizationDecision>,
187    /// Baseline join observations from the most recent execution.
188    pub last_observations: Vec<AdaptiveJoinObservation>,
189    /// Typed diagnostics emitted by the adaptive entry point.
190    pub diagnostics: Vec<AdaptiveReoptimizationDiagnostic>,
191    /// Tracked data-plane device-to-host calls added during the most recent adaptive path.
192    pub data_plane_dtoh_calls: u64,
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, Hash)]
196enum CommonSubexpressionKey {
197    Scan {
198        rel: RelId,
199        generation: u64,
200    },
201    Filter {
202        input: Box<CommonSubexpressionKey>,
203        predicate: String,
204    },
205    Project {
206        input: Box<CommonSubexpressionKey>,
207        columns: Vec<String>,
208    },
209    Join {
210        left: Box<CommonSubexpressionKey>,
211        right: Box<CommonSubexpressionKey>,
212        left_keys: Vec<usize>,
213        right_keys: Vec<usize>,
214    },
215    Union {
216        inputs: Vec<CommonSubexpressionKey>,
217    },
218    Distinct {
219        input: Box<CommonSubexpressionKey>,
220        key_cols: Vec<usize>,
221    },
222}
223
224/// Query executor that interprets RIR nodes using GPU kernels
225///
226/// The executor processes execution plans by iterating through strata and
227/// executing RIR node trees. It maintains a relation store for intermediate
228/// and final results.
229///
230/// # Example
231///
232/// ```ignore
233/// use std::sync::Arc;
234/// use xlog_runtime::Executor;
235/// use xlog_cuda::CudaKernelProvider;
236///
237/// let provider = Arc::new(CudaKernelProvider::new(device, memory)?);
238/// let mut executor = Executor::new(provider);
239///
240/// // Execute a plan
241/// let result = executor.execute_plan(&plan)?;
242/// ```
243pub struct Executor {
244    /// CUDA kernel provider for GPU operations
245    provider: Arc<CudaKernelProvider>,
246    /// Storage for named relations
247    store: RelationStore,
248    /// Mapping from RelId to relation name
249    rel_names: HashMap<RelId, String>,
250    /// Mapping from relation name to RelId
251    name_to_rel: HashMap<String, RelId>,
252    /// Runtime statistics for adaptive optimization
253    stats: StatsManager,
254    /// Cached build-side join indexes (adaptive indexing)
255    join_index_cache: JoinIndexCache,
256    /// Per-execution CSE cache for safe deterministic subplans.
257    common_subexpression_cache: HashMap<CommonSubexpressionKey, CudaBuffer>,
258    /// Runtime CSE telemetry for evidence and diagnostics.
259    common_subexpression_stats: CommonSubexpressionStats,
260    /// Runtime adaptive re-optimization telemetry for adoption/rollback evidence.
261    adaptive_reoptimization_stats: AdaptiveReoptimizationStats,
262    /// Per-plan join observations captured before `StatsManager` is updated.
263    adaptive_join_observations: Vec<AdaptiveJoinObservation>,
264    /// Runtime configuration
265    config: RuntimeConfig,
266    /// Performance profiler for --stats output
267    profiler: Profiler,
268    /// ILP tensor mask registry
269    ilp_registry: IlpRegistry,
270    /// Last ILP tagged result metadata
271    ilp_last_result: Option<IlpTaggedResult>,
272    /// Number of times the env-gated WCOJ triangle dispatch
273    /// (`XLOG_USE_WCOJ_TRIANGLE_U32` / `RuntimeConfig::wcoj_triangle_dispatch`)
274    /// produced a result and the executor installed it. Tests use this
275    /// counter to assert that the WCOJ path actually fired vs. silently
276    /// falling back to the binary-join chain with the same answer.
277    wcoj_triangle_dispatch_count: u64,
278    /// Count of times `try_dispatch_wcoj_4cycle` produced a result and the
279    /// executor installed it. Tracks 4-cycle dispatches separately from triangle.
280    pub(super) wcoj_4cycle_dispatch_count: u64,
281    /// Count of times the chain dispatcher produced a result and the
282    /// executor installed it.
283    pub(super) chain_dispatch_count: u64,
284    /// Count of times `try_dispatch_wcoj_clique5` produced
285    /// a result and the executor installed it. Public accessor:
286    /// `Executor::wcoj_clique5_dispatch_count(&self) -> u64`.
287    pub(super) wcoj_clique5_dispatch_count: u64,
288    /// Count of times `try_dispatch_wcoj_clique6` produced
289    /// a result and the executor installed it.
290    pub(super) wcoj_clique6_dispatch_count: u64,
291    /// Count of times `try_dispatch_wcoj_clique7` produced
292    /// a result and the executor installed it.
293    pub(super) wcoj_clique7_dispatch_count: u64,
294    /// Count of times `try_dispatch_wcoj_clique8` produced
295    /// a result and the executor installed it.
296    pub(super) wcoj_clique8_dispatch_count: u64,
297    /// Number of recursive Merge-phase K-clique histogram refresh
298    /// boundaries observed.
299    pub(super) kclique_histogram_refresh_count: u64,
300    /// Cumulative nanoseconds spent in recursive Merge-phase K-clique
301    /// histogram refresh accounting.
302    pub(super) kclique_histogram_refresh_nanos: u128,
303    /// Count of times `execute_join` routed an inner-join to the
304    /// nested-loop provider entry point
305    /// (`CudaKernelProvider::nested_loop_join_v2_inner_u32_1key`)
306    /// because the eligibility predicate + Cartesian-product
307    /// threshold both held. Tests use this counter to assert that
308    /// the nested-loop path actually fired vs. silently falling back to
309    /// hash. Public accessor:
310    /// `Executor::nested_loop_dispatch_count(&self) -> u64`.
311    pub(super) nested_loop_dispatch_count: u64,
312    /// Counts WCOJ pipeline errors (layout or kernel failures) that were
313    /// converted into binary-join declines instead of propagating. Healthy
314    /// dispatch keeps this at 0; nonzero values expose regressed kernels
315    /// that would otherwise hide behind the silent-fallback contract.
316    /// `XLOG_WCOJ_STRICT=1` propagates the error instead. Public accessor:
317    /// `Executor::wcoj_error_decline_count(&self) -> u64`.
318    pub(super) wcoj_error_decline_count: u64,
319    /// Count of fused group-by-root count dispatches that produced the
320    /// installed result. Public accessor:
321    /// `Executor::wcoj_groupby_fusion_dispatch_count(&self) -> u64`.
322    pub(super) wcoj_groupby_fusion_dispatch_count: u64,
323    /// Count of generalized Free Join dispatches that produced the
324    /// installed result. Public accessor:
325    /// `Executor::free_join_dispatch_count(&self) -> u64`.
326    pub(super) free_join_dispatch_count: u64,
327    /// D3 — count of factorized recursive-delta dispatches that produced
328    /// an installed novel set. Public accessor:
329    /// `Executor::factorized_delta_dispatch_count(&self) -> u64`.
330    pub(super) factorized_delta_dispatch_count: u64,
331    /// Cached non-default stream for the WCOJ triangle dispatch hook.
332    /// Acquired lazily on first dispatch and reused thereafter — mirrors
333    /// [`xlog_cuda::CudaKernelProvider::recorded_op_stream`] for the
334    /// same reason: the device-runtime
335    /// [`xlog_cuda::device_runtime::StreamPool`] is grow-only with a
336    /// hard cap (default 16). Acquiring per-invocation would silently
337    /// drain the pool on long-lived runtimes (benchmarks, soak tests,
338    /// any program with >16 matching WCOJ-eligible rules) and route
339    /// subsequent dispatches through the binary-join fallback,
340    /// invalidating the dispatch counter and the gate-on path.
341    ///
342    /// **Shared across WCOJ shapes**: triangle and 4-cycle dispatch both
343    /// acquire and reuse this single stream. Renamed from
344    /// `wcoj_triangle_stream` when 4-cycle dispatch landed.
345    wcoj_dispatch_stream: OnceLock<xlog_cuda::device_runtime::StreamId>,
346    /// Diagnostic-only: per-dispatch WCOJ triangle phase
347    /// timings, populated by `try_dispatch_wcoj_triangle` when
348    /// the `wcoj-phase-timing` Cargo feature is on. Read by the
349    /// `wcoj_phase_report` binary in xlog-integration. Field is
350    /// absent under feature-off so production builds have zero
351    /// overhead.
352    #[cfg(feature = "wcoj-phase-timing")]
353    pub(super) last_wcoj_phase_timing:
354        std::sync::Mutex<Option<wcoj_phase_timing::WcojDispatchPhaseTiming>>,
355    /// Per-iteration recursive-SCC stats trace, populated by
356    /// `execute_recursive_scc` after each delta-relation and full-relation
357    /// cardinality update site. Field, types, accessor, and populating call
358    /// sites are gated on the `recursive-stats-trace` Cargo feature
359    /// (default OFF) so production builds carry zero trace overhead: no
360    /// field, no populating call site, no symbol. The recursive stats trace
361    /// test target declares this feature in its `required-features`, so it
362    /// is only built when the feature is enabled.
363    #[cfg(feature = "recursive-stats-trace")]
364    pub(super) last_recursive_stats_trace: RecursiveStatsTrace,
365}
366
367/// Recursive-SCC stats trace for recursive cardinality updates.
368///
369/// Captures one entry per `(iteration, predicate)` boundary
370/// at which `execute_recursive_scc` updates `StatsManager` for
371/// a recursive predicate's `(full_rel, delta_rel)` RelIds.
372/// Used by recursive stats trace tests to assert per-iteration cardinality
373/// evolution and binary-join estimate evolution without intrusive
374/// instrumentation.
375#[cfg(feature = "recursive-stats-trace")]
376#[derive(Debug, Default, Clone)]
377#[allow(missing_docs)]
378pub struct RecursiveStatsTrace {
379    pub entries: Vec<RecursiveStatsTraceEntry>,
380}
381
382/// One entry per `(iteration, pred)` boundary.
383///
384/// `iteration == 0` is the seed pass; `iteration >= 1` is the
385/// fixpoint loop. `phase` distinguishes the delta-recording site
386/// from the full-recording site so full-row growth assertions only
387/// see snapshots where `full_rel` advanced, and delta-evolution
388/// assertions only see delta-update snapshots.
389#[cfg(feature = "recursive-stats-trace")]
390#[derive(Debug, Clone)]
391#[allow(missing_docs)]
392pub struct RecursiveStatsTraceEntry {
393    pub iteration: usize,
394    pub pred: String,
395    pub full_rel: RelId,
396    pub delta_rel: RelId,
397    pub full_rows: u64,
398    pub delta_rows: u64,
399    pub phase: RecursiveStatsPhase,
400    /// Optional binary-join estimate the cost model would use
401    /// for the variant body's first binary hop. Triangle:
402    /// `(delta_e1_rel, e2_rel, &[1], &[0])`. 4-cycle: same
403    /// `(delta_e1_rel, e2_rel, &[1], &[0])` (slot 0 → slot 1
404    /// adjacency on the X variable).
405    pub binary_est_for_variant: Option<u64>,
406}
407
408#[cfg(feature = "recursive-stats-trace")]
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410#[allow(missing_docs)]
411pub enum RecursiveStatsPhase {
412    /// Seed pass — full_rel + delta_rel both updated; trace
413    /// entry contains both row counts. iteration == 0.
414    Seed,
415    /// Fixpoint loop delta update: `delta_rel` updated while `full_rel`
416    /// holds the previous iteration's value. Trace entry reports
417    /// `full_rows` as the previous-iteration cardinality it sees.
418    Phase2Delta,
419    /// Fixpoint loop full update after merge. Trace entry reports the new
420    /// full row count plus the `delta_rel` value just recorded by the
421    /// delta update.
422    Phase4Full,
423}
424
425impl Executor {
426    /// Create a new executor with the given kernel provider
427    ///
428    /// # Arguments
429    /// * `provider` - The CUDA kernel provider for GPU operations
430    pub fn new(provider: Arc<CudaKernelProvider>) -> Self {
431        Self::new_with_config(provider, RuntimeConfig::default())
432    }
433
434    /// Create a new executor with the given kernel provider and runtime config
435    pub fn new_with_config(provider: Arc<CudaKernelProvider>, config: RuntimeConfig) -> Self {
436        const DEFAULT_JOIN_INDEX_CACHE_BYTES: u64 = 256 * 1024 * 1024;
437        let max_index_cache_bytes =
438            (provider.memory().budget().device_bytes / 4).min(DEFAULT_JOIN_INDEX_CACHE_BYTES);
439        Self {
440            provider: provider.clone(),
441            store: RelationStore::new(provider.clone()),
442            rel_names: HashMap::new(),
443            name_to_rel: HashMap::new(),
444            stats: StatsManager::new(),
445            join_index_cache: JoinIndexCache::new(max_index_cache_bytes),
446            common_subexpression_cache: HashMap::new(),
447            common_subexpression_stats: CommonSubexpressionStats::default(),
448            adaptive_reoptimization_stats: AdaptiveReoptimizationStats::default(),
449            adaptive_join_observations: Vec::new(),
450            config,
451            profiler: Profiler::default(),
452            ilp_registry: IlpRegistry::new(),
453            ilp_last_result: None,
454            wcoj_triangle_dispatch_count: 0,
455            wcoj_4cycle_dispatch_count: 0,
456            chain_dispatch_count: 0,
457            wcoj_clique5_dispatch_count: 0,
458            wcoj_clique6_dispatch_count: 0,
459            wcoj_clique7_dispatch_count: 0,
460            wcoj_clique8_dispatch_count: 0,
461            kclique_histogram_refresh_count: 0,
462            kclique_histogram_refresh_nanos: 0,
463            nested_loop_dispatch_count: 0,
464            wcoj_error_decline_count: 0,
465            wcoj_groupby_fusion_dispatch_count: 0,
466            free_join_dispatch_count: 0,
467            factorized_delta_dispatch_count: 0,
468            wcoj_dispatch_stream: OnceLock::new(),
469            #[cfg(feature = "wcoj-phase-timing")]
470            last_wcoj_phase_timing: std::sync::Mutex::new(None),
471            #[cfg(feature = "recursive-stats-trace")]
472            last_recursive_stats_trace: RecursiveStatsTrace::default(),
473        }
474    }
475
476    /// Return the most recent recursive-SCC stats trace populated by
477    /// `execute_recursive_scc`. Gated on the `recursive-stats-trace`
478    /// Cargo feature; default OFF.
479    #[cfg(feature = "recursive-stats-trace")]
480    pub fn last_recursive_stats_trace(&self) -> &RecursiveStatsTrace {
481        &self.last_recursive_stats_trace
482    }
483
484    /// Take the most recent WCOJ triangle dispatch's per-phase
485    /// timing breakdown. Reading clears the slot — designed for
486    /// one-shot consumption by the `wcoj_phase_report` binary.
487    /// Returns `None` if no triangle has dispatched since the
488    /// last read (or since construction).
489    ///
490    /// Compiled in only with the `wcoj-phase-timing` Cargo
491    /// feature; production builds have no such method.
492    #[cfg(feature = "wcoj-phase-timing")]
493    pub fn take_wcoj_phase_timing(&self) -> Option<wcoj_phase_timing::WcojDispatchPhaseTiming> {
494        self.last_wcoj_phase_timing
495            .lock()
496            .ok()
497            .and_then(|mut g| g.take())
498    }
499
500    /// Enable or disable the performance profiler
501    ///
502    /// When enabled, execution statistics will be collected for --stats output.
503    pub fn set_profiling(&mut self, enabled: bool) {
504        self.profiler = Profiler::new(enabled);
505        if enabled {
506            self.profiler
507                .set_memory_budget(self.provider.memory().budget().device_bytes);
508        }
509    }
510
511    /// Check if profiling is enabled
512    pub fn is_profiling(&self) -> bool {
513        self.profiler.is_enabled()
514    }
515
516    /// Get execution statistics
517    ///
518    /// Returns collected statistics if profiling was enabled.
519    pub fn execution_stats(&self, total_output_rows: u64) -> ExecutionStats {
520        let mut stats = self.profiler.execution_stats(total_output_rows);
521        // WCOJ/multiway dispatch counters live on the executor, not the
522        // profiler. Surface them so a `--stats` run can be *verified* to have
523        // used the WCOJ kernels rather than silently falling back to binary
524        // joins (the failure mode that wasted a full GPU benchmark cycle).
525        stats.wcoj_triangle_dispatch_count = self.wcoj_triangle_dispatch_count();
526        stats.wcoj_4cycle_dispatch_count = self.wcoj_4cycle_dispatch_count();
527        stats.wcoj_groupby_fusion_dispatch_count = self.wcoj_groupby_fusion_dispatch_count();
528        stats.free_join_dispatch_count = self.free_join_dispatch_count();
529        stats.factorized_delta_dispatch_count = self.factorized_delta_dispatch_count();
530        stats.wcoj_error_decline_count = self.wcoj_error_decline_count();
531        stats
532    }
533
534    /// Get a reference to the relation store
535    pub fn store(&self) -> &RelationStore {
536        &self.store
537    }
538
539    /// Get a mutable reference to the relation store
540    pub fn store_mut(&mut self) -> &mut RelationStore {
541        &mut self.store
542    }
543
544    /// Get a mutable reference to the ILP registry.
545    pub fn ilp_registry_mut(&mut self) -> &mut IlpRegistry {
546        &mut self.ilp_registry
547    }
548
549    /// Get a shared reference to the ILP registry.
550    pub fn ilp_registry(&self) -> &IlpRegistry {
551        &self.ilp_registry
552    }
553
554    /// Get the last ILP tagged result.
555    pub fn ilp_last_result(&self) -> Option<&IlpTaggedResult> {
556        self.ilp_last_result.as_ref()
557    }
558
559    /// Store a relation buffer and invalidate join indices.
560    pub fn put_relation(&mut self, name: &str, buffer: CudaBuffer) {
561        self.store_put(name, buffer);
562    }
563
564    /// Get a reference to the runtime statistics manager
565    pub fn stats(&self) -> &StatsManager {
566        &self.stats
567    }
568
569    /// Return persistent join-index manager telemetry.
570    pub fn join_index_cache_stats(&self) -> JoinIndexCacheStats {
571        self.join_index_cache.stats()
572    }
573
574    /// Reset executor state for Monte Carlo sampling.
575    ///
576    /// Clears relation storage and join index cache while preserving relation registrations.
577    pub fn reset_for_mc(&mut self) {
578        self.store.clear();
579        self.join_index_cache.clear();
580        self.common_subexpression_cache.clear();
581        self.adaptive_join_observations.clear();
582    }
583
584    /// Targeted MC reset: preserve base/static relations and clear dynamic ones.
585    ///
586    /// Unlike [`Self::reset_for_mc`] which drops all relations, this method keeps the
587    /// relations listed in `preserve` untouched, removes every other relation,
588    /// then re-creates the relations specified in `clear_to_empty` as empty
589    /// GPU buffers with the given schemas.  The join-index cache is fully
590    /// invalidated because dynamic relations have changed.
591    ///
592    /// # Arguments
593    /// * `preserve` - Relation names to keep as-is (base/static facts).
594    /// * `clear_to_empty` - `(name, schema)` pairs for dynamic relations that
595    ///   should be present but empty after the reset.
596    pub fn reset_for_mc_relations(
597        &mut self,
598        preserve: &[&str],
599        clear_to_empty: &[(&str, Schema)],
600    ) -> Result<()> {
601        let preserve_set: HashSet<&str> = preserve.iter().copied().collect();
602        let existing_names: Vec<String> = self.store.names().map(|s| s.to_string()).collect();
603
604        for name in &existing_names {
605            if !preserve_set.contains(name.as_str()) {
606                self.store.remove(name);
607            }
608        }
609
610        for (name, schema) in clear_to_empty {
611            let empty = self.provider.create_empty_buffer(schema.clone())?;
612            self.store.put(name, empty);
613        }
614
615        self.join_index_cache.clear();
616        self.common_subexpression_cache.clear();
617        self.adaptive_join_observations.clear();
618        Ok(())
619    }
620
621    /// Reset executor state for ILP attempt reuse.
622    ///
623    /// Clears ILP registry (masks + tagged results), relation storage,
624    /// join index cache, stats, and profiler. Preserves relation name
625    /// registrations (rel_names, name_to_rel) since those are immutable
626    /// compile artifacts.
627    pub fn reset_for_ilp(&mut self) {
628        self.ilp_registry.clear();
629        self.ilp_last_result = None;
630        self.store.clear();
631        self.join_index_cache.clear();
632        self.common_subexpression_cache.clear();
633        self.adaptive_join_observations.clear();
634        self.stats = StatsManager::new();
635        self.profiler = Profiler::default();
636    }
637
638    /// Get a mutable reference to the runtime statistics manager
639    pub fn stats_mut(&mut self) -> &mut StatsManager {
640        &mut self.stats
641    }
642
643    /// Capture a runtime statistics snapshot, including predicate name mappings.
644    ///
645    /// Use this snapshot to seed the compiler/optimizer on subsequent compilations.
646    pub fn stats_snapshot(&self) -> StatsSnapshot {
647        let mut snapshot = self.stats.snapshot();
648        snapshot.rel_names = self
649            .rel_names
650            .iter()
651            .map(|(id, name)| (*id, name.clone()))
652            .collect();
653        snapshot
654    }
655
656    /// Return runtime CSE telemetry for evidence and diagnostics.
657    pub fn common_subexpression_stats(&self) -> &CommonSubexpressionStats {
658        &self.common_subexpression_stats
659    }
660
661    /// Return adaptive re-optimization telemetry for evidence and diagnostics.
662    pub fn adaptive_reoptimization_stats(&self) -> &AdaptiveReoptimizationStats {
663        &self.adaptive_reoptimization_stats
664    }
665
666    /// Replay the deterministic adaptive decision against captured telemetry.
667    pub fn replay_adaptive_reoptimization_decision(
668        &self,
669        observations: &[AdaptiveJoinObservation],
670    ) -> AdaptiveReoptimizationDecision {
671        self.adaptive_reoptimization_decision(observations)
672    }
673
674    fn common_subexpression_enabled(&self) -> bool {
675        self.config.resolved_common_subexpression_elimination()
676    }
677
678    fn adaptive_reoptimization_enabled(&self) -> bool {
679        self.config.resolved_adaptive_reoptimization()
680    }
681
682    fn adaptive_reoptimization_decision(
683        &self,
684        observations: &[AdaptiveJoinObservation],
685    ) -> AdaptiveReoptimizationDecision {
686        let min_misplan_ratio = self
687            .config
688            .resolved_adaptive_reoptimization_min_misplan_ratio();
689        let max_misplan_ratio = observations
690            .iter()
691            .map(|observation| observation.misplan_ratio)
692            .fold(1.0_f64, f64::max);
693
694        if !self.adaptive_reoptimization_enabled() {
695            return AdaptiveReoptimizationDecision {
696                action: AdaptiveReoptimizationAction::Disabled,
697                reason: "adaptive_reoptimization_disabled".to_string(),
698                max_misplan_ratio,
699                min_misplan_ratio,
700            };
701        }
702
703        if observations.is_empty() {
704            return AdaptiveReoptimizationDecision {
705                action: AdaptiveReoptimizationAction::Skipped,
706                reason: "no_join_telemetry".to_string(),
707                max_misplan_ratio,
708                min_misplan_ratio,
709            };
710        }
711
712        if max_misplan_ratio >= min_misplan_ratio {
713            AdaptiveReoptimizationDecision {
714                action: AdaptiveReoptimizationAction::AttemptCandidate,
715                reason: "misplan_threshold_crossed".to_string(),
716                max_misplan_ratio,
717                min_misplan_ratio,
718            }
719        } else {
720            AdaptiveReoptimizationDecision {
721                action: AdaptiveReoptimizationAction::Skipped,
722                reason: "misplan_threshold_not_crossed".to_string(),
723                max_misplan_ratio,
724                min_misplan_ratio,
725            }
726        }
727    }
728
729    fn record_adaptive_join_observation(
730        &mut self,
731        left_rel: RelId,
732        right_rel: RelId,
733        left_keys: &[usize],
734        right_keys: &[usize],
735        input_rows: u64,
736        actual_output_rows: u64,
737    ) {
738        let estimated_output_rows = self
739            .stats
740            .estimate_join_cardinality(left_rel, right_rel, left_keys, right_keys);
741        let estimated_selectivity = if input_rows > 0 {
742            estimated_output_rows as f64 / input_rows as f64
743        } else {
744            0.0
745        };
746        let actual_selectivity = if input_rows > 0 {
747            actual_output_rows as f64 / input_rows as f64
748        } else {
749            0.0
750        };
751        let cardinality_delta_abs = estimated_output_rows.abs_diff(actual_output_rows);
752        let selectivity_delta_abs = (estimated_selectivity - actual_selectivity).abs();
753        let left_heat = self
754            .stats
755            .get_relation_stats(left_rel)
756            .map(|stats| stats.heat)
757            .unwrap_or(0.0);
758        let right_heat = self
759            .stats
760            .get_relation_stats(right_rel)
761            .map(|stats| stats.heat)
762            .unwrap_or(0.0);
763        let heat_delta_abs = (left_heat - right_heat).abs();
764        let smaller = estimated_output_rows.min(actual_output_rows);
765        let larger = estimated_output_rows.max(actual_output_rows);
766        let misplan_ratio = if smaller == 0 {
767            if larger == 0 {
768                1.0
769            } else {
770                f64::INFINITY
771            }
772        } else {
773            (larger as f64 / smaller as f64).max(1.0)
774        };
775
776        self.adaptive_join_observations
777            .push(AdaptiveJoinObservation {
778                left_rel,
779                right_rel,
780                estimated_output_rows,
781                actual_output_rows,
782                cardinality_delta_abs,
783                estimated_selectivity,
784                actual_selectivity,
785                selectivity_delta_abs,
786                left_heat,
787                right_heat,
788                heat_delta_abs,
789                misplan_ratio,
790            });
791    }
792
793    fn plan_head_names(plan: &ExecutionPlan) -> Vec<String> {
794        let mut names = Vec::new();
795        for stratum in &plan.strata {
796            for scc_id in &stratum.sccs {
797                if let Some(rules) = plan.rules_by_scc.get(*scc_id as usize) {
798                    for rule in rules {
799                        if !names.iter().any(|name| name == &rule.head) {
800                            names.push(rule.head.clone());
801                        }
802                    }
803                }
804            }
805        }
806
807        if names.is_empty() {
808            for rules in &plan.rules_by_scc {
809                for rule in rules {
810                    if !names.iter().any(|name| name == &rule.head) {
811                        names.push(rule.head.clone());
812                    }
813                }
814            }
815        }
816
817        names
818    }
819
820    fn clone_store_snapshot(&self) -> Result<HashMap<String, CudaBuffer>> {
821        let names: Vec<String> = self.store.names().map(|name| name.to_string()).collect();
822        let mut snapshot = HashMap::with_capacity(names.len());
823        for name in names {
824            if let Some(buffer) = self.store.get(&name) {
825                snapshot.insert(name, self.clone_buffer(buffer)?);
826            }
827        }
828        Ok(snapshot)
829    }
830
831    fn restore_store_snapshot(&mut self, snapshot: HashMap<String, CudaBuffer>) {
832        let snapshot_names: HashSet<String> = snapshot.keys().cloned().collect();
833        let existing_names: Vec<String> = self.store.names().map(|name| name.to_string()).collect();
834        for name in existing_names {
835            if !snapshot_names.contains(&name) {
836                self.store.remove(&name);
837            }
838        }
839        for (name, buffer) in snapshot {
840            self.store.put(&name, buffer);
841        }
842    }
843
844    fn restore_stats_snapshot(&mut self, snapshot: &StatsSnapshot) {
845        self.stats.clear();
846        self.stats.merge_snapshot(snapshot);
847    }
848
849    fn clone_final_plan_output(&self, plan: &ExecutionPlan) -> Result<CudaBuffer> {
850        let head_names = Self::plan_head_names(plan);
851        if let Some(name) = head_names.last() {
852            let output = self.store.get(name).ok_or_else(|| {
853                XlogError::Execution(format!("adaptive reoptimization output missing: {name}"))
854            })?;
855            return self.clone_buffer(output);
856        }
857
858        self.provider.create_empty_buffer(Schema::new(vec![]))
859    }
860
861    fn plan_outputs_match(
862        &self,
863        head_names: &[String],
864        baseline_snapshot: &HashMap<String, CudaBuffer>,
865    ) -> Result<bool> {
866        for name in head_names {
867            let Some(baseline) = baseline_snapshot.get(name) else {
868                return Ok(false);
869            };
870            let Some(candidate) = self.store.get(name) else {
871                return Ok(false);
872            };
873            if !self.buffers_gpu_set_equivalent(baseline, candidate)? {
874                return Ok(false);
875            }
876        }
877        Ok(true)
878    }
879
880    fn buffers_gpu_set_equivalent(&self, left: &CudaBuffer, right: &CudaBuffer) -> Result<bool> {
881        if left.schema() != right.schema() {
882            return Ok(false);
883        }
884        let left_rows = self.provider.device_row_count(left)?;
885        let right_rows = self.provider.device_row_count(right)?;
886        if left_rows != right_rows {
887            return Ok(false);
888        }
889
890        let left_minus_right = self.provider.diff_full_row(left, right)?;
891        if self.provider.device_row_count(&left_minus_right)? != 0 {
892            return Ok(false);
893        }
894        let right_minus_left = self.provider.diff_full_row(right, left)?;
895        Ok(self.provider.device_row_count(&right_minus_left)? == 0)
896    }
897
898    fn record_adaptive_dtoh_delta(&mut self, before_dtoh_calls: u64) {
899        let after_dtoh_calls = self.provider.host_transfer_stats().dtoh_calls;
900        self.adaptive_reoptimization_stats.data_plane_dtoh_calls =
901            after_dtoh_calls.saturating_sub(before_dtoh_calls);
902    }
903
904    fn is_common_subexpression_cacheable(node: &RirNode) -> bool {
905        !matches!(node, RirNode::Unit | RirNode::Scan { .. })
906    }
907
908    fn record_common_subexpression_rejection(&mut self, reason: &'static str) {
909        self.common_subexpression_stats.unsafe_rejections = self
910            .common_subexpression_stats
911            .unsafe_rejections
912            .saturating_add(1);
913        if !self
914            .common_subexpression_stats
915            .rejection_reasons
916            .iter()
917            .any(|seen| seen == reason)
918        {
919            self.common_subexpression_stats
920                .rejection_reasons
921                .push(reason.to_string());
922        }
923    }
924
925    fn common_subexpression_key(&mut self, node: &RirNode) -> Option<CommonSubexpressionKey> {
926        match node {
927            RirNode::Unit => None,
928            RirNode::Scan { rel } => {
929                let generation = self
930                    .get_rel_name(*rel)
931                    .and_then(|name| self.store.version(name))
932                    .unwrap_or(0);
933                Some(CommonSubexpressionKey::Scan {
934                    rel: *rel,
935                    generation,
936                })
937            }
938            RirNode::Filter { input, predicate } => {
939                let input = self.common_subexpression_key(input)?;
940                Some(CommonSubexpressionKey::Filter {
941                    input: Box::new(input),
942                    predicate: Self::expr_cse_key(predicate),
943                })
944            }
945            RirNode::Project { input, columns } => {
946                let input = self.common_subexpression_key(input)?;
947                Some(CommonSubexpressionKey::Project {
948                    input: Box::new(input),
949                    columns: columns.iter().map(Self::project_expr_cse_key).collect(),
950                })
951            }
952            RirNode::Join {
953                left,
954                right,
955                left_keys,
956                right_keys,
957                join_type,
958            } => {
959                if *join_type != JoinType::Inner {
960                    self.record_common_subexpression_rejection("negation_or_outer_join_boundary");
961                    return None;
962                }
963                let left = self.common_subexpression_key(left)?;
964                let right = self.common_subexpression_key(right)?;
965                Some(CommonSubexpressionKey::Join {
966                    left: Box::new(left),
967                    right: Box::new(right),
968                    left_keys: left_keys.clone(),
969                    right_keys: right_keys.clone(),
970                })
971            }
972            RirNode::Union { inputs } => {
973                let mut input_keys = Vec::with_capacity(inputs.len());
974                for input in inputs {
975                    input_keys.push(self.common_subexpression_key(input)?);
976                }
977                Some(CommonSubexpressionKey::Union { inputs: input_keys })
978            }
979            RirNode::Distinct { input, key_cols } => {
980                let input = self.common_subexpression_key(input)?;
981                Some(CommonSubexpressionKey::Distinct {
982                    input: Box::new(input),
983                    key_cols: key_cols.clone(),
984                })
985            }
986            RirNode::Diff { .. } => {
987                self.record_common_subexpression_rejection("negation_or_difference_boundary");
988                None
989            }
990            RirNode::GroupBy { .. } => {
991                self.record_common_subexpression_rejection("aggregate_boundary");
992                None
993            }
994            RirNode::Fixpoint { .. } => {
995                self.record_common_subexpression_rejection("recursive_or_mutable_boundary");
996                None
997            }
998            RirNode::TensorMaskedJoin { .. } => {
999                self.record_common_subexpression_rejection("provenance_or_tensor_boundary");
1000                None
1001            }
1002            RirNode::MultiWayJoin { .. } | RirNode::ChainJoin { .. } => {
1003                self.record_common_subexpression_rejection("specialized_dispatch_boundary");
1004                None
1005            }
1006        }
1007    }
1008
1009    fn expr_cse_key(expr: &Expr) -> String {
1010        match expr {
1011            Expr::Column(idx) => format!("col:{idx}"),
1012            Expr::Const(value) => format!("const:{}", Self::const_cse_key(value)),
1013            Expr::Compare { left, op, right } => format!(
1014                "cmp:{}:{}:{}",
1015                Self::expr_cse_key(left),
1016                Self::compare_op_cse_key(*op),
1017                Self::expr_cse_key(right)
1018            ),
1019            Expr::And(items) => format!(
1020                "and:[{}]",
1021                items
1022                    .iter()
1023                    .map(Self::expr_cse_key)
1024                    .collect::<Vec<_>>()
1025                    .join(",")
1026            ),
1027            Expr::Or(items) => format!(
1028                "or:[{}]",
1029                items
1030                    .iter()
1031                    .map(Self::expr_cse_key)
1032                    .collect::<Vec<_>>()
1033                    .join(",")
1034            ),
1035            Expr::Not(inner) => format!("not:{}", Self::expr_cse_key(inner)),
1036            Expr::Add(left, right) => {
1037                format!(
1038                    "add:{}:{}",
1039                    Self::expr_cse_key(left),
1040                    Self::expr_cse_key(right)
1041                )
1042            }
1043            Expr::Sub(left, right) => {
1044                format!(
1045                    "sub:{}:{}",
1046                    Self::expr_cse_key(left),
1047                    Self::expr_cse_key(right)
1048                )
1049            }
1050            Expr::Mul(left, right) => {
1051                format!(
1052                    "mul:{}:{}",
1053                    Self::expr_cse_key(left),
1054                    Self::expr_cse_key(right)
1055                )
1056            }
1057            Expr::Div(left, right) => {
1058                format!(
1059                    "div:{}:{}",
1060                    Self::expr_cse_key(left),
1061                    Self::expr_cse_key(right)
1062                )
1063            }
1064            Expr::Mod(left, right) => {
1065                format!(
1066                    "mod:{}:{}",
1067                    Self::expr_cse_key(left),
1068                    Self::expr_cse_key(right)
1069                )
1070            }
1071            Expr::Abs(inner) => format!("abs:{}", Self::expr_cse_key(inner)),
1072            Expr::Min(left, right) => {
1073                format!(
1074                    "min:{}:{}",
1075                    Self::expr_cse_key(left),
1076                    Self::expr_cse_key(right)
1077                )
1078            }
1079            Expr::Max(left, right) => {
1080                format!(
1081                    "max:{}:{}",
1082                    Self::expr_cse_key(left),
1083                    Self::expr_cse_key(right)
1084                )
1085            }
1086            Expr::Pow(left, right) => {
1087                format!(
1088                    "pow:{}:{}",
1089                    Self::expr_cse_key(left),
1090                    Self::expr_cse_key(right)
1091                )
1092            }
1093            Expr::Cast(inner, ty) => format!("cast:{:?}:{}", ty, Self::expr_cse_key(inner)),
1094            Expr::Conditional {
1095                condition,
1096                then_expr,
1097                else_expr,
1098            } => format!(
1099                "if:{}:{}:{}",
1100                Self::expr_cse_key(condition),
1101                Self::expr_cse_key(then_expr),
1102                Self::expr_cse_key(else_expr)
1103            ),
1104        }
1105    }
1106
1107    fn project_expr_cse_key(expr: &ProjectExpr) -> String {
1108        match expr {
1109            ProjectExpr::Column(idx) => format!("col:{idx}"),
1110            ProjectExpr::Computed(expr, ty) => {
1111                format!("computed:{:?}:{}", ty, Self::expr_cse_key(expr))
1112            }
1113        }
1114    }
1115
1116    fn const_cse_key(value: &xlog_ir::ConstValue) -> String {
1117        match value {
1118            xlog_ir::ConstValue::U32(value) => format!("u32:{value}"),
1119            xlog_ir::ConstValue::U64(value) => format!("u64:{value}"),
1120            xlog_ir::ConstValue::I32(value) => format!("i32:{value}"),
1121            xlog_ir::ConstValue::I64(value) => format!("i64:{value}"),
1122            xlog_ir::ConstValue::F32(value) => format!("f32:{:08x}", value.to_bits()),
1123            xlog_ir::ConstValue::F64(value) => format!("f64:{:016x}", value.to_bits()),
1124            xlog_ir::ConstValue::Bool(value) => format!("bool:{value}"),
1125            xlog_ir::ConstValue::Symbol(value) => format!("symbol:{value:?}"),
1126        }
1127    }
1128
1129    fn compare_op_cse_key(op: xlog_ir::CompareOp) -> &'static str {
1130        match op {
1131            xlog_ir::CompareOp::Eq => "eq",
1132            xlog_ir::CompareOp::Ne => "ne",
1133            xlog_ir::CompareOp::Lt => "lt",
1134            xlog_ir::CompareOp::Le => "le",
1135            xlog_ir::CompareOp::Gt => "gt",
1136            xlog_ir::CompareOp::Ge => "ge",
1137        }
1138    }
1139
1140    fn store_put(&mut self, name: &str, buffer: CudaBuffer) {
1141        self.common_subexpression_cache.clear();
1142        self.store.put(name, buffer);
1143        if let Some(&rel_id) = self.name_to_rel.get(name) {
1144            self.join_index_cache.invalidate_rel(rel_id);
1145        }
1146    }
1147
1148    fn store_remove(&mut self, name: &str) -> Option<CudaBuffer> {
1149        self.common_subexpression_cache.clear();
1150        if let Some(&rel_id) = self.name_to_rel.get(name) {
1151            self.join_index_cache.invalidate_rel(rel_id);
1152        }
1153        self.store.remove(name)
1154    }
1155
1156    /// Register a relation name for a RelId
1157    ///
1158    /// This mapping is used when executing Scan nodes to look up relations
1159    /// by their RelId.
1160    ///
1161    /// # Arguments
1162    /// * `rel_id` - The relation identifier
1163    /// * `name` - The name to associate with the relation
1164    pub fn register_relation(&mut self, rel_id: RelId, name: &str) {
1165        self.rel_names.insert(rel_id, name.to_string());
1166        self.name_to_rel.insert(name.to_string(), rel_id);
1167        self.stats.register_relation(rel_id);
1168    }
1169
1170    /// Reverse-lookup a RelId by predicate name. Used by
1171    /// `execute_recursive_scc` to resolve a recursive predicate's
1172    /// full-rel RelId for `StatsManager::update_cardinality` calls at
1173    /// iteration boundaries. Returns `None` for unregistered names
1174    /// (defensive: production callers register IDB heads before
1175    /// `execute_plan`; tests that omit registration get a no-op stats
1176    /// update).
1177    fn name_to_rel_id(&self, name: &str) -> Option<RelId> {
1178        self.name_to_rel.get(name).copied()
1179    }
1180
1181    /// Get the relation name for a RelId
1182    fn get_rel_name(&self, rel_id: RelId) -> Option<&str> {
1183        self.rel_names.get(&rel_id).map(|s| s.as_str())
1184    }
1185
1186    /// Execute a baseline plan and conditionally adopt a compiler-supplied
1187    /// re-optimized candidate plan.
1188    ///
1189    /// The baseline runs through the normal [`Self::execute_plan`] path first,
1190    /// producing runtime join telemetry. If deterministic mis-plan thresholds
1191    /// fire and adaptive re-optimization is enabled, the candidate also runs
1192    /// through [`Self::execute_plan`]. Candidate outputs are compared on the GPU
1193    /// with deterministic full-row set difference; divergent or failing
1194    /// candidates roll back to the baseline relation/statistics snapshot.
1195    pub fn execute_plan_with_adaptive_candidate(
1196        &mut self,
1197        baseline_plan: &ExecutionPlan,
1198        candidate_plan: &ExecutionPlan,
1199    ) -> Result<CudaBuffer> {
1200        self.adaptive_reoptimization_stats.invocations = self
1201            .adaptive_reoptimization_stats
1202            .invocations
1203            .saturating_add(1);
1204        self.adaptive_reoptimization_stats.diagnostics.clear();
1205        let before_dtoh_calls = self.provider.host_transfer_stats().dtoh_calls;
1206
1207        self.execute_plan(baseline_plan)?;
1208        let baseline_observations = self.adaptive_join_observations.clone();
1209        self.adaptive_reoptimization_stats.last_observations = baseline_observations.clone();
1210        let decision = self.adaptive_reoptimization_decision(&baseline_observations);
1211        self.adaptive_reoptimization_stats.last_decision = Some(decision.clone());
1212
1213        match decision.action {
1214            AdaptiveReoptimizationAction::Disabled => {
1215                self.adaptive_reoptimization_stats.disabled = self
1216                    .adaptive_reoptimization_stats
1217                    .disabled
1218                    .saturating_add(1);
1219                self.record_adaptive_dtoh_delta(before_dtoh_calls);
1220                return self.clone_final_plan_output(baseline_plan);
1221            }
1222            AdaptiveReoptimizationAction::Skipped => {
1223                self.adaptive_reoptimization_stats.skipped =
1224                    self.adaptive_reoptimization_stats.skipped.saturating_add(1);
1225                self.record_adaptive_dtoh_delta(before_dtoh_calls);
1226                return self.clone_final_plan_output(baseline_plan);
1227            }
1228            AdaptiveReoptimizationAction::AttemptCandidate => {}
1229            AdaptiveReoptimizationAction::Adopted | AdaptiveReoptimizationAction::RolledBack => {
1230                unreachable!("decision replay never returns terminal adaptive actions")
1231            }
1232        }
1233
1234        let head_names = Self::plan_head_names(baseline_plan);
1235        let baseline_snapshot = self.clone_store_snapshot()?;
1236        let baseline_stats_snapshot = self.stats_snapshot();
1237
1238        if let Err(err) = self.execute_plan(candidate_plan) {
1239            self.restore_store_snapshot(baseline_snapshot);
1240            self.restore_stats_snapshot(&baseline_stats_snapshot);
1241            self.adaptive_reoptimization_stats.rolled_back = self
1242                .adaptive_reoptimization_stats
1243                .rolled_back
1244                .saturating_add(1);
1245            self.adaptive_reoptimization_stats
1246                .diagnostics
1247                .push(AdaptiveReoptimizationDiagnostic {
1248                    kind: AdaptiveReoptimizationDiagnosticKind::CandidateExecutionFailed,
1249                    message: err.to_string(),
1250                });
1251            self.adaptive_reoptimization_stats
1252                .diagnostics
1253                .push(AdaptiveReoptimizationDiagnostic {
1254                    kind: AdaptiveReoptimizationDiagnosticKind::RollbackRestoredBaseline,
1255                    message: "baseline_snapshot_restored".to_string(),
1256                });
1257            self.adaptive_reoptimization_stats.last_observations = baseline_observations;
1258            self.adaptive_reoptimization_stats.last_decision =
1259                Some(AdaptiveReoptimizationDecision {
1260                    action: AdaptiveReoptimizationAction::RolledBack,
1261                    reason: "candidate_execution_failed".to_string(),
1262                    max_misplan_ratio: decision.max_misplan_ratio,
1263                    min_misplan_ratio: decision.min_misplan_ratio,
1264                });
1265            self.record_adaptive_dtoh_delta(before_dtoh_calls);
1266            return self.clone_final_plan_output(baseline_plan);
1267        }
1268
1269        if !self.plan_outputs_match(&head_names, &baseline_snapshot)? {
1270            self.restore_store_snapshot(baseline_snapshot);
1271            self.restore_stats_snapshot(&baseline_stats_snapshot);
1272            self.adaptive_reoptimization_stats.rolled_back = self
1273                .adaptive_reoptimization_stats
1274                .rolled_back
1275                .saturating_add(1);
1276            self.adaptive_reoptimization_stats
1277                .diagnostics
1278                .push(AdaptiveReoptimizationDiagnostic {
1279                    kind: AdaptiveReoptimizationDiagnosticKind::CandidateOutputMismatch,
1280                    message: "candidate_output_mismatch".to_string(),
1281                });
1282            self.adaptive_reoptimization_stats
1283                .diagnostics
1284                .push(AdaptiveReoptimizationDiagnostic {
1285                    kind: AdaptiveReoptimizationDiagnosticKind::RollbackRestoredBaseline,
1286                    message: "baseline_snapshot_restored".to_string(),
1287                });
1288            self.adaptive_reoptimization_stats.last_observations = baseline_observations;
1289            self.adaptive_reoptimization_stats.last_decision =
1290                Some(AdaptiveReoptimizationDecision {
1291                    action: AdaptiveReoptimizationAction::RolledBack,
1292                    reason: "candidate_output_mismatch".to_string(),
1293                    max_misplan_ratio: decision.max_misplan_ratio,
1294                    min_misplan_ratio: decision.min_misplan_ratio,
1295                });
1296            self.record_adaptive_dtoh_delta(before_dtoh_calls);
1297            return self.clone_final_plan_output(baseline_plan);
1298        }
1299
1300        self.adaptive_reoptimization_stats.adopted =
1301            self.adaptive_reoptimization_stats.adopted.saturating_add(1);
1302        self.adaptive_reoptimization_stats.last_observations = baseline_observations;
1303        self.adaptive_reoptimization_stats.last_decision = Some(AdaptiveReoptimizationDecision {
1304            action: AdaptiveReoptimizationAction::Adopted,
1305            reason: "candidate_adopted".to_string(),
1306            max_misplan_ratio: decision.max_misplan_ratio,
1307            min_misplan_ratio: decision.min_misplan_ratio,
1308        });
1309        self.record_adaptive_dtoh_delta(before_dtoh_calls);
1310        self.clone_final_plan_output(candidate_plan)
1311    }
1312
1313    /// Execute a complete execution plan
1314    ///
1315    /// Iterates through strata in order, executing each one.
1316    /// Returns the result of the final query if present, or an empty buffer.
1317    ///
1318    /// # Arguments
1319    /// * `plan` - The execution plan to execute
1320    ///
1321    /// # Returns
1322    /// The result buffer from executing the plan
1323    ///
1324    /// # Errors
1325    /// Returns an error if any stratum or query execution fails
1326    pub fn execute_plan(&mut self, plan: &ExecutionPlan) -> Result<CudaBuffer> {
1327        self.adaptive_join_observations.clear();
1328        self.common_subexpression_cache.clear();
1329        // Opt-in deterministic-Datalog D2H gate. Enabled only for the
1330        // duration of this call; the provider is shared so we restore the
1331        // prior state on every exit path (including errors). This PR ships
1332        // the gate as opt-in only — known violating relational paths
1333        // (set difference, binary-join count/materialize) are scheduled for
1334        // replacement before the default flips.
1335        let gate = self.config.strict_deterministic_d2h;
1336        let prev_gate = self.provider.strict_deterministic_d2h_enabled();
1337        if gate && !prev_gate {
1338            // Only reset the violation counter when *this* call is what
1339            // engages the gate. If a caller has manually enabled the
1340            // gate to accumulate violations across a broader strict
1341            // section, we must not clobber their telemetry.
1342            self.provider.reset_deterministic_d2h_violations();
1343            self.provider.enable_strict_deterministic_d2h();
1344        }
1345        // Cloning the Arc keeps the guard independent of `self`, so the
1346        // guard can coexist with `&mut self` calls inside the strata loop.
1347        let _gate_guard = D2hGateGuard {
1348            provider: Arc::clone(&self.provider),
1349            engaged: gate,
1350            previous: prev_gate,
1351        };
1352
1353        // Execute strata in order
1354        for (idx, stratum) in plan.strata.iter().enumerate() {
1355            // Count rules and check if recursive
1356            let (num_rules, is_recursive) = stratum
1357                .sccs
1358                .iter()
1359                .map(|&scc_id| {
1360                    let rules = plan
1361                        .rules_by_scc
1362                        .get(scc_id as usize)
1363                        .map(|r| r.len())
1364                        .unwrap_or(0);
1365                    let recursive = plan
1366                        .sccs
1367                        .get(scc_id as usize)
1368                        .map(|s| s.is_recursive)
1369                        .unwrap_or(false);
1370                    (rules, recursive)
1371                })
1372                .fold((0, false), |(r, rec), (nr, nrec)| (r + nr, rec || nrec));
1373
1374            self.profiler.begin_stratum(idx, num_rules, is_recursive);
1375            self.execute_stratum_impl(stratum, plan)?;
1376
1377            // Record peak memory after stratum
1378            let mem_bytes = self.provider.memory().allocated_bytes();
1379            self.profiler.record_peak_memory(mem_bytes);
1380
1381            self.profiler.end_stratum();
1382        }
1383
1384        // Ensure all GPU work completes before returning control to callers.
1385        self.provider.device().synchronize()?;
1386        self.adaptive_reoptimization_stats.last_observations =
1387            self.adaptive_join_observations.clone();
1388
1389        // If there are no strata, return empty buffer
1390        self.provider.create_empty_buffer(Schema::new(vec![]))
1391    }
1392
1393    /// Execute a stratum (public API)
1394    ///
1395    /// This method cannot be called directly because stratum execution requires
1396    /// access to the full ExecutionPlan (for rules_by_scc mapping). Use
1397    /// `execute_plan` instead, which processes all strata with proper context.
1398    ///
1399    /// # Arguments
1400    /// * `_stratum` - The stratum (unused - see error)
1401    ///
1402    /// # Returns
1403    /// Evaluate a predicate expression for a single row
1404    #[cfg(test)]
1405    fn evaluate_predicate(
1406        expr: &Expr,
1407        columns: &[Vec<u8>],
1408        row_idx: usize,
1409        schema: &Schema,
1410    ) -> Result<bool> {
1411        match expr {
1412            Expr::Column(col_idx) => {
1413                // Interpret column value as boolean
1414                let col_type = schema.column_type(*col_idx);
1415                if let Some(ScalarType::Bool) = col_type {
1416                    Ok(columns
1417                        .get(*col_idx)
1418                        .map(|c| c.get(row_idx).copied().unwrap_or(0) != 0)
1419                        .unwrap_or(false))
1420                } else {
1421                    // Non-bool columns: check if non-zero
1422                    Ok(true)
1423                }
1424            }
1425
1426            Expr::Const(ConstValue::Bool(b)) => Ok(*b),
1427            Expr::Const(_) => Ok(true), // Non-bool constants are truthy
1428
1429            Expr::Compare { left, op, right } => {
1430                let use_float =
1431                    Self::expr_may_be_float(left, schema) || Self::expr_may_be_float(right, schema);
1432
1433                if use_float {
1434                    let left_val = Self::evaluate_expr_as_f64(left, columns, row_idx, schema)?;
1435                    let right_val = Self::evaluate_expr_as_f64(right, columns, row_idx, schema)?;
1436
1437                    Ok(match op {
1438                        CompareOp::Eq => left_val == right_val,
1439                        CompareOp::Ne => left_val != right_val,
1440                        CompareOp::Lt => left_val < right_val,
1441                        CompareOp::Le => left_val <= right_val,
1442                        CompareOp::Gt => left_val > right_val,
1443                        CompareOp::Ge => left_val >= right_val,
1444                    })
1445                } else {
1446                    let left_val = Self::evaluate_expr_as_i64(left, columns, row_idx, schema)?;
1447                    let right_val = Self::evaluate_expr_as_i64(right, columns, row_idx, schema)?;
1448
1449                    Ok(match op {
1450                        CompareOp::Eq => left_val == right_val,
1451                        CompareOp::Ne => left_val != right_val,
1452                        CompareOp::Lt => left_val < right_val,
1453                        CompareOp::Le => left_val <= right_val,
1454                        CompareOp::Gt => left_val > right_val,
1455                        CompareOp::Ge => left_val >= right_val,
1456                    })
1457                }
1458            }
1459
1460            Expr::And(exprs) => {
1461                for e in exprs {
1462                    if !Self::evaluate_predicate(e, columns, row_idx, schema)? {
1463                        return Ok(false);
1464                    }
1465                }
1466                Ok(true)
1467            }
1468
1469            Expr::Or(exprs) => {
1470                for e in exprs {
1471                    if Self::evaluate_predicate(e, columns, row_idx, schema)? {
1472                        return Ok(true);
1473                    }
1474                }
1475                Ok(false)
1476            }
1477
1478            Expr::Not(inner) => Ok(!Self::evaluate_predicate(inner, columns, row_idx, schema)?),
1479
1480            // Arithmetic expressions are not used as predicates directly
1481            Expr::Add(_, _)
1482            | Expr::Sub(_, _)
1483            | Expr::Mul(_, _)
1484            | Expr::Div(_, _)
1485            | Expr::Mod(_, _)
1486            | Expr::Abs(_)
1487            | Expr::Min(_, _)
1488            | Expr::Max(_, _)
1489            | Expr::Pow(_, _)
1490            | Expr::Cast(_, _)
1491            | Expr::Conditional { .. } => Err(XlogError::Execution(
1492                "Arithmetic expression cannot be evaluated as boolean predicate".into(),
1493            )),
1494        }
1495    }
1496
1497    #[cfg(test)]
1498    fn evaluate_expr_as_f64(
1499        expr: &Expr,
1500        columns: &[Vec<u8>],
1501        row_idx: usize,
1502        schema: &Schema,
1503    ) -> Result<f64> {
1504        match expr {
1505            Expr::Column(col_idx) => {
1506                let col_type = schema.column_type(*col_idx).unwrap_or(ScalarType::U32);
1507                let col_data = columns
1508                    .get(*col_idx)
1509                    .ok_or_else(|| XlogError::Execution(format!("Column {} not found", col_idx)))?;
1510
1511                let type_size = col_type.size_bytes();
1512                let start = row_idx * type_size;
1513
1514                Ok(match col_type {
1515                    ScalarType::F64 => {
1516                        let bytes = &col_data[start..start + 8];
1517                        f64::from_le_bytes([
1518                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1519                            bytes[7],
1520                        ])
1521                    }
1522                    ScalarType::F32 => {
1523                        let bytes = &col_data[start..start + 4];
1524                        f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64
1525                    }
1526                    ScalarType::U32 | ScalarType::Symbol => {
1527                        let bytes = &col_data[start..start + 4];
1528                        u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64
1529                    }
1530                    ScalarType::I32 => {
1531                        let bytes = &col_data[start..start + 4];
1532                        i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as f64
1533                    }
1534                    ScalarType::U64 => {
1535                        let bytes = &col_data[start..start + 8];
1536                        u64::from_le_bytes([
1537                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1538                            bytes[7],
1539                        ]) as f64
1540                    }
1541                    ScalarType::I64 => {
1542                        let bytes = &col_data[start..start + 8];
1543                        i64::from_le_bytes([
1544                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1545                            bytes[7],
1546                        ]) as f64
1547                    }
1548                    ScalarType::Bool => col_data.get(start).copied().unwrap_or(0) as f64,
1549                })
1550            }
1551
1552            Expr::Const(val) => Ok(match val {
1553                ConstValue::U32(v) => *v as f64,
1554                ConstValue::I32(v) => *v as f64,
1555                ConstValue::U64(v) => *v as f64,
1556                ConstValue::I64(v) => *v as f64,
1557                ConstValue::Bool(b) => {
1558                    if *b {
1559                        1.0
1560                    } else {
1561                        0.0
1562                    }
1563                }
1564                ConstValue::F32(f) => *f as f64,
1565                ConstValue::F64(f) => *f,
1566                ConstValue::Symbol(_) => {
1567                    return Err(XlogError::Execution(
1568                        "Cannot evaluate Symbol constant as f64".to_string(),
1569                    ));
1570                }
1571            }),
1572
1573            Expr::Add(l, r) => Ok(Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?
1574                + Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?),
1575            Expr::Sub(l, r) => Ok(Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?
1576                - Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?),
1577            Expr::Mul(l, r) => Ok(Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?
1578                * Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?),
1579            Expr::Div(l, r) => {
1580                let left_val = Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?;
1581                let right_val = Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?;
1582                if right_val == 0.0 {
1583                    return Err(XlogError::Execution("Division by zero".to_string()));
1584                }
1585                Ok(left_val / right_val)
1586            }
1587            Expr::Mod(l, r) => {
1588                let left_val = Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?;
1589                let right_val = Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?;
1590                if right_val == 0.0 {
1591                    return Err(XlogError::Execution("Modulo by zero".to_string()));
1592                }
1593                Ok(left_val % right_val)
1594            }
1595            Expr::Abs(inner) => {
1596                Ok(Self::evaluate_expr_as_f64(inner, columns, row_idx, schema)?.abs())
1597            }
1598            Expr::Min(l, r) => Ok(Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?
1599                .min(Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?)),
1600            Expr::Max(l, r) => Ok(Self::evaluate_expr_as_f64(l, columns, row_idx, schema)?
1601                .max(Self::evaluate_expr_as_f64(r, columns, row_idx, schema)?)),
1602            Expr::Pow(base, exp) => Ok(Self::evaluate_expr_as_f64(base, columns, row_idx, schema)?
1603                .powf(Self::evaluate_expr_as_f64(exp, columns, row_idx, schema)?)),
1604            Expr::Cast(inner, target_type) => match target_type {
1605                ScalarType::F64 => Self::evaluate_expr_as_f64(inner, columns, row_idx, schema),
1606                ScalarType::F32 => {
1607                    Ok(Self::evaluate_expr_as_f64(inner, columns, row_idx, schema)? as f32 as f64)
1608                }
1609                _ => Ok(Self::evaluate_expr_as_i64(inner, columns, row_idx, schema)? as f64),
1610            },
1611
1612            _ => Err(XlogError::Execution(
1613                "Cannot evaluate compound expression as f64".to_string(),
1614            )),
1615        }
1616    }
1617
1618    /// Evaluate an expression as an i64 value
1619    #[cfg(test)]
1620    fn evaluate_expr_as_i64(
1621        expr: &Expr,
1622        columns: &[Vec<u8>],
1623        row_idx: usize,
1624        schema: &Schema,
1625    ) -> Result<i64> {
1626        match expr {
1627            Expr::Column(col_idx) => {
1628                let col_type = schema.column_type(*col_idx).unwrap_or(ScalarType::U32);
1629                let col_data = columns
1630                    .get(*col_idx)
1631                    .ok_or_else(|| XlogError::Execution(format!("Column {} not found", col_idx)))?;
1632
1633                let type_size = col_type.size_bytes();
1634                let start = row_idx * type_size;
1635
1636                Ok(match col_type {
1637                    ScalarType::U32 => {
1638                        let bytes = &col_data[start..start + 4];
1639                        u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as i64
1640                    }
1641                    ScalarType::I32 => {
1642                        let bytes = &col_data[start..start + 4];
1643                        i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as i64
1644                    }
1645                    ScalarType::U64 => {
1646                        let bytes = &col_data[start..start + 8];
1647                        u64::from_le_bytes([
1648                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1649                            bytes[7],
1650                        ]) as i64
1651                    }
1652                    ScalarType::I64 => {
1653                        let bytes = &col_data[start..start + 8];
1654                        i64::from_le_bytes([
1655                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1656                            bytes[7],
1657                        ])
1658                    }
1659                    ScalarType::Bool => col_data.get(start).copied().unwrap_or(0) as i64,
1660                    ScalarType::Symbol => {
1661                        let bytes = &col_data[start..start + 4];
1662                        u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as i64
1663                    }
1664                    ScalarType::F32 => {
1665                        let bytes = &col_data[start..start + 4];
1666                        let val = f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
1667                        val as i64
1668                    }
1669                    ScalarType::F64 => {
1670                        let bytes = &col_data[start..start + 8];
1671                        let val = f64::from_le_bytes([
1672                            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6],
1673                            bytes[7],
1674                        ]);
1675                        val as i64
1676                    }
1677                })
1678            }
1679
1680            Expr::Const(val) => Ok(match val {
1681                ConstValue::U32(v) => *v as i64,
1682                ConstValue::I32(v) => *v as i64,
1683                ConstValue::U64(v) => *v as i64,
1684                ConstValue::I64(v) => *v,
1685                ConstValue::Bool(b) => *b as i64,
1686                ConstValue::F32(f) => *f as i64,
1687                ConstValue::F64(f) => *f as i64,
1688                ConstValue::Symbol(_) => 0,
1689            }),
1690
1691            // Arithmetic expressions - evaluate them and return the result
1692            Expr::Add(l, r) => {
1693                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1694                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1695                Ok(left_val.wrapping_add(right_val))
1696            }
1697            Expr::Sub(l, r) => {
1698                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1699                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1700                Ok(left_val.wrapping_sub(right_val))
1701            }
1702            Expr::Mul(l, r) => {
1703                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1704                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1705                Ok(left_val.wrapping_mul(right_val))
1706            }
1707            Expr::Div(l, r) => {
1708                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1709                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1710                if right_val == 0 {
1711                    return Err(XlogError::Execution("Division by zero".to_string()));
1712                }
1713                Ok(left_val / right_val)
1714            }
1715            Expr::Mod(l, r) => {
1716                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1717                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1718                if right_val == 0 {
1719                    return Err(XlogError::Execution("Modulo by zero".to_string()));
1720                }
1721                Ok(left_val % right_val)
1722            }
1723            Expr::Abs(inner) => {
1724                let val = Self::evaluate_expr_as_i64(inner, columns, row_idx, schema)?;
1725                Ok(val.abs())
1726            }
1727            Expr::Min(l, r) => {
1728                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1729                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1730                Ok(left_val.min(right_val))
1731            }
1732            Expr::Max(l, r) => {
1733                let left_val = Self::evaluate_expr_as_i64(l, columns, row_idx, schema)?;
1734                let right_val = Self::evaluate_expr_as_i64(r, columns, row_idx, schema)?;
1735                Ok(left_val.max(right_val))
1736            }
1737            Expr::Pow(base, exp) => {
1738                let base_val = Self::evaluate_expr_as_i64(base, columns, row_idx, schema)?;
1739                let exp_val = Self::evaluate_expr_as_i64(exp, columns, row_idx, schema)?;
1740                if exp_val < 0 {
1741                    Err(XlogError::Execution(
1742                        "Negative exponent in integer pow".to_string(),
1743                    ))
1744                } else if exp_val > u32::MAX as i64 {
1745                    // Exponent too large - would overflow anyway
1746                    Ok(i64::MAX)
1747                } else {
1748                    Ok(base_val.pow(exp_val as u32))
1749                }
1750            }
1751            Expr::Cast(inner, _target_type) => {
1752                // For i64 evaluation, cast is a no-op since we evaluate everything as i64
1753                Self::evaluate_expr_as_i64(inner, columns, row_idx, schema)
1754            }
1755
1756            _ => Err(XlogError::Execution(
1757                "Cannot evaluate compound expression as value".to_string(),
1758            )),
1759        }
1760    }
1761
1762    /// Get the relation name for a RelId, creating a default name if not registered
1763    fn get_or_create_rel_name(&mut self, rel_id: RelId, default: &str) -> String {
1764        if let Some(name) = self.rel_names.get(&rel_id) {
1765            name.clone()
1766        } else {
1767            self.register_relation(rel_id, default);
1768            default.to_string()
1769        }
1770    }
1771
1772    // ============== Helper methods ==============
1773
1774    /// Create an empty buffer with the given schema
1775    fn create_empty_buffer(&self, schema: Schema) -> Result<CudaBuffer> {
1776        self.provider.create_empty_buffer(schema)
1777    }
1778
1779    /// Clone a buffer (device-to-device copy)
1780    fn clone_buffer(&self, buffer: &CudaBuffer) -> Result<CudaBuffer> {
1781        if buffer.is_empty() {
1782            return self.create_empty_buffer(buffer.schema().clone());
1783        }
1784
1785        let mut result_columns = Vec::with_capacity(buffer.arity());
1786
1787        for col_idx in 0..buffer.arity() {
1788            let col_type_size = buffer
1789                .schema()
1790                .column_type(col_idx)
1791                .map(|t| t.size_bytes())
1792                .unwrap_or(4);
1793            let bytes = (buffer.num_rows() as usize) * col_type_size;
1794
1795            if let Some(src_col) = buffer.column(col_idx) {
1796                let mut dst_col = self.provider.memory().alloc::<u8>(bytes)?;
1797                if bytes > 0 {
1798                    self.provider
1799                        .device()
1800                        .inner()
1801                        .dtod_copy(src_col, &mut dst_col)
1802                        .map_err(|e| {
1803                            XlogError::Execution(format!("Failed to clone column on device: {}", e))
1804                        })?;
1805                }
1806                result_columns.push(dst_col.into());
1807            }
1808        }
1809
1810        let d_num_rows = self.clone_device_row_count(buffer)?;
1811        Ok(CudaBuffer::from_columns(
1812            result_columns,
1813            buffer.num_rows(),
1814            d_num_rows,
1815            buffer.schema().clone(),
1816        ))
1817    }
1818
1819    fn clone_device_row_count(&self, buffer: &CudaBuffer) -> Result<TrackedCudaSlice<u32>> {
1820        let mut d_num_rows = self.provider.memory().alloc::<u32>(1)?;
1821        self.provider
1822            .device()
1823            .inner()
1824            .dtod_copy(buffer.num_rows_device(), &mut d_num_rows)
1825            .map_err(|e| XlogError::Execution(format!("Failed to copy row count: {}", e)))?;
1826        Ok(d_num_rows)
1827    }
1828
1829    fn buffer_row_count(&self, buffer: &CudaBuffer) -> Result<u32> {
1830        if let Some(n) = buffer.cached_row_count() {
1831            return Ok(n);
1832        }
1833        // Metadata-only read: row counts are control-plane state, not
1834        // tuple data. Route through `dtoh_scalar_untracked` so the
1835        // metadata-vs-data-plane contract stays grepable and the
1836        // deterministic-D2H gate continues to allow it. Re-map the
1837        // provider-level `XlogError::Kernel` into `XlogError::Execution`
1838        // with the executor's historical "Failed to read row count"
1839        // context so callers see a consistent error category.
1840        let n = self
1841            .provider
1842            .dtoh_scalar_untracked::<u32>(buffer.num_rows_device(), 0)
1843            .map_err(|e| XlogError::Execution(format!("Failed to read row count: {}", e)))?;
1844        buffer.set_cached_row_count_if_unset(n);
1845        Ok(n)
1846    }
1847}
1848
1849/// RAII guard that restores the provider's deterministic-D2H gate state on
1850/// drop. Engaged only when `Executor::execute_plan` opted in via
1851/// `RuntimeConfig::strict_deterministic_d2h`.
1852struct D2hGateGuard {
1853    provider: Arc<CudaKernelProvider>,
1854    engaged: bool,
1855    previous: bool,
1856}
1857
1858impl Drop for D2hGateGuard {
1859    fn drop(&mut self) {
1860        if !self.engaged {
1861            return;
1862        }
1863        if self.previous {
1864            self.provider.enable_strict_deterministic_d2h();
1865        } else {
1866            self.provider.disable_strict_deterministic_d2h();
1867        }
1868    }
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    use super::*;
1874    use std::time::{Duration, Instant};
1875    use xlog_core::MemoryBudget;
1876    use xlog_cuda::{CudaDevice, GpuMemoryManager};
1877    use xlog_ir::{CompiledRule, RirMeta, Scc};
1878
1879    fn has_cuda_device() -> bool {
1880        // Check if CUDA device is available using CudaDevice wrapper
1881        CudaDevice::new(0).is_ok()
1882    }
1883
1884    fn create_test_executor() -> Option<Executor> {
1885        if !has_cuda_device() {
1886            return None;
1887        }
1888        let device = Arc::new(CudaDevice::new(0).ok()?);
1889        let budget = MemoryBudget::with_limit(1024 * 1024 * 1024); // 1 GB
1890        let memory = Arc::new(GpuMemoryManager::new(device.clone(), budget));
1891        let provider = Arc::new(CudaKernelProvider::new(device, memory).ok()?);
1892        Some(Executor::new(provider))
1893    }
1894
1895    fn create_test_executor_with_config(config: RuntimeConfig) -> Option<Executor> {
1896        if !has_cuda_device() {
1897            return None;
1898        }
1899        let device = Arc::new(CudaDevice::new(0).ok()?);
1900        let budget = MemoryBudget::with_limit(1024 * 1024 * 1024); // 1 GB
1901        let memory = Arc::new(GpuMemoryManager::new(device.clone(), budget));
1902        let provider = Arc::new(CudaKernelProvider::new(device, memory).ok()?);
1903        Some(Executor::new_with_config(provider, config))
1904    }
1905
1906    fn device_row_count(executor: &Executor, rows: u64) -> TrackedCudaSlice<u32> {
1907        let rows_u32 = u32::try_from(rows).expect("row count fits u32");
1908        let mut d_num_rows = executor.provider.memory().alloc::<u32>(1).expect("alloc");
1909        executor
1910            .provider
1911            .device()
1912            .inner()
1913            .htod_sync_copy_into(&[rows_u32], &mut d_num_rows)
1914            .expect("htod");
1915        d_num_rows
1916    }
1917
1918    fn create_test_buffer(executor: &Executor, data: &[u32], col_name: &str) -> CudaBuffer {
1919        let schema = Schema::new(vec![(col_name.to_string(), ScalarType::U32)]);
1920        let bytes: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
1921
1922        let mut col = executor
1923            .provider
1924            .memory()
1925            .alloc::<u8>(bytes.len())
1926            .expect("alloc");
1927        executor
1928            .provider
1929            .device()
1930            .inner()
1931            .htod_sync_copy_into(&bytes, &mut col)
1932            .expect("htod");
1933
1934        let rows = data.len() as u64;
1935        let d_num_rows = device_row_count(executor, rows);
1936        CudaBuffer::from_columns(vec![col.into()], rows, d_num_rows, schema)
1937    }
1938
1939    fn read_buffer_u32(executor: &Executor, buffer: &CudaBuffer, col: usize) -> Vec<u32> {
1940        executor
1941            .provider
1942            .download_column::<u32>(buffer, col)
1943            .unwrap_or_default()
1944    }
1945
1946    fn buffer_row_count(executor: &Executor, buffer: &CudaBuffer) -> u32 {
1947        if let Some(n) = buffer.cached_row_count() {
1948            return n;
1949        }
1950        let mut host_rows = [0u32];
1951        executor
1952            .provider
1953            .device()
1954            .inner()
1955            .dtoh_sync_copy_into(buffer.num_rows_device(), &mut host_rows)
1956            .expect("dtoh row count");
1957        buffer.set_cached_row_count_if_unset(host_rows[0]);
1958        host_rows[0]
1959    }
1960
1961    fn to_f64_column_bytes(values: &[f64]) -> Vec<u8> {
1962        values.iter().flat_map(|v| v.to_le_bytes()).collect()
1963    }
1964
1965    fn to_f32_column_bytes(values: &[f32]) -> Vec<u8> {
1966        values.iter().flat_map(|v| v.to_le_bytes()).collect()
1967    }
1968
1969    // ============== Basic Executor Tests ==============
1970
1971    #[test]
1972    fn test_executor_creation() {
1973        let executor = match create_test_executor() {
1974            Some(e) => e,
1975            None => {
1976                eprintln!("Skipping test: no CUDA device available");
1977                return;
1978            }
1979        };
1980
1981        assert!(executor.store().is_empty());
1982    }
1983
1984    #[test]
1985    fn test_predicate_f64_comparisons() {
1986        let schema = Schema::new(vec![("x".to_string(), ScalarType::F64)]);
1987        let values = [1.0f64, 2.0, 3.0, f64::NAN];
1988        let columns = vec![to_f64_column_bytes(&values)];
1989
1990        let gt_two = Expr::Compare {
1991            left: Box::new(Expr::Column(0)),
1992            op: CompareOp::Gt,
1993            right: Box::new(Expr::Const(ConstValue::F64(2.0))),
1994        };
1995
1996        let results: Vec<bool> = (0..values.len())
1997            .map(|row| Executor::evaluate_predicate(&gt_two, &columns, row, &schema).unwrap())
1998            .collect();
1999        assert_eq!(results, vec![false, false, true, false]);
2000
2001        let eq_nan = Expr::Compare {
2002            left: Box::new(Expr::Column(0)),
2003            op: CompareOp::Eq,
2004            right: Box::new(Expr::Const(ConstValue::F64(f64::NAN))),
2005        };
2006        let results: Vec<bool> = (0..values.len())
2007            .map(|row| Executor::evaluate_predicate(&eq_nan, &columns, row, &schema).unwrap())
2008            .collect();
2009        assert_eq!(results, vec![false, false, false, false]);
2010
2011        let ne_nan = Expr::Compare {
2012            left: Box::new(Expr::Column(0)),
2013            op: CompareOp::Ne,
2014            right: Box::new(Expr::Const(ConstValue::F64(f64::NAN))),
2015        };
2016        let results: Vec<bool> = (0..values.len())
2017            .map(|row| Executor::evaluate_predicate(&ne_nan, &columns, row, &schema).unwrap())
2018            .collect();
2019        assert_eq!(results, vec![true, true, true, true]);
2020    }
2021
2022    #[test]
2023    fn test_predicate_f32_comparisons() {
2024        let schema = Schema::new(vec![("x".to_string(), ScalarType::F32)]);
2025        let values = [1.0f32, 2.0, 3.0, f32::NAN];
2026        let columns = vec![to_f32_column_bytes(&values)];
2027
2028        let le_two = Expr::Compare {
2029            left: Box::new(Expr::Column(0)),
2030            op: CompareOp::Le,
2031            right: Box::new(Expr::Const(ConstValue::F32(2.0))),
2032        };
2033
2034        let results: Vec<bool> = (0..values.len())
2035            .map(|row| Executor::evaluate_predicate(&le_two, &columns, row, &schema).unwrap())
2036            .collect();
2037        assert_eq!(results, vec![true, true, false, false]);
2038    }
2039
2040    #[test]
2041    fn test_predicate_mixed_float_int_comparisons() {
2042        let schema = Schema::new(vec![
2043            ("x".to_string(), ScalarType::F64),
2044            ("y".to_string(), ScalarType::U32),
2045        ]);
2046
2047        let x = [1.5f64, 2.0, 2.5];
2048        let y = [1u32, 2, 3];
2049        let columns = vec![
2050            to_f64_column_bytes(&x),
2051            y.iter().flat_map(|v| v.to_le_bytes()).collect(),
2052        ];
2053
2054        let x_gt_2 = Expr::Compare {
2055            left: Box::new(Expr::Column(0)),
2056            op: CompareOp::Gt,
2057            right: Box::new(Expr::Const(ConstValue::U32(2))),
2058        };
2059        let results: Vec<bool> = (0..x.len())
2060            .map(|row| Executor::evaluate_predicate(&x_gt_2, &columns, row, &schema).unwrap())
2061            .collect();
2062        assert_eq!(results, vec![false, false, true]);
2063
2064        let y_lt_2_5 = Expr::Compare {
2065            left: Box::new(Expr::Column(1)),
2066            op: CompareOp::Lt,
2067            right: Box::new(Expr::Const(ConstValue::F64(2.5))),
2068        };
2069        let results: Vec<bool> = (0..y.len())
2070            .map(|row| Executor::evaluate_predicate(&y_lt_2_5, &columns, row, &schema).unwrap())
2071            .collect();
2072        assert_eq!(results, vec![true, true, false]);
2073    }
2074
2075    #[test]
2076    fn test_register_and_get_relation() {
2077        let mut executor = match create_test_executor() {
2078            Some(e) => e,
2079            None => {
2080                eprintln!("Skipping test: no CUDA device available");
2081                return;
2082            }
2083        };
2084
2085        // Register a relation
2086        executor.register_relation(RelId(1), "test_rel");
2087
2088        // Verify mapping
2089        assert_eq!(executor.get_rel_name(RelId(1)), Some("test_rel"));
2090        assert_eq!(executor.get_rel_name(RelId(2)), None);
2091    }
2092
2093    // ============== Scan Node Tests ==============
2094
2095    #[test]
2096    fn test_execute_scan_not_found() {
2097        let mut executor = match create_test_executor() {
2098            Some(e) => e,
2099            None => {
2100                eprintln!("Skipping test: no CUDA device available");
2101                return;
2102            }
2103        };
2104
2105        executor.register_relation(RelId(1), "missing_rel");
2106
2107        let node = RirNode::Scan { rel: RelId(1) };
2108        let result = executor.execute_node(&node);
2109
2110        assert!(result.is_err());
2111    }
2112
2113    #[test]
2114    fn test_execute_scan_success() {
2115        let mut executor = match create_test_executor() {
2116            Some(e) => e,
2117            None => {
2118                eprintln!("Skipping test: no CUDA device available");
2119                return;
2120            }
2121        };
2122
2123        // Create and store a buffer
2124        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2125        executor.store_mut().put("test_rel", buffer);
2126        executor.register_relation(RelId(1), "test_rel");
2127
2128        // Execute scan
2129        let node = RirNode::Scan { rel: RelId(1) };
2130        let result = executor.execute_node(&node);
2131
2132        assert!(result.is_ok());
2133        let result = result.unwrap();
2134        assert_eq!(buffer_row_count(&executor, &result), 5);
2135
2136        let values = read_buffer_u32(&executor, &result, 0);
2137        assert_eq!(values, vec![1, 2, 3, 4, 5]);
2138    }
2139
2140    // ============== Filter Node Tests ==============
2141
2142    #[test]
2143    fn test_execute_filter_empty_input() {
2144        let executor = match create_test_executor() {
2145            Some(e) => e,
2146            None => {
2147                eprintln!("Skipping test: no CUDA device available");
2148                return;
2149            }
2150        };
2151
2152        let schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2153        let empty = executor.create_empty_buffer(schema).unwrap();
2154
2155        let predicate = Expr::Const(ConstValue::Bool(true));
2156        let result = executor.execute_filter(&empty, &predicate);
2157
2158        assert!(result.is_ok());
2159        let result = result.unwrap();
2160        assert_eq!(buffer_row_count(&executor, &result), 0);
2161    }
2162
2163    #[test]
2164    fn test_execute_filter_all_match() {
2165        let executor = match create_test_executor() {
2166            Some(e) => e,
2167            None => {
2168                eprintln!("Skipping test: no CUDA device available");
2169                return;
2170            }
2171        };
2172
2173        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2174        let predicate = Expr::Const(ConstValue::Bool(true));
2175
2176        let result = executor.execute_filter(&buffer, &predicate);
2177        assert!(result.is_ok());
2178
2179        let result = result.unwrap();
2180        assert_eq!(buffer_row_count(&executor, &result), 5);
2181    }
2182
2183    #[test]
2184    fn test_execute_filter_none_match() {
2185        let executor = match create_test_executor() {
2186            Some(e) => e,
2187            None => {
2188                eprintln!("Skipping test: no CUDA device available");
2189                return;
2190            }
2191        };
2192
2193        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2194        let predicate = Expr::Const(ConstValue::Bool(false));
2195
2196        let result = executor.execute_filter(&buffer, &predicate);
2197        assert!(result.is_ok());
2198        let result = result.unwrap();
2199        assert_eq!(buffer_row_count(&executor, &result), 0);
2200    }
2201
2202    #[test]
2203    fn test_execute_filter_comparison() {
2204        let executor = match create_test_executor() {
2205            Some(e) => e,
2206            None => {
2207                eprintln!("Skipping test: no CUDA device available");
2208                return;
2209            }
2210        };
2211
2212        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2213
2214        // Filter: key > 3
2215        let predicate = Expr::Compare {
2216            left: Box::new(Expr::Column(0)),
2217            op: CompareOp::Gt,
2218            right: Box::new(Expr::Const(ConstValue::U32(3))),
2219        };
2220
2221        let result = executor.execute_filter(&buffer, &predicate);
2222        assert!(result.is_ok());
2223
2224        let result = result.unwrap();
2225        assert_eq!(buffer_row_count(&executor, &result), 2);
2226
2227        let values = read_buffer_u32(&executor, &result, 0);
2228        assert_eq!(values, vec![4, 5]);
2229    }
2230
2231    #[test]
2232    fn test_execute_filter_and() {
2233        let executor = match create_test_executor() {
2234            Some(e) => e,
2235            None => {
2236                eprintln!("Skipping test: no CUDA device available");
2237                return;
2238            }
2239        };
2240
2241        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2242
2243        // Filter: key >= 2 AND key <= 4
2244        let predicate = Expr::And(vec![
2245            Expr::Compare {
2246                left: Box::new(Expr::Column(0)),
2247                op: CompareOp::Ge,
2248                right: Box::new(Expr::Const(ConstValue::U32(2))),
2249            },
2250            Expr::Compare {
2251                left: Box::new(Expr::Column(0)),
2252                op: CompareOp::Le,
2253                right: Box::new(Expr::Const(ConstValue::U32(4))),
2254            },
2255        ]);
2256
2257        let result = executor.execute_filter(&buffer, &predicate);
2258        assert!(result.is_ok());
2259
2260        let result = result.unwrap();
2261        assert_eq!(buffer_row_count(&executor, &result), 3);
2262
2263        let values = read_buffer_u32(&executor, &result, 0);
2264        assert_eq!(values, vec![2, 3, 4]);
2265    }
2266
2267    // ============== Project Node Tests ==============
2268
2269    #[test]
2270    fn test_execute_project_empty_input() {
2271        let executor = match create_test_executor() {
2272            Some(e) => e,
2273            None => {
2274                eprintln!("Skipping test: no CUDA device available");
2275                return;
2276            }
2277        };
2278
2279        let schema = Schema::new(vec![
2280            ("a".to_string(), ScalarType::U32),
2281            ("b".to_string(), ScalarType::U32),
2282        ]);
2283        let empty = executor.create_empty_buffer(schema).unwrap();
2284
2285        let result = executor.execute_project(&empty, &[ProjectExpr::Column(0)]);
2286        assert!(result.is_ok());
2287
2288        let result = result.unwrap();
2289        assert_eq!(buffer_row_count(&executor, &result), 0);
2290        assert_eq!(result.arity(), 1);
2291    }
2292
2293    #[test]
2294    fn test_execute_project_reorder() {
2295        let executor = match create_test_executor() {
2296            Some(e) => e,
2297            None => {
2298                eprintln!("Skipping test: no CUDA device available");
2299                return;
2300            }
2301        };
2302
2303        // Create a 2-column buffer
2304        let schema = Schema::new(vec![
2305            ("a".to_string(), ScalarType::U32),
2306            ("b".to_string(), ScalarType::U32),
2307        ]);
2308
2309        let a_data: Vec<u8> = [1u32, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect();
2310        let b_data: Vec<u8> = [10u32, 20, 30]
2311            .iter()
2312            .flat_map(|v| v.to_le_bytes())
2313            .collect();
2314
2315        let mut col_a = executor
2316            .provider
2317            .memory()
2318            .alloc::<u8>(a_data.len())
2319            .unwrap();
2320        let mut col_b = executor
2321            .provider
2322            .memory()
2323            .alloc::<u8>(b_data.len())
2324            .unwrap();
2325
2326        executor
2327            .provider
2328            .device()
2329            .inner()
2330            .htod_sync_copy_into(&a_data, &mut col_a)
2331            .unwrap();
2332        executor
2333            .provider
2334            .device()
2335            .inner()
2336            .htod_sync_copy_into(&b_data, &mut col_b)
2337            .unwrap();
2338
2339        let d_num_rows = device_row_count(&executor, 3);
2340        let buffer =
2341            CudaBuffer::from_columns(vec![col_a.into(), col_b.into()], 3, d_num_rows, schema);
2342
2343        // Project: [b, a] (reverse order)
2344        let result =
2345            executor.execute_project(&buffer, &[ProjectExpr::Column(1), ProjectExpr::Column(0)]);
2346        assert!(result.is_ok());
2347
2348        let result = result.unwrap();
2349        assert_eq!(buffer_row_count(&executor, &result), 3);
2350        assert_eq!(result.arity(), 2);
2351
2352        // First column should be b's values
2353        let col0 = read_buffer_u32(&executor, &result, 0);
2354        assert_eq!(col0, vec![10, 20, 30]);
2355
2356        // Second column should be a's values
2357        let col1 = read_buffer_u32(&executor, &result, 1);
2358        assert_eq!(col1, vec![1, 2, 3]);
2359    }
2360
2361    #[test]
2362    fn test_execute_computed_projection_wiring() {
2363        // Test that ProjectExpr::Computed is handled correctly
2364        // Even if arithmetic stubs return errors, verify the flow is correct
2365        let executor = match create_test_executor() {
2366            Some(e) => e,
2367            None => {
2368                eprintln!("Skipping test: no CUDA device available");
2369                return;
2370            }
2371        };
2372
2373        // Create a 2-column buffer
2374        let schema = Schema::new(vec![
2375            ("a".to_string(), ScalarType::U32),
2376            ("b".to_string(), ScalarType::U32),
2377        ]);
2378
2379        let a_data: Vec<u8> = [10u32, 20, 30]
2380            .iter()
2381            .flat_map(|v| v.to_le_bytes())
2382            .collect();
2383        let b_data: Vec<u8> = [1u32, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect();
2384
2385        let mut col_a = executor
2386            .provider
2387            .memory()
2388            .alloc::<u8>(a_data.len())
2389            .unwrap();
2390        let mut col_b = executor
2391            .provider
2392            .memory()
2393            .alloc::<u8>(b_data.len())
2394            .unwrap();
2395
2396        executor
2397            .provider
2398            .device()
2399            .inner()
2400            .htod_sync_copy_into(&a_data, &mut col_a)
2401            .unwrap();
2402        executor
2403            .provider
2404            .device()
2405            .inner()
2406            .htod_sync_copy_into(&b_data, &mut col_b)
2407            .unwrap();
2408
2409        let d_num_rows = device_row_count(&executor, 3);
2410        let buffer =
2411            CudaBuffer::from_columns(vec![col_a.into(), col_b.into()], 3, d_num_rows, schema);
2412
2413        // Project with computed expression: a + b
2414        let add_expr = Expr::Add(Box::new(Expr::Column(0)), Box::new(Expr::Column(1)));
2415        let projections = vec![
2416            ProjectExpr::Column(0),                           // Pass through column a
2417            ProjectExpr::Computed(add_expr, ScalarType::U32), // Compute a + b
2418        ];
2419
2420        let result = executor.execute_project(&buffer, &projections);
2421
2422        // The wiring should be correct - result depends on whether CUDA arithmetic kernels are available
2423        // If available: result has 2 columns with computed values
2424        // If not available: may return error from provider stubs
2425        match result {
2426            Ok(res) => {
2427                // Wiring worked and arithmetic kernels are available
2428                assert_eq!(buffer_row_count(&executor, &res), 3);
2429                assert_eq!(res.arity(), 2);
2430
2431                // First column should be a's values (pass-through)
2432                let col0 = read_buffer_u32(&executor, &res, 0);
2433                assert_eq!(col0, vec![10, 20, 30]);
2434
2435                // Second column should be a + b = [11, 22, 33]
2436                let col1 = read_buffer_u32(&executor, &res, 1);
2437                assert_eq!(col1, vec![11, 22, 33]);
2438            }
2439            Err(e) => {
2440                // Arithmetic kernels not available - that's OK for this test
2441                // The important thing is that the wiring reached the provider
2442                let err_msg = format!("{}", e);
2443                assert!(
2444                    err_msg.contains("not implemented")
2445                        || err_msg.contains("not yet implemented")
2446                        || err_msg.contains("not supported")
2447                        || err_msg.contains("stub")
2448                        || err_msg.contains("Unsupported")
2449                        || err_msg.contains("arithmetic kernels"),
2450                    "Unexpected error: {}. Expected arithmetic kernel stub error.",
2451                    err_msg
2452                );
2453            }
2454        }
2455    }
2456
2457    // ============== Union Node Tests ==============
2458
2459    #[test]
2460    fn test_execute_union_empty_inputs() {
2461        let executor = match create_test_executor() {
2462            Some(e) => e,
2463            None => {
2464                eprintln!("Skipping test: no CUDA device available");
2465                return;
2466            }
2467        };
2468
2469        let result = executor.execute_union(&[]);
2470        assert!(result.is_ok());
2471        let result = result.unwrap();
2472        assert_eq!(buffer_row_count(&executor, &result), 0);
2473    }
2474
2475    #[test]
2476    fn test_execute_union_single_input() {
2477        let executor = match create_test_executor() {
2478            Some(e) => e,
2479            None => {
2480                eprintln!("Skipping test: no CUDA device available");
2481                return;
2482            }
2483        };
2484
2485        let buffer = create_test_buffer(&executor, &[1, 2, 3], "key");
2486
2487        let result = executor.execute_union(&[buffer]);
2488        assert!(result.is_ok());
2489
2490        let result = result.unwrap();
2491        assert_eq!(buffer_row_count(&executor, &result), 3);
2492
2493        let values = read_buffer_u32(&executor, &result, 0);
2494        assert_eq!(values, vec![1, 2, 3]);
2495    }
2496
2497    #[test]
2498    fn test_execute_union_multiple_inputs() {
2499        let executor = match create_test_executor() {
2500            Some(e) => e,
2501            None => {
2502                eprintln!("Skipping test: no CUDA device available");
2503                return;
2504            }
2505        };
2506
2507        let buffer1 = create_test_buffer(&executor, &[1, 2], "key");
2508        let buffer2 = create_test_buffer(&executor, &[3, 4], "key");
2509        let buffer3 = create_test_buffer(&executor, &[5], "key");
2510
2511        let result = executor.execute_union(&[buffer1, buffer2, buffer3]);
2512        assert!(result.is_ok());
2513
2514        let result = result.unwrap();
2515        assert_eq!(buffer_row_count(&executor, &result), 5);
2516    }
2517
2518    // ============== Distinct Node Tests ==============
2519
2520    #[test]
2521    fn test_execute_distinct_empty() {
2522        let executor = match create_test_executor() {
2523            Some(e) => e,
2524            None => {
2525                eprintln!("Skipping test: no CUDA device available");
2526                return;
2527            }
2528        };
2529
2530        let schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2531        let empty = executor.create_empty_buffer(schema).unwrap();
2532
2533        let result = executor.execute_distinct(&empty, &[0]);
2534        assert!(result.is_ok());
2535        let result = result.unwrap();
2536        assert_eq!(buffer_row_count(&executor, &result), 0);
2537    }
2538
2539    // ============== Diff Node Tests ==============
2540
2541    #[test]
2542    fn test_execute_diff() {
2543        let executor = match create_test_executor() {
2544            Some(e) => e,
2545            None => {
2546                eprintln!("Skipping test: no CUDA device available");
2547                return;
2548            }
2549        };
2550
2551        let left = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2552        let right = create_test_buffer(&executor, &[2, 4], "key");
2553
2554        let result = executor.execute_diff(&left, &right);
2555        assert!(result.is_ok());
2556
2557        let result = result.unwrap();
2558        assert_eq!(buffer_row_count(&executor, &result), 3);
2559
2560        let values = read_buffer_u32(&executor, &result, 0);
2561        assert_eq!(values, vec![1, 3, 5]);
2562    }
2563
2564    // ============== Fixpoint Tests ==============
2565
2566    #[test]
2567    fn test_execute_fixpoint_base_only() {
2568        // Test fixpoint with a base case that reaches fixpoint immediately
2569        // (recursive step produces nothing new)
2570        let mut executor = match create_test_executor() {
2571            Some(e) => e,
2572            None => {
2573                eprintln!("Skipping test: no CUDA device available");
2574                return;
2575            }
2576        };
2577
2578        // Create base relation
2579        let buffer = create_test_buffer(&executor, &[1, 2, 3], "key");
2580        executor.store_mut().put("base_rel", buffer);
2581        executor.register_relation(RelId(1), "base_rel");
2582
2583        // Create an empty recursive relation (simulating a recursive step that produces nothing)
2584        let empty_schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2585        let empty_buffer = executor.create_empty_buffer(empty_schema).unwrap();
2586        executor.store_mut().put("empty_rel", empty_buffer);
2587        executor.register_relation(RelId(4), "empty_rel");
2588
2589        // Base: scan base_rel
2590        // Recursive: scan empty_rel (produces nothing new)
2591        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2592        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2593
2594        let node = RirNode::Fixpoint {
2595            scc_id: 0,
2596            base,
2597            recursive,
2598            delta_rel: RelId(2),
2599            full_rel: RelId(3),
2600        };
2601
2602        let result = executor.execute_node(&node);
2603        assert!(result.is_ok());
2604
2605        // Should return base case since recursive produces nothing
2606        let result = result.unwrap();
2607        assert_eq!(buffer_row_count(&executor, &result), 3);
2608        let values = read_buffer_u32(&executor, &result, 0);
2609        assert_eq!(values, vec![1, 2, 3]);
2610    }
2611
2612    #[test]
2613    fn test_execute_fixpoint_empty_base() {
2614        // Test fixpoint with empty base case
2615        let mut executor = match create_test_executor() {
2616            Some(e) => e,
2617            None => {
2618                eprintln!("Skipping test: no CUDA device available");
2619                return;
2620            }
2621        };
2622
2623        // Create empty base relation
2624        let empty_schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2625        let empty_buffer = executor.create_empty_buffer(empty_schema.clone()).unwrap();
2626        executor.store_mut().put("empty_base", empty_buffer);
2627        executor.register_relation(RelId(1), "empty_base");
2628
2629        // Create recursive relation (won't be used since base is empty)
2630        let rec_buffer = create_test_buffer(&executor, &[4, 5, 6], "key");
2631        executor.store_mut().put("rec_rel", rec_buffer);
2632        executor.register_relation(RelId(4), "rec_rel");
2633
2634        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2635        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2636
2637        let node = RirNode::Fixpoint {
2638            scc_id: 0,
2639            base,
2640            recursive,
2641            delta_rel: RelId(2),
2642            full_rel: RelId(3),
2643        };
2644
2645        let result = executor.execute_node(&node);
2646        assert!(result.is_ok());
2647
2648        // Should return empty since base is empty
2649        let result = result.unwrap();
2650        assert_eq!(buffer_row_count(&executor, &result), 0);
2651    }
2652
2653    #[test]
2654    fn test_execute_fixpoint_one_iteration() {
2655        // Test fixpoint that converges after one iteration
2656        let mut executor = match create_test_executor() {
2657            Some(e) => e,
2658            None => {
2659                eprintln!("Skipping test: no CUDA device available");
2660                return;
2661            }
2662        };
2663
2664        // Base: [1, 2]
2665        let base_buffer = create_test_buffer(&executor, &[1, 2], "key");
2666        executor.store_mut().put("base_rel", base_buffer);
2667        executor.register_relation(RelId(1), "base_rel");
2668
2669        // Recursive produces [1, 2, 3] - after diff with R, only [3] remains
2670        let rec_buffer = create_test_buffer(&executor, &[1, 2, 3], "key");
2671        executor.store_mut().put("rec_rel", rec_buffer);
2672        executor.register_relation(RelId(4), "rec_rel");
2673
2674        // After first iteration, R = [1, 2, 3], recursive produces [1, 2, 3] again
2675        // diff([1, 2, 3], [1, 2, 3]) = empty -> fixpoint reached
2676
2677        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2678        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2679
2680        let node = RirNode::Fixpoint {
2681            scc_id: 0,
2682            base,
2683            recursive,
2684            delta_rel: RelId(2),
2685            full_rel: RelId(3),
2686        };
2687
2688        let result = executor.execute_node(&node);
2689        assert!(result.is_ok());
2690
2691        let result = result.unwrap();
2692        // Result should be [1, 2, 3]
2693        assert_eq!(buffer_row_count(&executor, &result), 3);
2694    }
2695
2696    #[test]
2697    fn test_execute_fixpoint_multiple_iterations() {
2698        // Test fixpoint that requires multiple iterations to converge
2699        // This simulates transitive closure behavior
2700        let mut executor = match create_test_executor() {
2701            Some(e) => e,
2702            None => {
2703                eprintln!("Skipping test: no CUDA device available");
2704                return;
2705            }
2706        };
2707
2708        // Base: [1]
2709        let base_buffer = create_test_buffer(&executor, &[1], "key");
2710        executor.store_mut().put("base_rel", base_buffer);
2711        executor.register_relation(RelId(1), "base_rel");
2712
2713        // For this test, we need a recursive rule that can expand
2714        // Since we can't easily simulate join-based recursion without complex setup,
2715        // we'll test a simpler case where recursive produces cumulative data
2716
2717        // Recursive relation will produce [1, 2] in first iteration,
2718        // then [1, 2, 3] in second iteration, etc.
2719        // This requires a more complex setup, so let's test the basic convergence
2720
2721        // Simplified test: recursive produces union of base with [2]
2722        // First iteration: R=[1], rec produces [1, 2] -> delta_new = [2]
2723        // Second iteration: R=[1, 2], rec produces [1, 2] -> delta_new = empty
2724        let rec_buffer = create_test_buffer(&executor, &[1, 2], "key");
2725        executor.store_mut().put("rec_rel", rec_buffer);
2726        executor.register_relation(RelId(4), "rec_rel");
2727
2728        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2729        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2730
2731        let node = RirNode::Fixpoint {
2732            scc_id: 0,
2733            base,
2734            recursive,
2735            delta_rel: RelId(2),
2736            full_rel: RelId(3),
2737        };
2738
2739        let result = executor.execute_node(&node);
2740        assert!(result.is_ok());
2741
2742        let result = result.unwrap();
2743        // Result should be union of [1] and [2] = [1, 2]
2744        assert_eq!(buffer_row_count(&executor, &result), 2);
2745    }
2746
2747    #[test]
2748    fn test_execute_fixpoint_via_node() {
2749        // Test fixpoint through execute_node to ensure the match arm works
2750        let mut executor = match create_test_executor() {
2751            Some(e) => e,
2752            None => {
2753                eprintln!("Skipping test: no CUDA device available");
2754                return;
2755            }
2756        };
2757
2758        // Create and store a base buffer
2759        let buffer = create_test_buffer(&executor, &[1, 2, 3], "key");
2760        executor.store_mut().put("base_rel", buffer);
2761        executor.register_relation(RelId(1), "base_rel");
2762
2763        // Empty recursive means immediate fixpoint
2764        let empty_schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2765        let empty_buffer = executor.create_empty_buffer(empty_schema).unwrap();
2766        executor.store_mut().put("empty_rel", empty_buffer);
2767        executor.register_relation(RelId(4), "empty_rel");
2768
2769        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2770        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2771
2772        let node = RirNode::Fixpoint {
2773            scc_id: 0,
2774            base,
2775            recursive,
2776            delta_rel: RelId(2),
2777            full_rel: RelId(3),
2778        };
2779
2780        let result = executor.execute_node(&node);
2781        assert!(result.is_ok());
2782
2783        let result = result.unwrap();
2784        assert_eq!(buffer_row_count(&executor, &result), 3);
2785    }
2786
2787    #[test]
2788    fn test_fixpoint_cleanup() {
2789        // Test that fixpoint properly cleans up delta and full relations
2790        let mut executor = match create_test_executor() {
2791            Some(e) => e,
2792            None => {
2793                eprintln!("Skipping test: no CUDA device available");
2794                return;
2795            }
2796        };
2797
2798        let buffer = create_test_buffer(&executor, &[1, 2], "key");
2799        executor.store_mut().put("base_rel", buffer);
2800        executor.register_relation(RelId(1), "base_rel");
2801
2802        let empty_schema = Schema::new(vec![("key".to_string(), ScalarType::U32)]);
2803        let empty_buffer = executor.create_empty_buffer(empty_schema).unwrap();
2804        executor.store_mut().put("empty_rel", empty_buffer);
2805        executor.register_relation(RelId(4), "empty_rel");
2806
2807        // Register names for delta and full relations to check cleanup
2808        executor.register_relation(RelId(2), "__delta_test");
2809        executor.register_relation(RelId(3), "__full_test");
2810
2811        let base = Box::new(RirNode::Scan { rel: RelId(1) });
2812        let recursive = Box::new(RirNode::Scan { rel: RelId(4) });
2813
2814        let node = RirNode::Fixpoint {
2815            scc_id: 0,
2816            base,
2817            recursive,
2818            delta_rel: RelId(2),
2819            full_rel: RelId(3),
2820        };
2821
2822        let result = executor.execute_node(&node);
2823        assert!(result.is_ok());
2824
2825        // After fixpoint, the delta and full relations should be cleaned up
2826        assert!(!executor.store().contains("__delta_test"));
2827        assert!(!executor.store().contains("__full_test"));
2828    }
2829
2830    // ============== Execute Plan Tests ==============
2831
2832    #[test]
2833    fn test_execute_plan_empty() {
2834        let mut executor = match create_test_executor() {
2835            Some(e) => e,
2836            None => {
2837                eprintln!("Skipping test: no CUDA device available");
2838                return;
2839            }
2840        };
2841
2842        let plan = ExecutionPlan::new(vec![]);
2843
2844        let result = executor.execute_plan(&plan);
2845        assert!(result.is_ok());
2846        let result = result.unwrap();
2847        assert_eq!(buffer_row_count(&executor, &result), 0);
2848    }
2849
2850    #[test]
2851    fn test_execute_plan_with_stratum() {
2852        let mut executor = match create_test_executor() {
2853            Some(e) => e,
2854            None => {
2855                eprintln!("Skipping test: no CUDA device available");
2856                return;
2857            }
2858        };
2859
2860        // Create input relation
2861        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2862        executor.store_mut().put("input", buffer);
2863        executor.register_relation(RelId(1), "input");
2864
2865        // Build a simple plan
2866        let scc = Scc {
2867            id: 0,
2868            predicates: vec!["output".to_string()],
2869            is_recursive: false,
2870        };
2871
2872        let rule = CompiledRule {
2873            head: "output".to_string(),
2874            body: RirNode::Scan { rel: RelId(1) },
2875            meta: RirMeta::default(),
2876        };
2877
2878        let stratum = Stratum {
2879            id: 0,
2880            sccs: vec![0],
2881        };
2882
2883        let plan = ExecutionPlan {
2884            sccs: vec![scc],
2885            strata: vec![stratum],
2886            rules_by_scc: vec![vec![rule]],
2887            est_memory_peak: 0,
2888            rel_arities: std::collections::HashMap::new(),
2889        };
2890
2891        let result = executor.execute_plan(&plan);
2892        assert!(result.is_ok());
2893
2894        // Verify output relation was created
2895        assert!(executor.store().contains("output"));
2896        let output = executor.store().get("output").unwrap();
2897        assert_eq!(buffer_row_count(&executor, output), 5);
2898    }
2899
2900    #[test]
2901    fn test_apply_deltas_and_recompute_updates_dependents() {
2902        let mut executor = match create_test_executor() {
2903            Some(e) => e,
2904            None => {
2905                eprintln!("Skipping test: no CUDA device available");
2906                return;
2907            }
2908        };
2909
2910        let input = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2911        executor.store_mut().put("input", input);
2912        executor.register_relation(RelId(1), "input");
2913
2914        // SCC0: identity rule for input (mirrors how compiled facts appear as scan rules).
2915        // SCC1: output depends on input.
2916        let scc0 = Scc {
2917            id: 0,
2918            predicates: vec!["input".to_string()],
2919            is_recursive: false,
2920        };
2921        let scc1 = Scc {
2922            id: 1,
2923            predicates: vec!["output".to_string()],
2924            is_recursive: false,
2925        };
2926
2927        let input_rule = CompiledRule {
2928            head: "input".to_string(),
2929            body: RirNode::Scan { rel: RelId(1) },
2930            meta: RirMeta::default(),
2931        };
2932
2933        let output_rule = CompiledRule {
2934            head: "output".to_string(),
2935            body: RirNode::Filter {
2936                input: Box::new(RirNode::Scan { rel: RelId(1) }),
2937                predicate: Expr::Compare {
2938                    left: Box::new(Expr::Column(0)),
2939                    op: CompareOp::Gt,
2940                    right: Box::new(Expr::Const(ConstValue::U32(2))),
2941                },
2942            },
2943            meta: RirMeta::default(),
2944        };
2945
2946        let stratum = Stratum {
2947            id: 0,
2948            sccs: vec![0, 1],
2949        };
2950
2951        let plan = ExecutionPlan {
2952            sccs: vec![scc0, scc1],
2953            strata: vec![stratum],
2954            rules_by_scc: vec![vec![input_rule], vec![output_rule]],
2955            est_memory_peak: 0,
2956            rel_arities: std::collections::HashMap::new(),
2957        };
2958
2959        executor.execute_plan(&plan).expect("initial execute_plan");
2960        let initial_out = executor.store().get("output").expect("output missing");
2961        let initial_vals = read_buffer_u32(&executor, initial_out, 0);
2962        assert_eq!(initial_vals, vec![3, 4, 5]);
2963
2964        let delete_buf = create_test_buffer(&executor, &[5], "key");
2965        let insert_buf = create_test_buffer(&executor, &[10], "key");
2966
2967        let mut deltas = HashMap::new();
2968        deltas.insert(
2969            "input".to_string(),
2970            RelationDelta::new(Some(insert_buf), Some(delete_buf)),
2971        );
2972
2973        executor
2974            .apply_deltas_and_recompute(&plan, &deltas)
2975            .expect("apply_deltas_and_recompute");
2976
2977        let out = executor
2978            .store()
2979            .get("output")
2980            .expect("output missing after recompute");
2981        let vals = read_buffer_u32(&executor, out, 0);
2982        assert_eq!(vals, vec![3, 4, 10]);
2983    }
2984
2985    #[test]
2986    fn test_apply_deltas_and_recompute_insert_only_recomputes_anti_join() {
2987        let mut executor = match create_test_executor() {
2988            Some(e) => e,
2989            None => {
2990                eprintln!("Skipping test: no CUDA device available");
2991                return;
2992            }
2993        };
2994
2995        let lhs = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
2996        executor.store_mut().put("lhs", lhs);
2997        executor.register_relation(RelId(1), "lhs");
2998
2999        let blocked = create_test_buffer(&executor, &[], "key");
3000        executor.store_mut().put("blocked", blocked);
3001        executor.register_relation(RelId(2), "blocked");
3002
3003        // SCC0: lhs identity rule
3004        // SCC1: blocked identity rule
3005        // SCC2: out = lhs \ blocked (anti-join)
3006        let scc0 = Scc {
3007            id: 0,
3008            predicates: vec!["lhs".to_string()],
3009            is_recursive: false,
3010        };
3011        let scc1 = Scc {
3012            id: 1,
3013            predicates: vec!["blocked".to_string()],
3014            is_recursive: false,
3015        };
3016        let scc2 = Scc {
3017            id: 2,
3018            predicates: vec!["out".to_string()],
3019            is_recursive: false,
3020        };
3021
3022        let lhs_rule = CompiledRule {
3023            head: "lhs".to_string(),
3024            body: RirNode::Scan { rel: RelId(1) },
3025            meta: RirMeta::default(),
3026        };
3027        let blocked_rule = CompiledRule {
3028            head: "blocked".to_string(),
3029            body: RirNode::Scan { rel: RelId(2) },
3030            meta: RirMeta::default(),
3031        };
3032        let out_rule = CompiledRule {
3033            head: "out".to_string(),
3034            body: RirNode::Join {
3035                left: Box::new(RirNode::Scan { rel: RelId(1) }),
3036                right: Box::new(RirNode::Scan { rel: RelId(2) }),
3037                left_keys: vec![0],
3038                right_keys: vec![0],
3039                join_type: JoinType::Anti,
3040            },
3041            meta: RirMeta::default(),
3042        };
3043
3044        let stratum = Stratum {
3045            id: 0,
3046            sccs: vec![0, 1, 2],
3047        };
3048
3049        let plan = ExecutionPlan {
3050            sccs: vec![scc0, scc1, scc2],
3051            strata: vec![stratum],
3052            rules_by_scc: vec![vec![lhs_rule], vec![blocked_rule], vec![out_rule]],
3053            est_memory_peak: 0,
3054            rel_arities: std::collections::HashMap::new(),
3055        };
3056
3057        executor.execute_plan(&plan).expect("initial execute_plan");
3058        let initial = executor.store().get("out").expect("out missing");
3059        let initial_vals = read_buffer_u32(&executor, initial, 0);
3060        assert_eq!(initial_vals, vec![1, 2, 3, 4, 5]);
3061
3062        // Insert into the "blocked" relation: output should shrink.
3063        let insert_buf = create_test_buffer(&executor, &[2, 4], "key");
3064        let mut deltas = HashMap::new();
3065        deltas.insert(
3066            "blocked".to_string(),
3067            RelationDelta::new(Some(insert_buf), None),
3068        );
3069
3070        executor
3071            .apply_deltas_and_recompute(&plan, &deltas)
3072            .expect("apply_deltas_and_recompute");
3073
3074        let out = executor
3075            .store()
3076            .get("out")
3077            .expect("out missing after update");
3078        let vals = read_buffer_u32(&executor, out, 0);
3079        assert_eq!(vals, vec![1, 3, 5]);
3080    }
3081
3082    // ============== RIR Node Composition Tests ==============
3083
3084    #[test]
3085    fn test_execute_filter_project_chain() {
3086        let mut executor = match create_test_executor() {
3087            Some(e) => e,
3088            None => {
3089                eprintln!("Skipping test: no CUDA device available");
3090                return;
3091            }
3092        };
3093
3094        // Create input relation
3095        let buffer = create_test_buffer(&executor, &[1, 2, 3, 4, 5], "key");
3096        executor.store_mut().put("input", buffer);
3097        executor.register_relation(RelId(1), "input");
3098
3099        // Build: Project(Filter(Scan))
3100        let scan = RirNode::Scan { rel: RelId(1) };
3101        let filter = RirNode::Filter {
3102            input: Box::new(scan),
3103            predicate: Expr::Compare {
3104                left: Box::new(Expr::Column(0)),
3105                op: CompareOp::Gt,
3106                right: Box::new(Expr::Const(ConstValue::U32(2))),
3107            },
3108        };
3109        let project = RirNode::Project {
3110            input: Box::new(filter),
3111            columns: vec![ProjectExpr::Column(0)],
3112        };
3113
3114        let result = executor.execute_node(&project);
3115        assert!(result.is_ok());
3116
3117        let result = result.unwrap();
3118        assert_eq!(buffer_row_count(&executor, &result), 3);
3119
3120        let values = read_buffer_u32(&executor, &result, 0);
3121        assert_eq!(values, vec![3, 4, 5]);
3122    }
3123
3124    // ============== Common Subexpression Elimination Tests ==============
3125
3126    fn duplicate_join_union_plan() -> RirNode {
3127        let join = RirNode::Join {
3128            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3129            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3130            left_keys: vec![0],
3131            right_keys: vec![0],
3132            join_type: JoinType::Inner,
3133        };
3134        RirNode::Union {
3135            inputs: vec![join.clone(), join],
3136        }
3137    }
3138
3139    fn seed_cse_join_fixture(executor: &mut Executor, right: &[u32]) {
3140        executor.register_relation(RelId(1), "left");
3141        executor.register_relation(RelId(2), "right");
3142        let left = create_test_buffer(executor, &[1, 2, 3, 4], "key");
3143        let right = create_test_buffer(executor, right, "key");
3144        executor.put_relation("left", left);
3145        executor.put_relation("right", right);
3146    }
3147
3148    #[test]
3149    fn test_common_subexpression_cache_reuses_duplicate_inner_join_when_enabled() {
3150        let mut executor = match create_test_executor_with_config(
3151            RuntimeConfig::default().with_common_subexpression_elimination(Some(true)),
3152        ) {
3153            Some(e) => e,
3154            None => {
3155                eprintln!("Skipping test: no CUDA device available");
3156                return;
3157            }
3158        };
3159        seed_cse_join_fixture(&mut executor, &[2, 3, 5]);
3160
3161        let result = executor
3162            .execute_node(&duplicate_join_union_plan())
3163            .expect("duplicate join union executes");
3164
3165        assert_eq!(buffer_row_count(&executor, &result), 2);
3166        let stats = executor.common_subexpression_stats();
3167        assert_eq!(stats.hits, 1);
3168        assert!(stats.misses >= 1);
3169        assert_eq!(stats.unsafe_rejections, 0);
3170    }
3171
3172    #[test]
3173    fn test_common_subexpression_off_on_preserves_output_and_records_reuse_only_when_enabled() {
3174        let mut disabled = match create_test_executor_with_config(
3175            RuntimeConfig::default().with_common_subexpression_elimination(Some(false)),
3176        ) {
3177            Some(e) => e,
3178            None => {
3179                eprintln!("Skipping test: no CUDA device available");
3180                return;
3181            }
3182        };
3183        let mut enabled = match create_test_executor_with_config(
3184            RuntimeConfig::default().with_common_subexpression_elimination(Some(true)),
3185        ) {
3186            Some(e) => e,
3187            None => {
3188                eprintln!("Skipping test: no CUDA device available");
3189                return;
3190            }
3191        };
3192        seed_cse_join_fixture(&mut disabled, &[2, 3, 5]);
3193        seed_cse_join_fixture(&mut enabled, &[2, 3, 5]);
3194        let plan = duplicate_join_union_plan();
3195
3196        disabled.provider.reset_d2h_transfer_count();
3197        enabled.provider.reset_d2h_transfer_count();
3198        let disabled_result = disabled.execute_node(&plan).expect("disabled CSE output");
3199        let enabled_result = enabled.execute_node(&plan).expect("enabled CSE output");
3200        let disabled_d2h = disabled.provider.d2h_transfer_count();
3201        let enabled_d2h = enabled.provider.d2h_transfer_count();
3202
3203        assert_eq!(
3204            read_buffer_u32(&disabled, &disabled_result, 0),
3205            read_buffer_u32(&enabled, &enabled_result, 0)
3206        );
3207        assert_eq!(enabled_d2h, disabled_d2h);
3208        assert_eq!(disabled.common_subexpression_stats().hits, 0);
3209        assert_eq!(disabled.common_subexpression_stats().misses, 0);
3210        assert_eq!(enabled.common_subexpression_stats().hits, 1);
3211    }
3212
3213    #[test]
3214    fn test_common_subexpression_cache_invalidates_on_relation_generation_change() {
3215        let mut executor = match create_test_executor_with_config(
3216            RuntimeConfig::default().with_common_subexpression_elimination(Some(true)),
3217        ) {
3218            Some(e) => e,
3219            None => {
3220                eprintln!("Skipping test: no CUDA device available");
3221                return;
3222            }
3223        };
3224        seed_cse_join_fixture(&mut executor, &[2, 3, 5]);
3225        let plan = duplicate_join_union_plan();
3226
3227        executor.execute_node(&plan).expect("first execution");
3228        assert_eq!(executor.common_subexpression_stats().hits, 1);
3229
3230        let changed_right = create_test_buffer(&executor, &[4], "key");
3231        executor.put_relation("right", changed_right);
3232        let result = executor.execute_node(&plan).expect("second execution");
3233
3234        assert_eq!(buffer_row_count(&executor, &result), 1);
3235        let stats = executor.common_subexpression_stats();
3236        assert_eq!(stats.hits, 2);
3237        assert!(stats.misses >= 2);
3238    }
3239
3240    #[test]
3241    fn test_common_subexpression_cache_rejects_unsafe_difference_boundary() {
3242        let mut executor = match create_test_executor_with_config(
3243            RuntimeConfig::default().with_common_subexpression_elimination(Some(true)),
3244        ) {
3245            Some(e) => e,
3246            None => {
3247                eprintln!("Skipping test: no CUDA device available");
3248                return;
3249            }
3250        };
3251        seed_cse_join_fixture(&mut executor, &[2, 3, 5]);
3252        let diff = RirNode::Diff {
3253            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3254            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3255        };
3256
3257        executor
3258            .execute_node(&RirNode::Union {
3259                inputs: vec![diff.clone(), diff],
3260            })
3261            .expect("unsafe duplicate diff still executes without CSE sharing");
3262
3263        let stats = executor.common_subexpression_stats();
3264        assert_eq!(stats.hits, 0);
3265        assert!(stats.unsafe_rejections >= 1);
3266        assert!(stats
3267            .rejection_reasons
3268            .iter()
3269            .any(|reason| reason == "negation_or_difference_boundary"));
3270    }
3271
3272    #[test]
3273    fn test_common_subexpression_key_rejects_aggregate_and_tensor_boundaries() {
3274        let mut executor = match create_test_executor_with_config(
3275            RuntimeConfig::default().with_common_subexpression_elimination(Some(true)),
3276        ) {
3277            Some(e) => e,
3278            None => {
3279                eprintln!("Skipping test: no CUDA device available");
3280                return;
3281            }
3282        };
3283        let aggregate = RirNode::GroupBy {
3284            input: Box::new(RirNode::Scan { rel: RelId(1) }),
3285            key_cols: vec![0],
3286            aggs: vec![(0, xlog_core::AggOp::Count)],
3287        };
3288        let tensor = RirNode::TensorMaskedJoin {
3289            mask_name: "W".to_string(),
3290            schema_size: 1,
3291            left_keys: vec![0],
3292            right_keys: vec![0],
3293            rel_index: vec![(RelId(1), "left".to_string())],
3294            head_rel_name: "head".to_string(),
3295            head_rel_id: RelId(3),
3296            max_active_rules: 1,
3297            head_projection: vec![0],
3298        };
3299        let chain = RirNode::ChainJoin {
3300            left: Box::new(RirNode::Scan { rel: RelId(1) }),
3301            right: Box::new(RirNode::Scan { rel: RelId(2) }),
3302            left_key: 0,
3303            right_key: 0,
3304            output_columns: vec![ProjectExpr::Column(0)],
3305            fallback: Box::new(RirNode::Join {
3306                left: Box::new(RirNode::Scan { rel: RelId(1) }),
3307                right: Box::new(RirNode::Scan { rel: RelId(2) }),
3308                left_keys: vec![0],
3309                right_keys: vec![0],
3310                join_type: JoinType::Inner,
3311            }),
3312        };
3313
3314        assert!(executor.common_subexpression_key(&aggregate).is_none());
3315        assert!(executor.common_subexpression_key(&tensor).is_none());
3316        assert!(executor.common_subexpression_key(&chain).is_none());
3317
3318        let reasons = &executor.common_subexpression_stats().rejection_reasons;
3319        assert!(reasons.iter().any(|reason| reason == "aggregate_boundary"));
3320        assert!(reasons
3321            .iter()
3322            .any(|reason| reason == "provenance_or_tensor_boundary"));
3323        assert!(reasons
3324            .iter()
3325            .any(|reason| reason == "specialized_dispatch_boundary"));
3326    }
3327
3328    // ============== Adaptive Runtime Re-Optimization Tests ==============
3329
3330    fn adaptive_scc() -> Scc {
3331        Scc {
3332            id: 0,
3333            predicates: vec!["out".to_string()],
3334            is_recursive: false,
3335        }
3336    }
3337
3338    fn adaptive_stratum() -> Stratum {
3339        Stratum {
3340            id: 0,
3341            sccs: vec![0],
3342        }
3343    }
3344
3345    fn adaptive_rule(body: RirNode) -> CompiledRule {
3346        CompiledRule {
3347            head: "out".to_string(),
3348            body,
3349            meta: RirMeta::default(),
3350        }
3351    }
3352
3353    fn adaptive_plan(body: RirNode) -> ExecutionPlan {
3354        ExecutionPlan {
3355            sccs: vec![adaptive_scc()],
3356            strata: vec![adaptive_stratum()],
3357            rules_by_scc: vec![vec![adaptive_rule(body)]],
3358            est_memory_peak: 0,
3359            rel_arities: std::collections::HashMap::new(),
3360        }
3361    }
3362
3363    fn adaptive_baseline_join_plan() -> ExecutionPlan {
3364        adaptive_plan(RirNode::Project {
3365            input: Box::new(RirNode::Join {
3366                left: Box::new(RirNode::Scan { rel: RelId(1) }),
3367                right: Box::new(RirNode::Scan { rel: RelId(2) }),
3368                left_keys: vec![0],
3369                right_keys: vec![0],
3370                join_type: JoinType::Inner,
3371            }),
3372            columns: vec![ProjectExpr::Column(0)],
3373        })
3374    }
3375
3376    fn adaptive_scan_candidate_plan(rel: RelId) -> ExecutionPlan {
3377        adaptive_plan(RirNode::Scan { rel })
3378    }
3379
3380    fn seed_adaptive_fixture(executor: &mut Executor, right: &[u32]) {
3381        executor.register_relation(RelId(1), "left");
3382        executor.register_relation(RelId(2), "right");
3383        let left = create_test_buffer(executor, &[1, 2, 3, 4, 5, 6, 7, 8], "key");
3384        let right = create_test_buffer(executor, right, "key");
3385        executor.put_relation("left", left);
3386        executor.put_relation("right", right);
3387    }
3388
3389    #[test]
3390    fn test_adaptive_reoptimization_disabled_uses_baseline_and_records_decision() {
3391        let mut executor = match create_test_executor_with_config(
3392            RuntimeConfig::default().with_adaptive_reoptimization(Some(false)),
3393        ) {
3394            Some(e) => e,
3395            None => {
3396                eprintln!("Skipping test: no CUDA device available");
3397                return;
3398            }
3399        };
3400        seed_adaptive_fixture(&mut executor, &[1, 2, 3, 4, 5, 6, 7, 8]);
3401
3402        let baseline = adaptive_baseline_join_plan();
3403        let candidate = adaptive_scan_candidate_plan(RelId(2));
3404        let result = executor
3405            .execute_plan_with_adaptive_candidate(&baseline, &candidate)
3406            .expect("disabled adaptation executes baseline");
3407
3408        assert_eq!(
3409            read_buffer_u32(&executor, &result, 0),
3410            (1..=8).collect::<Vec<_>>()
3411        );
3412        let stats = executor.adaptive_reoptimization_stats();
3413        assert_eq!(stats.disabled, 1);
3414        assert_eq!(stats.adopted, 0);
3415        assert_eq!(stats.rolled_back, 0);
3416        assert_eq!(
3417            stats.last_decision.as_ref().map(|decision| decision.action),
3418            Some(AdaptiveReoptimizationAction::Disabled)
3419        );
3420    }
3421
3422    #[test]
3423    fn test_adaptive_reoptimization_adopts_equivalent_candidate_and_records_telemetry() {
3424        let mut executor = match create_test_executor_with_config(
3425            RuntimeConfig::default().with_adaptive_reoptimization(Some(true)),
3426        ) {
3427            Some(e) => e,
3428            None => {
3429                eprintln!("Skipping test: no CUDA device available");
3430                return;
3431            }
3432        };
3433        seed_adaptive_fixture(&mut executor, &[1, 2, 3, 4, 5, 6, 7, 8]);
3434        executor.provider.reset_host_transfer_stats();
3435
3436        let baseline = adaptive_baseline_join_plan();
3437        let candidate = adaptive_scan_candidate_plan(RelId(1));
3438        let result = executor
3439            .execute_plan_with_adaptive_candidate(&baseline, &candidate)
3440            .expect("equivalent candidate is adopted");
3441
3442        assert_eq!(
3443            read_buffer_u32(&executor, &result, 0),
3444            (1..=8).collect::<Vec<_>>()
3445        );
3446        let stats = executor.adaptive_reoptimization_stats();
3447        assert_eq!(stats.adopted, 1);
3448        assert_eq!(stats.rolled_back, 0);
3449        assert_eq!(stats.last_observations.len(), 1);
3450        assert!(stats.last_observations[0].cardinality_delta_abs > 0);
3451        assert!(stats.last_observations[0].selectivity_delta_abs > 0.0);
3452        assert_eq!(stats.data_plane_dtoh_calls, 0);
3453    }
3454
3455    #[test]
3456    fn test_adaptive_reoptimization_rolls_back_bad_candidate_with_typed_diagnostic() {
3457        let mut executor = match create_test_executor_with_config(
3458            RuntimeConfig::default().with_adaptive_reoptimization(Some(true)),
3459        ) {
3460            Some(e) => e,
3461            None => {
3462                eprintln!("Skipping test: no CUDA device available");
3463                return;
3464            }
3465        };
3466        seed_adaptive_fixture(&mut executor, &[1, 2, 3, 4, 5, 6, 7, 8]);
3467
3468        let baseline = adaptive_baseline_join_plan();
3469        let bad_candidate = adaptive_scan_candidate_plan(RelId(2));
3470        executor.put_relation("right", create_test_buffer(&executor, &[99], "key"));
3471        let result = executor
3472            .execute_plan_with_adaptive_candidate(&baseline, &bad_candidate)
3473            .expect("bad candidate rolls back to baseline output");
3474
3475        assert_eq!(read_buffer_u32(&executor, &result, 0), Vec::<u32>::new());
3476        let out = executor.store().get("out").expect("rollback restored out");
3477        assert_eq!(read_buffer_u32(&executor, out, 0), Vec::<u32>::new());
3478        let stats = executor.adaptive_reoptimization_stats();
3479        assert_eq!(stats.adopted, 0);
3480        assert_eq!(stats.rolled_back, 1);
3481        assert!(stats.diagnostics.iter().any(|diagnostic| {
3482            diagnostic.kind == AdaptiveReoptimizationDiagnosticKind::CandidateOutputMismatch
3483        }));
3484    }
3485
3486    #[test]
3487    fn test_adaptive_reoptimization_decisions_are_deterministic_under_replay() {
3488        let mut executor = match create_test_executor_with_config(
3489            RuntimeConfig::default().with_adaptive_reoptimization(Some(true)),
3490        ) {
3491            Some(e) => e,
3492            None => {
3493                eprintln!("Skipping test: no CUDA device available");
3494                return;
3495            }
3496        };
3497        seed_adaptive_fixture(&mut executor, &[1, 2, 3, 4, 5, 6, 7, 8]);
3498        let baseline = adaptive_baseline_join_plan();
3499        executor
3500            .execute_plan(&baseline)
3501            .expect("baseline execution records telemetry");
3502        let observations = executor
3503            .adaptive_reoptimization_stats()
3504            .last_observations
3505            .clone();
3506
3507        let first = executor.replay_adaptive_reoptimization_decision(&observations);
3508        for _ in 0..100 {
3509            assert_eq!(
3510                executor.replay_adaptive_reoptimization_decision(&observations),
3511                first
3512            );
3513        }
3514    }
3515
3516    // ============== Persistent Hash Index Manager Tests ==============
3517
3518    fn persistent_index_join_plan() -> ExecutionPlan {
3519        adaptive_baseline_join_plan()
3520    }
3521
3522    fn persistent_index_heavy_join_plan(repetitions: usize) -> ExecutionPlan {
3523        let mut inputs = Vec::with_capacity(repetitions);
3524        for _ in 0..repetitions {
3525            inputs.push(RirNode::Project {
3526                input: Box::new(RirNode::Join {
3527                    left: Box::new(RirNode::Scan { rel: RelId(1) }),
3528                    right: Box::new(RirNode::Scan { rel: RelId(2) }),
3529                    left_keys: vec![0],
3530                    right_keys: vec![0],
3531                    join_type: JoinType::Semi,
3532                }),
3533                columns: vec![ProjectExpr::Column(0)],
3534            });
3535        }
3536        adaptive_plan(RirNode::Union { inputs })
3537    }
3538
3539    fn seed_persistent_index_fixture(executor: &mut Executor, rows: u32) {
3540        executor.register_relation(RelId(1), "left");
3541        executor.register_relation(RelId(2), "right");
3542        let values: Vec<u32> = (0..rows).collect();
3543        let left = create_test_buffer(executor, &values, "key");
3544        let right = create_test_buffer(executor, &values, "key");
3545        executor.put_relation("left", left);
3546        executor.put_relation("right", right);
3547    }
3548
3549    fn seed_persistent_index_performance_fixture(
3550        executor: &mut Executor,
3551        left_rows: u32,
3552        right_rows: u32,
3553    ) {
3554        executor.register_relation(RelId(1), "left");
3555        executor.register_relation(RelId(2), "right");
3556        let left_values: Vec<u32> = (0..left_rows).collect();
3557        let right_values: Vec<u32> = (0..right_rows).collect();
3558        let left = create_test_buffer(executor, &left_values, "key");
3559        let right = create_test_buffer(executor, &right_values, "key");
3560        executor.put_relation("left", left);
3561        executor.put_relation("right", right);
3562    }
3563
3564    fn warm_persistent_index(executor: &mut Executor, plan: &ExecutionPlan, times: usize) {
3565        for _ in 0..times {
3566            executor.execute_plan(plan).expect("persistent index plan");
3567        }
3568    }
3569
3570    fn median_duration(samples: &mut [Duration]) -> Duration {
3571        samples.sort_unstable();
3572        samples[samples.len() / 2]
3573    }
3574
3575    fn measure_persistent_index_fixture(
3576        mut executor: Executor,
3577        plan: &ExecutionPlan,
3578        warmup: usize,
3579        iterations: usize,
3580    ) -> (
3581        Duration,
3582        u64,
3583        JoinIndexCacheStats,
3584        xlog_cuda::provider::HostTransferStats,
3585    ) {
3586        let mut output_rows = None;
3587        warm_persistent_index(&mut executor, plan, warmup);
3588        executor.provider.reset_host_transfer_stats();
3589
3590        let mut samples = Vec::with_capacity(iterations);
3591        for _ in 0..iterations {
3592            let start = Instant::now();
3593            let output = executor.execute_plan(plan).expect("persistent index plan");
3594            executor
3595                .provider
3596                .device()
3597                .synchronize()
3598                .expect("sync device");
3599            samples.push(start.elapsed());
3600            output_rows = Some(if let Some(buffer) = executor.store().get("out") {
3601                executor
3602                    .buffer_row_count(buffer)
3603                    .expect("read output row count")
3604                    .into()
3605            } else {
3606                output.num_rows()
3607            });
3608        }
3609
3610        (
3611            median_duration(&mut samples),
3612            output_rows.expect("at least one measured execution"),
3613            executor.join_index_cache_stats(),
3614            executor.provider.host_transfer_stats(),
3615        )
3616    }
3617
3618    #[test]
3619    fn test_persistent_hash_index_reuses_across_repeated_session_evaluations() {
3620        let mut executor = match create_test_executor_with_config(
3621            RuntimeConfig::default().with_persistent_hash_indexes(Some(true)),
3622        ) {
3623            Some(e) => e,
3624            None => {
3625                eprintln!("Skipping test: no CUDA device available");
3626                return;
3627            }
3628        };
3629        seed_persistent_index_fixture(&mut executor, 2_500);
3630        let plan = persistent_index_join_plan();
3631        executor.provider.reset_host_transfer_stats();
3632
3633        warm_persistent_index(&mut executor, &plan, 5);
3634
3635        let stats = executor.join_index_cache_stats();
3636        let transfers = executor.provider.host_transfer_stats();
3637        assert_eq!(stats.builds, 1);
3638        assert!(stats.hits >= 1);
3639        assert_eq!(stats.stale_rejections, 0);
3640        assert_eq!(stats.entries, 1);
3641        assert!(stats.total_bytes > 0);
3642        assert_eq!(transfers.dtoh_calls, 0);
3643        assert_eq!(transfers.htod_calls, 0);
3644    }
3645
3646    #[test]
3647    fn test_persistent_hash_index_invalidates_on_relation_generation_change() {
3648        let mut executor = match create_test_executor_with_config(
3649            RuntimeConfig::default().with_persistent_hash_indexes(Some(true)),
3650        ) {
3651            Some(e) => e,
3652            None => {
3653                eprintln!("Skipping test: no CUDA device available");
3654                return;
3655            }
3656        };
3657        seed_persistent_index_fixture(&mut executor, 2_500);
3658        let plan = persistent_index_join_plan();
3659        warm_persistent_index(&mut executor, &plan, 5);
3660        assert_eq!(executor.join_index_cache_stats().entries, 1);
3661
3662        let changed_values: Vec<u32> = (10_000..12_500).collect();
3663        let changed_right = create_test_buffer(&executor, &changed_values, "key");
3664        executor.put_relation("right", changed_right);
3665
3666        let stats = executor.join_index_cache_stats();
3667        assert_eq!(stats.entries, 0);
3668        assert!(stats.invalidations >= 1);
3669    }
3670
3671    #[test]
3672    fn test_persistent_hash_index_background_build_records_requests() {
3673        let mut executor = match create_test_executor_with_config(
3674            RuntimeConfig::default()
3675                .with_persistent_hash_indexes(Some(true))
3676                .with_persistent_hash_index_background_build(Some(true)),
3677        ) {
3678            Some(e) => e,
3679            None => {
3680                eprintln!("Skipping test: no CUDA device available");
3681                return;
3682            }
3683        };
3684        seed_persistent_index_fixture(&mut executor, 2_500);
3685        let plan = persistent_index_join_plan();
3686
3687        warm_persistent_index(&mut executor, &plan, 5);
3688
3689        let stats = executor.join_index_cache_stats();
3690        assert_eq!(stats.background_build_requests, 1);
3691        assert_eq!(stats.background_builds_completed, 1);
3692        assert_eq!(stats.entries, 1);
3693    }
3694
3695    #[test]
3696    fn test_persistent_hash_index_background_build_defers_current_join_reuse() {
3697        let mut executor = match create_test_executor_with_config(
3698            RuntimeConfig::default()
3699                .with_persistent_hash_indexes(Some(true))
3700                .with_persistent_hash_index_background_build(Some(true)),
3701        ) {
3702            Some(e) => e,
3703            None => {
3704                eprintln!("Skipping test: no CUDA device available");
3705                return;
3706            }
3707        };
3708        seed_persistent_index_fixture(&mut executor, 2_500);
3709        let plan = persistent_index_join_plan();
3710
3711        let mut before_build = executor.join_index_cache_stats();
3712        let mut after_build = None;
3713        for _ in 0..5 {
3714            executor
3715                .execute_plan(&plan)
3716                .expect("background-build warm evaluation");
3717            let stats = executor.join_index_cache_stats();
3718            if stats.background_build_requests > before_build.background_build_requests {
3719                after_build = Some(stats);
3720                break;
3721            }
3722            before_build = stats;
3723        }
3724
3725        let after_first = after_build.expect("background build request observed");
3726        assert_eq!(after_first.background_build_requests, 1);
3727        assert_eq!(after_first.background_builds_completed, 1);
3728        assert_eq!(after_first.background_builds_deferred, 1);
3729        assert_eq!(
3730            after_first.hits, before_build.hits,
3731            "background build must not be consumed by the same evaluation that requested it"
3732        );
3733        assert_eq!(after_first.entries, 1);
3734
3735        executor
3736            .execute_plan(&plan)
3737            .expect("second evaluation reuses completed background index");
3738        let after_second = executor.join_index_cache_stats();
3739        assert_eq!(after_second.background_build_requests, 1);
3740        assert_eq!(after_second.background_builds_deferred, 1);
3741        assert!(after_second.hits >= 1);
3742    }
3743
3744    #[test]
3745    fn test_persistent_hash_index_performance_fixture_meets_speedup_target() {
3746        const LEFT_ROWS: u32 = 8;
3747        const RIGHT_ROWS: u32 = 8_000_000;
3748        const JOIN_REPETITIONS: usize = 1;
3749        const WARMUP: usize = 12;
3750        const ITERATIONS: usize = 9;
3751
3752        let mut cached = match create_test_executor_with_config(
3753            RuntimeConfig::default().with_persistent_hash_indexes(Some(true)),
3754        ) {
3755            Some(e) => e,
3756            None => {
3757                eprintln!("Skipping test: no CUDA device available");
3758                return;
3759            }
3760        };
3761        seed_persistent_index_performance_fixture(&mut cached, LEFT_ROWS, RIGHT_ROWS);
3762
3763        let mut uncached = match create_test_executor_with_config(
3764            RuntimeConfig::default().with_persistent_hash_indexes(Some(false)),
3765        ) {
3766            Some(e) => e,
3767            None => {
3768                eprintln!("Skipping test: no CUDA device available");
3769                return;
3770            }
3771        };
3772        seed_persistent_index_performance_fixture(&mut uncached, LEFT_ROWS, RIGHT_ROWS);
3773
3774        let plan = persistent_index_heavy_join_plan(JOIN_REPETITIONS);
3775        let (cached_median, cached_rows, cached_stats, cached_transfers) =
3776            measure_persistent_index_fixture(cached, &plan, WARMUP, ITERATIONS);
3777        let (uncached_median, uncached_rows, uncached_stats, uncached_transfers) =
3778            measure_persistent_index_fixture(uncached, &plan, WARMUP, ITERATIONS);
3779
3780        let speedup_ratio = uncached_median.as_secs_f64() / cached_median.as_secs_f64();
3781        eprintln!(
3782            "persistent_hash_index_perf left_rows={} right_rows={} join_repetitions={} warmup={} iterations={} \
3783             cached_median_sec={:.9} uncached_median_sec={:.9} speedup_ratio={:.3} \
3784             cached_output_rows={} uncached_output_rows={} cached_builds={} cached_hits={} \
3785             uncached_builds={} cached_dtoh_calls={} cached_htod_calls={}",
3786            LEFT_ROWS,
3787            RIGHT_ROWS,
3788            JOIN_REPETITIONS,
3789            WARMUP,
3790            ITERATIONS,
3791            cached_median.as_secs_f64(),
3792            uncached_median.as_secs_f64(),
3793            speedup_ratio,
3794            cached_rows,
3795            uncached_rows,
3796            cached_stats.builds,
3797            cached_stats.hits,
3798            uncached_stats.builds,
3799            cached_transfers.dtoh_calls,
3800            cached_transfers.htod_calls
3801        );
3802
3803        assert_eq!(cached_rows, uncached_rows);
3804        assert_eq!(cached_rows, LEFT_ROWS as u64);
3805        assert_eq!(cached_stats.builds, 1);
3806        assert!(cached_stats.hits >= ITERATIONS as u64);
3807        assert_eq!(uncached_stats.builds, 0);
3808        assert_eq!(cached_transfers.dtoh_calls, 0);
3809        assert_eq!(cached_transfers.htod_calls, 0);
3810        assert_eq!(uncached_transfers.dtoh_calls, 0);
3811        assert_eq!(uncached_transfers.htod_calls, 0);
3812        assert!(
3813            speedup_ratio >= 1.5,
3814            "persistent index speedup {:.3} below 1.5 target",
3815            speedup_ratio
3816        );
3817    }
3818
3819    // ============== MC Relation Reset Tests ==============
3820
3821    #[test]
3822    fn test_reset_for_mc_relations_preserves_static_and_clears_dynamic() {
3823        let mut executor = match create_test_executor() {
3824            Some(e) => e,
3825            None => {
3826                eprintln!("Skipping: no CUDA device");
3827                return;
3828            }
3829        };
3830
3831        executor.register_relation(RelId(1), "base_rel");
3832        executor.register_relation(RelId(2), "dyn_rel");
3833
3834        let schema = Schema::new(vec![("x".to_string(), ScalarType::U32)]);
3835        let base = create_test_buffer(&executor, &[1u32], "x");
3836        let dyn_buf = create_test_buffer(&executor, &[9u32], "x");
3837        executor.put_relation("base_rel", base);
3838        executor.put_relation("dyn_rel", dyn_buf);
3839
3840        executor
3841            .reset_for_mc_relations(&["base_rel"], &[("dyn_rel", schema.clone())])
3842            .unwrap();
3843
3844        assert_eq!(
3845            buffer_row_count(&executor, executor.store().get("base_rel").unwrap()),
3846            1
3847        );
3848        assert_eq!(
3849            buffer_row_count(&executor, executor.store().get("dyn_rel").unwrap()),
3850            0
3851        );
3852    }
3853}