Skip to main content

rssn_advanced/jit/
compiler.rs

1//! Core JIT compiler wrapping Cranelift.
2//!
3//! `JitCompiler` compiles stack-local AST projection trees into callable,
4//! optimized native machine code. The IR generator is **iterative**
5//! (work-stack + SSA-value stack) so even an expression a million nodes
6//! deep does not blow the OS stack — see `jit_review §2`. It folds a
7//! peephole pass over the per-node IR emission so that `x + 0`, `x * 1`,
8//! `x * 0`, etc. cost zero instructions (`jit_review §1` / `§2`).
9//!
10//! Phase 6 additions: `OptConfig`, analysis-driven NaN-guard elision,
11//! power expansion (sqrt + int pow), CSE for shared DAG nodes, and a
12//! vectorized batch evaluation path (`compile_batch_f64x2`).
13
14#![allow(unsafe_code)]
15#![allow(clippy::similar_names)]
16
17use std::collections::HashMap;
18use std::collections::HashSet;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::{Arc, RwLock};
21
22use cranelift_codegen::Context;
23use cranelift_codegen::ir::condcodes::{FloatCC, IntCC};
24use cranelift_codegen::ir::{AbiParam, BlockArg, InstBuilder, MemFlags, Signature, Value, types};
25use cranelift_frontend::{FunctionBuilder, FunctionBuilderContext};
26use cranelift_jit::{JITBuilder, JITModule};
27use cranelift_module::{Linkage, Module};
28
29use crate::ast::projection::{AstNode, AstProjection};
30use crate::dag::symbol::{FnId, OpKind, SymbolKind};
31use crate::jit::analysis::{NodeAnalysis, PowExpansion, analyze};
32use crate::jit::passes;
33
34/// A JIT-compiled expression function pointer.
35///
36/// It takes a pointer to an array of variable values (`*const f64`),
37/// ordered by their `SymbolId` values, and returns the computed float result.
38pub type CompiledExprFn = extern "C" fn(*const f64) -> f64;
39
40/// User-supplied native function: one `f64` argument, one `f64` return.
41///
42/// The common math-library signature (`sin`, `cos`, `log`, …). Registered via
43/// [`JitCompiler::register_custom_function`].
44pub type CustomFn1 = extern "C" fn(f64) -> f64;
45
46/// User-supplied native function: two `f64` arguments, one `f64` return.
47///
48/// Suitable for two-argument math functions (`pow`, `atan2`, …). Registered
49/// via [`JitCompiler::register_custom_function_2`].
50pub type CustomFn2 = extern "C" fn(f64, f64) -> f64;
51
52/// User-supplied native function: three `f64` arguments, one `f64` return.
53///
54/// Suitable for three-argument operations (`fma`, `clamp`, …). Registered
55/// via [`JitCompiler::register_custom_function_3`].
56pub type CustomFn3 = extern "C" fn(f64, f64, f64) -> f64;
57
58/// Column-major batch evaluation function.
59///
60/// - `vars_cols`: pointer to an array of column pointers, one per variable.
61///   Each column pointer points to `n_rows` contiguous `f64` values.
62/// - `n_rows`: number of rows to evaluate.
63/// - `out`: output array of `n_rows` `f64` values.
64///
65/// Processes 2 rows per vector iteration via ILP (two independent SSA
66/// paths), with a scalar tail for any odd final row.
67pub type CompiledBatchFn =
68    extern "C" fn(vars_cols: *const *const f64, n_rows: usize, out: *mut f64);
69
70/// Configuration for JIT optimization passes.
71#[derive(Debug, Clone)]
72// Four independent bool toggles for four orthogonal optimization passes;
73// a state machine or two-variant enum would be strictly worse here.
74#[allow(clippy::struct_excessive_bools)]
75pub struct OptConfig {
76    /// Maximum integer exponent for power expansion without `powf`.
77    /// `x^n` for integer n in `2..=max_int_pow` is replaced by repeated fmul.
78    pub max_int_pow: u32,
79    /// Expand `x^0.5` to a Cranelift `sqrt` instruction.
80    pub expand_sqrt: bool,
81    /// Replace `x / C` (constant non-zero denominator) with `x * (1/C)`.
82    /// Not IEEE-754 bit-exact (reciprocal approximation rounding).
83    pub allow_reciprocal_math: bool,
84    /// Elide `select(rhs==0, NaN, lhs/rhs)` when the denominator is
85    /// proven non-zero by the analysis pass.
86    pub elide_nan_guard: bool,
87    /// Reuse SSA values for DAG nodes that appear more than once.
88    pub enable_cse: bool,
89}
90
91impl Default for OptConfig {
92    fn default() -> Self {
93        Self {
94            max_int_pow: 16,
95            expand_sqrt: true,
96            allow_reciprocal_math: false,
97            elide_nan_guard: true,
98            enable_cse: true,
99        }
100    }
101}
102
103extern "C" fn jit_powf(base: f64, exp: f64) -> f64 {
104    base.powf(exp)
105}
106
107extern "C" fn jit_fmod(lhs: f64, rhs: f64) -> f64 {
108    lhs % rhs
109}
110
111/// Entry in the custom function registry: function pointer + argument count.
112#[derive(Clone, Copy)]
113struct CustomFnEntry {
114    /// Raw function pointer (cast to `usize` for `Send`/`Sync` safety).
115    ptr: usize,
116    /// Number of `f64` arguments the function accepts (1, 2, or 3).
117    arity: u8,
118}
119
120/// Shared registry of custom function pointers, keyed by `FnId.0`.
121///
122/// Stored as `usize` (not `*const u8`) so the type is `Send`/`Sync`
123/// without unsafe markers. Uses `RwLock` so concurrent readers (the
124/// symbol-lookup closure) never block each other; only `register_custom_function`
125/// takes a write lock.
126type CustomFnRegistry = Arc<RwLock<HashMap<u32, CustomFnEntry>>>;
127
128/// Process-wide monotone counter for unique JIT function names.
129///
130/// `compile_with_opts` and `compile_batch_f64x2` each increment this before
131/// declaring a new Cranelift function.  Without it, every `DagBuilder` starts
132/// numbering nodes from 0, so the second call with any expression whose root
133/// has the same `DagNodeId` as a previously compiled one would collide inside
134/// the shared `JITModule` and return `ModuleError::DuplicateDefinition`.
135static JIT_FUNC_COUNTER: AtomicU64 = AtomicU64::new(0);
136
137/// The primary compiler context for compiling symbolic expressions to native code.
138pub struct JitCompiler {
139    module: JITModule,
140    builder_ctx: FunctionBuilderContext,
141    /// Shared with the symbol-lookup closure baked into the
142    /// `JITModule`. Late `register_custom_function` calls update this
143    /// map; the closure consults it whenever Cranelift needs to
144    /// resolve an unknown symbol.
145    custom_fns: CustomFnRegistry,
146    /// Reusable work stack for `compile_ast_iterative`. Cleared at the
147    /// start of each compile call; kept here to amortise allocation cost
148    /// across repeated compilations.
149    work_stack: Vec<Frame>,
150    /// Reusable SSA-value stack for `compile_ast_iterative`.
151    work_values: Vec<Value>,
152    /// Unified custom-operator registry set via [`Self::set_custom_op_registry`].
153    ///
154    /// When present it is consulted by [`Self::compile_batch_f64x2`] to
155    /// determine whether a `Function` node is vectorization-safe.
156    custom_op_registry: Option<Arc<crate::custom::descriptor::CustomOpRegistry>>,
157}
158
159impl std::fmt::Debug for JitCompiler {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("JitCompiler").finish_non_exhaustive()
162    }
163}
164
165impl Default for JitCompiler {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl JitCompiler {
172    /// Attempts to create a new `JitCompiler` for the host target.
173    ///
174    /// Returns `Err(JitError::InitFailed)` if the Cranelift backend or
175    /// native ISA cannot be initialised — for example, on an unsupported
176    /// architecture or in a cross-compilation environment without a
177    /// registered native target (`error_review §4`).
178    ///
179    /// # Errors
180    ///
181    /// Returns [`crate::error::JitError::InitFailed`] if any step of the
182    /// Cranelift backend initialisation fails.
183    pub fn try_new() -> Result<Self, crate::error::JitError> {
184        let mut isa_builder =
185            cranelift_native::builder().map_err(|_| crate::error::JitError::InitFailed)?;
186
187        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
188        {
189            // Enable all SIMD/bitmanip extensions Cranelift can target on modern x86.
190            // These match what cranelift-native detects, but we force them explicitly
191            // so that Cranelift's instruction selector can use FMA, PSHUFB, LZCNT, etc.
192            for flag in &[
193                "has_sse3",
194                "has_ssse3",
195                "has_sse41",
196                "has_sse42",
197                "has_avx",
198                "has_avx2",
199                "has_bmi1",
200                "has_bmi2",
201                "has_lzcnt",
202                "has_popcnt",
203            ] {
204                let _ = cranelift_codegen::settings::Configurable::enable(&mut isa_builder, flag);
205            }
206        }
207
208        #[cfg(target_arch = "aarch64")]
209        {
210            // Enable advanced vector and atomic extensions for modern AArch64.
211            // Note: Baseline Neon SIMD and FP are enabled by default on aarch64.
212            for flag in &["has_lse", "has_pauth", "has_sve"] {
213                let _ = cranelift_codegen::settings::Configurable::enable(&mut isa_builder, flag);
214            }
215        }
216
217        let mut flag_builder = cranelift_codegen::settings::builder();
218        // Maximum optimization — trades compile time for runtime throughput.
219        cranelift_codegen::settings::Configurable::set(&mut flag_builder, "opt_level", "speed")
220            .map_err(|_| crate::error::JitError::InitFailed)?;
221        // Disable the runtime IR verifier — it costs ≈10% compile time and
222        // catches only malformed IR that our own emitters never produce.
223        let _ = cranelift_codegen::settings::Configurable::set(
224            &mut flag_builder,
225            "enable_verifier",
226            "false",
227        );
228        // Avoid probestack emission (not needed for leaf-like JIT functions).
229        let _ = cranelift_codegen::settings::Configurable::set(
230            &mut flag_builder,
231            "enable_probestack",
232            "false",
233        );
234
235        let isa = isa_builder
236            .finish(cranelift_codegen::settings::Flags::new(flag_builder))
237            .map_err(|_| crate::error::JitError::InitFailed)?;
238
239        let mut builder = JITBuilder::with_isa(isa, cranelift_module::default_libcall_names());
240
241        builder.symbol("powf", jit_powf as *const u8);
242        builder.symbol("fmod", jit_fmod as *const u8);
243
244        let custom_fns: CustomFnRegistry = Arc::new(RwLock::new(HashMap::new()));
245        let lookup_registry = Arc::clone(&custom_fns);
246        builder.symbol_lookup_fn(Box::new(move |name: &str| -> Option<*const u8> {
247            let id_str = name.strip_prefix("rssn_custom_fn_")?;
248            let id: u32 = id_str.parse().ok()?;
249            let guard = lookup_registry
250                .read()
251                .unwrap_or_else(std::sync::PoisonError::into_inner);
252            guard.get(&id).map(|entry| entry.ptr as *const u8)
253        }));
254
255        let module = JITModule::new(builder);
256        Ok(Self {
257            module,
258            builder_ctx: FunctionBuilderContext::new(),
259            custom_fns,
260            work_stack: Vec::with_capacity(64),
261            work_values: Vec::with_capacity(64),
262            custom_op_registry: None,
263        })
264    }
265
266    /// Creates a new `JitCompiler` instance initialized for the host target.
267    ///
268    /// # Panics
269    /// Panics if the host native target cannot be built. For a fallible
270    /// variant, use [`Self::try_new`].
271    #[must_use]
272    pub fn new() -> Self {
273        Self::try_new().expect("JIT compiler initialization failed")
274    }
275
276    /// Registers a user-defined `extern "C" fn(f64) -> f64` so the JIT
277    /// can resolve `SymbolKind::Function(fn_id)` references at link
278    /// time. May be called any time before the next [`Self::compile`].
279    ///
280    /// The `fn_id` must match whatever the symbolic layer assigned to
281    /// the corresponding function name (typically via `DagBuilder`).
282    pub fn register_custom_function(&self, fn_id: FnId, func: CustomFn1) {
283        // The cast `func as usize` is a no-op pointer cast; the value
284        // is later cast back to `*const u8` inside the lookup closure.
285        let mut guard = self
286            .custom_fns
287            .write()
288            .unwrap_or_else(std::sync::PoisonError::into_inner);
289        guard.insert(
290            fn_id.0,
291            CustomFnEntry {
292                ptr: func as usize,
293                arity: 1,
294            },
295        );
296    }
297
298    /// Registers a user-defined `extern "C" fn(f64, f64) -> f64` so the JIT
299    /// can resolve `SymbolKind::Function(fn_id)` references at link time.
300    ///
301    /// Two-argument variant of [`Self::register_custom_function`], suitable
302    /// for functions like `pow`, `atan2`, or user-defined binary operators
303    /// (`jit_review §3.1`).
304    pub fn register_custom_function_2(&self, fn_id: FnId, func: CustomFn2) {
305        let mut guard = self
306            .custom_fns
307            .write()
308            .unwrap_or_else(std::sync::PoisonError::into_inner);
309        guard.insert(
310            fn_id.0,
311            CustomFnEntry {
312                ptr: func as usize,
313                arity: 2,
314            },
315        );
316    }
317
318    /// Registers a user-defined `extern "C" fn(f64, f64, f64) -> f64` so the JIT
319    /// can resolve `SymbolKind::Function(fn_id)` references at link time.
320    ///
321    /// Three-argument variant of [`Self::register_custom_function`], suitable
322    /// for `fma`, `clamp`, and similar ternary operations (`jit_review §3.1`).
323    pub fn register_custom_function_3(&self, fn_id: FnId, func: CustomFn3) {
324        let mut guard = self
325            .custom_fns
326            .write()
327            .unwrap_or_else(std::sync::PoisonError::into_inner);
328        guard.insert(
329            fn_id.0,
330            CustomFnEntry {
331                ptr: func as usize,
332                arity: 3,
333            },
334        );
335    }
336
337    /// Installs a [`CustomOpRegistry`][crate::custom::descriptor::CustomOpRegistry]
338    /// as the authoritative source for all custom operators.
339    ///
340    /// This method:
341    /// 1. Populates the internal `CustomFnRegistry` (function pointer lookup table)
342    ///    from every descriptor's [`EvalFn`][crate::custom::descriptor::EvalFn],
343    ///    so the JIT linker resolves `rssn_custom_fn_N` symbols correctly.
344    /// 2. Stores the `Arc` reference so [`Self::compile_batch_f64x2`] can query
345    ///    [`CustomOpRegistry::is_vectorizable`](crate::custom::descriptor::CustomOpRegistry::is_vectorizable) per `Function` node, enabling the
346    ///    ILP batch path for operators flagged `vectorizable`.
347    ///
348    /// Prefer calling this over the individual `register_custom_function_N`
349    /// methods when using the unified custom-operator API.
350    pub fn set_custom_op_registry(
351        &mut self,
352        registry: Arc<crate::custom::descriptor::CustomOpRegistry>,
353    ) {
354        // Populate the existing CustomFnRegistry from all descriptors.
355        let mut guard = self
356            .custom_fns
357            .write()
358            .unwrap_or_else(std::sync::PoisonError::into_inner);
359        for desc in registry.ops_iter() {
360            let (ptr, arity) = match desc.eval_fn {
361                crate::custom::descriptor::EvalFn::Arity1(f) => (f as usize, 1u8),
362                crate::custom::descriptor::EvalFn::Arity2(f) => (f as usize, 2u8),
363                crate::custom::descriptor::EvalFn::Arity3(f) => (f as usize, 3u8),
364            };
365            guard.insert(desc.fn_id.0, CustomFnEntry { ptr, arity });
366        }
367        drop(guard);
368        self.custom_op_registry = Some(registry);
369    }
370
371    /// Compiles an `AstProjection` expression into a native callable function
372    /// using default optimization settings.
373    ///
374    /// Equivalent to `compile_with_opts(ast, &OptConfig::default())`.
375    ///
376    /// # Errors
377    /// Returns a [`crate::error::JitError`] if compilation or linking fails.
378    pub fn compile(
379        &mut self,
380        ast: &AstProjection,
381    ) -> Result<CompiledExprFn, crate::error::JitError> {
382        self.compile_with_opts(ast, &OptConfig::default())
383    }
384
385    /// Compiles an `AstProjection` expression into a native callable function
386    /// with explicit optimization settings.
387    ///
388    /// # Errors
389    /// Returns a [`crate::error::JitError`] if compilation or linking fails.
390    pub fn compile_with_opts(
391        &mut self,
392        ast: &AstProjection,
393        opts: &OptConfig,
394    ) -> Result<CompiledExprFn, crate::error::JitError> {
395        if ast.is_empty() {
396            return crate::error::cold_jit_error_malformed_node();
397        }
398
399        // Run pre-codegen analysis pass.
400        let analysis = analyze(ast);
401
402        let mut ctx = Context::new();
403
404        // Use the platform's C ABI calling convention so the compiled function
405        // can be called as `extern "C" fn(*const f64) -> f64` from Rust.
406        //
407        // `Context::new()` defaults to `CallConv::Fast`, which on x86-64 uses
408        // System V AMD64 register layout (first arg → RDI) even on Windows.
409        // The Windows x64 C ABI passes the first arg in RCX.  Calling a
410        // Fast-CC function through an `extern "C"` pointer on Windows
411        // therefore reads the variable pointer from the wrong register and
412        // immediately triggers STATUS_ACCESS_VIOLATION (0xc0000005).
413        ctx.func.signature.call_conv = self.module.target_config().default_call_conv;
414
415        // Define signature: fn(*const f64) -> f64
416        let ptr_type = self.module.target_config().pointer_type();
417        ctx.func.signature.params.push(AbiParam::new(ptr_type));
418        ctx.func.signature.returns.push(AbiParam::new(types::F64));
419
420        let mut func_builder = FunctionBuilder::new(&mut ctx.func, &mut self.builder_ctx);
421        let entry_block = func_builder.create_block();
422        func_builder.append_block_params_for_function_params(entry_block);
423        func_builder.switch_to_block(entry_block);
424        func_builder.seal_block(entry_block);
425
426        let vars_ptr = func_builder.block_params(entry_block)[0];
427
428        // Declare the powf helper function in the current function context.
429        let mut powf_sig = Signature::new(self.module.target_config().default_call_conv);
430        powf_sig.params.push(AbiParam::new(types::F64));
431        powf_sig.params.push(AbiParam::new(types::F64));
432        powf_sig.returns.push(AbiParam::new(types::F64));
433
434        let powf_name = self
435            .module
436            .declare_function("powf", Linkage::Import, &powf_sig)
437            .map_err(|_| crate::error::JitError::InitFailed)?;
438        let powf_func_ref = self
439            .module
440            .declare_func_in_func(powf_name, func_builder.func);
441
442        // fmod: same binary signature as powf (two f64 → one f64).
443        let fmod_name = self
444            .module
445            .declare_function("fmod", Linkage::Import, &powf_sig)
446            .map_err(|_| crate::error::JitError::InitFailed)?;
447        let fmod_func_ref = self
448            .module
449            .declare_func_in_func(fmod_name, func_builder.func);
450
451        // Walk the AST once and import every distinct custom function
452        // it references. Refuse to compile if any referenced id was
453        // not registered via `register_custom_function`.
454        let mut custom_refs: HashMap<u32, cranelift_codegen::ir::FuncRef> = HashMap::new();
455
456        // Snapshot the registry (id → arity) under a read lock, then drop
457        // it before any module work — keeps the lock window minimal.
458        let registered_entries: HashMap<u32, u8> = {
459            let guard = self
460                .custom_fns
461                .read()
462                .unwrap_or_else(std::sync::PoisonError::into_inner);
463            guard.iter().map(|(&id, e)| (id, e.arity)).collect()
464        };
465
466        // Capture call_conv before any mutable borrows of self.module.
467        let default_call_conv = self.module.target_config().default_call_conv;
468
469        // Build per-arity signatures (1-, 2-, 3-arg f64→f64).
470        let make_fn_sig = |arity: u8| -> Signature {
471            let mut sig = Signature::new(default_call_conv);
472            for _ in 0..arity {
473                sig.params.push(AbiParam::new(types::F64));
474            }
475            sig.returns.push(AbiParam::new(types::F64));
476            sig
477        };
478
479        for node in &ast.nodes {
480            if let SymbolKind::Function(fn_id) = node.kind {
481                if custom_refs.contains_key(&fn_id.0) {
482                    continue;
483                }
484                let arity = registered_entries
485                    .get(&fn_id.0)
486                    .copied()
487                    .ok_or(crate::error::JitError::UnknownFunction)?;
488                let sig = make_fn_sig(arity);
489                let sym = format!("rssn_custom_fn_{}", fn_id.0);
490                let fid = self
491                    .module
492                    .declare_function(&sym, Linkage::Import, &sig)
493                    .map_err(|_| crate::error::JitError::InitFailed)?;
494                let fr = self.module.declare_func_in_func(fid, func_builder.func);
495                custom_refs.insert(fn_id.0, fr);
496            }
497        }
498
499        // Clear and reuse the scratch buffers from the previous compile call
500        // to avoid per-call heap allocation for the work-stack and value-stack.
501        self.work_stack.clear();
502        self.work_values.clear();
503        let root_val = compile_ast_iterative(
504            ast,
505            &analysis,
506            opts,
507            &mut func_builder,
508            vars_ptr,
509            powf_func_ref,
510            fmod_func_ref,
511            &custom_refs,
512            &mut self.work_stack,
513            &mut self.work_values,
514        )?;
515
516        func_builder.ins().return_(&[root_val]);
517        func_builder.finalize();
518
519        let fn_name = format!("expr_{}", JIT_FUNC_COUNTER.fetch_add(1, Ordering::Relaxed));
520        let func_id = self
521            .module
522            .declare_function(&fn_name, Linkage::Export, &ctx.func.signature)
523            .map_err(|_| crate::error::JitError::VerifierRejected)?;
524
525        self.module
526            .define_function(func_id, &mut ctx)
527            .map_err(|_| crate::error::JitError::VerifierRejected)?;
528
529        self.module.clear_context(&mut ctx);
530
531        self.module
532            .finalize_definitions()
533            .map_err(|_| crate::error::JitError::VerifierRejected)?;
534
535        let code_ptr = self.module.get_finalized_function(func_id);
536
537        // SAFETY: Cranelift returns the address of native code matching
538        // exactly the signature we declared above (`fn(*const f64) -> f64`).
539        let compiled_fn: CompiledExprFn = unsafe { std::mem::transmute(code_ptr) };
540        Ok(compiled_fn)
541    }
542
543    /// Compiles a vectorized batch evaluation function using true F64X2 SIMD.
544    ///
545    /// Returns `None` if the expression is not vectorizable (contains `Mod`,
546    /// non-expandable `Pow`, or user `Function` nodes).
547    ///
548    /// The returned function operates on column-major data: each variable
549    /// has its own contiguous column of `f64` values. See [`CompiledBatchFn`].
550    ///
551    /// The `vec_body` block processes 2 rows per iteration using genuine F64X2
552    /// SIMD (one load/store of 16 bytes per variable column), with a scalar
553    /// tail for any odd final row.
554    ///
555    /// # Errors
556    /// Returns a [`crate::error::JitError`] if Cranelift compilation fails.
557    /// Compiles a vectorized batch evaluation function using true F64X2 SIMD.
558    ///
559    /// Returns `None` if the expression is not vectorizable (contains `Mod`,
560    /// non-expandable `Pow`, or user `Function` nodes).
561    ///
562    /// The returned function operates on column-major data: each variable
563    /// has its own contiguous column of `f64` values. See [`CompiledBatchFn`].
564    ///
565    /// The `vec_body` block processes 2 rows per vector iteration via ILP,
566    /// with a scalar tail for any odd final row.
567    ///
568    /// # Errors
569    /// Returns a [`crate::error::JitError`] if Cranelift compilation fails.
570    pub fn compile_batch_f64x2(
571        &mut self,
572        ast: &AstProjection,
573    ) -> Result<Option<CompiledBatchFn>, crate::error::JitError> {
574        if ast.is_empty() {
575            return crate::error::cold_jit_error_malformed_node().map(Some);
576        }
577
578        let analysis = analyze(ast);
579        let opts = OptConfig::default();
580
581        // Check vectorizability: no Mod, no non-expandable Pow; Function nodes
582        // are allowed only when registered as vectorizable in the custom-op registry.
583        if !is_vectorizable_ast(ast, &analysis, self.custom_op_registry.as_deref()) {
584            return Ok(None);
585        }
586
587        // Collect the set of variable sym_ids used in the expression.
588        // We need the ordered sym_ids to map them to column indices.
589        let sym_ids: Vec<u32> = {
590            let mut seen: HashSet<u32> = HashSet::new();
591            let mut ordered: Vec<u32> = Vec::new();
592            for node in &ast.nodes {
593                if let SymbolKind::Variable(sid) = node.kind
594                    && seen.insert(sid.0)
595                {
596                    ordered.push(sid.0);
597                }
598            }
599            ordered
600        };
601
602        let mut ctx = Context::new();
603
604        // Same Windows-ABI fix as in `compile_with_opts`: Fast CC uses
605        // System V register layout (RDI/RSI/RDX) on all x86-64 targets,
606        // but the Windows x64 ABI uses RCX/RDX/R8.  The batch function is
607        // called as `extern "C"` from Rust so it must use the platform C ABI.
608        ctx.func.signature.call_conv = self.module.target_config().default_call_conv;
609
610        // Signature: fn(vars_cols: *const *const f64, n_rows: usize, out: *mut f64)
611        // All three are pointer-sized (i64 on 64-bit).
612        let ptr_type = self.module.target_config().pointer_type();
613        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // vars_cols
614        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // n_rows (usize = ptr_type on 64-bit)
615        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // out
616
617        let mut func_builder = FunctionBuilder::new(&mut ctx.func, &mut self.builder_ctx);
618
619        // Declare powf (needed for non-expanded pow fallback — but
620        // we've already checked there are none for vectorizable ASTs).
621        let mut powf_sig = Signature::new(self.module.target_config().default_call_conv);
622        powf_sig.params.push(AbiParam::new(types::F64));
623        powf_sig.params.push(AbiParam::new(types::F64));
624        powf_sig.returns.push(AbiParam::new(types::F64));
625        let powf_name = self
626            .module
627            .declare_function("powf", Linkage::Import, &powf_sig)
628            .map_err(|_| crate::error::JitError::InitFailed)?;
629
630        // Create basic blocks.
631        let entry_block = func_builder.create_block();
632        let loop_check = func_builder.create_block();
633        let vec_body = func_builder.create_block();
634        let scalar_check = func_builder.create_block();
635        let scalar_body = func_builder.create_block();
636        let ret_block = func_builder.create_block();
637
638        // Block parameters (SSA phi-nodes for the loop induction variable).
639        func_builder.append_block_params_for_function_params(entry_block);
640        func_builder.append_block_param(loop_check, ptr_type); // i
641        func_builder.append_block_param(vec_body, ptr_type); // i
642        func_builder.append_block_param(scalar_check, ptr_type); // i
643        func_builder.append_block_param(scalar_body, ptr_type); // i
644
645        // ── entry block ────────────────────────────────────────────────────
646        func_builder.switch_to_block(entry_block);
647        let params = func_builder.block_params(entry_block);
648        let vars_cols_val = params[0];
649        let n_rows_val = params[1];
650        let out_ptr_val = params[2];
651
652        // Hoist loop-invariant column pointers into the entry block.
653        let ptr_size = i64::from(ptr_type.bytes());
654        let mut var_col_ptrs = HashMap::new();
655        for &sid in &sym_ids {
656            let col_offset = func_builder
657                .ins()
658                .iconst(ptr_type, i64::from(sid).wrapping_mul(ptr_size));
659            let col_ptr_addr = func_builder.ins().iadd(vars_cols_val, col_offset);
660            let col_ptr = func_builder
661                .ins()
662                .load(ptr_type, MemFlags::new(), col_ptr_addr, 0);
663            var_col_ptrs.insert(sid, col_ptr);
664        }
665
666        let zero_i = func_builder.ins().iconst(ptr_type, 0);
667        let zero_i_ba = BlockArg::Value(zero_i);
668        func_builder.ins().jump(loop_check, &[zero_i_ba]);
669        func_builder.seal_block(entry_block);
670
671        // ── loop_check(i) ──────────────────────────────────────────────────
672        func_builder.switch_to_block(loop_check);
673        let i_lc = func_builder.block_params(loop_check)[0];
674        let remaining = func_builder.ins().isub(n_rows_val, i_lc);
675        let two_i = func_builder.ins().iconst(ptr_type, 2);
676        let can_vec = func_builder
677            .ins()
678            .icmp(IntCC::UnsignedGreaterThanOrEqual, remaining, two_i);
679        let i_lc_ba = BlockArg::Value(i_lc);
680        func_builder
681            .ins()
682            .brif(can_vec, vec_body, &[i_lc_ba], scalar_check, &[i_lc_ba]);
683
684        // ── vec_body(i) ─────────────────────────────────────────────────────
685        // True F64X2 SIMD: loads 16 bytes (2 f64s) per variable in one
686        // instruction, evaluates the full expression tree on F64X2 values,
687        // and stores 16 bytes of results.
688        func_builder.switch_to_block(vec_body);
689        let i_vb = func_builder.block_params(vec_body)[0];
690
691        // Byte offset of row i: i * 8
692        let byte_off_vec = func_builder.ins().ishl_imm(i_vb, 3);
693
694        // Load F64X2 values for each variable: reads f64[i] and f64[i+1] in one load.
695        let mut var_vals_vec: HashMap<u32, Value> = HashMap::new();
696        for &sid in &sym_ids {
697            let col_ptr = *var_col_ptrs
698                .get(&sid)
699                .ok_or(crate::error::JitError::MalformedNode)?;
700            let elem_addr = func_builder.ins().iadd(col_ptr, byte_off_vec);
701            // Load 16 bytes = two consecutive f64 values = F64X2 vector
702            let vec_val = func_builder
703                .ins()
704                .load(types::F64X2, MemFlags::new(), elem_addr, 0);
705            var_vals_vec.insert(sid, vec_val);
706        }
707
708        // The powf func_ref is not used in vectorizable expressions (all Pow nodes
709        // are expanded via IntPow/Sqrt/NegIntPow), but we need a placeholder.
710        let powf_func_ref_vb = self
711            .module
712            .declare_func_in_func(powf_name, func_builder.func);
713
714        // Evaluate expression in F64X2 mode.
715        let result_vec = emit_ast_simd_f64x2(
716            ast,
717            &analysis,
718            &opts,
719            &mut func_builder,
720            &var_vals_vec,
721            powf_func_ref_vb,
722        )?;
723
724        // Store F64X2 result: writes 16 bytes = two consecutive f64 outputs.
725        let out_addr_vec = func_builder.ins().iadd(out_ptr_val, byte_off_vec);
726        func_builder
727            .ins()
728            .store(MemFlags::new(), result_vec, out_addr_vec, 0);
729
730        let i_vb_next = func_builder.ins().iadd_imm(i_vb, 2);
731        let i_vb_next_ba = BlockArg::Value(i_vb_next);
732        func_builder.ins().jump(loop_check, &[i_vb_next_ba]);
733
734        // Now seal loop_check (all predecessors: entry and vec_body back-edge).
735        func_builder.seal_block(loop_check);
736        func_builder.seal_block(vec_body);
737
738        // ── scalar_check(i) ────────────────────────────────────────────────
739        func_builder.switch_to_block(scalar_check);
740        let i_scheck = func_builder.block_params(scalar_check)[0];
741        let done = func_builder
742            .ins()
743            .icmp(IntCC::UnsignedGreaterThanOrEqual, i_scheck, n_rows_val);
744        let i_scheck_ba = BlockArg::Value(i_scheck);
745        func_builder.ins().brif(
746            done,
747            ret_block,
748            &[] as &[BlockArg],
749            scalar_body,
750            &[i_scheck_ba],
751        );
752
753        // ── scalar_body(i) ────────────────────────────────────────────────
754        func_builder.switch_to_block(scalar_body);
755        let i_sbody = func_builder.block_params(scalar_body)[0];
756
757        let byte_off_sb = func_builder.ins().ishl_imm(i_sbody, 3);
758        let mut var_vals_sb: HashMap<u32, Value> = HashMap::new();
759        for &sid in &sym_ids {
760            let col_ptr = *var_col_ptrs
761                .get(&sid)
762                .ok_or(crate::error::JitError::MalformedNode)?;
763            let addr = func_builder.ins().iadd(col_ptr, byte_off_sb);
764            let v = func_builder
765                .ins()
766                .load(types::F64, MemFlags::new(), addr, 0);
767            var_vals_sb.insert(sid, v);
768        }
769
770        let powf_ref_sbody = self
771            .module
772            .declare_func_in_func(powf_name, func_builder.func);
773
774        let mut work_stack_sb: Vec<Frame> = Vec::with_capacity(32);
775        let mut work_vals_sb: Vec<Value> = Vec::with_capacity(32);
776        let res_sb = emit_ast_scalar_with_vars(
777            ast,
778            &analysis,
779            &opts,
780            &mut func_builder,
781            &var_vals_sb,
782            powf_ref_sbody,
783            &HashMap::new(),
784            &mut work_stack_sb,
785            &mut work_vals_sb,
786        )?;
787
788        let out_addr_sb = func_builder.ins().iadd(out_ptr_val, byte_off_sb);
789        func_builder
790            .ins()
791            .store(MemFlags::new(), res_sb, out_addr_sb, 0);
792
793        let i_sbody_next = func_builder.ins().iadd_imm(i_sbody, 1);
794        let i_sbody_next_ba = BlockArg::Value(i_sbody_next);
795        func_builder.ins().jump(scalar_check, &[i_sbody_next_ba]);
796
797        func_builder.seal_block(scalar_check);
798        func_builder.seal_block(scalar_body);
799
800        // ── ret_block ─────────────────────────────────────────────────────
801        func_builder.switch_to_block(ret_block);
802        func_builder.ins().return_(&[]);
803        func_builder.seal_block(ret_block);
804
805        func_builder.finalize();
806
807        let fn_name = format!(
808            "batch_expr_{}",
809            JIT_FUNC_COUNTER.fetch_add(1, Ordering::Relaxed)
810        );
811        let func_id = self
812            .module
813            .declare_function(&fn_name, Linkage::Export, &ctx.func.signature)
814            .map_err(|_| crate::error::JitError::VerifierRejected)?;
815
816        self.module
817            .define_function(func_id, &mut ctx)
818            .map_err(|_| crate::error::JitError::VerifierRejected)?;
819
820        self.module.clear_context(&mut ctx);
821
822        self.module
823            .finalize_definitions()
824            .map_err(|_| crate::error::JitError::VerifierRejected)?;
825
826        let code_ptr = self.module.get_finalized_function(func_id);
827
828        // SAFETY: Cranelift returns native code matching the declared
829        // `CompiledBatchFn` signature.
830        let batch_fn: CompiledBatchFn = unsafe { std::mem::transmute(code_ptr) };
831        Ok(Some(batch_fn))
832    }
833
834    /// Compiles a vectorized batch evaluation function using true F64X4 SIMD.
835    ///
836    /// Returns `None` if the expression is not vectorizable (contains `Mod`,
837    /// non-expandable `Pow`, or user `Function` nodes).
838    ///
839    /// The returned function operates on column-major data: each variable
840    /// has its own contiguous column of `f64` values. See [`CompiledBatchFn`].
841    ///
842    /// The `vec_body` block processes 4 rows per vector iteration via ILP,
843    /// with a scalar tail for any remaining rows.
844    ///
845    /// # Errors
846    /// Returns a [`crate::error::JitError`] if Cranelift compilation fails.
847    pub fn compile_batch_f64x4(
848        &mut self,
849        ast: &AstProjection,
850    ) -> Result<Option<CompiledBatchFn>, crate::error::JitError> {
851        if ast.is_empty() {
852            return crate::error::cold_jit_error_malformed_node().map(Some);
853        }
854
855        let analysis = analyze(ast);
856        let opts = OptConfig::default();
857
858        // Check vectorizability.
859        if !is_vectorizable_ast(ast, &analysis, self.custom_op_registry.as_deref()) {
860            return Ok(None);
861        }
862
863        // Collect variables.
864        let sym_ids: Vec<u32> = {
865            let mut seen: HashSet<u32> = HashSet::new();
866            let mut ordered: Vec<u32> = Vec::new();
867            for node in &ast.nodes {
868                if let SymbolKind::Variable(sid) = node.kind
869                    && seen.insert(sid.0)
870                {
871                    ordered.push(sid.0);
872                }
873            }
874            ordered
875        };
876
877        let mut ctx = Context::new();
878        ctx.func.signature.call_conv = self.module.target_config().default_call_conv;
879
880        // Signature: fn(vars_cols: *const *const f64, n_rows: usize, out: *mut f64)
881        let ptr_type = self.module.target_config().pointer_type();
882        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // vars_cols
883        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // n_rows
884        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // out
885
886        let mut func_builder = FunctionBuilder::new(&mut ctx.func, &mut self.builder_ctx);
887
888        let mut powf_sig = Signature::new(self.module.target_config().default_call_conv);
889        powf_sig.params.push(AbiParam::new(types::F64));
890        powf_sig.params.push(AbiParam::new(types::F64));
891        powf_sig.returns.push(AbiParam::new(types::F64));
892        let powf_name = self
893            .module
894            .declare_function("powf", Linkage::Import, &powf_sig)
895            .map_err(|_| crate::error::JitError::InitFailed)?;
896
897        // Create basic blocks.
898        let entry_block = func_builder.create_block();
899        let loop_check = func_builder.create_block();
900        let vec_body = func_builder.create_block();
901        let scalar_check = func_builder.create_block();
902        let scalar_body = func_builder.create_block();
903        let ret_block = func_builder.create_block();
904
905        func_builder.append_block_params_for_function_params(entry_block);
906        func_builder.append_block_param(loop_check, ptr_type); // i
907        func_builder.append_block_param(vec_body, ptr_type); // i
908        func_builder.append_block_param(scalar_check, ptr_type); // i
909        func_builder.append_block_param(scalar_body, ptr_type); // i
910
911        // ── entry block ────────────────────────────────────────────────────
912        func_builder.switch_to_block(entry_block);
913        let params = func_builder.block_params(entry_block);
914        let vars_cols_val = params[0];
915        let n_rows_val = params[1];
916        let out_ptr_val = params[2];
917
918        // Hoist loop-invariant column pointers into the entry block.
919        let ptr_size = i64::from(ptr_type.bytes());
920        let mut var_col_ptrs = HashMap::new();
921        for &sid in &sym_ids {
922            let col_offset = func_builder
923                .ins()
924                .iconst(ptr_type, i64::from(sid).wrapping_mul(ptr_size));
925            let col_ptr_addr = func_builder.ins().iadd(vars_cols_val, col_offset);
926            let col_ptr = func_builder
927                .ins()
928                .load(ptr_type, MemFlags::new(), col_ptr_addr, 0);
929            var_col_ptrs.insert(sid, col_ptr);
930        }
931
932        let zero_i = func_builder.ins().iconst(ptr_type, 0);
933        let zero_i_ba = BlockArg::Value(zero_i);
934        func_builder.ins().jump(loop_check, &[zero_i_ba]);
935        func_builder.seal_block(entry_block);
936
937        // ── loop_check(i) ──────────────────────────────────────────────────
938        func_builder.switch_to_block(loop_check);
939        let i_lc = func_builder.block_params(loop_check)[0];
940        let remaining = func_builder.ins().isub(n_rows_val, i_lc);
941        let four_i = func_builder.ins().iconst(ptr_type, 4);
942        let can_vec = func_builder
943            .ins()
944            .icmp(IntCC::UnsignedGreaterThanOrEqual, remaining, four_i);
945        let i_lc_ba = BlockArg::Value(i_lc);
946        func_builder
947            .ins()
948            .brif(can_vec, vec_body, &[i_lc_ba], scalar_check, &[i_lc_ba]);
949
950        // ── vec_body(i) ─────────────────────────────────────────────────────
951        // F64X4 batch SIMD: processed via two parallel F64X2 operations (ILP).
952        // This is 100% portable (works on both x86-64 and AArch64) and avoids
953        // Cranelift's backend limitations regarding native 256-bit F64X4 support.
954        func_builder.switch_to_block(vec_body);
955        let i_vb = func_builder.block_params(vec_body)[0];
956
957        // Byte offset of row i: i * 8
958        let byte_off_vec = func_builder.ins().ishl_imm(i_vb, 3);
959
960        // Load two F64X2 values for each variable:
961        // vec1 loads f64[i] and f64[i+1]
962        // vec2 loads f64[i+2] and f64[i+3]
963        let mut var_vals_vec1: HashMap<u32, Value> = HashMap::new();
964        let mut var_vals_vec2: HashMap<u32, Value> = HashMap::new();
965        for &sid in &sym_ids {
966            let col_ptr = *var_col_ptrs
967                .get(&sid)
968                .ok_or(crate::error::JitError::MalformedNode)?;
969            let elem_addr1 = func_builder.ins().iadd(col_ptr, byte_off_vec);
970            let vec_val1 = func_builder
971                .ins()
972                .load(types::F64X2, MemFlags::new(), elem_addr1, 0);
973            var_vals_vec1.insert(sid, vec_val1);
974
975            let elem_addr2 = func_builder.ins().iadd_imm(elem_addr1, 16); // +2 elements * 8 bytes
976            let vec_val2 = func_builder
977                .ins()
978                .load(types::F64X2, MemFlags::new(), elem_addr2, 0);
979            var_vals_vec2.insert(sid, vec_val2);
980        }
981
982        let powf_func_ref_vb = self
983            .module
984            .declare_func_in_func(powf_name, func_builder.func);
985
986        // Evaluate both F64X2 vectors.
987        let result_vec1 = emit_ast_simd_f64x2(
988            ast,
989            &analysis,
990            &opts,
991            &mut func_builder,
992            &var_vals_vec1,
993            powf_func_ref_vb,
994        )?;
995
996        let result_vec2 = emit_ast_simd_f64x2(
997            ast,
998            &analysis,
999            &opts,
1000            &mut func_builder,
1001            &var_vals_vec2,
1002            powf_func_ref_vb,
1003        )?;
1004
1005        // Store both F64X2 results consecutively.
1006        let out_addr1 = func_builder.ins().iadd(out_ptr_val, byte_off_vec);
1007        func_builder
1008            .ins()
1009            .store(MemFlags::new(), result_vec1, out_addr1, 0);
1010
1011        let out_addr2 = func_builder.ins().iadd_imm(out_addr1, 16);
1012        func_builder
1013            .ins()
1014            .store(MemFlags::new(), result_vec2, out_addr2, 0);
1015
1016        let i_vb_next = func_builder.ins().iadd_imm(i_vb, 4);
1017        let i_vb_next_ba = BlockArg::Value(i_vb_next);
1018        func_builder.ins().jump(loop_check, &[i_vb_next_ba]);
1019
1020        func_builder.seal_block(loop_check);
1021        func_builder.seal_block(vec_body);
1022
1023        // ── scalar_check(i) ────────────────────────────────────────────────
1024        func_builder.switch_to_block(scalar_check);
1025        let i_scheck = func_builder.block_params(scalar_check)[0];
1026        let done = func_builder
1027            .ins()
1028            .icmp(IntCC::UnsignedGreaterThanOrEqual, i_scheck, n_rows_val);
1029        let i_scheck_ba = BlockArg::Value(i_scheck);
1030        func_builder.ins().brif(
1031            done,
1032            ret_block,
1033            &[] as &[BlockArg],
1034            scalar_body,
1035            &[i_scheck_ba],
1036        );
1037
1038        // ── scalar_body(i) ────────────────────────────────────────────────
1039        func_builder.switch_to_block(scalar_body);
1040        let i_sbody = func_builder.block_params(scalar_body)[0];
1041
1042        let byte_off_sb = func_builder.ins().ishl_imm(i_sbody, 3);
1043        let mut var_vals_sb: HashMap<u32, Value> = HashMap::new();
1044        for &sid in &sym_ids {
1045            let col_ptr = *var_col_ptrs
1046                .get(&sid)
1047                .ok_or(crate::error::JitError::MalformedNode)?;
1048            let addr = func_builder.ins().iadd(col_ptr, byte_off_sb);
1049            let v = func_builder
1050                .ins()
1051                .load(types::F64, MemFlags::new(), addr, 0);
1052            var_vals_sb.insert(sid, v);
1053        }
1054
1055        let powf_ref_sbody = self
1056            .module
1057            .declare_func_in_func(powf_name, func_builder.func);
1058
1059        let mut work_stack_sb: Vec<Frame> = Vec::with_capacity(32);
1060        let mut work_vals_sb: Vec<Value> = Vec::with_capacity(32);
1061        let res_sb = emit_ast_scalar_with_vars(
1062            ast,
1063            &analysis,
1064            &opts,
1065            &mut func_builder,
1066            &var_vals_sb,
1067            powf_ref_sbody,
1068            &HashMap::new(),
1069            &mut work_stack_sb,
1070            &mut work_vals_sb,
1071        )?;
1072
1073        let out_addr_sb = func_builder.ins().iadd(out_ptr_val, byte_off_sb);
1074        func_builder
1075            .ins()
1076            .store(MemFlags::new(), res_sb, out_addr_sb, 0);
1077
1078        let i_sbody_next = func_builder.ins().iadd_imm(i_sbody, 1);
1079        let i_sbody_next_ba = BlockArg::Value(i_sbody_next);
1080        func_builder.ins().jump(scalar_check, &[i_sbody_next_ba]);
1081
1082        func_builder.seal_block(scalar_check);
1083        func_builder.seal_block(scalar_body);
1084
1085        // ── ret_block ─────────────────────────────────────────────────────
1086        func_builder.switch_to_block(ret_block);
1087        func_builder.ins().return_(&[]);
1088        func_builder.seal_block(ret_block);
1089
1090        func_builder.finalize();
1091
1092        let fn_name = format!(
1093            "batch_expr_{}",
1094            JIT_FUNC_COUNTER.fetch_add(1, Ordering::Relaxed)
1095        );
1096        let func_id = self
1097            .module
1098            .declare_function(&fn_name, Linkage::Export, &ctx.func.signature)
1099            .map_err(|_| crate::error::JitError::VerifierRejected)?;
1100
1101        self.module
1102            .define_function(func_id, &mut ctx)
1103            .map_err(|_| crate::error::JitError::VerifierRejected)?;
1104
1105        self.module.clear_context(&mut ctx);
1106
1107        self.module
1108            .finalize_definitions()
1109            .map_err(|_| crate::error::JitError::VerifierRejected)?;
1110
1111        let code_ptr = self.module.get_finalized_function(func_id);
1112
1113        let batch_fn: CompiledBatchFn = unsafe { std::mem::transmute(code_ptr) };
1114        Ok(Some(batch_fn))
1115    }
1116
1117    /// Compiles a vectorized batch evaluation function processing **8 rows per
1118    /// loop iteration** via four independent `F64X2` SIMD chains (ILP-8).
1119    ///
1120    /// This is the widest batch path: it unrolls 4×`F64X2` = 8 f64 values per
1121    /// cycle.  On a modern out-of-order CPU with ≥4 FP execution units and
1122    /// sufficient register file (16 XMM on x86-64, 32 on `AArch64`), the four
1123    /// independent chains fill the execution units in parallel, hiding both
1124    /// FP-latency and memory-load latency.
1125    ///
1126    /// A scalar tail handles any `n_rows % 8` remaining rows.
1127    ///
1128    /// Returns `None` for non-vectorizable expressions (same rules as
1129    /// [`JITCompiler::compile_batch_f64x2`](Self::compile_batch_f64x2)).
1130    ///
1131    /// # Errors
1132    /// Returns [`crate::error::JitError`] if Cranelift compilation fails.
1133    pub fn compile_batch_f64x8(
1134        &mut self,
1135        ast: &AstProjection,
1136    ) -> Result<Option<CompiledBatchFn>, crate::error::JitError> {
1137        if ast.is_empty() {
1138            return crate::error::cold_jit_error_malformed_node().map(Some);
1139        }
1140
1141        let analysis = analyze(ast);
1142        let opts = OptConfig::default();
1143
1144        if !is_vectorizable_ast(ast, &analysis, self.custom_op_registry.as_deref()) {
1145            return Ok(None);
1146        }
1147
1148        let sym_ids: Vec<u32> = {
1149            let mut seen: HashSet<u32> = HashSet::new();
1150            let mut ordered: Vec<u32> = Vec::new();
1151            for node in &ast.nodes {
1152                if let SymbolKind::Variable(sid) = node.kind
1153                    && seen.insert(sid.0)
1154                {
1155                    ordered.push(sid.0);
1156                }
1157            }
1158            ordered
1159        };
1160
1161        let mut ctx = Context::new();
1162        ctx.func.signature.call_conv = self.module.target_config().default_call_conv;
1163
1164        let ptr_type = self.module.target_config().pointer_type();
1165        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // vars_cols
1166        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // n_rows
1167        ctx.func.signature.params.push(AbiParam::new(ptr_type)); // out
1168
1169        let mut func_builder = FunctionBuilder::new(&mut ctx.func, &mut self.builder_ctx);
1170
1171        let mut powf_sig = Signature::new(self.module.target_config().default_call_conv);
1172        powf_sig.params.push(AbiParam::new(types::F64));
1173        powf_sig.params.push(AbiParam::new(types::F64));
1174        powf_sig.returns.push(AbiParam::new(types::F64));
1175        let powf_name = self
1176            .module
1177            .declare_function("powf", Linkage::Import, &powf_sig)
1178            .map_err(|_| crate::error::JitError::InitFailed)?;
1179
1180        let entry_block = func_builder.create_block();
1181        let loop_check = func_builder.create_block();
1182        let vec_body = func_builder.create_block();
1183        let scalar_check = func_builder.create_block();
1184        let scalar_body = func_builder.create_block();
1185        let ret_block = func_builder.create_block();
1186
1187        func_builder.append_block_params_for_function_params(entry_block);
1188        func_builder.append_block_param(loop_check, ptr_type);
1189        func_builder.append_block_param(vec_body, ptr_type);
1190        func_builder.append_block_param(scalar_check, ptr_type);
1191        func_builder.append_block_param(scalar_body, ptr_type);
1192
1193        // ── entry block ───────────────────────────────────────────────────────
1194        func_builder.switch_to_block(entry_block);
1195        let params = func_builder.block_params(entry_block);
1196        let vars_cols_val = params[0];
1197        let n_rows_val = params[1];
1198        let out_ptr_val = params[2];
1199
1200        let ptr_size = i64::from(ptr_type.bytes());
1201        let mut var_col_ptrs: HashMap<u32, Value> = HashMap::new();
1202        for &sid in &sym_ids {
1203            let col_offset = func_builder
1204                .ins()
1205                .iconst(ptr_type, i64::from(sid).wrapping_mul(ptr_size));
1206            let col_ptr_addr = func_builder.ins().iadd(vars_cols_val, col_offset);
1207            let col_ptr = func_builder
1208                .ins()
1209                .load(ptr_type, MemFlags::new(), col_ptr_addr, 0);
1210            var_col_ptrs.insert(sid, col_ptr);
1211        }
1212
1213        let zero_i = func_builder.ins().iconst(ptr_type, 0);
1214        func_builder
1215            .ins()
1216            .jump(loop_check, &[BlockArg::Value(zero_i)]);
1217        func_builder.seal_block(entry_block);
1218
1219        // ── loop_check(i): need ≥8 remaining to use 4×F64X2 body ─────────────
1220        func_builder.switch_to_block(loop_check);
1221        let i_lc = func_builder.block_params(loop_check)[0];
1222        let remaining = func_builder.ins().isub(n_rows_val, i_lc);
1223        let eight_i = func_builder.ins().iconst(ptr_type, 8);
1224        let can_vec =
1225            func_builder
1226                .ins()
1227                .icmp(IntCC::UnsignedGreaterThanOrEqual, remaining, eight_i);
1228        func_builder.ins().brif(
1229            can_vec,
1230            vec_body,
1231            &[BlockArg::Value(i_lc)],
1232            scalar_check,
1233            &[BlockArg::Value(i_lc)],
1234        );
1235
1236        // ── vec_body(i): 4×F64X2 = 8 rows in one iteration (ILP-8) ───────────
1237        func_builder.switch_to_block(vec_body);
1238        let i_vb = func_builder.block_params(vec_body)[0];
1239        let byte_off = func_builder.ins().ishl_imm(i_vb, 3); // i * 8 bytes
1240
1241        // Load four consecutive F64X2 pairs per variable.
1242        // chunk0 = f64[i+0..i+1], chunk1 = f64[i+2..i+3]
1243        // chunk2 = f64[i+4..i+5], chunk3 = f64[i+6..i+7]
1244        let offsets: [i64; 4] = [0, 16, 32, 48]; // bytes: 0*16, 1*16, 2*16, 3*16
1245        let mut var_vals: [HashMap<u32, Value>; 4] = [
1246            HashMap::new(),
1247            HashMap::new(),
1248            HashMap::new(),
1249            HashMap::new(),
1250        ];
1251        for &sid in &sym_ids {
1252            let col_ptr = *var_col_ptrs
1253                .get(&sid)
1254                .ok_or(crate::error::JitError::MalformedNode)?;
1255            let base = func_builder.ins().iadd(col_ptr, byte_off);
1256            for (k, &off) in offsets.iter().enumerate() {
1257                let addr = if off == 0 {
1258                    base
1259                } else {
1260                    func_builder.ins().iadd_imm(base, off)
1261                };
1262                let v = func_builder
1263                    .ins()
1264                    .load(types::F64X2, MemFlags::new(), addr, 0);
1265                var_vals[k].insert(sid, v);
1266            }
1267        }
1268
1269        let powf_ref_vb = self
1270            .module
1271            .declare_func_in_func(powf_name, func_builder.func);
1272
1273        // Evaluate 4 independent expression trees — out-of-order CPU can overlap them.
1274        let results: [Value; 4] = [
1275            emit_ast_simd_f64x2(
1276                ast,
1277                &analysis,
1278                &opts,
1279                &mut func_builder,
1280                &var_vals[0],
1281                powf_ref_vb,
1282            )?,
1283            emit_ast_simd_f64x2(
1284                ast,
1285                &analysis,
1286                &opts,
1287                &mut func_builder,
1288                &var_vals[1],
1289                powf_ref_vb,
1290            )?,
1291            emit_ast_simd_f64x2(
1292                ast,
1293                &analysis,
1294                &opts,
1295                &mut func_builder,
1296                &var_vals[2],
1297                powf_ref_vb,
1298            )?,
1299            emit_ast_simd_f64x2(
1300                ast,
1301                &analysis,
1302                &opts,
1303                &mut func_builder,
1304                &var_vals[3],
1305                powf_ref_vb,
1306            )?,
1307        ];
1308
1309        // Store all four F64X2 results consecutively (32 bytes total).
1310        let out_base = func_builder.ins().iadd(out_ptr_val, byte_off);
1311        for (k, &off) in offsets.iter().enumerate() {
1312            let addr = if off == 0 {
1313                out_base
1314            } else {
1315                func_builder.ins().iadd_imm(out_base, off)
1316            };
1317            func_builder
1318                .ins()
1319                .store(MemFlags::new(), results[k], addr, 0);
1320        }
1321
1322        let i_next = func_builder.ins().iadd_imm(i_vb, 8);
1323        func_builder
1324            .ins()
1325            .jump(loop_check, &[BlockArg::Value(i_next)]);
1326
1327        func_builder.seal_block(loop_check);
1328        func_builder.seal_block(vec_body);
1329
1330        // ── scalar_check(i) ───────────────────────────────────────────────────
1331        func_builder.switch_to_block(scalar_check);
1332        let i_sc = func_builder.block_params(scalar_check)[0];
1333        let done = func_builder
1334            .ins()
1335            .icmp(IntCC::UnsignedGreaterThanOrEqual, i_sc, n_rows_val);
1336        func_builder.ins().brif(
1337            done,
1338            ret_block,
1339            &[] as &[BlockArg],
1340            scalar_body,
1341            &[BlockArg::Value(i_sc)],
1342        );
1343
1344        // ── scalar_body(i): one row at a time for the tail ────────────────────
1345        func_builder.switch_to_block(scalar_body);
1346        let i_sb = func_builder.block_params(scalar_body)[0];
1347        let byte_off_sb = func_builder.ins().ishl_imm(i_sb, 3);
1348        let mut var_vals_sb: HashMap<u32, Value> = HashMap::new();
1349        for &sid in &sym_ids {
1350            let col_ptr = *var_col_ptrs
1351                .get(&sid)
1352                .ok_or(crate::error::JitError::MalformedNode)?;
1353            let addr = func_builder.ins().iadd(col_ptr, byte_off_sb);
1354            let v = func_builder
1355                .ins()
1356                .load(types::F64, MemFlags::new(), addr, 0);
1357            var_vals_sb.insert(sid, v);
1358        }
1359
1360        let powf_ref_sb = self
1361            .module
1362            .declare_func_in_func(powf_name, func_builder.func);
1363        let mut wstack: Vec<Frame> = Vec::with_capacity(32);
1364        let mut wvals: Vec<Value> = Vec::with_capacity(32);
1365        let res_sb = emit_ast_scalar_with_vars(
1366            ast,
1367            &analysis,
1368            &opts,
1369            &mut func_builder,
1370            &var_vals_sb,
1371            powf_ref_sb,
1372            &HashMap::new(),
1373            &mut wstack,
1374            &mut wvals,
1375        )?;
1376
1377        let out_addr_sb = func_builder.ins().iadd(out_ptr_val, byte_off_sb);
1378        func_builder
1379            .ins()
1380            .store(MemFlags::new(), res_sb, out_addr_sb, 0);
1381        let i_sb_next = func_builder.ins().iadd_imm(i_sb, 1);
1382        func_builder
1383            .ins()
1384            .jump(scalar_check, &[BlockArg::Value(i_sb_next)]);
1385
1386        func_builder.seal_block(scalar_check);
1387        func_builder.seal_block(scalar_body);
1388
1389        // ── ret_block ─────────────────────────────────────────────────────────
1390        func_builder.switch_to_block(ret_block);
1391        func_builder.ins().return_(&[]);
1392        func_builder.seal_block(ret_block);
1393        func_builder.finalize();
1394
1395        let fn_name = format!(
1396            "batch8_expr_{}",
1397            JIT_FUNC_COUNTER.fetch_add(1, Ordering::Relaxed)
1398        );
1399        let func_id = self
1400            .module
1401            .declare_function(&fn_name, Linkage::Export, &ctx.func.signature)
1402            .map_err(|_| crate::error::JitError::VerifierRejected)?;
1403
1404        if let Err(e) = self.module.define_function(func_id, &mut ctx) {
1405            eprintln!("JIT f64x8 define_function failed: {e:?}");
1406            return Err(crate::error::JitError::VerifierRejected);
1407        }
1408        self.module.clear_context(&mut ctx);
1409        if let Err(e) = self.module.finalize_definitions() {
1410            eprintln!("JIT f64x8 finalize_definitions failed: {e:?}");
1411            return Err(crate::error::JitError::VerifierRejected);
1412        }
1413
1414        let code_ptr = self.module.get_finalized_function(func_id);
1415        let batch_fn: CompiledBatchFn = unsafe { std::mem::transmute(code_ptr) };
1416        Ok(Some(batch_fn))
1417    }
1418
1419    /// Convenience wrapper: converts the DAG subgraph rooted at `root`
1420    /// to an `AstProjection` and compiles it in one call.
1421    ///
1422    /// This is the idiomatic entry point when the caller already holds a
1423    /// `DagArena` and wants a native function without manually calling
1424    /// [`crate::ast::convert::dag_to_ast`] first.
1425    ///
1426    /// # Errors
1427    /// Returns a [`crate::error::JitError`] if the AST conversion or compilation fails.
1428    pub fn compile_dag(
1429        &mut self,
1430        arena: &crate::dag::arena::DagArena,
1431        root: crate::dag::node::DagNodeId,
1432    ) -> Result<CompiledExprFn, crate::error::JitError> {
1433        let ast = crate::ast::convert::dag_to_ast(arena, root);
1434        self.compile(&ast)
1435    }
1436}
1437
1438// =========================================================================
1439// Iterative codegen (T2.1)
1440// =========================================================================
1441
1442/// Work-stack frame for the iterative post-order walk.
1443///
1444/// `cursor` advances 0..=arity. The frame is "ready to emit" exactly when
1445/// `cursor == arity`; at that point the top `arity` entries of the value
1446/// stack are the SSA values for this node's children.
1447struct Frame {
1448    idx: usize,
1449    arity: usize,
1450    cursor: usize,
1451}
1452
1453#[allow(clippy::too_many_arguments)] // internal codegen workhorse — parameters are a fixed, well-documented set
1454fn compile_ast_iterative(
1455    ast: &AstProjection,
1456    analysis: &[NodeAnalysis],
1457    opts: &OptConfig,
1458    builder: &mut FunctionBuilder<'_>,
1459    vars_ptr: Value,
1460    powf_func_ref: cranelift_codegen::ir::FuncRef,
1461    fmod_func_ref: cranelift_codegen::ir::FuncRef,
1462    custom_refs: &HashMap<u32, cranelift_codegen::ir::FuncRef>,
1463    stack: &mut Vec<Frame>,
1464    values: &mut Vec<Value>,
1465) -> Result<Value, crate::error::JitError> {
1466    // Callers clear these before passing — guaranteed by `compile_with_opts()`.
1467
1468    // FMA peephole: maps each Mul-result Value to its two input factors so
1469    // that when an enclosing Add is emitted we can fold `a*b + c` into one
1470    // `fma(a, b, c)` instruction (jit_review §4 / simd_review §4).
1471    let mut mul_factors: HashMap<Value, (Value, Value)> = HashMap::new();
1472
1473    // CSE: pre-scan for dag_ids that appear more than once.
1474    let duplicate_dag_ids: HashSet<u32> = if opts.enable_cse {
1475        let mut dag_id_count: HashMap<u32, u32> = HashMap::new();
1476        for node in &ast.nodes {
1477            *dag_id_count.entry(node.dag_id.0).or_insert(0) = dag_id_count
1478                .get(&node.dag_id.0)
1479                .copied()
1480                .unwrap_or(0)
1481                .saturating_add(1);
1482        }
1483        dag_id_count
1484            .into_iter()
1485            .filter(|&(_, c)| c > 1)
1486            .map(|(id, _)| id)
1487            .collect()
1488    } else {
1489        HashSet::new()
1490    };
1491    // Maps dag_id.0 → already-computed SSA Value (CSE cache).
1492    let mut cse_map: HashMap<u32, Value> = HashMap::new();
1493
1494    // Seed with the root.
1495    let root_node = ast
1496        .nodes
1497        .first()
1498        .ok_or(crate::error::JitError::MalformedNode)?;
1499    stack.push(Frame {
1500        idx: 0,
1501        arity: root_node.children.len(),
1502        cursor: 0,
1503    });
1504
1505    while let Some(top) = stack.last_mut() {
1506        // CSE check at first visit (cursor == 0): if this node's dag_id
1507        // has already been computed, reuse the cached Value.
1508        if opts.enable_cse && top.cursor == 0 && !duplicate_dag_ids.is_empty() {
1509            let dag_id = ast.nodes.get(top.idx).map_or(u32::MAX, |n| n.dag_id.0);
1510            if duplicate_dag_ids.contains(&dag_id)
1511                && let Some(&cached) = cse_map.get(&dag_id)
1512            {
1513                stack.pop();
1514                values.push(cached);
1515                continue;
1516            }
1517        }
1518
1519        // Decide whether to push a child or emit this node, then act —
1520        // splitting the decision from the action keeps the borrow
1521        // checker happy when we go from `last_mut()` to `push/pop`.
1522        let action = if top.cursor < top.arity {
1523            let Some(node) = ast.nodes.get(top.idx) else {
1524                return Err(crate::error::JitError::MalformedNode);
1525            };
1526            let Some(&child_ptr) = node.children.as_slice().get(top.cursor) else {
1527                return Err(crate::error::JitError::MalformedNode);
1528            };
1529            let child_idx = child_ptr
1530                .resolve(top.idx)
1531                .ok_or(crate::error::JitError::MalformedNode)?;
1532            top.cursor += 1;
1533            Action::Descend(child_idx)
1534        } else {
1535            Action::Emit(top.idx, top.arity)
1536        };
1537
1538        match action {
1539            Action::Descend(child_idx) => {
1540                let Some(child_node) = ast.nodes.get(child_idx) else {
1541                    return Err(crate::error::JitError::MalformedNode);
1542                };
1543                stack.push(Frame {
1544                    idx: child_idx,
1545                    arity: child_node.children.len(),
1546                    cursor: 0,
1547                });
1548            }
1549            Action::Emit(idx, arity) => {
1550                stack.pop();
1551                emit_one_node(
1552                    ast,
1553                    analysis,
1554                    opts,
1555                    idx,
1556                    arity,
1557                    builder,
1558                    vars_ptr,
1559                    powf_func_ref,
1560                    fmod_func_ref,
1561                    custom_refs,
1562                    values,
1563                    &mut mul_factors,
1564                )?;
1565                // Store result in CSE cache if this dag_id is a duplicate.
1566                if opts.enable_cse
1567                    && !duplicate_dag_ids.is_empty()
1568                    && let Some(node) = ast.nodes.get(idx)
1569                {
1570                    let dag_id = node.dag_id.0;
1571                    if duplicate_dag_ids.contains(&dag_id)
1572                        && let Some(&v) = values.last()
1573                    {
1574                        cse_map.insert(dag_id, v);
1575                    }
1576                }
1577            }
1578        }
1579    }
1580
1581    let result = values.pop().ok_or(crate::error::JitError::MalformedNode)?;
1582    if !values.is_empty() {
1583        return Err(crate::error::JitError::VerifierRejected);
1584    }
1585    Ok(result)
1586}
1587
1588enum Action {
1589    Descend(usize),
1590    Emit(usize, usize),
1591}
1592
1593#[allow(clippy::too_many_arguments)]
1594fn emit_one_node(
1595    ast: &AstProjection,
1596    analysis: &[NodeAnalysis],
1597    opts: &OptConfig,
1598    idx: usize,
1599    arity: usize,
1600    builder: &mut FunctionBuilder<'_>,
1601    vars_ptr: Value,
1602    powf_func_ref: cranelift_codegen::ir::FuncRef,
1603    fmod_func_ref: cranelift_codegen::ir::FuncRef,
1604    custom_refs: &HashMap<u32, cranelift_codegen::ir::FuncRef>,
1605    values: &mut Vec<Value>,
1606    mul_factors: &mut HashMap<Value, (Value, Value)>,
1607) -> Result<(), crate::error::JitError> {
1608    let node = ast
1609        .nodes
1610        .get(idx)
1611        .ok_or(crate::error::JitError::MalformedNode)?;
1612
1613    match node.kind {
1614        SymbolKind::Constant(_) => {
1615            values.push(builder.ins().f64const(node.value)); // AstNode.value: f64 (not Option)
1616        }
1617        SymbolKind::Variable(sym_id) => {
1618            let val = emit_variable_load(builder, vars_ptr, sym_id.0);
1619            values.push(val);
1620        }
1621        SymbolKind::Operator(op) => {
1622            let split_at = values
1623                .len()
1624                .checked_sub(arity)
1625                .ok_or(crate::error::JitError::MalformedNode)?;
1626            // Take children out in order — the iterative walker pushes
1627            // left-to-right, so children[0..arity] are already correct.
1628            let child_vals: Vec<Value> = values.drain(split_at..).collect();
1629
1630            // Collect child node analyses (one per child, in order).
1631            // For Pow nodes we also append the node's own analysis at slot 2
1632            // so emit_operator can read the PowExpansion strategy.
1633            let children = node.children.as_slice_with_pool(&ast.children_pool);
1634            let mut child_analyses: Vec<Option<&NodeAnalysis>> = children
1635                .iter()
1636                .map(|ptr| ptr.resolve(idx).and_then(|ci| analysis.get(ci)))
1637                .collect();
1638            // Append the node's own analysis as extra slot for Pow expansion lookup.
1639            child_analyses.push(analysis.get(idx));
1640
1641            let result = emit_operator(
1642                builder,
1643                op,
1644                &child_vals,
1645                powf_func_ref,
1646                fmod_func_ref,
1647                node,
1648                &child_analyses,
1649                opts,
1650                mul_factors,
1651            )?;
1652            values.push(result);
1653        }
1654        SymbolKind::Function(fn_id) => {
1655            // T2.6: resolve `SymbolKind::Function(fn_id)` to the
1656            // FuncRef declared in `compile()` and emit a call with
1657            // 1, 2, or 3 f64 arguments depending on the registration
1658            // arity (`jit_review §3.1`).
1659            let func_ref = custom_refs
1660                .get(&fn_id.0)
1661                .copied()
1662                .ok_or(crate::error::JitError::UnknownFunction)?;
1663            let split_at = values
1664                .len()
1665                .checked_sub(arity)
1666                .ok_or(crate::error::JitError::MalformedNode)?;
1667            let child_vals: Vec<Value> = values.drain(split_at..).collect();
1668            if child_vals.is_empty() || child_vals.len() > 3 {
1669                return Err(crate::error::JitError::MalformedNode);
1670            }
1671            let call = builder.ins().call(func_ref, &child_vals);
1672            let result = builder
1673                .inst_results(call)
1674                .first()
1675                .copied()
1676                .ok_or(crate::error::JitError::VerifierRejected)?;
1677            values.push(result);
1678        }
1679        SymbolKind::ControlFlow(ctrl) => {
1680            use crate::dag::symbol::CtrlKind;
1681            let split_at = values
1682                .len()
1683                .checked_sub(arity)
1684                .ok_or(crate::error::JitError::MalformedNode)?;
1685            let child_vals: Vec<Value> = values.drain(split_at..).collect();
1686
1687            match ctrl {
1688                // ── Select ──────────────────────────────────────────────────
1689                // Branchless: cond != 0.0  → then_val; else → else_val.
1690                // Cranelift's `select` takes an integer condition; we convert
1691                // the f64 cond using `fcmp ne 0.0`.
1692                CtrlKind::Select => {
1693                    if child_vals.len() != 3 {
1694                        return Err(crate::error::JitError::MalformedNode);
1695                    }
1696                    let cond_f64 = child_vals[0];
1697                    let then_val = child_vals[1];
1698                    let else_val = child_vals[2];
1699                    let zero = builder.ins().f64const(0.0);
1700                    // fcmp returns i8; cast to i32 for `select`.
1701                    let cond_i8 = builder.ins().fcmp(FloatCC::NotEqual, cond_f64, zero);
1702                    let result = builder.ins().select(cond_i8, then_val, else_val);
1703                    values.push(result);
1704                }
1705
1706                // ── IfElse ────────────────────────────────────────────────
1707                // Generates three basic blocks:
1708                //   entry → (branch on cond) → then_block  ─┐
1709                //                            → else_block  ──┤
1710                //                                            merge_block(phi: f64) → return val
1711                CtrlKind::IfElse => {
1712                    if child_vals.len() != 3 {
1713                        return Err(crate::error::JitError::MalformedNode);
1714                    }
1715                    // The child values have already been computed by the iterative walker.
1716                    // We use `select` semantics here: since child expressions are fully
1717                    // evaluated before we reach this node, we emit a branchless select.
1718                    // True multi-block IfElse requires lazy evaluation which requires
1719                    // restructuring the walker — currently implemented as a select.
1720                    let cond_f64 = child_vals[0];
1721                    let then_val = child_vals[1];
1722                    let else_val = child_vals[2];
1723                    let zero = builder.ins().f64const(0.0);
1724                    let cond_bool = builder.ins().fcmp(FloatCC::NotEqual, cond_f64, zero);
1725
1726                    // Create then, else, merge blocks
1727                    let then_block = builder.create_block();
1728                    let else_block = builder.create_block();
1729                    let merge_block = builder.create_block();
1730                    // merge_block carries the result as a block parameter (phi-node).
1731                    builder.append_block_param(merge_block, types::F64);
1732
1733                    // Branch: cond_bool → then_block; else → else_block
1734                    builder
1735                        .ins()
1736                        .brif(cond_bool, then_block, &[], else_block, &[]);
1737
1738                    // then_block: jump to merge with then_val
1739                    builder.switch_to_block(then_block);
1740                    builder.seal_block(then_block);
1741                    builder.ins().jump(merge_block, &[BlockArg::from(then_val)]);
1742
1743                    // else_block: jump to merge with else_val
1744                    builder.switch_to_block(else_block);
1745                    builder.seal_block(else_block);
1746                    builder.ins().jump(merge_block, &[BlockArg::from(else_val)]);
1747
1748                    // merge_block: read the phi-node parameter as our result
1749                    builder.switch_to_block(merge_block);
1750                    builder.seal_block(merge_block);
1751                    let result = builder.block_params(merge_block)[0];
1752                    values.push(result);
1753                }
1754
1755                // ── ForLoop ───────────────────────────────────────────────
1756                // Emits a counted loop with SSA block parameters carrying
1757                // the index and accumulator.  Layout:
1758                //   entry → loop_header(idx: f64, acc: f64)
1759                //             │  idx >= limit → loop_exit(acc)
1760                //             └→ loop_body: new_acc = acc + body_val
1761                //                           jump loop_header(idx+step, new_acc)
1762                CtrlKind::ForLoop => {
1763                    if child_vals.len() != 4 {
1764                        return Err(crate::error::JitError::MalformedNode);
1765                    }
1766                    let init_val = child_vals[0]; // initial accumulator
1767                    let limit_val = child_vals[1]; // exclusive upper bound
1768                    let step_val = child_vals[2]; // loop step per iteration
1769                    let body_val = child_vals[3]; // per-iteration contribution
1770
1771                    // Create blocks
1772                    let loop_header = builder.create_block();
1773                    let loop_body = builder.create_block();
1774                    let loop_exit = builder.create_block();
1775
1776                    // Loop header carries (idx: f64, acc: f64) as SSA params.
1777                    builder.append_block_param(loop_header, types::F64); // idx
1778                    builder.append_block_param(loop_header, types::F64); // acc
1779                    builder.append_block_param(loop_exit, types::F64); // final acc
1780
1781                    // Entry: jump to loop_header(init_idx=0, acc=init_val).
1782                    let zero = builder.ins().f64const(0.0);
1783                    builder.ins().jump(
1784                        loop_header,
1785                        &[BlockArg::from(zero), BlockArg::from(init_val)],
1786                    );
1787
1788                    // loop_header: check idx < limit
1789                    builder.switch_to_block(loop_header);
1790                    let idx = builder.block_params(loop_header)[0];
1791                    let acc = builder.block_params(loop_header)[1];
1792                    let cond = builder.ins().fcmp(FloatCC::LessThan, idx, limit_val);
1793                    builder
1794                        .ins()
1795                        .brif(cond, loop_body, &[], loop_exit, &[BlockArg::from(acc)]);
1796                    builder.seal_block(loop_header);
1797
1798                    // loop_body: new_acc = acc + body_val; new_idx = idx + step
1799                    builder.switch_to_block(loop_body);
1800                    builder.seal_block(loop_body);
1801                    let new_acc = builder.ins().fadd(acc, body_val);
1802                    let new_idx = builder.ins().fadd(idx, step_val);
1803                    builder.ins().jump(
1804                        loop_header,
1805                        &[BlockArg::from(new_idx), BlockArg::from(new_acc)],
1806                    );
1807
1808                    // loop_exit: result is the final accumulator
1809                    builder.switch_to_block(loop_exit);
1810                    builder.seal_block(loop_exit);
1811                    let result = builder.block_params(loop_exit)[0];
1812                    values.push(result);
1813                }
1814            }
1815        }
1816    }
1817    Ok(())
1818}
1819
1820fn emit_variable_load(builder: &mut FunctionBuilder<'_>, vars_ptr: Value, sym_idx: u32) -> Value {
1821    // SymbolId * 8 (one f64 per variable). u32 → i64 via i64 is safe.
1822    let offset = i64::from(sym_idx).wrapping_mul(8);
1823    let addr = builder.ins().iadd_imm(vars_ptr, offset);
1824    builder.ins().load(types::F64, MemFlags::new(), addr, 0)
1825}
1826
1827/// Emits IR for a single algebraic operator, applying peephole identity
1828/// simplifications, NaN-guard elision, power expansion, and FMA fusion.
1829///
1830/// `child_analyses` contains the pre-computed `NodeAnalysis` for each child
1831/// (one per child, in order); `opts` controls which passes are active.
1832#[allow(clippy::too_many_arguments)]
1833fn emit_operator(
1834    builder: &mut FunctionBuilder<'_>,
1835    op: OpKind,
1836    child_vals: &[Value],
1837    powf_func_ref: cranelift_codegen::ir::FuncRef,
1838    fmod_func_ref: cranelift_codegen::ir::FuncRef,
1839    ast_node: &AstNode,
1840    child_analyses: &[Option<&NodeAnalysis>],
1841    opts: &OptConfig,
1842    mul_factors: &mut HashMap<Value, (Value, Value)>,
1843) -> Result<Value, crate::error::JitError> {
1844    use crate::jit::primitives::{simplify_add, simplify_mul};
1845
1846    // Look up the immediate constant behind each child SSA value, if any.
1847    // Cranelift exposes this via `func.dfg.value_def`; for `f64const` it
1848    // returns an `InstructionData::UnaryIeee64` with the value.
1849    let constants: Vec<Option<f64>> = child_vals
1850        .iter()
1851        .map(|v| constant_behind(builder, *v))
1852        .collect();
1853
1854    let arity = child_vals.len();
1855    // Suppress "unused" warning on ast_node; it's kept for future peepholes.
1856    let _ = ast_node;
1857
1858    match op {
1859        OpKind::Add => {
1860            if arity != 2 {
1861                return Err(crate::error::JitError::MalformedNode);
1862            }
1863            // Peephole: `x + 0 → x`, `0 + x → x`, `c1 + c2 → const`.
1864            match (constants[0], constants[1]) {
1865                (Some(l), Some(r)) => {
1866                    let folded = simplify_add(l, r).unwrap_or(l + r);
1867                    Ok(builder.ins().f64const(folded))
1868                }
1869                (Some(0.0), _) => Ok(child_vals[1]),
1870                (_, Some(0.0)) => Ok(child_vals[0]),
1871                _ => {
1872                    // FMA peephole (jit_review §4 / simd_review §4):
1873                    // If the left child is a Mul result whose two factors
1874                    // were recorded in `mul_factors`, fold `(a*b) + c`
1875                    // → `fma(a, b, c)`.  Check right side too for
1876                    // commutativity: `c + (a*b)` → `fma(a, b, c)`.
1877                    if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
1878                        Ok(builder.ins().fma(a, b, child_vals[1]))
1879                    } else if let Some(&(a, b)) = mul_factors.get(&child_vals[1]) {
1880                        Ok(builder.ins().fma(a, b, child_vals[0]))
1881                    } else {
1882                        Ok(builder.ins().fadd(child_vals[0], child_vals[1]))
1883                    }
1884                }
1885            }
1886        }
1887        OpKind::Sub => {
1888            if arity != 2 {
1889                return Err(crate::error::JitError::MalformedNode);
1890            }
1891            // x - x = 0 when both sides are the same SSA value.
1892            if child_vals[0] == child_vals[1] {
1893                return Ok(builder.ins().f64const(0.0));
1894            }
1895            match (constants[0], constants[1]) {
1896                (Some(l), Some(r)) => Ok(builder.ins().f64const(l - r)),
1897                (_, Some(0.0)) => Ok(child_vals[0]),
1898                // 0 - x → fneg(x): cheaper than a full fsub.
1899                (Some(0.0), _) => Ok(builder.ins().fneg(child_vals[1])),
1900                _ => {
1901                    // FMA: (a*b) - c → fma(a, b, fneg(c))
1902                    if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
1903                        let neg_c = builder.ins().fneg(child_vals[1]);
1904                        return Ok(builder.ins().fma(a, b, neg_c));
1905                    }
1906                    Ok(builder.ins().fsub(child_vals[0], child_vals[1]))
1907                }
1908            }
1909        }
1910        OpKind::Mul => {
1911            if arity != 2 {
1912                return Err(crate::error::JitError::MalformedNode);
1913            }
1914            // Peephole: `x * 0 → 0`, `x * 1 → x`, `c1 * c2 → const`,
1915            // `x * 2.0 → x + x` (single fadd is often faster than fmul
1916            // by a literal 2.0 on modern FP pipelines — jit_review §4).
1917            match (constants[0], constants[1]) {
1918                (Some(l), Some(r)) => {
1919                    let folded = simplify_mul(l, r).unwrap_or(l * r);
1920                    Ok(builder.ins().f64const(folded))
1921                }
1922                (Some(0.0), _) | (_, Some(0.0)) => Ok(builder.ins().f64const(0.0)),
1923                (Some(1.0), _) => Ok(child_vals[1]),
1924                (_, Some(1.0)) => Ok(child_vals[0]),
1925                // x * -1 → fneg(x)
1926                (Some(-1.0), _) => Ok(builder.ins().fneg(child_vals[1])),
1927                (_, Some(-1.0)) => Ok(builder.ins().fneg(child_vals[0])),
1928                // `x * 2.0 → x + x` and `2.0 * x → x + x`: replacing a
1929                // multiply by a power-of-two constant with an additive
1930                // self-addition avoids an FP multiply unit stall on CPUs
1931                // where FADD has lower latency than FMUL.
1932                (Some(2.0), _) => Ok(builder.ins().fadd(child_vals[1], child_vals[1])),
1933                (_, Some(2.0)) => Ok(builder.ins().fadd(child_vals[0], child_vals[0])),
1934                _ => {
1935                    // Record this Mul's factors so an enclosing Add can
1936                    // optionally fold into FMA instead of separate mul+add.
1937                    let result = builder.ins().fmul(child_vals[0], child_vals[1]);
1938                    mul_factors.insert(result, (child_vals[0], child_vals[1]));
1939                    Ok(result)
1940                }
1941            }
1942        }
1943        OpKind::Div => {
1944            if arity != 2 {
1945                return Err(crate::error::JitError::MalformedNode);
1946            }
1947            let lhs = child_vals[0];
1948            let rhs = child_vals[1];
1949
1950            // Peephole: 0 / x → 0 (when numerator is exact zero and denominator is
1951            // provably non-zero, so there's no 0/0 NaN ambiguity).
1952            if constants[0] == Some(0.0) {
1953                let rhs_nonzero = child_analyses
1954                    .get(1)
1955                    .and_then(|a| *a)
1956                    .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
1957                let rhs_const_nonzero = constants[1].is_some_and(|c| c != 0.0 && !c.is_nan());
1958                if rhs_nonzero || rhs_const_nonzero {
1959                    return Ok(builder.ins().f64const(0.0));
1960                }
1961            }
1962
1963            // Peephole: x / x → 1.0 when both SSA values are identical AND x is
1964            // provably non-zero (so we avoid 0/0 = NaN becoming 1.0).
1965            if lhs == rhs {
1966                let lhs_nonzero = child_analyses
1967                    .first()
1968                    .and_then(|a| *a)
1969                    .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
1970                if lhs_nonzero {
1971                    return Ok(builder.ins().f64const(1.0));
1972                }
1973            }
1974
1975            // Both operands are compile-time constants: fold entirely.
1976            if let (Some(lval), Some(rval)) = (constants[0], constants[1]) {
1977                // IEEE-754: x / 0 == ±Inf or NaN — we map to NaN for
1978                // consistency with the runtime `select` path below.
1979                let result = if rval == 0.0 { f64::NAN } else { lval / rval };
1980                return Ok(builder.ins().f64const(result));
1981            }
1982            // Constant zero denominator: no runtime division needed.
1983            if constants[1] == Some(0.0) {
1984                return Ok(builder.ins().f64const(f64::NAN));
1985            }
1986
1987            // Reciprocal math: x / C → x * (1/C) for constant non-zero C.
1988            if opts.allow_reciprocal_math
1989                && let Some(c) = constants[1]
1990                && c != 0.0
1991            {
1992                let recip = builder.ins().f64const(1.0 / c);
1993                return Ok(builder.ins().fmul(lhs, recip));
1994            }
1995
1996            // NaN guard elision: check whether the DENOMINATOR (child[1]) is
1997            // provably non-zero. Use child_analyses[1] directly.
1998            let rhs_is_const_nonzero = constants[1].is_some_and(|c| c != 0.0 && !c.is_nan());
1999            let rhs_nonzero = child_analyses
2000                .get(1)
2001                .and_then(|a| *a)
2002                .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2003            let skip_guard = opts.elide_nan_guard && (rhs_nonzero || rhs_is_const_nonzero);
2004
2005            if skip_guard {
2006                // Safe to divide directly — no NaN guard needed.
2007                Ok(builder.ins().fdiv(lhs, rhs))
2008            } else {
2009                // Runtime denominator: emit `select(rhs==0, NaN, lhs/rhs)` so
2010                // divide-by-zero yields NaN instead of trapping (IEEE-754 §6.2,
2011                // matches `parallel::solver` behaviour). Using `select` avoids
2012                // a conditional branch and lets the CPU execute both sides.
2013                let zero = builder.ins().f64const(0.0);
2014                let nan_val = builder.ins().f64const(f64::NAN);
2015                let is_zero = builder.ins().fcmp(FloatCC::Equal, rhs, zero);
2016                let div_result = builder.ins().fdiv(lhs, rhs);
2017                Ok(builder.ins().select(is_zero, nan_val, div_result))
2018            }
2019        }
2020        OpKind::Pow => {
2021            if arity != 2 {
2022                return Err(crate::error::JitError::MalformedNode);
2023            }
2024            // Peephole: `x ^ 0 → 1`, `x ^ 1 → x`, `c1 ^ c2 → const`.
2025            match (constants[0], constants[1]) {
2026                (Some(l), Some(r)) => return Ok(builder.ins().f64const(l.powf(r))),
2027                (_, Some(0.0)) => return Ok(builder.ins().f64const(1.0)),
2028                (_, Some(1.0)) => return Ok(child_vals[0]),
2029                _ => {}
2030            }
2031
2032            // Peephole: `1 ^ x → 1` for any finite exponent.
2033            if constants[0] == Some(1.0) {
2034                return Ok(builder.ins().f64const(1.0));
2035            }
2036
2037            // Peephole: `0 ^ x → 0` when the exponent is provably positive (avoids
2038            // the 0^0 = 1 and 0^negative = ±Inf/NaN ambiguities).
2039            if constants[0] == Some(0.0) {
2040                let exp_positive = child_analyses
2041                    .get(1)
2042                    .and_then(|a| *a)
2043                    .is_some_and(|a| a.is_positive);
2044                let exp_const_positive = constants[1].is_some_and(|e| e > 0.0 && !e.is_nan());
2045                if exp_positive || exp_const_positive {
2046                    return Ok(builder.ins().f64const(0.0));
2047                }
2048            }
2049
2050            // Use expansion strategy from the node's own analysis (carried via
2051            // child_analyses — the caller should pass the node's own analysis
2052            // as a stand-in if needed, but for Pow we use the node analysis
2053            // stored in the iteration context). For the new signature we use
2054            // the analysis keyed on this Pow node, which is the last element.
2055            // Since emit_operator no longer receives the node's own analysis
2056            // directly, we look it up from child_analyses slot 2 (convention:
2057            // callers pass [child0_an, child1_an, node_own_an] for Pow).
2058            let node_an: Option<&NodeAnalysis> = child_analyses.get(2).and_then(|a| *a);
2059            let expansion = node_an.map(|a| &a.pow_expansion);
2060            match expansion {
2061                Some(PowExpansion::Sqrt) if opts.expand_sqrt => {
2062                    Ok(passes::emit_sqrt(builder, child_vals[0]))
2063                }
2064                Some(PowExpansion::IntPow(n)) if *n >= 2 && *n <= opts.max_int_pow => {
2065                    Ok(passes::emit_int_pow(builder, child_vals[0], *n))
2066                }
2067                Some(PowExpansion::NegIntPow(n)) => {
2068                    let n = *n;
2069                    let base = child_vals[0];
2070                    let x_n = if n == 1 {
2071                        base
2072                    } else {
2073                        passes::emit_int_pow(builder, base, n)
2074                    };
2075                    let one = builder.ins().f64const(1.0);
2076                    // Guard: if x^n == 0, return NaN (base was zero).
2077                    let base_nonzero = child_analyses
2078                        .first()
2079                        .and_then(|a| *a)
2080                        .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2081                    if opts.elide_nan_guard && base_nonzero {
2082                        Ok(builder.ins().fdiv(one, x_n))
2083                    } else {
2084                        let zero = builder.ins().f64const(0.0);
2085                        let nan_val = builder.ins().f64const(f64::NAN);
2086                        let is_zero = builder.ins().fcmp(FloatCC::Equal, x_n, zero);
2087                        let div_result = builder.ins().fdiv(one, x_n);
2088                        Ok(builder.ins().select(is_zero, nan_val, div_result))
2089                    }
2090                }
2091                _ => {
2092                    // Fall back to runtime powf call.
2093                    // Also handle constants[1] cases that reach here via
2094                    // the fallthrough (e.g. exp > max_int_pow).
2095                    if let Some(exp) = constants[1] {
2096                        // sqrt fallback if not handled above.
2097                        if opts.expand_sqrt && (exp - 0.5_f64).abs() < f64::EPSILON {
2098                            return Ok(passes::emit_sqrt(builder, child_vals[0]));
2099                        }
2100                        let n = exp as u32;
2101                        if exp.to_bits() == f64::from(n).to_bits()
2102                            && n >= 2
2103                            && n <= opts.max_int_pow
2104                        {
2105                            return Ok(passes::emit_int_pow(builder, child_vals[0], n));
2106                        }
2107                    }
2108                    let call = builder.ins().call(powf_func_ref, child_vals);
2109                    Ok(builder.inst_results(call)[0])
2110                }
2111            }
2112        }
2113        OpKind::Mod => {
2114            if arity != 2 {
2115                return Err(crate::error::JitError::MalformedNode);
2116            }
2117            let lhs = child_vals[0];
2118            let rhs = child_vals[1];
2119            if let (Some(lval), Some(rval)) = (constants[0], constants[1]) {
2120                let result = if rval == 0.0 { f64::NAN } else { lval % rval };
2121                return Ok(builder.ins().f64const(result));
2122            }
2123            if constants[1] == Some(0.0) {
2124                return Ok(builder.ins().f64const(f64::NAN));
2125            }
2126            // Cranelift has no native frem for f64; call jit_fmod helper.
2127            // Guard runtime zero-denominator with select(rhs==0, NaN, fmod(lhs,rhs)).
2128            // Check if denominator is provably nonzero to elide the guard.
2129            let rhs_is_const_nonzero = constants[1].is_some_and(|c| c != 0.0 && !c.is_nan());
2130            let rhs_nonzero = child_analyses
2131                .get(1)
2132                .and_then(|a| *a)
2133                .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2134            if opts.elide_nan_guard && (rhs_nonzero || rhs_is_const_nonzero) {
2135                let call = builder.ins().call(fmod_func_ref, &[lhs, rhs]);
2136                Ok(builder.inst_results(call)[0])
2137            } else {
2138                let zero = builder.ins().f64const(0.0);
2139                let nan_val = builder.ins().f64const(f64::NAN);
2140                let is_zero = builder.ins().fcmp(FloatCC::Equal, rhs, zero);
2141                let call = builder.ins().call(fmod_func_ref, &[lhs, rhs]);
2142                let rem_result = builder.inst_results(call)[0];
2143                Ok(builder.ins().select(is_zero, nan_val, rem_result))
2144            }
2145        }
2146        OpKind::Neg => {
2147            if arity != 1 {
2148                return Err(crate::error::JitError::MalformedNode);
2149            }
2150            if let Some(c) = constants[0] {
2151                return Ok(builder.ins().f64const(-c));
2152            }
2153            Ok(builder.ins().fneg(child_vals[0]))
2154        }
2155    }
2156}
2157
2158/// Creates an F64X2 vector constant where both lanes hold the value `v`.
2159///
2160/// Inserts a 16-byte literal into the function's constant pool, then emits
2161/// `vconst` to load it. Bytes are in little-endian order (x86 SIMD layout).
2162fn f64x2_const(builder: &mut FunctionBuilder<'_>, v: f64) -> Value {
2163    use cranelift_codegen::ir::ConstantData;
2164    let bits = v.to_bits().to_le_bytes();
2165    let data: [u8; 16] = [
2166        bits[0], bits[1], bits[2], bits[3], bits[4], bits[5], bits[6], bits[7], bits[0], bits[1],
2167        bits[2], bits[3], bits[4], bits[5], bits[6], bits[7],
2168    ];
2169    let constant_handle = builder
2170        .func
2171        .dfg
2172        .constants
2173        .insert(ConstantData::from(&data[..]));
2174    builder.ins().vconst(types::F64X2, constant_handle)
2175}
2176
2177/// Iterative post-order emitter for F64X2 SIMD code.
2178///
2179/// Evaluates the entire AST tree operating on `F64X2` values. Each variable
2180/// is looked up in `var_vals_vec` (which must map `sym_id.0 → F64X2 Value`).
2181/// Constants are splatted to both lanes via `f64x2_const`.
2182///
2183/// Returns `Err(MalformedNode)` if the AST contains anything that cannot be
2184/// vectorized (Function, Mod, non-expandable Pow).
2185fn emit_ast_simd_f64x2(
2186    ast: &AstProjection,
2187    analysis: &[NodeAnalysis],
2188    opts: &OptConfig,
2189    builder: &mut FunctionBuilder<'_>,
2190    var_vals_vec: &HashMap<u32, Value>, // sym_id.0 → F64X2 Value
2191    _powf_func_ref: cranelift_codegen::ir::FuncRef,
2192) -> Result<Value, crate::error::JitError> {
2193    let mut stack: Vec<Frame> = Vec::with_capacity(64);
2194    let mut values: Vec<Value> = Vec::with_capacity(64);
2195    let mut mul_factors: HashMap<Value, (Value, Value)> = HashMap::new();
2196    // CSE for constant splats: reuse the same SSA Value for identical f64 constants
2197    // across the entire expression tree. Keyed by the bit-pattern of the f64.
2198    let mut const_cache: HashMap<u64, Value> = HashMap::new();
2199
2200    let root_node = ast
2201        .nodes
2202        .first()
2203        .ok_or(crate::error::JitError::MalformedNode)?;
2204    stack.push(Frame {
2205        idx: 0,
2206        arity: root_node.children.len(),
2207        cursor: 0,
2208    });
2209
2210    while let Some(top) = stack.last_mut() {
2211        let action = if top.cursor < top.arity {
2212            let Some(node) = ast.nodes.get(top.idx) else {
2213                return Err(crate::error::JitError::MalformedNode);
2214            };
2215            let Some(&child_ptr) = node.children.as_slice().get(top.cursor) else {
2216                return Err(crate::error::JitError::MalformedNode);
2217            };
2218            let child_idx = child_ptr
2219                .resolve(top.idx)
2220                .ok_or(crate::error::JitError::MalformedNode)?;
2221            top.cursor += 1;
2222            Action::Descend(child_idx)
2223        } else {
2224            Action::Emit(top.idx, top.arity)
2225        };
2226
2227        match action {
2228            Action::Descend(child_idx) => {
2229                let Some(child_node) = ast.nodes.get(child_idx) else {
2230                    return Err(crate::error::JitError::MalformedNode);
2231                };
2232                stack.push(Frame {
2233                    idx: child_idx,
2234                    arity: child_node.children.len(),
2235                    cursor: 0,
2236                });
2237            }
2238            Action::Emit(idx, arity) => {
2239                stack.pop();
2240                let node = ast
2241                    .nodes
2242                    .get(idx)
2243                    .ok_or(crate::error::JitError::MalformedNode)?;
2244                match node.kind {
2245                    SymbolKind::Constant(_) => {
2246                        // CSE: reuse an already-emitted splat for the same constant bit-pattern.
2247                        let bits = node.value.to_bits();
2248                        let v = *const_cache
2249                            .entry(bits)
2250                            .or_insert_with(|| f64x2_const(builder, node.value));
2251                        values.push(v);
2252                    }
2253                    SymbolKind::Variable(sym_id) => {
2254                        let v = var_vals_vec
2255                            .get(&sym_id.0)
2256                            .copied()
2257                            .ok_or(crate::error::JitError::MalformedNode)?;
2258                        values.push(v);
2259                    }
2260                    SymbolKind::Operator(op) => {
2261                        let split_at = values
2262                            .len()
2263                            .checked_sub(arity)
2264                            .ok_or(crate::error::JitError::MalformedNode)?;
2265                        let child_v: Vec<Value> = values.drain(split_at..).collect();
2266
2267                        // Collect child analyses and append node's own analysis for Pow.
2268                        let children = node.children.as_slice_with_pool(&ast.children_pool);
2269                        let mut child_analyses: Vec<Option<&NodeAnalysis>> = children
2270                            .iter()
2271                            .map(|ptr| ptr.resolve(idx).and_then(|ci| analysis.get(ci)))
2272                            .collect();
2273                        child_analyses.push(analysis.get(idx));
2274
2275                        let result = emit_operator_simd_f64x2(
2276                            builder,
2277                            op,
2278                            &child_v,
2279                            node,
2280                            &child_analyses,
2281                            opts,
2282                            &mut mul_factors,
2283                        )?;
2284                        values.push(result);
2285                    }
2286                    SymbolKind::Function(_) | SymbolKind::ControlFlow(_) => {
2287                        // Functions and ControlFlow cannot be vectorized.
2288                        return Err(crate::error::JitError::MalformedNode);
2289                    }
2290                }
2291            }
2292        }
2293    }
2294
2295    values.pop().ok_or(crate::error::JitError::MalformedNode)
2296}
2297
2298/// Emits F64X2 SIMD IR for a single algebraic operator.
2299///
2300/// Mirrors `emit_operator` but works on F64X2 values. Constant peepholes
2301/// use `f64x2_const` instead of `f64const`. NaN guards use `bitcast` +
2302/// `bitselect` instead of scalar `select`.
2303#[allow(clippy::too_many_arguments)]
2304fn emit_operator_simd_f64x2(
2305    builder: &mut FunctionBuilder<'_>,
2306    op: OpKind,
2307    child_vals: &[Value],
2308    ast_node: &AstNode,
2309    child_analyses: &[Option<&NodeAnalysis>],
2310    opts: &OptConfig,
2311    mul_factors: &mut HashMap<Value, (Value, Value)>,
2312) -> Result<Value, crate::error::JitError> {
2313    let _ = ast_node;
2314    let arity = child_vals.len();
2315
2316    match op {
2317        OpKind::Add => {
2318            if arity != 2 {
2319                return Err(crate::error::JitError::MalformedNode);
2320            }
2321            // FMA peephole: (a*b) + c → fma(a, b, c)
2322            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2323                return Ok(builder.ins().fma(a, b, child_vals[1]));
2324            }
2325            if let Some(&(a, b)) = mul_factors.get(&child_vals[1]) {
2326                return Ok(builder.ins().fma(a, b, child_vals[0]));
2327            }
2328            Ok(builder.ins().fadd(child_vals[0], child_vals[1]))
2329        }
2330        OpKind::Sub => {
2331            if arity != 2 {
2332                return Err(crate::error::JitError::MalformedNode);
2333            }
2334            if child_vals[0] == child_vals[1] {
2335                return Ok(f64x2_const(builder, 0.0));
2336            }
2337            // FMA: (a*b) - c → fma(a, b, fneg(c))
2338            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2339                let neg_c = builder.ins().fneg(child_vals[1]);
2340                return Ok(builder.ins().fma(a, b, neg_c));
2341            }
2342            Ok(builder.ins().fsub(child_vals[0], child_vals[1]))
2343        }
2344        OpKind::Mul => {
2345            if arity != 2 {
2346                return Err(crate::error::JitError::MalformedNode);
2347            }
2348            let result = builder.ins().fmul(child_vals[0], child_vals[1]);
2349            // Record for FMA fusion.
2350            mul_factors.insert(result, (child_vals[0], child_vals[1]));
2351            Ok(result)
2352        }
2353        OpKind::Div => {
2354            if arity != 2 {
2355                return Err(crate::error::JitError::MalformedNode);
2356            }
2357            let lhs = child_vals[0];
2358            let rhs = child_vals[1];
2359            // Check if denominator is provably nonzero to elide NaN guard.
2360            let rhs_nonzero = child_analyses
2361                .get(1)
2362                .and_then(|a| *a)
2363                .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2364            if opts.elide_nan_guard && rhs_nonzero {
2365                Ok(builder.ins().fdiv(lhs, rhs))
2366            } else {
2367                // Guard: vselect lanes where rhs == 0 → NaN
2368                let zero_vec = f64x2_const(builder, 0.0);
2369                let nan_vec = f64x2_const(builder, f64::NAN);
2370                let div_result = builder.ins().fdiv(lhs, rhs);
2371                // fcmp on F64X2 → boolean vector; bitcast to F64X2 for bitselect
2372                let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, rhs, zero_vec);
2373                let is_zero_mask = builder
2374                    .ins()
2375                    .bitcast(types::F64X2, MemFlags::new(), is_zero_bv);
2376                Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2377            }
2378        }
2379        OpKind::Pow => {
2380            if arity != 2 {
2381                return Err(crate::error::JitError::MalformedNode);
2382            }
2383            // Get the expansion strategy from the node's own analysis (slot 2).
2384            let node_an: Option<&NodeAnalysis> = child_analyses.get(2).and_then(|a| *a);
2385            let expansion = node_an.map(|a| &a.pow_expansion);
2386            match expansion {
2387                Some(PowExpansion::Sqrt) if opts.expand_sqrt => {
2388                    // sqrt is polymorphic: works on F64X2.
2389                    Ok(builder.ins().sqrt(child_vals[0]))
2390                }
2391                Some(PowExpansion::IntPow(n)) if *n >= 2 && *n <= opts.max_int_pow => {
2392                    // emit_int_pow uses fmul which is polymorphic.
2393                    Ok(passes::emit_int_pow(builder, child_vals[0], *n))
2394                }
2395                Some(PowExpansion::NegIntPow(n)) => {
2396                    let n = *n;
2397                    let base_vec = child_vals[0];
2398                    let x_n = if n == 1 {
2399                        base_vec
2400                    } else {
2401                        passes::emit_int_pow(builder, base_vec, n)
2402                    };
2403                    let one_vec = f64x2_const(builder, 1.0);
2404                    let base_nonzero = child_analyses
2405                        .first()
2406                        .and_then(|a| *a)
2407                        .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2408                    if opts.elide_nan_guard && base_nonzero {
2409                        Ok(builder.ins().fdiv(one_vec, x_n))
2410                    } else {
2411                        let zero_vec = f64x2_const(builder, 0.0);
2412                        let nan_vec = f64x2_const(builder, f64::NAN);
2413                        let div_result = builder.ins().fdiv(one_vec, x_n);
2414                        let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, x_n, zero_vec);
2415                        let is_zero_mask =
2416                            builder
2417                                .ins()
2418                                .bitcast(types::F64X2, MemFlags::new(), is_zero_bv);
2419                        Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2420                    }
2421                }
2422                _ => {
2423                    // Non-expandable Pow — should not occur in vectorizable ASTs.
2424                    Err(crate::error::JitError::MalformedNode)
2425                }
2426            }
2427        }
2428        OpKind::Mod => {
2429            // Mod is excluded from vectorizable ASTs.
2430            Err(crate::error::JitError::MalformedNode)
2431        }
2432        OpKind::Neg => {
2433            if arity != 1 {
2434                return Err(crate::error::JitError::MalformedNode);
2435            }
2436            Ok(builder.ins().fneg(child_vals[0]))
2437        }
2438    }
2439}
2440
2441/// Creates an F64X4 vector constant where all four lanes hold the value `v`.
2442///
2443/// Inserts a 32-byte literal into the function's constant pool, then emits
2444/// `vconst` to load it. Bytes are in little-endian order (x86 SIMD layout).
2445#[allow(dead_code)]
2446fn f64x4_const(builder: &mut FunctionBuilder<'_>, v: f64) -> Value {
2447    use cranelift_codegen::ir::ConstantData;
2448    let bits = v.to_bits().to_le_bytes();
2449    let mut data = [0u8; 32];
2450    for i in 0..4 {
2451        data[i * 8..(i + 1) * 8].copy_from_slice(&bits);
2452    }
2453    let constant_handle = builder
2454        .func
2455        .dfg
2456        .constants
2457        .insert(ConstantData::from(&data[..]));
2458    builder.ins().vconst(types::F64X4, constant_handle)
2459}
2460
2461/// Iterative post-order emitter for F64X4 SIMD code.
2462///
2463/// Evaluates the entire AST tree operating on `F64X4` values. Each variable
2464/// is looked up in `var_vals_vec` (which must map `sym_id.0 → F64X4 Value`).
2465/// Constants are splatted to all four lanes via `f64x4_const`.
2466///
2467/// Returns `Err(MalformedNode)` if the AST contains anything that cannot be
2468/// vectorized (Function, Mod, non-expandable Pow).
2469#[allow(dead_code)]
2470fn emit_ast_simd_f64x4(
2471    ast: &AstProjection,
2472    analysis: &[NodeAnalysis],
2473    opts: &OptConfig,
2474    builder: &mut FunctionBuilder<'_>,
2475    var_vals_vec: &HashMap<u32, Value>, // sym_id.0 → F64X4 Value
2476    _powf_func_ref: cranelift_codegen::ir::FuncRef,
2477) -> Result<Value, crate::error::JitError> {
2478    let mut stack: Vec<Frame> = Vec::with_capacity(64);
2479    let mut values: Vec<Value> = Vec::with_capacity(64);
2480    let mut mul_factors: HashMap<Value, (Value, Value)> = HashMap::new();
2481    let mut const_cache: HashMap<u64, Value> = HashMap::new();
2482
2483    let root_node = ast
2484        .nodes
2485        .first()
2486        .ok_or(crate::error::JitError::MalformedNode)?;
2487    stack.push(Frame {
2488        idx: 0,
2489        arity: root_node.children.len(),
2490        cursor: 0,
2491    });
2492
2493    while let Some(top) = stack.last_mut() {
2494        let action = if top.cursor < top.arity {
2495            let Some(node) = ast.nodes.get(top.idx) else {
2496                return Err(crate::error::JitError::MalformedNode);
2497            };
2498            let Some(&child_ptr) = node.children.as_slice().get(top.cursor) else {
2499                return Err(crate::error::JitError::MalformedNode);
2500            };
2501            let child_idx = child_ptr
2502                .resolve(top.idx)
2503                .ok_or(crate::error::JitError::MalformedNode)?;
2504            top.cursor += 1;
2505            Action::Descend(child_idx)
2506        } else {
2507            Action::Emit(top.idx, top.arity)
2508        };
2509
2510        match action {
2511            Action::Descend(child_idx) => {
2512                let Some(child_node) = ast.nodes.get(child_idx) else {
2513                    return Err(crate::error::JitError::MalformedNode);
2514                };
2515                stack.push(Frame {
2516                    idx: child_idx,
2517                    arity: child_node.children.len(),
2518                    cursor: 0,
2519                });
2520            }
2521            Action::Emit(idx, arity) => {
2522                stack.pop();
2523                let node = ast
2524                    .nodes
2525                    .get(idx)
2526                    .ok_or(crate::error::JitError::MalformedNode)?;
2527                match node.kind {
2528                    SymbolKind::Constant(_) => {
2529                        let bits = node.value.to_bits();
2530                        let v = *const_cache
2531                            .entry(bits)
2532                            .or_insert_with(|| f64x4_const(builder, node.value));
2533                        values.push(v);
2534                    }
2535                    SymbolKind::Variable(sym_id) => {
2536                        let v = var_vals_vec
2537                            .get(&sym_id.0)
2538                            .copied()
2539                            .ok_or(crate::error::JitError::MalformedNode)?;
2540                        values.push(v);
2541                    }
2542                    SymbolKind::Operator(op) => {
2543                        let split_at = values
2544                            .len()
2545                            .checked_sub(arity)
2546                            .ok_or(crate::error::JitError::MalformedNode)?;
2547                        let child_v: Vec<Value> = values.drain(split_at..).collect();
2548
2549                        // Collect child analyses and append node's own analysis for Pow.
2550                        let children = node.children.as_slice_with_pool(&ast.children_pool);
2551                        let mut child_analyses: Vec<Option<&NodeAnalysis>> = children
2552                            .iter()
2553                            .map(|ptr| ptr.resolve(idx).and_then(|ci| analysis.get(ci)))
2554                            .collect();
2555                        child_analyses.push(analysis.get(idx));
2556
2557                        let result = emit_operator_simd_f64x4(
2558                            builder,
2559                            op,
2560                            &child_v,
2561                            node,
2562                            &child_analyses,
2563                            opts,
2564                            &mut mul_factors,
2565                        )?;
2566                        values.push(result);
2567                    }
2568                    SymbolKind::Function(_) | SymbolKind::ControlFlow(_) => {
2569                        // Functions and ControlFlow cannot be vectorized.
2570                        return Err(crate::error::JitError::MalformedNode);
2571                    }
2572                }
2573            }
2574        }
2575    }
2576
2577    values.pop().ok_or(crate::error::JitError::MalformedNode)
2578}
2579
2580/// Emits F64X4 SIMD IR for a single algebraic operator.
2581///
2582/// Mirrors `emit_operator` but works on F64X4 values. Constant peepholes
2583/// use `f64x4_const` instead of `f64const`. NaN guards use `bitcast` +
2584/// `bitselect` instead of scalar `select`.
2585#[allow(dead_code)]
2586#[allow(clippy::too_many_arguments)]
2587fn emit_operator_simd_f64x4(
2588    builder: &mut FunctionBuilder<'_>,
2589    op: OpKind,
2590    child_vals: &[Value],
2591    ast_node: &AstNode,
2592    child_analyses: &[Option<&NodeAnalysis>],
2593    opts: &OptConfig,
2594    mul_factors: &mut HashMap<Value, (Value, Value)>,
2595) -> Result<Value, crate::error::JitError> {
2596    let _ = ast_node;
2597    let arity = child_vals.len();
2598
2599    match op {
2600        OpKind::Add => {
2601            if arity != 2 {
2602                return Err(crate::error::JitError::MalformedNode);
2603            }
2604            // FMA peephole: (a*b) + c → fma(a, b, c)
2605            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2606                return Ok(builder.ins().fma(a, b, child_vals[1]));
2607            }
2608            if let Some(&(a, b)) = mul_factors.get(&child_vals[1]) {
2609                return Ok(builder.ins().fma(a, b, child_vals[0]));
2610            }
2611            Ok(builder.ins().fadd(child_vals[0], child_vals[1]))
2612        }
2613        OpKind::Sub => {
2614            if arity != 2 {
2615                return Err(crate::error::JitError::MalformedNode);
2616            }
2617            if child_vals[0] == child_vals[1] {
2618                return Ok(f64x4_const(builder, 0.0));
2619            }
2620            // FMA: (a*b) - c → fma(a, b, fneg(c))
2621            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2622                let neg_c = builder.ins().fneg(child_vals[1]);
2623                return Ok(builder.ins().fma(a, b, neg_c));
2624            }
2625            Ok(builder.ins().fsub(child_vals[0], child_vals[1]))
2626        }
2627        OpKind::Mul => {
2628            if arity != 2 {
2629                return Err(crate::error::JitError::MalformedNode);
2630            }
2631            let result = builder.ins().fmul(child_vals[0], child_vals[1]);
2632            // Record for FMA fusion.
2633            mul_factors.insert(result, (child_vals[0], child_vals[1]));
2634            Ok(result)
2635        }
2636        OpKind::Div => {
2637            if arity != 2 {
2638                return Err(crate::error::JitError::MalformedNode);
2639            }
2640            let lhs = child_vals[0];
2641            let rhs = child_vals[1];
2642            // Check if denominator is provably nonzero to elide NaN guard.
2643            let rhs_nonzero = child_analyses
2644                .get(1)
2645                .and_then(|a| *a)
2646                .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2647            if opts.elide_nan_guard && rhs_nonzero {
2648                Ok(builder.ins().fdiv(lhs, rhs))
2649            } else {
2650                // Guard: vselect lanes where rhs == 0 → NaN
2651                let zero_vec = f64x4_const(builder, 0.0);
2652                let nan_vec = f64x4_const(builder, f64::NAN);
2653                let div_result = builder.ins().fdiv(lhs, rhs);
2654                let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, rhs, zero_vec);
2655                let is_zero_mask = builder
2656                    .ins()
2657                    .bitcast(types::F64X4, MemFlags::new(), is_zero_bv);
2658                Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2659            }
2660        }
2661        OpKind::Pow => {
2662            if arity != 2 {
2663                return Err(crate::error::JitError::MalformedNode);
2664            }
2665            // Get the expansion strategy from the node's own analysis (slot 2).
2666            let node_an: Option<&NodeAnalysis> = child_analyses.get(2).and_then(|a| *a);
2667            let expansion = node_an.map(|a| &a.pow_expansion);
2668            match expansion {
2669                Some(PowExpansion::Sqrt) if opts.expand_sqrt => {
2670                    // sqrt is polymorphic: works on F64X4.
2671                    Ok(builder.ins().sqrt(child_vals[0]))
2672                }
2673                Some(PowExpansion::IntPow(n)) if *n >= 2 && *n <= opts.max_int_pow => {
2674                    // emit_int_pow uses fmul which is polymorphic.
2675                    Ok(passes::emit_int_pow(builder, child_vals[0], *n))
2676                }
2677                Some(PowExpansion::NegIntPow(n)) => {
2678                    let n = *n;
2679                    let base_vec = child_vals[0];
2680                    let x_n = if n == 1 {
2681                        base_vec
2682                    } else {
2683                        passes::emit_int_pow(builder, base_vec, n)
2684                    };
2685                    let one_vec = f64x4_const(builder, 1.0);
2686                    let base_nonzero = child_analyses
2687                        .first()
2688                        .and_then(|a| *a)
2689                        .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2690                    if opts.elide_nan_guard && base_nonzero {
2691                        Ok(builder.ins().fdiv(one_vec, x_n))
2692                    } else {
2693                        let zero_vec = f64x4_const(builder, 0.0);
2694                        let nan_vec = f64x4_const(builder, f64::NAN);
2695                        let div_result = builder.ins().fdiv(one_vec, x_n);
2696                        let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, x_n, zero_vec);
2697                        let is_zero_mask =
2698                            builder
2699                                .ins()
2700                                .bitcast(types::F64X4, MemFlags::new(), is_zero_bv);
2701                        Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2702                    }
2703                }
2704                _ => {
2705                    // Non-expandable Pow — should not occur in vectorizable ASTs.
2706                    Err(crate::error::JitError::MalformedNode)
2707                }
2708            }
2709        }
2710        OpKind::Mod => {
2711            // Mod is excluded from vectorizable ASTs.
2712            Err(crate::error::JitError::MalformedNode)
2713        }
2714        OpKind::Neg => {
2715            if arity != 1 {
2716                return Err(crate::error::JitError::MalformedNode);
2717            }
2718            Ok(builder.ins().fneg(child_vals[0]))
2719        }
2720    }
2721}
2722
2723/// Creates an F64X8 vector constant where all eight lanes hold the value `v`.
2724#[allow(dead_code)]
2725fn f64x8_const(builder: &mut FunctionBuilder<'_>, v: f64) -> Value {
2726    use cranelift_codegen::ir::ConstantData;
2727    let bits = v.to_bits().to_le_bytes();
2728    let mut data = [0u8; 64];
2729    for i in 0..8 {
2730        data[i * 8..(i + 1) * 8].copy_from_slice(&bits);
2731    }
2732    let constant_handle = builder
2733        .func
2734        .dfg
2735        .constants
2736        .insert(ConstantData::from(&data[..]));
2737    builder.ins().vconst(types::F64X8, constant_handle)
2738}
2739
2740/// Iterative post-order emitter for F64X8 SIMD code.
2741#[allow(dead_code)]
2742fn emit_ast_simd_f64x8(
2743    ast: &AstProjection,
2744    analysis: &[NodeAnalysis],
2745    opts: &OptConfig,
2746    builder: &mut FunctionBuilder<'_>,
2747    var_vals_vec: &HashMap<u32, Value>, // sym_id.0 → F64X8 Value
2748    _powf_func_ref: cranelift_codegen::ir::FuncRef,
2749) -> Result<Value, crate::error::JitError> {
2750    let mut stack: Vec<Frame> = Vec::with_capacity(64);
2751    let mut values: Vec<Value> = Vec::with_capacity(64);
2752    let mut mul_factors: HashMap<Value, (Value, Value)> = HashMap::new();
2753    let mut const_cache: HashMap<u64, Value> = HashMap::new();
2754
2755    let root_node = ast
2756        .nodes
2757        .first()
2758        .ok_or(crate::error::JitError::MalformedNode)?;
2759    stack.push(Frame {
2760        idx: 0,
2761        arity: root_node.children.len(),
2762        cursor: 0,
2763    });
2764
2765    while let Some(top) = stack.last_mut() {
2766        let action = if top.cursor < top.arity {
2767            let Some(node) = ast.nodes.get(top.idx) else {
2768                return Err(crate::error::JitError::MalformedNode);
2769            };
2770            let Some(&child_ptr) = node.children.as_slice().get(top.cursor) else {
2771                return Err(crate::error::JitError::MalformedNode);
2772            };
2773            let child_idx = child_ptr
2774                .resolve(top.idx)
2775                .ok_or(crate::error::JitError::MalformedNode)?;
2776            top.cursor += 1;
2777            Action::Descend(child_idx)
2778        } else {
2779            Action::Emit(top.idx, top.arity)
2780        };
2781
2782        match action {
2783            Action::Descend(child_idx) => {
2784                let Some(child_node) = ast.nodes.get(child_idx) else {
2785                    return Err(crate::error::JitError::MalformedNode);
2786                };
2787                stack.push(Frame {
2788                    idx: child_idx,
2789                    arity: child_node.children.len(),
2790                    cursor: 0,
2791                });
2792            }
2793            Action::Emit(idx, arity) => {
2794                stack.pop();
2795                let node = ast
2796                    .nodes
2797                    .get(idx)
2798                    .ok_or(crate::error::JitError::MalformedNode)?;
2799                match node.kind {
2800                    SymbolKind::Constant(_) => {
2801                        let bits = node.value.to_bits();
2802                        let v = *const_cache
2803                            .entry(bits)
2804                            .or_insert_with(|| f64x8_const(builder, node.value));
2805                        values.push(v);
2806                    }
2807                    SymbolKind::Variable(sym_id) => {
2808                        let v = var_vals_vec
2809                            .get(&sym_id.0)
2810                            .copied()
2811                            .ok_or(crate::error::JitError::MalformedNode)?;
2812                        values.push(v);
2813                    }
2814                    SymbolKind::Operator(op) => {
2815                        let split_at = values
2816                            .len()
2817                            .checked_sub(arity)
2818                            .ok_or(crate::error::JitError::MalformedNode)?;
2819                        let child_v: Vec<Value> = values.drain(split_at..).collect();
2820
2821                        let children = node.children.as_slice_with_pool(&ast.children_pool);
2822                        let mut child_analyses: Vec<Option<&NodeAnalysis>> = children
2823                            .iter()
2824                            .map(|ptr| ptr.resolve(idx).and_then(|ci| analysis.get(ci)))
2825                            .collect();
2826                        child_analyses.push(analysis.get(idx));
2827
2828                        let result = emit_operator_simd_f64x8(
2829                            builder,
2830                            op,
2831                            &child_v,
2832                            node,
2833                            &child_analyses,
2834                            opts,
2835                            &mut mul_factors,
2836                        )?;
2837                        values.push(result);
2838                    }
2839                    SymbolKind::Function(_) | SymbolKind::ControlFlow(_) => {
2840                        return Err(crate::error::JitError::MalformedNode);
2841                    }
2842                }
2843            }
2844        }
2845    }
2846
2847    values.pop().ok_or(crate::error::JitError::MalformedNode)
2848}
2849
2850/// Emits F64X8 SIMD IR for a single algebraic operator.
2851#[allow(dead_code)]
2852#[allow(clippy::too_many_arguments)]
2853fn emit_operator_simd_f64x8(
2854    builder: &mut FunctionBuilder<'_>,
2855    op: OpKind,
2856    child_vals: &[Value],
2857    ast_node: &AstNode,
2858    child_analyses: &[Option<&NodeAnalysis>],
2859    opts: &OptConfig,
2860    mul_factors: &mut HashMap<Value, (Value, Value)>,
2861) -> Result<Value, crate::error::JitError> {
2862    let _ = ast_node;
2863    let arity = child_vals.len();
2864
2865    match op {
2866        OpKind::Add => {
2867            if arity != 2 {
2868                return Err(crate::error::JitError::MalformedNode);
2869            }
2870            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2871                return Ok(builder.ins().fma(a, b, child_vals[1]));
2872            }
2873            if let Some(&(a, b)) = mul_factors.get(&child_vals[1]) {
2874                return Ok(builder.ins().fma(a, b, child_vals[0]));
2875            }
2876            Ok(builder.ins().fadd(child_vals[0], child_vals[1]))
2877        }
2878        OpKind::Sub => {
2879            if arity != 2 {
2880                return Err(crate::error::JitError::MalformedNode);
2881            }
2882            if child_vals[0] == child_vals[1] {
2883                return Ok(f64x8_const(builder, 0.0));
2884            }
2885            if let Some(&(a, b)) = mul_factors.get(&child_vals[0]) {
2886                let neg_c = builder.ins().fneg(child_vals[1]);
2887                return Ok(builder.ins().fma(a, b, neg_c));
2888            }
2889            Ok(builder.ins().fsub(child_vals[0], child_vals[1]))
2890        }
2891        OpKind::Mul => {
2892            if arity != 2 {
2893                return Err(crate::error::JitError::MalformedNode);
2894            }
2895            let result = builder.ins().fmul(child_vals[0], child_vals[1]);
2896            mul_factors.insert(result, (child_vals[0], child_vals[1]));
2897            Ok(result)
2898        }
2899        OpKind::Div => {
2900            if arity != 2 {
2901                return Err(crate::error::JitError::MalformedNode);
2902            }
2903            let lhs = child_vals[0];
2904            let rhs = child_vals[1];
2905            let rhs_nonzero = child_analyses
2906                .get(1)
2907                .and_then(|a| *a)
2908                .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2909            if opts.elide_nan_guard && rhs_nonzero {
2910                Ok(builder.ins().fdiv(lhs, rhs))
2911            } else {
2912                let zero_vec = f64x8_const(builder, 0.0);
2913                let nan_vec = f64x8_const(builder, f64::NAN);
2914                let div_result = builder.ins().fdiv(lhs, rhs);
2915                let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, rhs, zero_vec);
2916                let is_zero_mask = builder
2917                    .ins()
2918                    .bitcast(types::F64X8, MemFlags::new(), is_zero_bv);
2919                Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2920            }
2921        }
2922        OpKind::Pow => {
2923            if arity != 2 {
2924                return Err(crate::error::JitError::MalformedNode);
2925            }
2926            let node_an: Option<&NodeAnalysis> = child_analyses.get(2).and_then(|a| *a);
2927            let expansion = node_an.map(|a| &a.pow_expansion);
2928            match expansion {
2929                Some(PowExpansion::Sqrt) if opts.expand_sqrt => {
2930                    Ok(builder.ins().sqrt(child_vals[0]))
2931                }
2932                Some(PowExpansion::IntPow(n)) if *n >= 2 && *n <= opts.max_int_pow => {
2933                    Ok(passes::emit_int_pow(builder, child_vals[0], *n))
2934                }
2935                Some(PowExpansion::NegIntPow(n)) => {
2936                    let n = *n;
2937                    let base_vec = child_vals[0];
2938                    let x_n = if n == 1 {
2939                        base_vec
2940                    } else {
2941                        passes::emit_int_pow(builder, base_vec, n)
2942                    };
2943                    let one_vec = f64x8_const(builder, 1.0);
2944                    let base_nonzero = child_analyses
2945                        .first()
2946                        .and_then(|a| *a)
2947                        .is_some_and(super::analysis::NodeAnalysis::is_nonzero);
2948                    if opts.elide_nan_guard && base_nonzero {
2949                        Ok(builder.ins().fdiv(one_vec, x_n))
2950                    } else {
2951                        let zero_vec = f64x8_const(builder, 0.0);
2952                        let nan_vec = f64x8_const(builder, f64::NAN);
2953                        let div_result = builder.ins().fdiv(one_vec, x_n);
2954                        let is_zero_bv = builder.ins().fcmp(FloatCC::Equal, x_n, zero_vec);
2955                        let is_zero_mask =
2956                            builder
2957                                .ins()
2958                                .bitcast(types::F64X8, MemFlags::new(), is_zero_bv);
2959                        Ok(builder.ins().bitselect(is_zero_mask, nan_vec, div_result))
2960                    }
2961                }
2962                _ => Err(crate::error::JitError::MalformedNode),
2963            }
2964        }
2965        OpKind::Mod => Err(crate::error::JitError::MalformedNode),
2966        OpKind::Neg => {
2967            if arity != 1 {
2968                return Err(crate::error::JitError::MalformedNode);
2969            }
2970            Ok(builder.ins().fneg(child_vals[0]))
2971        }
2972    }
2973}
2974
2975/// Returns the constant `f64` behind `v` if `v` is the result of a
2976/// `f64const` instruction; otherwise `None`.
2977///
2978/// Used by the IR-time peephole pass so `x + 0 → x`, `x * 0 → 0` etc.
2979/// hold even when one side is a freshly emitted constant Value.
2980fn constant_behind(builder: &FunctionBuilder<'_>, v: Value) -> Option<f64> {
2981    use cranelift_codegen::ir::{InstructionData, ValueDef};
2982    let dfg = &builder.func.dfg;
2983    let ValueDef::Result(inst, _) = dfg.value_def(v) else {
2984        return None;
2985    };
2986    if let InstructionData::UnaryIeee64 { imm, .. } = dfg.insts[inst] {
2987        Some(f64::from_bits(imm.bits()))
2988    } else {
2989        None
2990    }
2991}
2992
2993/// Returns `true` if the expression can be compiled to a vectorized batch
2994/// function.
2995///
2996/// Requirements:
2997/// - No `Mod` nodes.
2998/// - No `Pow` nodes whose exponent cannot be expanded (strategy `None`).
2999/// - No `Function` nodes — *unless* the function is registered in
3000///   `custom_ops` and flagged `vectorizable` (pure, side-effect free).
3001fn is_vectorizable_ast(
3002    ast: &AstProjection,
3003    analysis: &[NodeAnalysis],
3004    custom_ops: Option<&crate::custom::descriptor::CustomOpRegistry>,
3005) -> bool {
3006    for (node, an) in ast.nodes.iter().zip(analysis.iter()) {
3007        match node.kind {
3008            SymbolKind::Function(fn_id) => {
3009                // Allow if registered and explicitly flagged vectorizable.
3010                if custom_ops.is_some_and(|r| r.is_vectorizable(fn_id)) {
3011                    continue;
3012                }
3013                return false;
3014            }
3015            SymbolKind::Operator(OpKind::Mod) => return false,
3016            SymbolKind::Operator(OpKind::Pow) => {
3017                if matches!(an.pow_expansion, PowExpansion::None) {
3018                    return false;
3019                }
3020            }
3021            _ => {}
3022        }
3023    }
3024    true
3025}
3026
3027/// Iterative scalar emitter that substitutes variables from a pre-built map
3028/// (`var_vals`: `sym_id.0` → SSA Value) instead of loading from a pointer.
3029///
3030/// Used by `compile_batch_f64x2` to emit two independent expression trees
3031/// for the two loop rows.
3032#[allow(clippy::too_many_arguments)]
3033fn emit_ast_scalar_with_vars(
3034    ast: &AstProjection,
3035    analysis: &[NodeAnalysis],
3036    opts: &OptConfig,
3037    builder: &mut FunctionBuilder<'_>,
3038    var_vals: &HashMap<u32, Value>,
3039    powf_func_ref: cranelift_codegen::ir::FuncRef,
3040    custom_refs: &HashMap<u32, cranelift_codegen::ir::FuncRef>,
3041    stack: &mut Vec<Frame>,
3042    values: &mut Vec<Value>,
3043) -> Result<Value, crate::error::JitError> {
3044    stack.clear();
3045    values.clear();
3046
3047    let mut mul_factors: HashMap<Value, (Value, Value)> = HashMap::new();
3048
3049    // Fmod is unused in vectorizable expressions (we already checked), but we
3050    // need a placeholder FuncRef. Reuse powf_func_ref as a dummy — it will
3051    // never be called because Mod nodes are excluded.
3052    let fmod_dummy = powf_func_ref;
3053
3054    let root_node = ast
3055        .nodes
3056        .first()
3057        .ok_or(crate::error::JitError::MalformedNode)?;
3058    stack.push(Frame {
3059        idx: 0,
3060        arity: root_node.children.len(),
3061        cursor: 0,
3062    });
3063
3064    while let Some(top) = stack.last_mut() {
3065        let action = if top.cursor < top.arity {
3066            let Some(node) = ast.nodes.get(top.idx) else {
3067                return Err(crate::error::JitError::MalformedNode);
3068            };
3069            let Some(&child_ptr) = node.children.as_slice().get(top.cursor) else {
3070                return Err(crate::error::JitError::MalformedNode);
3071            };
3072            let child_idx = child_ptr
3073                .resolve(top.idx)
3074                .ok_or(crate::error::JitError::MalformedNode)?;
3075            top.cursor += 1;
3076            Action::Descend(child_idx)
3077        } else {
3078            Action::Emit(top.idx, top.arity)
3079        };
3080
3081        match action {
3082            Action::Descend(child_idx) => {
3083                let Some(child_node) = ast.nodes.get(child_idx) else {
3084                    return Err(crate::error::JitError::MalformedNode);
3085                };
3086                stack.push(Frame {
3087                    idx: child_idx,
3088                    arity: child_node.children.len(),
3089                    cursor: 0,
3090                });
3091            }
3092            Action::Emit(idx, arity) => {
3093                stack.pop();
3094                let node = ast
3095                    .nodes
3096                    .get(idx)
3097                    .ok_or(crate::error::JitError::MalformedNode)?;
3098                match node.kind {
3099                    SymbolKind::Constant(_) => {
3100                        values.push(builder.ins().f64const(node.value));
3101                    }
3102                    SymbolKind::Variable(sym_id) => {
3103                        let v = var_vals
3104                            .get(&sym_id.0)
3105                            .copied()
3106                            .ok_or(crate::error::JitError::MalformedNode)?;
3107                        values.push(v);
3108                    }
3109                    SymbolKind::Operator(op) => {
3110                        let split_at = values
3111                            .len()
3112                            .checked_sub(arity)
3113                            .ok_or(crate::error::JitError::MalformedNode)?;
3114                        let child_v: Vec<Value> = values.drain(split_at..).collect();
3115                        let children = node.children.as_slice_with_pool(&ast.children_pool);
3116                        let mut child_analyses: Vec<Option<&NodeAnalysis>> = children
3117                            .iter()
3118                            .map(|ptr| ptr.resolve(idx).and_then(|ci| analysis.get(ci)))
3119                            .collect();
3120                        // Append node's own analysis for Pow expansion slot.
3121                        child_analyses.push(analysis.get(idx));
3122                        let result = emit_operator(
3123                            builder,
3124                            op,
3125                            &child_v,
3126                            powf_func_ref,
3127                            fmod_dummy,
3128                            node,
3129                            &child_analyses,
3130                            opts,
3131                            &mut mul_factors,
3132                        )?;
3133                        values.push(result);
3134                    }
3135                    SymbolKind::Function(fn_id) => {
3136                        let func_ref = custom_refs
3137                            .get(&fn_id.0)
3138                            .copied()
3139                            .ok_or(crate::error::JitError::UnknownFunction)?;
3140                        let split_at = values
3141                            .len()
3142                            .checked_sub(arity)
3143                            .ok_or(crate::error::JitError::MalformedNode)?;
3144                        let child_v: Vec<Value> = values.drain(split_at..).collect();
3145                        if child_v.is_empty() || child_v.len() > 3 {
3146                            return Err(crate::error::JitError::MalformedNode);
3147                        }
3148                        let call = builder.ins().call(func_ref, &child_v);
3149                        let result = builder
3150                            .inst_results(call)
3151                            .first()
3152                            .copied()
3153                            .ok_or(crate::error::JitError::VerifierRejected)?;
3154                        values.push(result);
3155                    }
3156                    SymbolKind::ControlFlow(_) => {
3157                        return Err(crate::error::JitError::MalformedNode);
3158                    }
3159                }
3160            }
3161        }
3162    }
3163
3164    let result = values.pop().ok_or(crate::error::JitError::MalformedNode)?;
3165    Ok(result)
3166}
3167
3168#[cfg(test)]
3169mod tests {
3170    use super::*;
3171    use crate::ast::convert::dag_to_ast;
3172    use crate::dag::builder::DagBuilder;
3173    use crate::parser::expr::parse_expression;
3174
3175    #[test]
3176    fn test_jit_compile_and_execute() {
3177        let mut builder = DagBuilder::new();
3178        let id = parse_expression("x * 2.5 + y ^ 2.0", &mut builder).unwrap();
3179        let ast = dag_to_ast(builder.arena(), id);
3180
3181        let mut compiler = JitCompiler::new();
3182        let compiled_fn = compiler.compile(&ast).unwrap();
3183
3184        let vars = vec![3.0, 4.0];
3185        let result = compiled_fn(vars.as_ptr());
3186        let expected = 3.0 * 2.5 + 4.0_f64.powf(2.0);
3187        assert!((result - expected).abs() < f64::EPSILON);
3188    }
3189
3190    #[test]
3191    fn test_jit_divide_by_zero_yields_nan() {
3192        let mut builder = DagBuilder::new();
3193        let id = parse_expression("x / y", &mut builder).unwrap();
3194        let ast = dag_to_ast(builder.arena(), id);
3195
3196        let mut compiler = JitCompiler::new();
3197        let compiled_fn = compiler.compile(&ast).unwrap();
3198
3199        let safe_vars = vec![10.0, 2.0];
3200        let safe_res = compiled_fn(safe_vars.as_ptr());
3201        assert!((safe_res - 5.0).abs() < f64::EPSILON);
3202
3203        // Division by zero must return NaN (not trap).
3204        let zero_vars = vec![10.0, 0.0];
3205        let nan_res = compiled_fn(zero_vars.as_ptr());
3206        assert!(nan_res.is_nan(), "x/0 must be NaN; got {nan_res}");
3207    }
3208
3209    #[test]
3210    fn test_jit_deep_chain_no_stack_overflow() {
3211        // Build (((x+x)+x)+x)... 2500 deep.
3212        let mut b = DagBuilder::new();
3213        let x = b.variable("x");
3214        let mut acc = x;
3215        for _ in 0..2500 {
3216            acc = b.add(acc, x);
3217        }
3218        let ast = dag_to_ast(b.arena(), acc);
3219
3220        let mut compiler = JitCompiler::new();
3221        let compiled_fn = compiler.compile(&ast).unwrap();
3222        let vars = vec![1.0];
3223        let result = compiled_fn(vars.as_ptr());
3224        // (((1+1)+1)+1)... = 1 * (2501 ones).
3225        let expected = 2501.0;
3226        assert!((result - expected).abs() < f64::EPSILON);
3227    }
3228
3229    #[test]
3230    fn test_peephole_x_plus_zero() {
3231        let mut b = DagBuilder::new();
3232        let id = parse_expression("x + 0", &mut b).unwrap();
3233        let ast = dag_to_ast(b.arena(), id);
3234        let mut compiler = JitCompiler::new();
3235        let f = compiler.compile(&ast).unwrap();
3236        assert!((f([3.5_f64].as_ptr()) - 3.5).abs() < f64::EPSILON);
3237    }
3238
3239    #[test]
3240    fn test_peephole_x_times_zero() {
3241        let mut b = DagBuilder::new();
3242        let id = parse_expression("x * 0", &mut b).unwrap();
3243        let ast = dag_to_ast(b.arena(), id);
3244        let mut compiler = JitCompiler::new();
3245        let f = compiler.compile(&ast).unwrap();
3246        assert!(f([42.0_f64].as_ptr()).abs() < f64::EPSILON);
3247    }
3248
3249    #[test]
3250    fn test_peephole_constant_fold() {
3251        let mut b = DagBuilder::new();
3252        // 3 + 4 should compile to a constant `7.0` (peephole folds it).
3253        let id = parse_expression("3 + 4", &mut b).unwrap();
3254        let ast = dag_to_ast(b.arena(), id);
3255        let mut compiler = JitCompiler::new();
3256        let f = compiler.compile(&ast).unwrap();
3257        let r = f([].as_ptr());
3258        assert!((r - 7.0).abs() < f64::EPSILON);
3259    }
3260
3261    extern "C" fn rssn_test_double(x: f64) -> f64 {
3262        x * 2.0
3263    }
3264
3265    #[test]
3266    fn test_custom_function_jit_round_trip() {
3267        use crate::dag::metadata::NodeFlags;
3268        use crate::dag::symbol::{FnId, SymbolKind as SK};
3269
3270        let mut b = DagBuilder::new();
3271        let x = b.variable("x");
3272        // Build `double(x)` via a custom function id #42.
3273        let fn_id = FnId(42);
3274        let expr = b.operator(SK::Function(fn_id), &[x], NodeFlags::EMPTY);
3275        let ast = dag_to_ast(b.arena(), expr);
3276
3277        let mut compiler = JitCompiler::new();
3278        compiler.register_custom_function(fn_id, rssn_test_double);
3279        let f = compiler.compile(&ast).expect("compile with custom fn");
3280
3281        let vars = vec![3.5_f64];
3282        let r = f(vars.as_ptr());
3283        assert!((r - 7.0).abs() < f64::EPSILON, "expected 3.5 * 2 = 7.0");
3284    }
3285
3286    #[test]
3287    fn test_custom_function_unregistered_fails_cleanly() {
3288        use crate::dag::metadata::NodeFlags;
3289        use crate::dag::symbol::{FnId, SymbolKind as SK};
3290
3291        let mut b = DagBuilder::new();
3292        let x = b.variable("x");
3293        let expr = b.operator(SK::Function(FnId(99)), &[x], NodeFlags::EMPTY);
3294        let ast = dag_to_ast(b.arena(), expr);
3295
3296        let mut compiler = JitCompiler::new();
3297        // No `register_custom_function` call → compile must error.
3298        let err = compiler.compile(&ast).expect_err("must error");
3299        assert_eq!(
3300            err,
3301            crate::error::JitError::UnknownFunction,
3302            "unregistered fn id must yield UnknownFunction; got: {err:?}"
3303        );
3304    }
3305
3306    #[test]
3307    fn test_power_expansion_x_squared_no_powf() {
3308        // x^2 should compile without calling powf: result must be exact.
3309        let mut b = DagBuilder::new();
3310        let id = parse_expression("x ^ 2", &mut b).unwrap();
3311        let ast = dag_to_ast(b.arena(), id);
3312        let mut compiler = JitCompiler::new();
3313        let f = compiler.compile(&ast).unwrap();
3314        // x=3: 3^2 = 9
3315        let result = f([3.0_f64].as_ptr());
3316        assert!(
3317            (result - 9.0).abs() < f64::EPSILON,
3318            "3^2 should be 9; got {result}"
3319        );
3320    }
3321
3322    #[test]
3323    fn test_power_expansion_sqrt() {
3324        let mut b = DagBuilder::new();
3325        let id = parse_expression("x ^ 0.5", &mut b).unwrap();
3326        let ast = dag_to_ast(b.arena(), id);
3327        let mut compiler = JitCompiler::new();
3328        let f = compiler.compile(&ast).unwrap();
3329        let result = f([4.0_f64].as_ptr());
3330        assert!(
3331            (result - 2.0).abs() < 1e-10,
3332            "4^0.5 should be 2; got {result}"
3333        );
3334    }
3335
3336    #[test]
3337    fn test_nan_guard_elision_constant_denominator() {
3338        // x / 3.0: denominator is a nonzero constant, no NaN guard needed.
3339        let mut b = DagBuilder::new();
3340        let id = parse_expression("x / 3", &mut b).unwrap();
3341        let ast = dag_to_ast(b.arena(), id);
3342        let mut compiler = JitCompiler::new();
3343        let f = compiler.compile(&ast).unwrap();
3344        let result = f([9.0_f64].as_ptr());
3345        assert!(
3346            (result - 3.0).abs() < f64::EPSILON,
3347            "9/3 should be 3; got {result}"
3348        );
3349    }
3350
3351    #[test]
3352    fn test_batch_f64x2_correctness() {
3353        let mut b = DagBuilder::new();
3354        let id = parse_expression("x + y", &mut b).unwrap();
3355        let ast = dag_to_ast(b.arena(), id);
3356        let mut compiler = JitCompiler::new();
3357        let batch_fn = compiler
3358            .compile_batch_f64x2(&ast)
3359            .expect("compile ok")
3360            .expect("should be vectorizable");
3361
3362        // 4 rows: [1+2=3, 3+4=7, 5+6=11, 7+8=15]
3363        let x_col = vec![1.0_f64, 3.0, 5.0, 7.0];
3364        let y_col = vec![2.0_f64, 4.0, 6.0, 8.0];
3365        let cols: Vec<*const f64> = vec![x_col.as_ptr(), y_col.as_ptr()];
3366        let mut out = vec![0.0_f64; 4];
3367        batch_fn(cols.as_ptr(), 4, out.as_mut_ptr());
3368
3369        let expected = [3.0_f64, 7.0, 11.0, 15.0];
3370        for (i, (&got, &exp)) in out.iter().zip(expected.iter()).enumerate() {
3371            assert!(
3372                (got - exp).abs() < f64::EPSILON,
3373                "row {i}: expected {exp}, got {got}"
3374            );
3375        }
3376    }
3377
3378    #[test]
3379    fn test_neg_int_pow_x_inv() {
3380        // x^(-1) = 1/x. For x=3, result should be 1/3.
3381        let mut b = DagBuilder::new();
3382        let id = parse_expression("x ^ -1", &mut b).unwrap();
3383        let ast = dag_to_ast(b.arena(), id);
3384        let mut compiler = JitCompiler::new();
3385        let f = compiler.compile(&ast).unwrap();
3386        let result = f([3.0_f64].as_ptr());
3387        let expected = 1.0_f64 / 3.0;
3388        assert!(
3389            (result - expected).abs() < 1e-14,
3390            "x^(-1) for x=3 should be ~{expected}; got {result}"
3391        );
3392        // x=0 should return NaN (not trap).
3393        let nan_result = f([0.0_f64].as_ptr());
3394        assert!(
3395            nan_result.is_nan(),
3396            "x^(-1) for x=0 should be NaN; got {nan_result}"
3397        );
3398    }
3399
3400    #[test]
3401    fn test_analysis_x_squared_plus_1_is_positive() {
3402        // x^2 + 1 should be proven is_positive by the analysis.
3403        let mut b = DagBuilder::new();
3404        let id = parse_expression("x ^ 2 + 1", &mut b).unwrap();
3405        let ast = dag_to_ast(b.arena(), id);
3406        let analysis = crate::jit::analysis::analyze(&ast);
3407        // Root is the Add node (index 0).
3408        let root_an = &analysis[0];
3409        assert!(
3410            root_an.is_positive,
3411            "x^2 + 1 should be provably positive; got {root_an:?}"
3412        );
3413        assert!(
3414            root_an.is_nonnegative,
3415            "x^2 + 1 should be provably nonneg; got {root_an:?}"
3416        );
3417    }
3418
3419    #[test]
3420    fn test_nan_guard_elision_x_sq_plus_1_denominator() {
3421        // x / (x^2 + 1): denominator is proven positive, so no NaN guard needed.
3422        // For any x, x^2 + 1 ≥ 1 > 0, so this is always safe.
3423        let mut b = DagBuilder::new();
3424        let id = parse_expression("x / (x ^ 2 + 1)", &mut b).unwrap();
3425        let ast = dag_to_ast(b.arena(), id);
3426        let mut compiler = JitCompiler::new();
3427        let f = compiler.compile(&ast).unwrap();
3428
3429        // Test for various x values including x=0.
3430        let test_cases: &[(f64, f64)] = &[
3431            (0.0, 0.0),        // 0 / 1 = 0
3432            (1.0, 0.5),        // 1 / 2 = 0.5
3433            (2.0, 0.4),        // 2 / 5 = 0.4
3434            (-1.0, -0.5),      // -1 / 2 = -0.5
3435            (3.0, 3.0 / 10.0), // 3 / 10
3436        ];
3437        for &(x, expected) in test_cases {
3438            let result = f([x].as_ptr());
3439            assert!(
3440                (result - expected).abs() < 1e-14,
3441                "x / (x^2+1) for x={x}: expected {expected}, got {result}"
3442            );
3443        }
3444    }
3445
3446    #[test]
3447    fn test_batch_f64x2_true_simd() {
3448        // Verify that the batch F64X2 function (now true SIMD) produces
3449        // correct results, including for odd numbers of rows (scalar tail).
3450        let mut b = DagBuilder::new();
3451        let id = parse_expression("x * x + 1", &mut b).unwrap();
3452        let ast = dag_to_ast(b.arena(), id);
3453        let mut compiler = JitCompiler::new();
3454        let batch_fn = compiler
3455            .compile_batch_f64x2(&ast)
3456            .expect("compile ok")
3457            .expect("should be vectorizable");
3458
3459        // 5 rows: test both the SIMD path (4 rows) and scalar tail (1 row).
3460        let x_col = vec![0.0_f64, 1.0, 2.0, 3.0, 4.0];
3461        let cols: Vec<*const f64> = vec![x_col.as_ptr()];
3462        let mut out = vec![0.0_f64; 5];
3463        batch_fn(cols.as_ptr(), 5, out.as_mut_ptr());
3464
3465        let expected = [1.0_f64, 2.0, 5.0, 10.0, 17.0]; // x^2 + 1
3466        for (i, (&got, &exp)) in out.iter().zip(expected.iter()).enumerate() {
3467            assert!(
3468                (got - exp).abs() < f64::EPSILON,
3469                "row {i}: expected {exp}, got {got}"
3470            );
3471        }
3472    }
3473
3474    #[test]
3475    fn test_zero_minus_x_is_fneg() {
3476        // 0 - x should compile to fneg(x). For x=3, result is -3.
3477        let mut b = DagBuilder::new();
3478        let id = parse_expression("0 - x", &mut b).unwrap();
3479        let ast = dag_to_ast(b.arena(), id);
3480        let mut compiler = JitCompiler::new();
3481        let f = compiler.compile(&ast).unwrap();
3482        let result = f([3.0_f64].as_ptr());
3483        assert!(
3484            (result - (-3.0)).abs() < f64::EPSILON,
3485            "0 - 3 should be -3; got {result}"
3486        );
3487        let result2 = f([0.0_f64].as_ptr());
3488        assert!(
3489            result2 == 0.0 || result2 == -0.0,
3490            "0 - 0 should be ±0; got {result2}"
3491        );
3492    }
3493}