Skip to main content

oxigdal_gpu/
fft.rs

1//! Radix-2 1D FFT GPU compute kernel for OxiGDAL.
2//!
3//! This module implements a monomorphic Cooley-Tukey Decimation-In-Time (DIT)
4//! radix-2 FFT using WGSL compute shaders dispatched via wgpu.  Each workgroup
5//! processes exactly one transform; the batch variant dispatches multiple
6//! workgroups in parallel.
7//!
8//! # Constraints
9//!
10//! - Size must be a power of two in the range `[4, 2048]`.
11//! - All butterfly stages and the bit-reversal function are **unrolled** in
12//!   the emitted WGSL because WGSL workgroup-shared memory paths do not allow
13//!   fully dynamic loop bounds at all WGSL validation levels.
14//!
15//! # Sign convention
16//!
17//! | Mode    | twiddle angle              | normalisation |
18//! |---------|----------------------------|---------------|
19//! | Forward | `-2π · k / N`              | none          |
20//! | Inverse | `+2π · k / N`              | divide by N   |
21
22use crate::context::GpuContext;
23use crate::error::{GpuError, GpuResult};
24use crate::shaders::{
25    ComputePipelineBuilder, WgslShader, create_compute_bind_group_layout, storage_buffer_layout,
26};
27use std::f32::consts::PI;
28use std::sync::Arc;
29use tracing::debug;
30use wgpu::{BindGroupDescriptor, BindGroupEntry, BufferDescriptor, BufferUsages};
31
32// ─────────────────────────────────────────────────────────────────────────────
33// Pure-Rust helper functions (no GPU required)
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Reverse the bottom `log2_n` bits of `x`.
37///
38/// # Examples
39///
40/// ```
41/// use oxigdal_gpu::fft::bit_reverse;
42///
43/// // 3-bit reversal (for N=8)
44/// assert_eq!(bit_reverse(0b001, 3), 0b100); // 1 → 4
45/// assert_eq!(bit_reverse(0b011, 3), 0b110); // 3 → 6
46/// ```
47pub fn bit_reverse(x: u32, log2_n: u32) -> u32 {
48    let mut result = 0u32;
49    let mut v = x;
50    for _ in 0..log2_n {
51        result = (result << 1) | (v & 1);
52        v >>= 1;
53    }
54    result
55}
56
57/// Compute the twiddle factor for position `k` of an `n`-point transform.
58///
59/// Returns `(cos(angle), sin(angle))` where:
60/// - forward (`inverse = false`): `angle = -2π·k/n`
61/// - inverse  (`inverse = true`) : `angle = +2π·k/n`
62pub fn twiddle_factor(k: u32, n: u32, inverse: bool) -> (f32, f32) {
63    let sign = if inverse { 1.0_f32 } else { -1.0_f32 };
64    let angle = sign * 2.0 * PI * (k as f32) / (n as f32);
65    (angle.cos(), angle.sin())
66}
67
68// ─────────────────────────────────────────────────────────────────────────────
69// WGSL shader code generation
70// ─────────────────────────────────────────────────────────────────────────────
71
72/// Emit a monomorphic WGSL compute shader for a radix-2 Cooley-Tukey DIT FFT
73/// of exactly `size` points.
74///
75/// The workgroup size is `size / 2`; each workgroup processes exactly one
76/// independent FFT.  Batch processing is achieved by dispatching multiple
77/// workgroups (`dispatch_workgroups(batch_count, 1, 1)`).
78///
79/// # Panics
80///
81/// Does not panic; input is validated in [`Fft1d::new`] before this is called.
82pub fn make_fft_shader_source(size: u32, inverse: bool) -> String {
83    let log2_n = size.trailing_zeros();
84    let half_size = size / 2;
85    let sign_str = if inverse { "1.0" } else { "-1.0" };
86    let is_inverse_str = if inverse { "true" } else { "false" };
87
88    // Build the bit_rev function body (unrolled log2_n-bit reversal)
89    let bit_rev_fn = build_bit_rev_fn(log2_n);
90
91    // Build unrolled butterfly stages
92    let butterfly_stages = build_butterfly_stages(log2_n, half_size, sign_str);
93
94    // Optional 1/N normalisation for inverse FFT
95    let normalise_block = if inverse {
96        format!(
97            r#"
98    // --- Inverse normalisation: divide by N ---
99    shared_real[thread_id] /= f32({size}u);
100    shared_imag[thread_id] /= f32({size}u);
101    shared_real[thread_id + {half_size}u] /= f32({size}u);
102    shared_imag[thread_id + {half_size}u] /= f32({size}u);
103    workgroupBarrier();
104"#,
105            size = size,
106            half_size = half_size,
107        )
108    } else {
109        String::new()
110    };
111
112    // The shader template
113    format!(
114        r#"// FFT size = {size}, log2 = {log2_n}, inverse = {is_inverse_str}
115@group(0) @binding(0) var<storage, read_write> real_buf: array<f32>;
116@group(0) @binding(1) var<storage, read_write> imag_buf: array<f32>;
117
118var<workgroup> shared_real: array<f32, {size}>;
119var<workgroup> shared_imag: array<f32, {size}>;
120
121{bit_rev_fn}
122
123@compute @workgroup_size({half_size}, 1, 1)
124fn main(
125    @builtin(local_invocation_id) lid: vec3<u32>,
126    @builtin(workgroup_id) wid: vec3<u32>,
127) {{
128    let thread_id: u32 = lid.x;
129    let batch_offset: u32 = wid.x * {size}u;
130
131    // --- 1. Bit-reversal permutation ---
132    let rev0: u32 = bit_rev(thread_id);
133    let rev1: u32 = bit_rev(thread_id + {half_size}u);
134    shared_real[rev0] = real_buf[batch_offset + thread_id];
135    shared_imag[rev0] = imag_buf[batch_offset + thread_id];
136    shared_real[rev1] = real_buf[batch_offset + thread_id + {half_size}u];
137    shared_imag[rev1] = imag_buf[batch_offset + thread_id + {half_size}u];
138    workgroupBarrier();
139
140    // --- 2. FFT butterfly stages (unrolled {log2_n} stages) ---
141{butterfly_stages}
142{normalise_block}
143    // --- 3. Write back ---
144    real_buf[batch_offset + thread_id] = shared_real[thread_id];
145    imag_buf[batch_offset + thread_id] = shared_imag[thread_id];
146    real_buf[batch_offset + thread_id + {half_size}u] = shared_real[thread_id + {half_size}u];
147    imag_buf[batch_offset + thread_id + {half_size}u] = shared_imag[thread_id + {half_size}u];
148}}
149"#,
150        size = size,
151        log2_n = log2_n,
152        half_size = half_size,
153        is_inverse_str = is_inverse_str,
154        bit_rev_fn = bit_rev_fn,
155        butterfly_stages = butterfly_stages,
156        normalise_block = normalise_block,
157    )
158}
159
160/// Build the WGSL `bit_rev` function with an unrolled `log2_n`-bit reversal.
161fn build_bit_rev_fn(log2_n: u32) -> String {
162    let mut body = String::new();
163    body.push_str("fn bit_rev(x: u32) -> u32 {\n");
164    body.push_str("    var v: u32 = x;\n");
165    body.push_str("    var r: u32 = 0u;\n");
166    for _ in 0..log2_n {
167        body.push_str("    r = (r << 1u) | (v & 1u);\n");
168        body.push_str("    v = v >> 1u;\n");
169    }
170    body.push_str("    return r;\n");
171    body.push_str("}\n");
172    body
173}
174
175/// Build unrolled WGSL for all `log2_n` butterfly stages of a radix-2 DIT FFT.
176fn build_butterfly_stages(log2_n: u32, _half_size: u32, sign_str: &str) -> String {
177    let mut code = String::new();
178    for s in 0..log2_n {
179        let span = 1u32 << (s + 1);
180        let half_span = span >> 1;
181        code.push_str(&format!(
182            r#"
183    // Stage {s}: span = {span}, half_span = {half_span}
184    {{
185        let span_{s}: u32      = {span}u;
186        let half_span_{s}: u32 = {half_span}u;
187        let pos_{s}: u32  = thread_id % half_span_{s};
188        let grp_{s}: u32  = thread_id / half_span_{s};
189        let i_{s}: u32    = grp_{s} * span_{s} + pos_{s};
190        let j_{s}: u32    = i_{s} + half_span_{s};
191        let angle_{s}: f32 = {sign_str} * 2.0 * 3.14159265358979323846 * f32(pos_{s}) / f32(span_{s});
192        let wr_{s}: f32  = cos(angle_{s});
193        let wi_{s}: f32  = sin(angle_{s});
194        let tr_{s}: f32  = wr_{s} * shared_real[j_{s}] - wi_{s} * shared_imag[j_{s}];
195        let ti_{s}: f32  = wr_{s} * shared_imag[j_{s}] + wi_{s} * shared_real[j_{s}];
196        shared_real[j_{s}] = shared_real[i_{s}] - tr_{s};
197        shared_imag[j_{s}] = shared_imag[i_{s}] - ti_{s};
198        shared_real[i_{s}] = shared_real[i_{s}] + tr_{s};
199        shared_imag[i_{s}] = shared_imag[i_{s}] + ti_{s};
200        workgroupBarrier();
201    }}
202"#,
203            s = s,
204            span = span,
205            half_span = half_span,
206            sign_str = sign_str,
207        ));
208    }
209    code
210}
211
212// ─────────────────────────────────────────────────────────────────────────────
213// Fft1d struct
214// ─────────────────────────────────────────────────────────────────────────────
215
216/// GPU-accelerated radix-2 1D FFT (or IFFT) kernel.
217///
218/// A single `Fft1d` instance is bound to a specific transform size and
219/// direction (forward/inverse).  Construct it once with [`Fft1d::new`] and
220/// reuse it for multiple [`Fft1d::execute`] or [`Fft1d::execute_batch`] calls.
221pub struct Fft1d {
222    pipeline: Arc<wgpu::ComputePipeline>,
223    bind_group_layout: wgpu::BindGroupLayout,
224    /// Number of complex samples in one transform.
225    size: u32,
226    /// `true` for IFFT (inverse); `false` for FFT (forward).
227    inverse: bool,
228}
229
230impl Fft1d {
231    /// Create a new FFT kernel for the given transform size and direction.
232    ///
233    /// # Validation (pure-Rust, no GPU required)
234    ///
235    /// - `size` must be a power of two.
236    /// - `size` must be in the range `[4, 2048]`.
237    ///
238    /// # Errors
239    ///
240    /// Returns `GpuError::InvalidConfig` if the size fails validation, or a
241    /// pipeline / shader error if WGSL compilation fails on the GPU device.
242    pub fn new(ctx: &GpuContext, size: u32, inverse: bool) -> GpuResult<Self> {
243        // ---- Pure-Rust validation (no GPU touched) ----
244        if !size.is_power_of_two() {
245            return Err(GpuError::invalid_kernel_params(
246                "FFT size must be power of two",
247            ));
248        }
249        if size < 4 {
250            return Err(GpuError::invalid_kernel_params("FFT size must be >= 4"));
251        }
252        if size > 2048 {
253            return Err(GpuError::invalid_kernel_params("FFT size must be <= 2048"));
254        }
255
256        // ---- Shader compilation ----
257        let shader_src = make_fft_shader_source(size, inverse);
258        let mut shader = WgslShader::new(shader_src, "main");
259        let shader_module = shader.compile(ctx.device())?;
260
261        // Bind group layout: two read-write storage buffers (real + imag).
262        let bind_group_layout = create_compute_bind_group_layout(
263            ctx.device(),
264            &[
265                storage_buffer_layout(0, false), // real_buf  (read_write)
266                storage_buffer_layout(1, false), // imag_buf  (read_write)
267            ],
268            Some("Fft1d Bind Group Layout"),
269        )?;
270
271        let pipeline = ComputePipelineBuilder::new(ctx.device(), shader_module, "main")
272            .bind_group_layout(&bind_group_layout)
273            .label("Fft1d Pipeline")
274            .build()?;
275
276        debug!("Fft1d created: size={}, inverse={}", size, inverse);
277
278        Ok(Self {
279            pipeline: Arc::new(pipeline),
280            bind_group_layout,
281            size,
282            inverse,
283        })
284    }
285
286    // ---- Accessors -------------------------------------------------------
287
288    /// Transform size (number of complex samples).
289    pub fn size(&self) -> u32 {
290        self.size
291    }
292
293    /// `true` when this kernel computes the inverse FFT.
294    pub fn is_inverse(&self) -> bool {
295        self.inverse
296    }
297
298    // ---- Single transform -----------------------------------------------
299
300    /// Execute a single 1D FFT/IFFT.
301    ///
302    /// # Arguments
303    ///
304    /// * `real_in` — real parts of the input (length must equal `self.size`).
305    /// * `imag_in` — imaginary parts of the input (length must equal `self.size`).
306    ///
307    /// # Returns
308    ///
309    /// A tuple `(real_out, imag_out)` each of length `self.size`.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if input lengths are wrong or any GPU operation fails.
314    pub fn execute(
315        &self,
316        ctx: &GpuContext,
317        real_in: &[f32],
318        imag_in: &[f32],
319    ) -> GpuResult<(Vec<f32>, Vec<f32>)> {
320        let n = self.size as usize;
321        if real_in.len() != n {
322            return Err(GpuError::invalid_kernel_params(format!(
323                "real_in length {} != FFT size {}",
324                real_in.len(),
325                n
326            )));
327        }
328        if imag_in.len() != n {
329            return Err(GpuError::invalid_kernel_params(format!(
330                "imag_in length {} != FFT size {}",
331                imag_in.len(),
332                n
333            )));
334        }
335
336        let results = self.dispatch_batch_internal(ctx, &[(real_in.to_vec(), imag_in.to_vec())])?;
337        let (r, i) = results.into_iter().next().ok_or_else(|| {
338            GpuError::internal("dispatch returned empty batch for single transform")
339        })?;
340        Ok((r, i))
341    }
342
343    /// Execute a batch of FFT/IFFT transforms in one GPU dispatch.
344    ///
345    /// All transforms in the batch must use the same `size` (enforced by the
346    /// shader's workgroup layout).  Each workgroup handles one transform.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if any batch element has the wrong size or if a GPU
351    /// operation fails.
352    pub fn execute_batch(
353        &self,
354        ctx: &GpuContext,
355        batch: &[(Vec<f32>, Vec<f32>)],
356    ) -> GpuResult<Vec<(Vec<f32>, Vec<f32>)>> {
357        let n = self.size as usize;
358        for (idx, (r, im)) in batch.iter().enumerate() {
359            if r.len() != n {
360                return Err(GpuError::invalid_kernel_params(format!(
361                    "batch[{}].real length {} != FFT size {}",
362                    idx,
363                    r.len(),
364                    n
365                )));
366            }
367            if im.len() != n {
368                return Err(GpuError::invalid_kernel_params(format!(
369                    "batch[{}].imag length {} != FFT size {}",
370                    idx,
371                    im.len(),
372                    n
373                )));
374            }
375        }
376
377        self.dispatch_batch_internal(ctx, batch)
378    }
379
380    // ---- Internal GPU dispatch ------------------------------------------
381
382    fn dispatch_batch_internal(
383        &self,
384        ctx: &GpuContext,
385        batch: &[(Vec<f32>, Vec<f32>)],
386    ) -> GpuResult<Vec<(Vec<f32>, Vec<f32>)>> {
387        let batch_count = batch.len() as u32;
388        let n = self.size as usize;
389        let total = batch_count as usize * n;
390
391        // --- Interleave inputs into flat real and imag arrays ---
392        let mut flat_real: Vec<f32> = Vec::with_capacity(total);
393        let mut flat_imag: Vec<f32> = Vec::with_capacity(total);
394        for (r, im) in batch {
395            flat_real.extend_from_slice(r);
396            flat_imag.extend_from_slice(im);
397        }
398
399        // --- Create storage buffers (STORAGE | COPY_SRC | COPY_DST) ---
400        let buf_size = (total * std::mem::size_of::<f32>()) as u64;
401        // Align to COPY_BUFFER_ALIGNMENT (256 bytes)
402        let aligned_buf_size = (buf_size + 255) & !255;
403
404        let real_buf = ctx.device().create_buffer(&BufferDescriptor {
405            label: Some("Fft1d real_buf"),
406            size: aligned_buf_size,
407            usage: BufferUsages::STORAGE | BufferUsages::COPY_SRC | BufferUsages::COPY_DST,
408            mapped_at_creation: false,
409        });
410        let imag_buf = ctx.device().create_buffer(&BufferDescriptor {
411            label: Some("Fft1d imag_buf"),
412            size: aligned_buf_size,
413            usage: BufferUsages::STORAGE | BufferUsages::COPY_SRC | BufferUsages::COPY_DST,
414            mapped_at_creation: false,
415        });
416
417        // --- Upload inputs ---
418        ctx.queue()
419            .write_buffer(&real_buf, 0, bytemuck::cast_slice(&flat_real));
420        ctx.queue()
421            .write_buffer(&imag_buf, 0, bytemuck::cast_slice(&flat_imag));
422
423        // --- Build bind group ---
424        let bind_group = ctx.device().create_bind_group(&BindGroupDescriptor {
425            label: Some("Fft1d BindGroup"),
426            layout: &self.bind_group_layout,
427            entries: &[
428                BindGroupEntry {
429                    binding: 0,
430                    resource: real_buf.as_entire_binding(),
431                },
432                BindGroupEntry {
433                    binding: 1,
434                    resource: imag_buf.as_entire_binding(),
435                },
436            ],
437        });
438
439        // --- Encode compute pass ---
440        let mut encoder = ctx
441            .device()
442            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
443                label: Some("Fft1d encoder"),
444            });
445
446        {
447            let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
448                label: Some("Fft1d compute pass"),
449                timestamp_writes: None,
450            });
451            cpass.set_pipeline(&self.pipeline);
452            cpass.set_bind_group(0, &bind_group, &[]);
453            cpass.dispatch_workgroups(batch_count, 1, 1);
454        }
455
456        // --- Create staging buffers for readback ---
457        let staging_real = ctx.device().create_buffer(&BufferDescriptor {
458            label: Some("Fft1d staging_real"),
459            size: aligned_buf_size,
460            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
461            mapped_at_creation: false,
462        });
463        let staging_imag = ctx.device().create_buffer(&BufferDescriptor {
464            label: Some("Fft1d staging_imag"),
465            size: aligned_buf_size,
466            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
467            mapped_at_creation: false,
468        });
469
470        encoder.copy_buffer_to_buffer(&real_buf, 0, &staging_real, 0, aligned_buf_size);
471        encoder.copy_buffer_to_buffer(&imag_buf, 0, &staging_imag, 0, aligned_buf_size);
472
473        ctx.queue().submit(Some(encoder.finish()));
474
475        // Map before poll: poll drives both submit completion and map callbacks.
476        let real_slice = staging_real.slice(..);
477        let (real_tx, real_rx) = std::sync::mpsc::sync_channel(1);
478        real_slice.map_async(wgpu::MapMode::Read, move |result| {
479            let _ = real_tx.send(result);
480        });
481
482        let imag_slice = staging_imag.slice(..);
483        let (imag_tx, imag_rx) = std::sync::mpsc::sync_channel(1);
484        imag_slice.map_async(wgpu::MapMode::Read, move |result| {
485            let _ = imag_tx.send(result);
486        });
487
488        ctx.device()
489            .poll(wgpu::PollType::wait_indefinitely())
490            .map_err(|e| GpuError::execution_failed(format!("device poll failed: {e}")))?;
491
492        let real_out = Self::finish_staging_read(&staging_real, real_slice, real_rx, total)?;
493        let imag_out = Self::finish_staging_read(&staging_imag, imag_slice, imag_rx, total)?;
494
495        debug!(
496            "Fft1d dispatched: batch={}, size={}, inverse={}",
497            batch_count, self.size, self.inverse
498        );
499
500        // --- Split flat outputs into per-transform pairs ---
501        let mut results = Vec::with_capacity(batch_count as usize);
502        for i in 0..batch_count as usize {
503            let r = real_out[i * n..(i + 1) * n].to_vec();
504            let im = imag_out[i * n..(i + 1) * n].to_vec();
505            results.push((r, im));
506        }
507        Ok(results)
508    }
509
510    /// Complete a staging-buffer read whose `map_async` has already been
511    /// scheduled and whose mapping callback has been driven by a preceding
512    /// `device.poll(...)`.  This function only does `recv` + read + `unmap`.
513    fn finish_staging_read(
514        staging: &wgpu::Buffer,
515        slice: wgpu::BufferSlice<'_>,
516        rx: std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
517        count: usize,
518    ) -> GpuResult<Vec<f32>> {
519        rx.recv()
520            .map_err(|_| GpuError::buffer_mapping("staging channel closed"))?
521            .map_err(|e| GpuError::buffer_mapping(e.to_string()))?;
522
523        let mapped = slice.get_mapped_range();
524        let floats: &[f32] = bytemuck::cast_slice(&mapped[..count * 4]);
525        let out = floats.to_vec();
526        drop(mapped);
527        staging.unmap();
528        Ok(out)
529    }
530}
531
532// ─────────────────────────────────────────────────────────────────────────────
533// Public pure-Rust validation helper
534// ─────────────────────────────────────────────────────────────────────────────
535
536/// Validate that `size` satisfies the FFT kernel constraints (power of two,
537/// in the range `[4, 2048]`).
538///
539/// This function performs the same three checks as [`Fft1d::new`] **before**
540/// any wgpu call is made.  It is exposed as `pub` so that integration tests
541/// can exercise the validation logic without constructing a real GPU context.
542///
543/// # Errors
544///
545/// Returns [`GpuError::InvalidKernelParams`] with a descriptive message when
546/// any constraint is violated.
547pub fn validate_size(size: u32) -> GpuResult<()> {
548    if !size.is_power_of_two() {
549        return Err(GpuError::invalid_kernel_params(
550            "FFT size must be power of two",
551        ));
552    }
553    if size < 4 {
554        return Err(GpuError::invalid_kernel_params("FFT size must be >= 4"));
555    }
556    if size > 2048 {
557        return Err(GpuError::invalid_kernel_params("FFT size must be <= 2048"));
558    }
559    Ok(())
560}
561
562// ─────────────────────────────────────────────────────────────────────────────
563// Unit tests (pure-Rust, no GPU required unless noted)
564// ─────────────────────────────────────────────────────────────────────────────
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn test_bit_reverse_known_values() {
572        // 3-bit reversal (N = 8)
573        assert_eq!(bit_reverse(0, 3), 0);
574        assert_eq!(bit_reverse(1, 3), 4);
575        assert_eq!(bit_reverse(2, 3), 2);
576        assert_eq!(bit_reverse(3, 3), 6);
577        assert_eq!(bit_reverse(4, 3), 1);
578        assert_eq!(bit_reverse(5, 3), 5);
579        assert_eq!(bit_reverse(6, 3), 3);
580        assert_eq!(bit_reverse(7, 3), 7);
581    }
582
583    #[test]
584    fn test_twiddle_factor_known() {
585        let (r, i) = twiddle_factor(0, 4, false);
586        assert!((r - 1.0).abs() < 1e-6, "cos(0) = 1, got {}", r);
587        assert!(i.abs() < 1e-6, "sin(0) = 0, got {}", i);
588
589        let (r, i) = twiddle_factor(1, 4, false);
590        assert!(r.abs() < 1e-6, "cos(-π/2) ≈ 0, got {}", r);
591        assert!((i + 1.0).abs() < 1e-6, "sin(-π/2) = -1, got {}", i);
592    }
593
594    #[test]
595    fn test_make_shader_contains_log2n_3() {
596        let src = make_fft_shader_source(8, false);
597        // log2(8) = 3; shader should contain "3u" (bits in bit_rev) or stage count "3"
598        assert!(
599            src.contains("3u") || src.contains("3"),
600            "shader must reference log2(8) = 3"
601        );
602    }
603
604    #[test]
605    fn test_make_shader_inverse_contains_scale() {
606        let src = make_fft_shader_source(8, true);
607        // inverse shader must contain 1/8 normalisation; either "8u" or "8.0" or the literal
608        assert!(
609            src.contains("8u") || src.contains("8.0") || src.contains("0.125"),
610            "inverse shader must contain 1/N scale factor for N=8"
611        );
612    }
613
614    #[test]
615    fn test_new_rejects_non_power_of_two() {
616        // Pure validation — no GPU context needed because the check runs first
617        // We call validate_size directly by relying on the error branch
618        let result = validate_size(6);
619        assert!(
620            result.is_err(),
621            "size=6 is not a power of two, must be rejected"
622        );
623    }
624
625    #[test]
626    fn test_new_rejects_size_below_min() {
627        let result = validate_size(2);
628        assert!(result.is_err(), "size=2 < 4, must be rejected");
629    }
630
631    #[test]
632    fn test_new_rejects_size_above_max() {
633        let result = validate_size(4096);
634        assert!(result.is_err(), "size=4096 > 2048, must be rejected");
635    }
636}