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