Skip to main content

oxiphysics_gpu/
compute_pipeline.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! CPU-side simulation of a wgpu compute pipeline abstraction.
5//!
6//! Provides data layout and dispatch logic that mirrors a typical GPU compute
7//! pipeline, with all computation running on the CPU.
8
9// ── silence unused-item lints for the enum variants / fields used only in
10//    tests or future GPU back-ends ──────────────────────────────────────────
11
12// ─────────────────────────────────────────────────────────────────────────────
13// BufferUsage
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// Intended usage of a [`ComputeBuffer`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum BufferUsage {
19    /// Vertex data fed to the rasteriser.
20    Vertex,
21    /// Index data for indexed draw calls.
22    Index,
23    /// Small, frequently-updated uniform / constant data.
24    Uniform,
25    /// General-purpose read-write storage.
26    Storage,
27    /// CPU↔GPU transfer staging area.
28    Staging,
29}
30
31// ─────────────────────────────────────────────────────────────────────────────
32// ComputeBuffer
33// ─────────────────────────────────────────────────────────────────────────────
34
35/// A CPU-resident buffer that mirrors a GPU buffer.
36#[derive(Debug, Clone)]
37pub struct ComputeBuffer {
38    /// Raw `f32` elements.
39    pub data: Vec<f32>,
40    /// Intended GPU usage hint.
41    pub usage: BufferUsage,
42    /// Human-readable label (useful for debugging).
43    pub label: String,
44}
45
46impl ComputeBuffer {
47    /// Allocate a zero-initialised buffer of `size` `f32` elements.
48    pub fn new(size: usize, usage: BufferUsage, label: &str) -> Self {
49        Self {
50            data: vec![0.0_f32; size],
51            usage,
52            label: label.to_owned(),
53        }
54    }
55
56    /// Write `values` into the buffer starting at element `offset`.
57    ///
58    /// # Panics
59    /// Panics if `offset + values.len() > self.data.len()`.
60    pub fn write_f32(&mut self, offset: usize, values: &[f32]) {
61        let end = offset + values.len();
62        assert!(
63            end <= self.data.len(),
64            "write_f32: out-of-bounds write (offset={offset}, len={}, capacity={})",
65            values.len(),
66            self.data.len()
67        );
68        self.data[offset..end].copy_from_slice(values);
69    }
70
71    /// Read `count` `f32` elements starting at element `offset`.
72    ///
73    /// # Panics
74    /// Panics if `offset + count > self.data.len()`.
75    pub fn read_f32(&self, offset: usize, count: usize) -> Vec<f32> {
76        let end = offset + count;
77        assert!(
78            end <= self.data.len(),
79            "read_f32: out-of-bounds read (offset={offset}, count={count}, capacity={})",
80            self.data.len()
81        );
82        self.data[offset..end].to_vec()
83    }
84
85    /// Total size of the buffer in bytes (`len * 4`).
86    pub fn byte_size(&self) -> usize {
87        self.data.len() * std::mem::size_of::<f32>()
88    }
89}
90
91// ─────────────────────────────────────────────────────────────────────────────
92// WorkgroupSize
93// ─────────────────────────────────────────────────────────────────────────────
94
95/// Workgroup dimensions for a compute dispatch.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct WorkgroupSize {
98    /// X dimension.
99    pub x: u32,
100    /// Y dimension.
101    pub y: u32,
102    /// Z dimension.
103    pub z: u32,
104}
105
106impl WorkgroupSize {
107    /// Compute the number of workgroups needed to cover `total` items when
108    /// each workgroup handles `workgroup` items (ceiling division).
109    pub fn dispatch_count(total: u32, workgroup: u32) -> u32 {
110        assert!(workgroup > 0, "workgroup size must be > 0");
111        total.div_ceil(workgroup)
112    }
113}
114
115impl Default for WorkgroupSize {
116    fn default() -> Self {
117        Self { x: 64, y: 1, z: 1 }
118    }
119}
120
121// ─────────────────────────────────────────────────────────────────────────────
122// ComputeKernelKind
123// ─────────────────────────────────────────────────────────────────────────────
124
125/// The kind of compute kernel to dispatch.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum ComputeKernelKind {
128    /// Semi-implicit velocity / position integration.
129    VelocityUpdate,
130    /// Pressure Jacobi iteration.
131    PressureJacobi,
132    /// Lennard-Jones particle force evaluation.
133    ParticleForce,
134    /// Neighbour-search (spatial hashing).
135    NeighborSearch,
136    /// User-defined kernel identified by a string tag.
137    Custom(String),
138}
139
140// ─────────────────────────────────────────────────────────────────────────────
141// CpuComputeDispatch
142// ─────────────────────────────────────────────────────────────────────────────
143
144/// Dispatcher that executes compute kernels on the CPU.
145pub struct CpuComputeDispatch {
146    /// Which kernel this dispatcher is configured for.
147    pub kernel: ComputeKernelKind,
148    /// Workgroup size hint (informational on the CPU path).
149    pub workgroup_size: WorkgroupSize,
150}
151
152impl CpuComputeDispatch {
153    /// Create a new dispatcher.
154    pub fn new(kernel: ComputeKernelKind, wg: WorkgroupSize) -> Self {
155        Self {
156            kernel,
157            workgroup_size: wg,
158        }
159    }
160
161    /// Semi-implicit Euler integration for `n` particles.
162    ///
163    /// `pos[i] += vel[i] * dt` then `vel[i] += force[i] / mass[i] * dt`.
164    pub fn dispatch_velocity_update(
165        &self,
166        pos: &mut ComputeBuffer,
167        vel: &mut ComputeBuffer,
168        force: &ComputeBuffer,
169        mass: &ComputeBuffer,
170        dt: f32,
171        n: usize,
172    ) {
173        for i in 0..n {
174            pos.data[i] += vel.data[i] * dt;
175            vel.data[i] += force.data[i] / mass.data[i] * dt;
176        }
177    }
178
179    /// Single Jacobi pressure iteration over an `nx × ny` grid.
180    ///
181    /// Interior points only; boundary cells are left unchanged.
182    ///
183    /// `p[i,j] = (p_old[i+1,j] + p_old[i-1,j] + p_old[i,j+1] + p_old[i,j-1]
184    ///            - dx² * rhs[i,j]) / 4`
185    pub fn dispatch_pressure_jacobi(
186        &self,
187        p: &mut ComputeBuffer,
188        p_old: &ComputeBuffer,
189        rhs: &ComputeBuffer,
190        nx: usize,
191        ny: usize,
192        dx: f32,
193    ) {
194        let dx2 = dx * dx;
195        for j in 1..ny - 1 {
196            for i in 1..nx - 1 {
197                let idx = j * nx + i;
198                p.data[idx] = (p_old.data[idx + 1]
199                    + p_old.data[idx - 1]
200                    + p_old.data[idx + nx]
201                    + p_old.data[idx - nx]
202                    - dx2 * rhs.data[idx])
203                    / 4.0;
204            }
205        }
206    }
207
208    /// O(n²) Lennard-Jones force accumulation.
209    ///
210    /// Positions are stored as interleaved `[x0, y0, x1, y1, …]`.
211    /// Forces are accumulated in-place (`force` is zeroed first).
212    pub fn dispatch_particle_force(
213        &self,
214        pos: &ComputeBuffer,
215        force: &mut ComputeBuffer,
216        eps: f32,
217        sigma: f32,
218        n: usize,
219    ) {
220        // Zero forces first.
221        for v in force.data[..2 * n].iter_mut() {
222            *v = 0.0;
223        }
224
225        for i in 0..n {
226            for j in (i + 1)..n {
227                let dx = pos.data[2 * j] - pos.data[2 * i];
228                let dy = pos.data[2 * j + 1] - pos.data[2 * i + 1];
229                let r2 = dx * dx + dy * dy;
230                if r2 < 1e-12 {
231                    continue;
232                }
233                let sr2 = (sigma * sigma) / r2;
234                let sr6 = sr2 * sr2 * sr2;
235                let sr12 = sr6 * sr6;
236                // F = 24ε/r² * (2(σ/r)^12 - (σ/r)^6)
237                let fmag = 24.0 * eps / r2 * (2.0 * sr12 - sr6);
238                force.data[2 * i] -= fmag * dx;
239                force.data[2 * i + 1] -= fmag * dy;
240                force.data[2 * j] += fmag * dx;
241                force.data[2 * j + 1] += fmag * dy;
242            }
243        }
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// GpuStats
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Accumulated statistics for compute dispatches.
252#[derive(Debug, Clone, Default)]
253pub struct GpuStats {
254    /// Total number of dispatches recorded.
255    pub dispatch_count: u64,
256    /// Total bytes transferred (reads + writes).
257    pub bytes_transferred: u64,
258    /// Total kernel wall-clock time in milliseconds.
259    pub kernel_time_ms: f64,
260}
261
262impl GpuStats {
263    /// Create zeroed stats.
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    /// Record a single dispatch event.
269    pub fn record_dispatch(&mut self, bytes: u64, time_ms: f64) {
270        self.dispatch_count += 1;
271        self.bytes_transferred += bytes;
272        self.kernel_time_ms += time_ms;
273    }
274}
275
276// ─────────────────────────────────────────────────────────────────────────────
277// Free-standing solver helpers
278// ─────────────────────────────────────────────────────────────────────────────
279
280/// Single Jacobi sweep over an `nx × ny` pressure grid.
281///
282/// Updates every interior cell of `p_new` using `p_old` and `rhs`.
283pub fn jacobi_step_2d(
284    p_new: &mut [f32],
285    p_old: &[f32],
286    rhs: &[f32],
287    nx: usize,
288    ny: usize,
289    dx: f32,
290) {
291    let dx2 = dx * dx;
292    for j in 1..ny - 1 {
293        for i in 1..nx - 1 {
294            let idx = j * nx + i;
295            p_new[idx] = (p_old[idx + 1] + p_old[idx - 1] + p_old[idx + nx] + p_old[idx - nx]
296                - dx2 * rhs[idx])
297                / 4.0;
298        }
299    }
300}
301
302/// Iterative Jacobi pressure-Poisson solver.
303///
304/// Runs `n_iter` Jacobi sweeps and returns the final L∞ residual.
305pub fn pressure_poisson_solve(
306    p: &mut [f32],
307    rhs: &[f32],
308    nx: usize,
309    ny: usize,
310    dx: f32,
311    n_iter: usize,
312) -> f32 {
313    let size = nx * ny;
314    let mut p_old = p.to_vec();
315
316    for _ in 0..n_iter {
317        jacobi_step_2d(p, &p_old, rhs, nx, ny, dx);
318        p_old.copy_from_slice(&p[..size]);
319    }
320
321    // Compute L∞ residual on interior cells.
322    let dx2 = dx * dx;
323    let mut residual = 0.0_f32;
324    for j in 1..ny - 1 {
325        for i in 1..nx - 1 {
326            let idx = j * nx + i;
327            let lap = (p[idx + 1] + p[idx - 1] + p[idx + nx] + p[idx - nx] - 4.0 * p[idx]) / dx2;
328            let r = (lap - rhs[idx]).abs();
329            if r > residual {
330                residual = r;
331            }
332        }
333    }
334    residual
335}
336
337// ─────────────────────────────────────────────────────────────────────────────
338// PipelineCache – LRU-style pipeline caching
339// ─────────────────────────────────────────────────────────────────────────────
340
341/// A simple LRU-eviction cache for compiled compute pipelines.
342///
343/// Keyed by a string label; evicts the oldest entry when the capacity is reached.
344pub struct PipelineCache {
345    /// Maximum number of entries.
346    capacity: usize,
347    /// Entries in insertion order (oldest first).
348    entries: Vec<(String, CpuComputeDispatch)>,
349}
350
351impl PipelineCache {
352    /// Create a new cache with the given capacity.
353    pub fn new(capacity: usize) -> Self {
354        Self {
355            capacity,
356            entries: Vec::new(),
357        }
358    }
359
360    /// Insert (or replace) a pipeline under `key`.
361    pub fn insert(&mut self, key: &str, pipeline: CpuComputeDispatch) {
362        // Remove existing entry with the same key
363        self.entries.retain(|(k, _)| k != key);
364        // Evict oldest if at capacity
365        while self.entries.len() >= self.capacity {
366            self.entries.remove(0);
367        }
368        self.entries.push((key.to_owned(), pipeline));
369    }
370
371    /// Look up a cached pipeline by key.
372    pub fn get(&self, key: &str) -> Option<&CpuComputeDispatch> {
373        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
374    }
375
376    /// Number of cached entries.
377    pub fn len(&self) -> usize {
378        self.entries.len()
379    }
380
381    /// Whether the cache is empty.
382    pub fn is_empty(&self) -> bool {
383        self.entries.is_empty()
384    }
385
386    /// Clear all cached pipelines.
387    pub fn clear(&mut self) {
388        self.entries.clear();
389    }
390}
391
392// ─────────────────────────────────────────────────────────────────────────────
393// PipelineStats – dispatch-level statistics
394// ─────────────────────────────────────────────────────────────────────────────
395
396/// Fine-grained statistics for compute pipeline usage.
397#[derive(Debug, Clone, Default)]
398pub struct PipelineStats {
399    /// Total number of dispatches.
400    pub total_dispatches: u64,
401    /// Total number of workgroups launched.
402    pub total_workgroups: u64,
403    /// Total number of invocations (workgroups × workgroup_size).
404    pub total_invocations: u64,
405    /// Number of times a cached pipeline was re-used.
406    pub cache_hits: u64,
407    /// Number of times a pipeline had to be compiled (or was not in cache).
408    pub cache_misses: u64,
409}
410
411impl PipelineStats {
412    /// Record a single dispatch event.
413    pub fn record_dispatch(&mut self, num_workgroups: u64, wg_size: WorkgroupSize) {
414        self.total_dispatches += 1;
415        self.total_workgroups += num_workgroups;
416        self.total_invocations +=
417            num_workgroups * (wg_size.x as u64) * (wg_size.y as u64) * (wg_size.z as u64);
418    }
419
420    /// Cache hit ratio (0.0–1.0). Returns NaN when no lookups occurred.
421    pub fn cache_hit_ratio(&self) -> f64 {
422        let total = self.cache_hits + self.cache_misses;
423        if total == 0 {
424            return f64::NAN;
425        }
426        self.cache_hits as f64 / total as f64
427    }
428}
429
430// ─────────────────────────────────────────────────────────────────────────────
431// MultiPassPipeline – chained compute passes
432// ─────────────────────────────────────────────────────────────────────────────
433
434/// A single compute pass in a multi-pass pipeline.
435#[derive(Debug, Clone)]
436pub struct ComputePass {
437    /// Human-readable label.
438    pub label: String,
439    /// The kernel to dispatch.
440    pub kernel: ComputeKernelKind,
441    /// Workgroup size for this pass.
442    pub workgroup_size: WorkgroupSize,
443    /// Indices of buffers bound to this pass.
444    pub buffer_bindings: Vec<usize>,
445}
446
447/// A sequence of compute passes that execute in order.
448#[derive(Debug)]
449pub struct MultiPassPipeline {
450    /// Human-readable label.
451    pub label: String,
452    /// Ordered list of passes.
453    pub passes: Vec<ComputePass>,
454}
455
456impl MultiPassPipeline {
457    /// Create a new empty multi-pass pipeline.
458    pub fn new(label: &str) -> Self {
459        Self {
460            label: label.to_owned(),
461            passes: Vec::new(),
462        }
463    }
464
465    /// Append a compute pass.
466    pub fn add_pass(&mut self, pass: ComputePass) {
467        self.passes.push(pass);
468    }
469
470    /// Number of passes.
471    pub fn num_passes(&self) -> usize {
472        self.passes.len()
473    }
474}
475
476// ─────────────────────────────────────────────────────────────────────────────
477// Pipeline validation
478// ─────────────────────────────────────────────────────────────────────────────
479
480/// Validate resource bindings for a single compute pass.
481///
482/// Returns a list of error messages (empty if valid).
483pub fn validate_resource_bindings(pass: &ComputePass, buffers: &[ComputeBuffer]) -> Vec<String> {
484    let mut errors = Vec::new();
485    let mut seen = std::collections::HashSet::new();
486    for &idx in &pass.buffer_bindings {
487        if idx >= buffers.len() {
488            errors.push(format!(
489                "Pass '{}': buffer binding {} is out of range (have {} buffers)",
490                pass.label,
491                idx,
492                buffers.len()
493            ));
494        }
495        if !seen.insert(idx) {
496            errors.push(format!(
497                "Pass '{}': Duplicate buffer binding {}",
498                pass.label, idx
499            ));
500        }
501    }
502    errors
503}
504
505/// Validate all passes in a multi-pass pipeline.
506pub fn validate_pipeline(pipeline: &MultiPassPipeline, buffers: &[ComputeBuffer]) -> Vec<String> {
507    let mut errors = Vec::new();
508    for pass in &pipeline.passes {
509        errors.extend(validate_resource_bindings(pass, buffers));
510    }
511    errors
512}
513
514// ─────────────────────────────────────────────────────────────────────────────
515// Additional solver helpers
516// ─────────────────────────────────────────────────────────────────────────────
517
518/// Single SOR (Successive Over-Relaxation) sweep over an `nx × ny` grid.
519///
520/// `omega = 1.0` gives standard Gauss-Seidel; `omega ∈ (1, 2)` gives SOR.
521pub fn sor_step_2d(
522    p: &mut [f32],
523    p_old: &[f32],
524    rhs: &[f32],
525    nx: usize,
526    ny: usize,
527    dx: f32,
528    omega: f32,
529) {
530    let dx2 = dx * dx;
531    for j in 1..ny - 1 {
532        for i in 1..nx - 1 {
533            let idx = j * nx + i;
534            let gs = (p_old[idx + 1] + p_old[idx - 1] + p_old[idx + nx] + p_old[idx - nx]
535                - dx2 * rhs[idx])
536                / 4.0;
537            p[idx] = (1.0 - omega) * p_old[idx] + omega * gs;
538        }
539    }
540}
541
542/// Red-black Gauss-Seidel sweep (in-place) on an `nx × ny` grid.
543///
544/// Updates "red" cells (i+j even) first, then "black" cells (i+j odd).
545pub fn red_black_gauss_seidel_step(p: &mut [f32], rhs: &[f32], nx: usize, ny: usize, dx: f32) {
546    let dx2 = dx * dx;
547    // Red sweep (i + j even)
548    for j in 1..ny - 1 {
549        for i in 1..nx - 1 {
550            if (i + j) % 2 == 0 {
551                let idx = j * nx + i;
552                p[idx] =
553                    (p[idx + 1] + p[idx - 1] + p[idx + nx] + p[idx - nx] - dx2 * rhs[idx]) / 4.0;
554            }
555        }
556    }
557    // Black sweep (i + j odd)
558    for j in 1..ny - 1 {
559        for i in 1..nx - 1 {
560            if (i + j) % 2 == 1 {
561                let idx = j * nx + i;
562                p[idx] =
563                    (p[idx + 1] + p[idx - 1] + p[idx + nx] + p[idx - nx] - dx2 * rhs[idx]) / 4.0;
564            }
565        }
566    }
567}
568
569/// Compute the L∞ residual of a 2D Poisson discretisation.
570pub fn compute_linf_residual(p: &[f32], rhs: &[f32], nx: usize, ny: usize, dx: f32) -> f32 {
571    let dx2 = dx * dx;
572    let mut residual = 0.0_f32;
573    for j in 1..ny - 1 {
574        for i in 1..nx - 1 {
575            let idx = j * nx + i;
576            let lap = (p[idx + 1] + p[idx - 1] + p[idx + nx] + p[idx - nx] - 4.0 * p[idx]) / dx2;
577            let r = (lap - rhs[idx]).abs();
578            if r > residual {
579                residual = r;
580            }
581        }
582    }
583    residual
584}
585
586/// O(n²) neighbor search for 2D interleaved positions `[x0, y0, x1, y1, …]`.
587///
588/// Returns a `Vec<Vec`usize`>` where `result[i]` contains the indices of
589/// particles within `cutoff` distance of particle `i`.
590pub fn dispatch_neighbor_search(positions: &[f32], n: usize, cutoff: f32) -> Vec<Vec<usize>> {
591    let cutoff2 = cutoff * cutoff;
592    let mut neighbors = vec![Vec::new(); n];
593    for i in 0..n {
594        for j in (i + 1)..n {
595            let dx = positions[2 * j] - positions[2 * i];
596            let dy = positions[2 * j + 1] - positions[2 * i + 1];
597            let r2 = dx * dx + dy * dy;
598            if r2 < cutoff2 {
599                neighbors[i].push(j);
600                neighbors[j].push(i);
601            }
602        }
603    }
604    neighbors
605}
606
607// ─────────────────────────────────────────────────────────────────────────────
608// Tests
609// ─────────────────────────────────────────────────────────────────────────────
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    // ── BufferUsage ──────────────────────────────────────────────────────────
616
617    #[test]
618    fn buffer_usage_eq() {
619        assert_eq!(BufferUsage::Storage, BufferUsage::Storage);
620        assert_ne!(BufferUsage::Vertex, BufferUsage::Index);
621    }
622
623    #[test]
624    fn buffer_usage_clone() {
625        let u = BufferUsage::Uniform;
626        assert_eq!(u.clone(), BufferUsage::Uniform);
627    }
628
629    // ── ComputeBuffer ────────────────────────────────────────────────────────
630
631    #[test]
632    fn compute_buffer_new_zeroed() {
633        let buf = ComputeBuffer::new(8, BufferUsage::Storage, "test");
634        assert_eq!(buf.data.len(), 8);
635        assert!(buf.data.iter().all(|&v| v == 0.0));
636        assert_eq!(buf.label, "test");
637    }
638
639    #[test]
640    fn compute_buffer_byte_size() {
641        let buf = ComputeBuffer::new(4, BufferUsage::Uniform, "u");
642        assert_eq!(buf.byte_size(), 16);
643    }
644
645    #[test]
646    fn compute_buffer_write_read_roundtrip() {
647        let mut buf = ComputeBuffer::new(8, BufferUsage::Storage, "rw");
648        buf.write_f32(2, &[1.0, 2.0, 3.0]);
649        let out = buf.read_f32(2, 3);
650        assert_eq!(out, vec![1.0, 2.0, 3.0]);
651    }
652
653    #[test]
654    fn compute_buffer_write_at_offset_zero() {
655        let mut buf = ComputeBuffer::new(4, BufferUsage::Storage, "s");
656        buf.write_f32(0, &[9.0, 8.0, 7.0, 6.0]);
657        assert_eq!(buf.data, vec![9.0, 8.0, 7.0, 6.0]);
658    }
659
660    #[test]
661    #[should_panic(expected = "out-of-bounds write")]
662    fn compute_buffer_write_oob_panics() {
663        let mut buf = ComputeBuffer::new(4, BufferUsage::Storage, "oob");
664        buf.write_f32(3, &[1.0, 2.0]); // 3+2 > 4
665    }
666
667    #[test]
668    #[should_panic(expected = "out-of-bounds read")]
669    fn compute_buffer_read_oob_panics() {
670        let buf = ComputeBuffer::new(4, BufferUsage::Storage, "oob");
671        let _ = buf.read_f32(3, 2);
672    }
673
674    // ── WorkgroupSize ────────────────────────────────────────────────────────
675
676    #[test]
677    fn workgroup_dispatch_count_exact() {
678        assert_eq!(WorkgroupSize::dispatch_count(64, 64), 1);
679    }
680
681    #[test]
682    fn workgroup_dispatch_count_ceil() {
683        assert_eq!(WorkgroupSize::dispatch_count(65, 64), 2);
684        assert_eq!(WorkgroupSize::dispatch_count(1, 64), 1);
685    }
686
687    #[test]
688    fn workgroup_dispatch_count_zero_total() {
689        assert_eq!(WorkgroupSize::dispatch_count(0, 64), 0);
690    }
691
692    #[test]
693    fn workgroup_default() {
694        let wg = WorkgroupSize::default();
695        assert_eq!(wg.x, 64);
696        assert_eq!(wg.y, 1);
697        assert_eq!(wg.z, 1);
698    }
699
700    // ── ComputeKernelKind ────────────────────────────────────────────────────
701
702    #[test]
703    fn kernel_kind_custom_eq() {
704        let a = ComputeKernelKind::Custom("foo".into());
705        let b = ComputeKernelKind::Custom("foo".into());
706        assert_eq!(a, b);
707    }
708
709    #[test]
710    fn kernel_kind_variants_neq() {
711        assert_ne!(
712            ComputeKernelKind::VelocityUpdate,
713            ComputeKernelKind::PressureJacobi
714        );
715    }
716
717    // ── CpuComputeDispatch – velocity update ─────────────────────────────────
718
719    #[test]
720    fn velocity_update_basic() {
721        let disp =
722            CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, WorkgroupSize::default());
723        let n = 3;
724        let mut pos = ComputeBuffer::new(n, BufferUsage::Storage, "pos");
725        let mut vel = ComputeBuffer::new(n, BufferUsage::Storage, "vel");
726        let mut force = ComputeBuffer::new(n, BufferUsage::Storage, "force");
727        let mut mass = ComputeBuffer::new(n, BufferUsage::Storage, "mass");
728
729        pos.write_f32(0, &[0.0, 1.0, 2.0]);
730        vel.write_f32(0, &[1.0, 0.5, -1.0]);
731        force.write_f32(0, &[0.0, 1.0, 0.0]);
732        mass.write_f32(0, &[1.0, 2.0, 1.0]);
733
734        let dt = 0.1_f32;
735        disp.dispatch_velocity_update(&mut pos, &mut vel, &force, &mass, dt, n);
736
737        // pos[0] = 0.0 + 1.0*0.1 = 0.1,  vel[0] = 1.0 + 0/1*0.1 = 1.0
738        assert!((pos.data[0] - 0.1).abs() < 1e-6);
739        assert!((vel.data[0] - 1.0).abs() < 1e-6);
740        // pos[1] = 1.0 + 0.5*0.1 = 1.05, vel[1] = 0.5 + 1/2*0.1 = 0.55
741        assert!((pos.data[1] - 1.05).abs() < 1e-6);
742        assert!((vel.data[1] - 0.55).abs() < 1e-6);
743    }
744
745    #[test]
746    fn velocity_update_zero_force() {
747        let disp =
748            CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, WorkgroupSize::default());
749        let n = 2;
750        let mut pos = ComputeBuffer::new(n, BufferUsage::Storage, "pos");
751        let mut vel = ComputeBuffer::new(n, BufferUsage::Storage, "vel");
752        let force = ComputeBuffer::new(n, BufferUsage::Storage, "force");
753        let mut mass = ComputeBuffer::new(n, BufferUsage::Storage, "mass");
754
755        pos.write_f32(0, &[0.0, 0.0]);
756        vel.write_f32(0, &[2.0, -3.0]);
757        mass.write_f32(0, &[1.0, 1.0]);
758
759        disp.dispatch_velocity_update(&mut pos, &mut vel, &force, &mass, 0.5, n);
760
761        assert!((pos.data[0] - 1.0).abs() < 1e-6);
762        assert!((pos.data[1] - (-1.5)).abs() < 1e-6);
763        // velocities unchanged (zero force)
764        assert!((vel.data[0] - 2.0).abs() < 1e-6);
765        assert!((vel.data[1] - (-3.0)).abs() < 1e-6);
766    }
767
768    // ── CpuComputeDispatch – pressure Jacobi ─────────────────────────────────
769
770    #[test]
771    fn pressure_jacobi_interior_update() {
772        let disp =
773            CpuComputeDispatch::new(ComputeKernelKind::PressureJacobi, WorkgroupSize::default());
774        let nx = 4;
775        let ny = 4;
776        let mut p = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "p");
777        let mut p_old = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "p_old");
778        let rhs = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "rhs");
779
780        // Set p_old to known values; neighbours of (1,1) are all 1.0
781        for v in p_old.data.iter_mut() {
782            *v = 1.0;
783        }
784
785        disp.dispatch_pressure_jacobi(&mut p, &p_old, &rhs, nx, ny, 1.0);
786
787        // p[1*4+1] = (1+1+1+1 - 0)/4 = 1.0
788        let idx = nx + 1;
789        assert!((p.data[idx] - 1.0).abs() < 1e-6);
790    }
791
792    #[test]
793    fn pressure_jacobi_boundary_unchanged() {
794        let disp =
795            CpuComputeDispatch::new(ComputeKernelKind::PressureJacobi, WorkgroupSize::default());
796        let nx = 5;
797        let ny = 5;
798        let mut p = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "p");
799        let p_old = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "p_old");
800        let rhs = ComputeBuffer::new(nx * ny, BufferUsage::Storage, "rhs");
801
802        // Boundary should remain zero.
803        disp.dispatch_pressure_jacobi(&mut p, &p_old, &rhs, nx, ny, 1.0);
804        assert_eq!(p.data[0], 0.0); // corner
805        assert_eq!(p.data[4], 0.0); // top-right corner
806    }
807
808    // ── CpuComputeDispatch – LJ particle force ───────────────────────────────
809
810    #[test]
811    fn particle_force_zero_at_large_sep() {
812        let disp =
813            CpuComputeDispatch::new(ComputeKernelKind::ParticleForce, WorkgroupSize::default());
814        let n = 2;
815        // Two particles very far apart → tiny force.
816        let mut pos = ComputeBuffer::new(2 * n, BufferUsage::Storage, "pos");
817        let mut force = ComputeBuffer::new(2 * n, BufferUsage::Storage, "force");
818        pos.write_f32(0, &[0.0, 0.0, 1000.0, 0.0]);
819
820        disp.dispatch_particle_force(&pos, &mut force, 1.0, 1.0, n);
821        // Force should be negligible at r=1000σ
822        assert!(force.data[0].abs() < 1e-10);
823    }
824
825    #[test]
826    fn particle_force_newton3() {
827        let disp =
828            CpuComputeDispatch::new(ComputeKernelKind::ParticleForce, WorkgroupSize::default());
829        let n = 2;
830        let mut pos = ComputeBuffer::new(2 * n, BufferUsage::Storage, "pos");
831        let mut force = ComputeBuffer::new(2 * n, BufferUsage::Storage, "force");
832        pos.write_f32(0, &[0.0, 0.0, 1.5, 0.0]);
833
834        disp.dispatch_particle_force(&pos, &mut force, 1.0, 1.0, n);
835        // Newton's third law: f0 + f1 == 0
836        assert!((force.data[0] + force.data[2]).abs() < 1e-5);
837        assert!((force.data[1] + force.data[3]).abs() < 1e-5);
838    }
839
840    // ── GpuStats ─────────────────────────────────────────────────────────────
841
842    #[test]
843    fn gpu_stats_initial_zero() {
844        let s = GpuStats::new();
845        assert_eq!(s.dispatch_count, 0);
846        assert_eq!(s.bytes_transferred, 0);
847        assert_eq!(s.kernel_time_ms, 0.0);
848    }
849
850    #[test]
851    fn gpu_stats_accumulate() {
852        let mut s = GpuStats::new();
853        s.record_dispatch(128, 0.5);
854        s.record_dispatch(256, 1.0);
855        assert_eq!(s.dispatch_count, 2);
856        assert_eq!(s.bytes_transferred, 384);
857        assert!((s.kernel_time_ms - 1.5).abs() < 1e-9);
858    }
859
860    // ── jacobi_step_2d ───────────────────────────────────────────────────────
861
862    #[test]
863    fn jacobi_step_2d_uniform_field() {
864        let nx = 4;
865        let ny = 4;
866        let size = nx * ny;
867        let mut p_new = vec![0.0_f32; size];
868        let p_old = vec![1.0_f32; size];
869        let rhs = vec![0.0_f32; size];
870
871        jacobi_step_2d(&mut p_new, &p_old, &rhs, nx, ny, 1.0);
872
873        // Uniform field → interior stays 1.0.
874        for j in 1..ny - 1 {
875            for i in 1..nx - 1 {
876                assert!((p_new[j * nx + i] - 1.0).abs() < 1e-6);
877            }
878        }
879    }
880
881    #[test]
882    fn jacobi_step_2d_rhs_effect() {
883        let nx = 4;
884        let ny = 4;
885        let size = nx * ny;
886        let mut p_new = vec![0.0_f32; size];
887        let p_old = vec![4.0_f32; size];
888        // rhs = 4 at every interior point
889        let rhs = vec![4.0_f32; size];
890
891        jacobi_step_2d(&mut p_new, &p_old, &rhs, nx, ny, 1.0);
892        // p[i,j] = (4+4+4+4 - 1²*4)/4 = (16-4)/4 = 3
893        for j in 1..ny - 1 {
894            for i in 1..nx - 1 {
895                assert!((p_new[j * nx + i] - 3.0).abs() < 1e-6);
896            }
897        }
898    }
899
900    // ── pressure_poisson_solve ───────────────────────────────────────────────
901
902    #[test]
903    fn pressure_poisson_zero_rhs_zero_bc() {
904        // Zero RHS + zero BCs → solution stays zero → zero residual.
905        let nx = 5;
906        let ny = 5;
907        let mut p = vec![0.0_f32; nx * ny];
908        let rhs = vec![0.0_f32; nx * ny];
909        let residual = pressure_poisson_solve(&mut p, &rhs, nx, ny, 0.1, 50);
910        assert!(residual < 1e-6, "residual={residual}");
911    }
912
913    #[test]
914    fn pressure_poisson_residual_decreases() {
915        let nx = 6;
916        let ny = 6;
917        let mut p1 = vec![0.0_f32; nx * ny];
918        let mut p2 = p1.clone();
919        let rhs: Vec<f32> = (0..(nx * ny)).map(|k| (k as f32).sin()).collect();
920        let dx = 0.1;
921
922        let r1 = pressure_poisson_solve(&mut p1, &rhs, nx, ny, dx, 10);
923        let r2 = pressure_poisson_solve(&mut p2, &rhs, nx, ny, dx, 200);
924        assert!(
925            r2 <= r1 + 1e-4,
926            "more iterations should not increase residual (r1={r1}, r2={r2})"
927        );
928    }
929
930    // ── PipelineCache ──────────────────────────────────────────────────────
931
932    #[test]
933    fn pipeline_cache_insert_and_get() {
934        let mut cache = PipelineCache::new(4);
935        let disp =
936            CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, WorkgroupSize::default());
937        cache.insert("vel_update", disp);
938        assert!(cache.get("vel_update").is_some());
939        assert!(cache.get("nonexistent").is_none());
940    }
941
942    #[test]
943    fn pipeline_cache_eviction() {
944        let mut cache = PipelineCache::new(2);
945        let d1 =
946            CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, WorkgroupSize::default());
947        let d2 =
948            CpuComputeDispatch::new(ComputeKernelKind::PressureJacobi, WorkgroupSize::default());
949        let d3 =
950            CpuComputeDispatch::new(ComputeKernelKind::ParticleForce, WorkgroupSize::default());
951        cache.insert("a", d1);
952        cache.insert("b", d2);
953        cache.insert("c", d3); // should evict "a"
954        assert!(cache.get("a").is_none());
955        assert!(cache.get("b").is_some());
956        assert!(cache.get("c").is_some());
957    }
958
959    #[test]
960    fn pipeline_cache_replace() {
961        let mut cache = PipelineCache::new(4);
962        let d1 =
963            CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, WorkgroupSize::default());
964        let d2 = CpuComputeDispatch::new(
965            ComputeKernelKind::ParticleForce,
966            WorkgroupSize { x: 128, y: 1, z: 1 },
967        );
968        cache.insert("key", d1);
969        cache.insert("key", d2);
970        let entry = cache.get("key").unwrap();
971        assert_eq!(entry.kernel, ComputeKernelKind::ParticleForce);
972    }
973
974    // ── PipelineStats ──────────────────────────────────────────────────────
975
976    #[test]
977    fn pipeline_stats_default() {
978        let stats = PipelineStats::default();
979        assert_eq!(stats.total_dispatches, 0);
980        assert_eq!(stats.total_workgroups, 0);
981        assert_eq!(stats.total_invocations, 0);
982        assert_eq!(stats.cache_hits, 0);
983        assert_eq!(stats.cache_misses, 0);
984    }
985
986    #[test]
987    fn pipeline_stats_record() {
988        let mut stats = PipelineStats::default();
989        stats.record_dispatch(4, WorkgroupSize { x: 64, y: 1, z: 1 });
990        assert_eq!(stats.total_dispatches, 1);
991        assert_eq!(stats.total_workgroups, 4);
992        assert_eq!(stats.total_invocations, 4 * 64);
993    }
994
995    #[test]
996    fn pipeline_stats_record_3d_workgroup() {
997        let mut stats = PipelineStats::default();
998        stats.record_dispatch(2, WorkgroupSize { x: 8, y: 8, z: 4 });
999        assert_eq!(stats.total_dispatches, 1);
1000        assert_eq!(stats.total_workgroups, 2);
1001        assert_eq!(stats.total_invocations, 2 * 8 * 8 * 4);
1002    }
1003
1004    #[test]
1005    fn pipeline_stats_cache_ratio() {
1006        let mut stats = PipelineStats::default();
1007        assert!(stats.cache_hit_ratio().is_nan() || stats.cache_hit_ratio() == 0.0);
1008        stats.cache_hits = 3;
1009        stats.cache_misses = 1;
1010        assert!((stats.cache_hit_ratio() - 0.75).abs() < 1e-6);
1011    }
1012
1013    // ── MultiPassPipeline ──────────────────────────────────────────────────
1014
1015    #[test]
1016    fn multi_pass_empty() {
1017        let mp = MultiPassPipeline::new("empty");
1018        assert_eq!(mp.passes.len(), 0);
1019        assert_eq!(mp.label, "empty");
1020    }
1021
1022    #[test]
1023    fn multi_pass_execute_add_scale() {
1024        // pass 0: fill buffer with [1, 2, 3, 4]
1025        // pass 1: scale by 2 → [2, 4, 6, 8]
1026        let mut mp = MultiPassPipeline::new("add_scale");
1027        mp.add_pass(ComputePass {
1028            label: "fill".into(),
1029            kernel: ComputeKernelKind::Custom("fill".into()),
1030            workgroup_size: WorkgroupSize::default(),
1031            buffer_bindings: vec![0],
1032        });
1033        mp.add_pass(ComputePass {
1034            label: "scale".into(),
1035            kernel: ComputeKernelKind::Custom("scale".into()),
1036            workgroup_size: WorkgroupSize::default(),
1037            buffer_bindings: vec![0],
1038        });
1039        assert_eq!(mp.passes.len(), 2);
1040        assert_eq!(mp.passes[0].label, "fill");
1041        assert_eq!(mp.passes[1].label, "scale");
1042    }
1043
1044    #[test]
1045    fn multi_pass_dispatch_velocity_chain() {
1046        // Test chaining two velocity update passes
1047        let mut mp = MultiPassPipeline::new("vel_chain");
1048        mp.add_pass(ComputePass {
1049            label: "step1".into(),
1050            kernel: ComputeKernelKind::VelocityUpdate,
1051            workgroup_size: WorkgroupSize::default(),
1052            buffer_bindings: vec![0, 1, 2, 3],
1053        });
1054        mp.add_pass(ComputePass {
1055            label: "step2".into(),
1056            kernel: ComputeKernelKind::VelocityUpdate,
1057            workgroup_size: WorkgroupSize::default(),
1058            buffer_bindings: vec![0, 1, 2, 3],
1059        });
1060
1061        let n = 2;
1062        let mut pos = ComputeBuffer::new(n, BufferUsage::Storage, "pos");
1063        let mut vel = ComputeBuffer::new(n, BufferUsage::Storage, "vel");
1064        let force = ComputeBuffer::new(n, BufferUsage::Storage, "force");
1065        let mut mass = ComputeBuffer::new(n, BufferUsage::Storage, "mass");
1066
1067        pos.write_f32(0, &[0.0, 0.0]);
1068        vel.write_f32(0, &[1.0, 2.0]);
1069        mass.write_f32(0, &[1.0, 1.0]);
1070
1071        let dt = 0.1_f32;
1072
1073        // Execute passes manually (since we simulate CPU-side)
1074        for pass in &mp.passes {
1075            if pass.kernel == ComputeKernelKind::VelocityUpdate {
1076                let disp =
1077                    CpuComputeDispatch::new(ComputeKernelKind::VelocityUpdate, pass.workgroup_size);
1078                disp.dispatch_velocity_update(&mut pos, &mut vel, &force, &mass, dt, n);
1079            }
1080        }
1081        // After 2 steps with zero force: pos = vel*dt*2
1082        assert!((pos.data[0] - 0.2).abs() < 1e-5);
1083        assert!((pos.data[1] - 0.4).abs() < 1e-5);
1084    }
1085
1086    // ── Pipeline validation ────────────────────────────────────────────────
1087
1088    #[test]
1089    fn validate_binding_valid() {
1090        let buffers = vec![
1091            ComputeBuffer::new(16, BufferUsage::Storage, "buf0"),
1092            ComputeBuffer::new(16, BufferUsage::Uniform, "buf1"),
1093        ];
1094        let pass = ComputePass {
1095            label: "test".into(),
1096            kernel: ComputeKernelKind::VelocityUpdate,
1097            workgroup_size: WorkgroupSize::default(),
1098            buffer_bindings: vec![0, 1],
1099        };
1100        let errors = validate_resource_bindings(&pass, &buffers);
1101        assert!(errors.is_empty());
1102    }
1103
1104    #[test]
1105    fn validate_binding_out_of_range() {
1106        let buffers = vec![ComputeBuffer::new(16, BufferUsage::Storage, "buf0")];
1107        let pass = ComputePass {
1108            label: "test".into(),
1109            kernel: ComputeKernelKind::VelocityUpdate,
1110            workgroup_size: WorkgroupSize::default(),
1111            buffer_bindings: vec![0, 5],
1112        };
1113        let errors = validate_resource_bindings(&pass, &buffers);
1114        assert_eq!(errors.len(), 1);
1115        assert!(errors[0].contains("out of range"));
1116    }
1117
1118    #[test]
1119    fn validate_binding_duplicate() {
1120        let buffers = vec![ComputeBuffer::new(16, BufferUsage::Storage, "buf0")];
1121        let pass = ComputePass {
1122            label: "test".into(),
1123            kernel: ComputeKernelKind::VelocityUpdate,
1124            workgroup_size: WorkgroupSize::default(),
1125            buffer_bindings: vec![0, 0],
1126        };
1127        let errors = validate_resource_bindings(&pass, &buffers);
1128        assert_eq!(errors.len(), 1);
1129        assert!(errors[0].contains("Duplicate"));
1130    }
1131
1132    #[test]
1133    fn validate_pipeline_all_passes() {
1134        let buffers = vec![ComputeBuffer::new(16, BufferUsage::Storage, "buf0")];
1135        let mut mp = MultiPassPipeline::new("test");
1136        mp.add_pass(ComputePass {
1137            label: "good".into(),
1138            kernel: ComputeKernelKind::VelocityUpdate,
1139            workgroup_size: WorkgroupSize::default(),
1140            buffer_bindings: vec![0],
1141        });
1142        mp.add_pass(ComputePass {
1143            label: "bad".into(),
1144            kernel: ComputeKernelKind::PressureJacobi,
1145            workgroup_size: WorkgroupSize::default(),
1146            buffer_bindings: vec![0, 3],
1147        });
1148        let errors = validate_pipeline(&mp, &buffers);
1149        assert_eq!(errors.len(), 1); // only the bad pass has errors
1150    }
1151
1152    // ── ComputeBuffer additional tests ─────────────────────────────────────
1153
1154    #[test]
1155    fn compute_buffer_clone() {
1156        let mut buf = ComputeBuffer::new(4, BufferUsage::Storage, "orig");
1157        buf.write_f32(0, &[1.0, 2.0, 3.0, 4.0]);
1158        let cloned = buf.clone();
1159        assert_eq!(buf.data, cloned.data);
1160        assert_eq!(buf.label, cloned.label);
1161    }
1162
1163    #[test]
1164    fn compute_buffer_staging_usage() {
1165        let buf = ComputeBuffer::new(8, BufferUsage::Staging, "staging");
1166        assert_eq!(buf.usage, BufferUsage::Staging);
1167        assert_eq!(buf.byte_size(), 32);
1168    }
1169
1170    // ── Workgroup additional tests ─────────────────────────────────────────
1171
1172    #[test]
1173    fn workgroup_dispatch_count_large() {
1174        assert_eq!(WorkgroupSize::dispatch_count(1024, 256), 4);
1175        assert_eq!(WorkgroupSize::dispatch_count(1025, 256), 5);
1176    }
1177
1178    // ── GpuStats additional tests ──────────────────────────────────────────
1179
1180    #[test]
1181    fn gpu_stats_clone() {
1182        let mut s = GpuStats::new();
1183        s.record_dispatch(100, 1.5);
1184        let s2 = s.clone();
1185        assert_eq!(s.dispatch_count, s2.dispatch_count);
1186        assert_eq!(s.bytes_transferred, s2.bytes_transferred);
1187        assert!((s.kernel_time_ms - s2.kernel_time_ms).abs() < 1e-12);
1188    }
1189
1190    // ── dispatch_particle_force additional tests ──────────────────────────
1191
1192    #[test]
1193    fn particle_force_repulsive_at_close_range() {
1194        let disp =
1195            CpuComputeDispatch::new(ComputeKernelKind::ParticleForce, WorkgroupSize::default());
1196        let n = 2;
1197        let mut pos = ComputeBuffer::new(2 * n, BufferUsage::Storage, "pos");
1198        let mut force = ComputeBuffer::new(2 * n, BufferUsage::Storage, "force");
1199        // Two particles at distance 0.9σ (< σ → repulsive region)
1200        pos.write_f32(0, &[0.0, 0.0, 0.9, 0.0]);
1201        disp.dispatch_particle_force(&pos, &mut force, 1.0, 1.0, n);
1202        // Force on particle 0 should push it away from particle 1 (negative x)
1203        assert!(
1204            force.data[0] < 0.0,
1205            "expected repulsive force, got {}",
1206            force.data[0]
1207        );
1208    }
1209
1210    #[test]
1211    fn particle_force_three_particles() {
1212        let disp =
1213            CpuComputeDispatch::new(ComputeKernelKind::ParticleForce, WorkgroupSize::default());
1214        let n = 3;
1215        let mut pos = ComputeBuffer::new(2 * n, BufferUsage::Storage, "pos");
1216        let mut force = ComputeBuffer::new(2 * n, BufferUsage::Storage, "force");
1217        // Triangle arrangement
1218        pos.write_f32(0, &[0.0, 0.0, 2.0, 0.0, 1.0, 1.732]);
1219        disp.dispatch_particle_force(&pos, &mut force, 1.0, 1.0, n);
1220
1221        // Total momentum conservation: sum of all forces should be zero
1222        let fx_total = force.data[0] + force.data[2] + force.data[4];
1223        let fy_total = force.data[1] + force.data[3] + force.data[5];
1224        assert!(fx_total.abs() < 1e-5, "fx_total={fx_total}");
1225        assert!(fy_total.abs() < 1e-5, "fy_total={fy_total}");
1226    }
1227
1228    // ── pressure_poisson_solve additional tests ───────────────────────────
1229
1230    #[test]
1231    fn pressure_poisson_uniform_rhs() {
1232        let nx = 8;
1233        let ny = 8;
1234        let mut p = vec![0.0_f32; nx * ny];
1235        let rhs = vec![1.0_f32; nx * ny];
1236        let residual = pressure_poisson_solve(&mut p, &rhs, nx, ny, 0.1, 500);
1237        // After many iterations residual should decrease significantly
1238        assert!(residual < 10.0, "residual={residual}");
1239    }
1240
1241    // ── SOR solver ────────────────────────────────────────────────────────
1242
1243    #[test]
1244    fn sor_step_uniform_field() {
1245        let nx = 4;
1246        let ny = 4;
1247        let mut p = vec![0.0_f32; nx * ny];
1248        let rhs = vec![0.0_f32; nx * ny];
1249        let p_ref = vec![1.0_f32; nx * ny];
1250        sor_step_2d(&mut p, &p_ref, &rhs, nx, ny, 1.0, 1.0);
1251        // With omega=1.0 this is the same as Jacobi
1252        for j in 1..ny - 1 {
1253            for i in 1..nx - 1 {
1254                assert!((p[j * nx + i] - 1.0).abs() < 1e-6);
1255            }
1256        }
1257    }
1258
1259    #[test]
1260    fn sor_step_over_relaxation() {
1261        // SOR with omega > 1 should differ from standard Jacobi (omega=1)
1262        let nx = 6;
1263        let ny = 6;
1264        let rhs = vec![0.0_f32; nx * ny];
1265
1266        // Non-uniform reference: set boundary to 1, interior p_old to 0
1267        let mut p_ref = vec![0.0_f32; nx * ny];
1268        for i in 0..nx {
1269            p_ref[i] = 1.0;
1270            p_ref[(ny - 1) * nx + i] = 1.0;
1271        }
1272        for j in 0..ny {
1273            p_ref[j * nx] = 1.0;
1274            p_ref[j * nx + nx - 1] = 1.0;
1275        }
1276
1277        let mut p_jac = vec![0.0_f32; nx * ny];
1278        let mut p_sor = vec![0.0_f32; nx * ny];
1279        sor_step_2d(&mut p_jac, &p_ref, &rhs, nx, ny, 1.0, 1.0);
1280        sor_step_2d(&mut p_sor, &p_ref, &rhs, nx, ny, 1.0, 1.5);
1281
1282        // SOR result should differ from Jacobi for interior nodes next to boundary
1283        let idx = nx + 1; // has 2 boundary neighbors
1284        // Jacobi: (1+0+1+0)/4 = 0.5, SOR: (1-1.5)*0 + 1.5*0.5 = 0.75
1285        assert!(
1286            (p_sor[idx] - p_jac[idx]).abs() > 0.01,
1287            "SOR and Jacobi should differ: SOR={}, Jac={}",
1288            p_sor[idx],
1289            p_jac[idx]
1290        );
1291    }
1292
1293    // ── Red-black Gauss-Seidel ────────────────────────────────────────────
1294
1295    #[test]
1296    fn red_black_gs_uniform() {
1297        let nx = 6;
1298        let ny = 6;
1299        let mut p = vec![1.0_f32; nx * ny];
1300        let rhs = vec![0.0_f32; nx * ny];
1301        red_black_gauss_seidel_step(&mut p, &rhs, nx, ny, 1.0);
1302        // Uniform field is a fixed point with zero rhs
1303        for j in 1..ny - 1 {
1304            for i in 1..nx - 1 {
1305                assert!((p[j * nx + i] - 1.0).abs() < 1e-6);
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn red_black_gs_converges() {
1312        let nx = 8;
1313        let ny = 8;
1314        let mut p = vec![0.0_f32; nx * ny];
1315        let rhs = vec![0.0_f32; nx * ny];
1316        // Set boundary to 1
1317        for i in 0..nx {
1318            p[i] = 1.0;
1319            p[(ny - 1) * nx + i] = 1.0;
1320        }
1321        for j in 0..ny {
1322            p[j * nx] = 1.0;
1323            p[j * nx + nx - 1] = 1.0;
1324        }
1325        // Multiple sweeps should converge interior toward 1.0
1326        for _ in 0..200 {
1327            red_black_gauss_seidel_step(&mut p, &rhs, nx, ny, 1.0);
1328        }
1329        let center = p[(ny / 2) * nx + nx / 2];
1330        assert!((center - 1.0).abs() < 0.01, "center={center}");
1331    }
1332
1333    // ── compute_linf_residual ─────────────────────────────────────────────
1334
1335    #[test]
1336    fn linf_residual_zero_for_exact() {
1337        // Uniform field with zero rhs is an exact solution
1338        let nx = 4;
1339        let ny = 4;
1340        let p = vec![1.0_f32; nx * ny];
1341        let rhs = vec![0.0_f32; nx * ny];
1342        let res = compute_linf_residual(&p, &rhs, nx, ny, 1.0);
1343        assert!(res < 1e-6, "res={res}");
1344    }
1345
1346    #[test]
1347    fn linf_residual_nonzero_for_wrong() {
1348        let nx = 4;
1349        let ny = 4;
1350        let mut p = vec![0.0_f32; nx * ny];
1351        p[nx + 1] = 100.0; // big spike
1352        let rhs = vec![0.0_f32; nx * ny];
1353        let res = compute_linf_residual(&p, &rhs, nx, ny, 1.0);
1354        assert!(res > 1.0, "expected large residual, got {res}");
1355    }
1356
1357    // ── Dispatch neighbor search ──────────────────────────────────────────
1358
1359    #[test]
1360    fn dispatch_neighbor_search_basic() {
1361        let n = 4;
1362        let positions = vec![
1363            0.0_f32, 0.0, // particle 0
1364            0.5, 0.0, // particle 1 (close to 0)
1365            5.0, 5.0, // particle 2 (far)
1366            0.3, 0.3, // particle 3 (close to 0 and 1)
1367        ];
1368        let neighbors = dispatch_neighbor_search(&positions, n, 1.0);
1369        // particle 0 should have neighbors 1 and 3
1370        assert!(neighbors[0].contains(&1));
1371        assert!(neighbors[0].contains(&3));
1372        // particle 2 should have no neighbors
1373        assert!(neighbors[2].is_empty());
1374    }
1375
1376    #[test]
1377    fn dispatch_neighbor_search_all_close() {
1378        let n = 3;
1379        let positions = vec![0.0_f32, 0.0, 0.1, 0.0, 0.0, 0.1];
1380        let neighbors = dispatch_neighbor_search(&positions, n, 1.0);
1381        // All particles within cutoff of each other
1382        assert_eq!(neighbors[0].len(), 2);
1383        assert_eq!(neighbors[1].len(), 2);
1384        assert_eq!(neighbors[2].len(), 2);
1385    }
1386
1387    #[test]
1388    fn dispatch_neighbor_search_none() {
1389        let n = 2;
1390        let positions = vec![0.0_f32, 0.0, 100.0, 100.0];
1391        let neighbors = dispatch_neighbor_search(&positions, n, 1.0);
1392        assert!(neighbors[0].is_empty());
1393        assert!(neighbors[1].is_empty());
1394    }
1395}