Skip to main content

proof_engine/math/
attractors.rs

1//! Strange attractor implementations — RK4 integration, Lyapunov exponents, warmup.
2//!
3//! All seven classical attractors are implemented with:
4//! - 4th-order Runge-Kutta (RK4) integration for accuracy
5//! - Lyapunov exponent approximation (numerical divergence tracking)
6//! - Warmup / transient discard so initial noise is removed before sampling
7//! - Normalised output bounding boxes (attractor fits ≈ unit cube)
8//! - Bifurcation parameter sweeps for each attractor family
9
10use glam::Vec3;
11
12// ── Attractor type ─────────────────────────────────────────────────────────────
13
14/// Which strange attractor to use.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum AttractorType {
17    Lorenz,
18    Rossler,
19    Chen,
20    Halvorsen,
21    Aizawa,
22    Thomas,
23    Dadras,
24    Sprott,
25    Rabinovich,
26    Burke,
27}
28
29impl AttractorType {
30    /// Human-readable name for display.
31    pub fn name(self) -> &'static str {
32        match self {
33            AttractorType::Lorenz      => "Lorenz",
34            AttractorType::Rossler     => "Rössler",
35            AttractorType::Chen        => "Chen",
36            AttractorType::Halvorsen   => "Halvorsen",
37            AttractorType::Aizawa      => "Aizawa",
38            AttractorType::Thomas      => "Thomas",
39            AttractorType::Dadras      => "Dadras",
40            AttractorType::Sprott      => "Sprott B",
41            AttractorType::Rabinovich  => "Rabinovich–Fabrikant",
42            AttractorType::Burke       => "Burke–Shaw",
43        }
44    }
45
46    /// All attractor variants, useful for iteration.
47    pub fn all() -> &'static [AttractorType] {
48        &[
49            AttractorType::Lorenz,
50            AttractorType::Rossler,
51            AttractorType::Chen,
52            AttractorType::Halvorsen,
53            AttractorType::Aizawa,
54            AttractorType::Thomas,
55            AttractorType::Dadras,
56            AttractorType::Sprott,
57            AttractorType::Rabinovich,
58            AttractorType::Burke,
59        ]
60    }
61
62    /// Approximate scale factor to normalise positions to ≈ unit cube.
63    pub fn normalization_scale(self) -> f32 {
64        match self {
65            AttractorType::Lorenz      => 1.0 / 30.0,
66            AttractorType::Rossler     => 1.0 / 12.0,
67            AttractorType::Chen        => 1.0 / 30.0,
68            AttractorType::Halvorsen   => 1.0 / 8.0,
69            AttractorType::Aizawa      => 1.0 / 1.5,
70            AttractorType::Thomas      => 1.0 / 3.5,
71            AttractorType::Dadras      => 1.0 / 10.0,
72            AttractorType::Sprott      => 1.0 / 2.0,
73            AttractorType::Rabinovich  => 1.0 / 3.0,
74            AttractorType::Burke       => 1.0 / 3.5,
75        }
76    }
77
78    /// Recommended integration step size (smaller = more accurate but slower).
79    pub fn recommended_dt(self) -> f32 {
80        match self {
81            AttractorType::Lorenz      => 0.005,
82            AttractorType::Rossler     => 0.01,
83            AttractorType::Chen        => 0.005,
84            AttractorType::Halvorsen   => 0.01,
85            AttractorType::Aizawa      => 0.01,
86            AttractorType::Thomas      => 0.05,
87            AttractorType::Dadras      => 0.005,
88            AttractorType::Sprott      => 0.01,
89            AttractorType::Rabinovich  => 0.005,
90            AttractorType::Burke       => 0.005,
91        }
92    }
93
94    /// Approximate Lyapunov exponent (positive → chaotic).
95    pub fn lyapunov_estimate(self) -> f32 {
96        match self {
97            AttractorType::Lorenz      => 0.9056,
98            AttractorType::Rossler     => 0.0714,
99            AttractorType::Chen        => 2.0272,
100            AttractorType::Halvorsen   => 0.8042,
101            AttractorType::Aizawa      => 0.0721,
102            AttractorType::Thomas      => 0.0550,
103            AttractorType::Dadras      => 0.5100,
104            AttractorType::Sprott      => 0.3600,
105            AttractorType::Rabinovich  => 0.1600,
106            AttractorType::Burke       => 0.3300,
107        }
108    }
109}
110
111// ── Derivative functions ───────────────────────────────────────────────────────
112
113/// Compute continuous-time derivatives at state `s` for the given attractor.
114/// Returns `(dx/dt, dy/dt, dz/dt)`.
115pub fn derivatives(attractor: AttractorType, s: Vec3) -> Vec3 {
116    let (x, y, z) = (s.x, s.y, s.z);
117    let (dx, dy, dz) = match attractor {
118        AttractorType::Lorenz => {
119            const SIGMA: f32 = 10.0;
120            const RHO:   f32 = 28.0;
121            const BETA:  f32 = 8.0 / 3.0;
122            (SIGMA * (y - x), x * (RHO - z) - y, x * y - BETA * z)
123        }
124        AttractorType::Rossler => {
125            const A: f32 = 0.2;
126            const B: f32 = 0.2;
127            const C: f32 = 5.7;
128            (-y - z, x + A * y, B + z * (x - C))
129        }
130        AttractorType::Chen => {
131            const A: f32 = 35.0;
132            const B: f32 = 3.0;
133            const C: f32 = 28.0;
134            (A * (y - x), (C - A) * x - x * z + C * y, x * y - B * z)
135        }
136        AttractorType::Halvorsen => {
137            const A: f32 = 1.4;
138            (
139                -A * x - 4.0 * y - 4.0 * z - y * y,
140                -A * y - 4.0 * z - 4.0 * x - z * z,
141                -A * z - 4.0 * x - 4.0 * y - x * x,
142            )
143        }
144        AttractorType::Aizawa => {
145            const A: f32 = 0.95;
146            const B: f32 = 0.7;
147            const C: f32 = 0.6;
148            const D: f32 = 3.5;
149            const E: f32 = 0.25;
150            const F: f32 = 0.1;
151            (
152                (z - B) * x - D * y,
153                D * x + (z - B) * y,
154                C + A * z - z.powi(3) / 3.0
155                    - (x * x + y * y) * (1.0 + E * z)
156                    + F * z * x.powi(3),
157            )
158        }
159        AttractorType::Thomas => {
160            const B: f32 = 0.208_186;
161            (y.sin() - B * x, z.sin() - B * y, x.sin() - B * z)
162        }
163        AttractorType::Dadras => {
164            const P: f32 = 3.0;
165            const Q: f32 = 2.7;
166            const R: f32 = 1.7;
167            const S: f32 = 2.0;
168            const H: f32 = 9.0;
169            (y - P * x + Q * y * z, R * y - x * z + z, S * x * y - H * z)
170        }
171        AttractorType::Sprott => {
172            // Sprott B: dx=yz, dy=x-y, dz=1-xy
173            (y * z, x - y, 1.0 - x * y)
174        }
175        AttractorType::Rabinovich => {
176            // Rabinovich–Fabrikant with canonical parameters
177            const GAMMA: f32 = 0.87;
178            const ALPHA: f32 = 1.1;
179            (
180                y * (z - 1.0 + x * x) + GAMMA * x,
181                x * (3.0 * z + 1.0 - x * x) + GAMMA * y,
182                -2.0 * z * (ALPHA + x * y),
183            )
184        }
185        AttractorType::Burke => {
186            // Burke–Shaw: dx=-s(x+y), dy=y-sx*z, dz=sx*y+v
187            const S: f32 = 10.0;
188            const V: f32 = 4.272;
189            (-S * (x + y), -y - S * x * z, S * x * y + V)
190        }
191    };
192    Vec3::new(dx, dy, dz)
193}
194
195// ── RK4 integrator ────────────────────────────────────────────────────────────
196
197/// Single RK4 step.
198/// More accurate than Euler at the cost of 4× function evaluations.
199#[inline]
200pub fn rk4_step(attractor: AttractorType, state: Vec3, dt: f32) -> Vec3 {
201    let k1 = derivatives(attractor, state);
202    let k2 = derivatives(attractor, state + k1 * (dt * 0.5));
203    let k3 = derivatives(attractor, state + k2 * (dt * 0.5));
204    let k4 = derivatives(attractor, state + k3 * dt);
205    state + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0)
206}
207
208/// Evolve by one step and return (new_state, displacement).
209/// Uses RK4 for accuracy.
210pub fn step(attractor: AttractorType, state: Vec3, dt: f32) -> (Vec3, Vec3) {
211    let next = rk4_step(attractor, state, dt);
212    (next, next - state)
213}
214
215/// Warm up an attractor (discard the transient trajectory).
216/// Call before sampling to ensure the state is on the attractor.
217pub fn warmup(attractor: AttractorType, mut state: Vec3, steps: usize) -> Vec3 {
218    let dt = attractor.recommended_dt();
219    for _ in 0..steps {
220        state = rk4_step(attractor, state, dt);
221    }
222    state
223}
224
225// ── Initial conditions ─────────────────────────────────────────────────────────
226
227/// Canonical initial conditions close to the attractor.
228pub fn initial_state(attractor: AttractorType) -> Vec3 {
229    match attractor {
230        AttractorType::Lorenz      => Vec3::new(0.1,  0.0,  0.0),
231        AttractorType::Rossler     => Vec3::new(0.1,  0.0,  0.0),
232        AttractorType::Chen        => Vec3::new(0.1,  0.0,  0.0),
233        AttractorType::Halvorsen   => Vec3::new(0.1,  0.0,  0.0),
234        AttractorType::Aizawa      => Vec3::new(0.1,  0.0,  0.0),
235        AttractorType::Thomas      => Vec3::new(0.1,  0.0,  0.0),
236        AttractorType::Dadras      => Vec3::new(0.1,  0.0,  0.0),
237        AttractorType::Sprott      => Vec3::new(1.0,  0.0,  0.5),
238        AttractorType::Rabinovich  => Vec3::new(0.05, 0.05, 0.5),
239        AttractorType::Burke       => Vec3::new(0.6, -0.4,  0.4),
240    }
241}
242
243/// Return a warmed-up initial state on the attractor surface.
244pub fn initial_state_warmed(attractor: AttractorType) -> Vec3 {
245    warmup(attractor, initial_state(attractor), 5000)
246}
247
248// ── AttractorSampler ───────────────────────────────────────────────────────────
249
250/// Stateful sampler that integrates an attractor over time.
251/// Maintains the current state and advances it on each call to `next()`.
252#[derive(Debug, Clone)]
253pub struct AttractorSampler {
254    pub attractor: AttractorType,
255    pub state:     Vec3,
256    pub dt:        f32,
257    pub time:      f32,
258    /// Scale applied to output positions (use `normalization_scale()` for unit cube).
259    pub scale:     f32,
260    /// Center offset applied after scaling.
261    pub center:    Vec3,
262    /// How many integration steps per `next()` call (sub-steps for stability).
263    pub substeps:  usize,
264}
265
266impl AttractorSampler {
267    pub fn new(attractor: AttractorType) -> Self {
268        let state = initial_state_warmed(attractor);
269        Self {
270            attractor,
271            state,
272            dt:       attractor.recommended_dt(),
273            time:     0.0,
274            scale:    attractor.normalization_scale(),
275            center:   Vec3::ZERO,
276            substeps: 1,
277        }
278    }
279
280    /// Advance by `substeps` integration steps and return the normalised position.
281    pub fn next(&mut self) -> Vec3 {
282        for _ in 0..self.substeps {
283            self.state  = rk4_step(self.attractor, self.state, self.dt);
284            self.time  += self.dt;
285        }
286        self.state * self.scale + self.center
287    }
288
289    /// Sample `n` points from the attractor trajectory.
290    pub fn sample_trajectory(&mut self, n: usize) -> Vec<Vec3> {
291        (0..n).map(|_| self.next()).collect()
292    }
293
294    /// Reset to a fresh warmed initial condition.
295    pub fn reset(&mut self) {
296        self.state = initial_state_warmed(self.attractor);
297        self.time  = 0.0;
298    }
299
300    /// Set the attractor type, resetting the state.
301    pub fn set_attractor(&mut self, attractor: AttractorType) {
302        self.attractor = attractor;
303        self.dt        = attractor.recommended_dt();
304        self.scale     = attractor.normalization_scale();
305        self.reset();
306    }
307
308    /// Current raw (un-normalised) state.
309    pub fn raw_state(&self) -> Vec3 {
310        self.state
311    }
312
313    /// Current normalised position.
314    pub fn position(&self) -> Vec3 {
315        self.state * self.scale + self.center
316    }
317
318    /// Instantaneous velocity vector in normalised space.
319    pub fn velocity(&self) -> Vec3 {
320        let d = derivatives(self.attractor, self.state);
321        d * (self.scale * self.dt)
322    }
323}
324
325// ── Lyapunov exponent approximation ───────────────────────────────────────────
326
327/// Compute the largest Lyapunov exponent numerically.
328///
329/// Uses the standard renormalization method:
330/// 1. Evolve two nearby trajectories.
331/// 2. After each step, measure the divergence.
332/// 3. Rescale the separation vector and accumulate log of growth.
333///
334/// `steps` is the number of integration steps (10_000 is usually enough).
335pub fn largest_lyapunov_exponent(
336    attractor: AttractorType,
337    initial:   Vec3,
338    steps:     usize,
339) -> f32 {
340    let dt     = attractor.recommended_dt();
341    let eps    = 1e-8_f32;
342    let mut s1 = warmup(attractor, initial, 2000);
343    // Perturb s2 slightly along (1, 1, 1) normalised
344    let mut s2 = s1 + Vec3::splat(eps / 3.0_f32.sqrt());
345
346    let mut lyapunov_sum = 0.0_f32;
347
348    for _ in 0..steps {
349        s1 = rk4_step(attractor, s1, dt);
350        s2 = rk4_step(attractor, s2, dt);
351
352        let sep = s2 - s1;
353        let d   = sep.length();
354        if d < 1e-15 { continue; }
355
356        lyapunov_sum += (d / eps).ln();
357
358        // Renormalize separation
359        s2 = s1 + sep * (eps / d);
360    }
361
362    lyapunov_sum / (steps as f32 * dt)
363}
364
365/// Compute all three Lyapunov exponents via QR decomposition (Gram–Schmidt).
366/// Returns `[λ1, λ2, λ3]` in descending order.
367pub fn lyapunov_spectrum(
368    attractor: AttractorType,
369    initial:   Vec3,
370    steps:     usize,
371) -> [f32; 3] {
372    let dt  = attractor.recommended_dt();
373    let eps = 1e-6_f32;
374
375    // Basis vectors (perturbed copies)
376    let mut state = warmup(attractor, initial, 2000);
377    let mut v1    = Vec3::new(eps,  0.0, 0.0);
378    let mut v2    = Vec3::new(0.0,  eps, 0.0);
379    let mut v3    = Vec3::new(0.0,  0.0, eps);
380
381    let mut sums = [0.0_f32; 3];
382
383    for _ in 0..steps {
384        let s0  = state;
385        state   = rk4_step(attractor, s0, dt);
386
387        // Evolve perturbations via the Jacobian (numerical, finite difference)
388        let evolve = |v: Vec3| -> Vec3 {
389            rk4_step(attractor, s0 + v, dt) - state
390        };
391
392        v1 = evolve(v1);
393        v2 = evolve(v2);
394        v3 = evolve(v3);
395
396        // Gram–Schmidt orthogonalisation
397        let n1  = v1.length().max(1e-30);
398        sums[0] += n1.ln();
399        v1       = v1 / n1;
400
401        v2       = v2 - v1 * v2.dot(v1);
402        let n2   = v2.length().max(1e-30);
403        sums[1] += n2.ln();
404        v2       = v2 / n2;
405
406        v3       = v3 - v1 * v3.dot(v1) - v2 * v3.dot(v2);
407        let n3   = v3.length().max(1e-30);
408        sums[2] += n3.ln();
409        v3       = v3 / n3;
410
411        // Reset to eps magnitude
412        v1 *= eps;
413        v2 *= eps;
414        v3 *= eps;
415    }
416
417    let t = steps as f32 * dt;
418    [sums[0] / t, sums[1] / t, sums[2] / t]
419}
420
421/// Kaplan–Yorke dimension from Lyapunov spectrum: D_KY = j + Σ(λ_i) / |λ_{j+1}|
422pub fn kaplan_yorke_dimension(spectrum: [f32; 3]) -> f32 {
423    let mut sorted = spectrum;
424    sorted.sort_by(|a, b| b.partial_cmp(a).unwrap());
425
426    let mut cumsum = 0.0_f32;
427    let mut j = 0usize;
428    for (i, &l) in sorted.iter().enumerate() {
429        if cumsum + l < 0.0 { break; }
430        cumsum += l;
431        j = i + 1;
432    }
433    if j >= 3 { return 3.0; }
434    j as f32 + cumsum / sorted[j].abs().max(1e-12)
435}
436
437// ── Attractor metadata and analysis ───────────────────────────────────────────
438
439/// Statistical metadata about an attractor's trajectory.
440#[derive(Debug, Clone)]
441pub struct AttractorStats {
442    pub attractor:    AttractorType,
443    pub bbox_min:     Vec3,
444    pub bbox_max:     Vec3,
445    pub centroid:     Vec3,
446    pub variance:     Vec3,
447    pub sample_count: usize,
448    /// Estimated largest Lyapunov exponent from the trajectory.
449    pub lyapunov_max: f32,
450}
451
452impl AttractorStats {
453    /// Compute statistics by sampling `n` points from a warmed attractor.
454    pub fn compute(attractor: AttractorType, n: usize) -> Self {
455        let mut sampler = AttractorSampler::new(attractor);
456        sampler.scale = 1.0; // raw coordinates
457
458        let mut min = Vec3::splat(f32::MAX);
459        let mut max = Vec3::splat(f32::MIN);
460        let mut sum = Vec3::ZERO;
461
462        let pts: Vec<Vec3> = (0..n).map(|_| {
463            let p = sampler.next();
464            min = min.min(p);
465            max = max.max(p);
466            sum += p;
467            p
468        }).collect();
469
470        let centroid = sum / n as f32;
471        let variance = pts.iter().fold(Vec3::ZERO, |acc, &p| {
472            let d = p - centroid;
473            acc + d * d
474        }) / n as f32;
475
476        let lyapunov_max = largest_lyapunov_exponent(
477            attractor, initial_state(attractor), 5000,
478        );
479
480        Self { attractor, bbox_min: min, bbox_max: max, centroid, variance, sample_count: n, lyapunov_max }
481    }
482
483    /// Axis-aligned bounding box size.
484    pub fn bbox_size(&self) -> Vec3 {
485        self.bbox_max - self.bbox_min
486    }
487
488    /// Effective scale factor to normalise into [-1, 1].
489    pub fn normalizing_scale(&self) -> f32 {
490        let s = self.bbox_size();
491        let m = s.x.max(s.y).max(s.z);
492        if m < 1e-10 { 1.0 } else { 2.0 / m }
493    }
494}
495
496// ── Bifurcation parameter sweeps ──────────────────────────────────────────────
497
498/// Lorenz attractor with configurable parameters.
499pub fn lorenz_parametric(s: Vec3, sigma: f32, rho: f32, beta: f32) -> Vec3 {
500    let (x, y, z) = (s.x, s.y, s.z);
501    Vec3::new(sigma * (y - x), x * (rho - z) - y, x * y - beta * z)
502}
503
504/// Rössler attractor with configurable parameters.
505pub fn rossler_parametric(s: Vec3, a: f32, b: f32, c: f32) -> Vec3 {
506    let (x, y, z) = (s.x, s.y, s.z);
507    Vec3::new(-y - z, x + a * y, b + z * (x - c))
508}
509
510/// Lorenz bifurcation diagram: sweep rho from `rho_min` to `rho_max`, return last-n states.
511pub fn lorenz_bifurcation(
512    rho_min:    f32,
513    rho_max:    f32,
514    rho_steps:  usize,
515    warmup_n:   usize,
516    sample_n:   usize,
517) -> Vec<(f32, Vec<f32>)> {
518    let sigma = 10.0_f32;
519    let beta  = 8.0_f32 / 3.0_f32;
520    let dt    = 0.005_f32;
521
522    (0..rho_steps).map(|i| {
523        let rho = rho_min + (rho_max - rho_min) * i as f32 / (rho_steps - 1) as f32;
524        let mut state = Vec3::new(0.1, 0.0, 0.0);
525
526        // Warmup
527        for _ in 0..warmup_n {
528            let k1 = lorenz_parametric(state, sigma, rho, beta);
529            let k2 = lorenz_parametric(state + k1 * (dt * 0.5), sigma, rho, beta);
530            let k3 = lorenz_parametric(state + k2 * (dt * 0.5), sigma, rho, beta);
531            let k4 = lorenz_parametric(state + k3 * dt, sigma, rho, beta);
532            state += (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0);
533        }
534
535        // Sample z-values
536        let zs: Vec<f32> = (0..sample_n).map(|_| {
537            let k1 = lorenz_parametric(state, sigma, rho, beta);
538            let k2 = lorenz_parametric(state + k1 * (dt * 0.5), sigma, rho, beta);
539            let k3 = lorenz_parametric(state + k2 * (dt * 0.5), sigma, rho, beta);
540            let k4 = lorenz_parametric(state + k3 * dt, sigma, rho, beta);
541            state += (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (dt / 6.0);
542            state.z
543        }).collect();
544
545        (rho, zs)
546    }).collect()
547}
548
549// ── Multi-attractor particle emitter helper ────────────────────────────────────
550
551/// A pool of attractor samplers — one per particle stream.
552/// Allows spawning particles from multiple independent trajectories.
553pub struct AttractorPool {
554    samplers:   Vec<AttractorSampler>,
555    round_robin: usize,
556}
557
558impl AttractorPool {
559    /// Create `n` independent samplers for the given attractor.
560    /// Each sampler is initialised from a slightly different state.
561    pub fn new(attractor: AttractorType, n: usize) -> Self {
562        let base = initial_state_warmed(attractor);
563        let dt   = attractor.recommended_dt();
564        let samplers = (0..n).map(|i| {
565            // Offset each sampler along the trajectory by `i * 200` steps
566            let offset_state = {
567                let mut s = base;
568                for _ in 0..(i * 200) {
569                    s = rk4_step(attractor, s, dt);
570                }
571                s
572            };
573            AttractorSampler {
574                attractor,
575                state:    offset_state,
576                dt,
577                time:     i as f32 * 200.0 * dt,
578                scale:    attractor.normalization_scale(),
579                center:   Vec3::ZERO,
580                substeps: 1,
581            }
582        }).collect();
583        Self { samplers, round_robin: 0 }
584    }
585
586    /// Get the next position from the pool, cycling through samplers.
587    pub fn next(&mut self) -> Vec3 {
588        if self.samplers.is_empty() {
589            return Vec3::ZERO;
590        }
591        let idx = self.round_robin % self.samplers.len();
592        self.round_robin = idx + 1;
593        self.samplers[idx].next()
594    }
595
596    /// Advance all samplers by one step without returning a position.
597    pub fn tick_all(&mut self) {
598        for s in &mut self.samplers {
599            s.next();
600        }
601    }
602
603    /// Return positions of all samplers (snapshot).
604    pub fn positions(&self) -> Vec<Vec3> {
605        self.samplers.iter().map(|s| s.position()).collect()
606    }
607
608    /// Set scale on all samplers.
609    pub fn set_scale(&mut self, scale: f32) {
610        for s in &mut self.samplers {
611            s.scale = scale;
612        }
613    }
614
615    /// Set center offset on all samplers.
616    pub fn set_center(&mut self, center: Vec3) {
617        for s in &mut self.samplers {
618            s.center = center;
619        }
620    }
621
622    pub fn len(&self) -> usize { self.samplers.len() }
623    pub fn is_empty(&self) -> bool { self.samplers.is_empty() }
624}
625
626// ── Poincaré section helper ────────────────────────────────────────────────────
627
628/// Collect Poincaré section crossings (z = `z_level`, z going upward).
629/// Returns the x,y coordinates at each crossing.
630pub fn poincare_section(
631    attractor: AttractorType,
632    z_level:   f32,
633    n_crossings: usize,
634) -> Vec<(f32, f32)> {
635    let dt   = attractor.recommended_dt();
636    let mut s = initial_state_warmed(attractor);
637    let mut crossings = Vec::with_capacity(n_crossings);
638    let mut prev_z = s.z;
639    let mut iterations = 0usize;
640    let max_iter = n_crossings * 100_000;
641
642    while crossings.len() < n_crossings && iterations < max_iter {
643        s = rk4_step(attractor, s, dt);
644        // Upward crossing of z_level
645        if prev_z < z_level && s.z >= z_level {
646            // Linear interpolation to find exact crossing
647            let t = (z_level - prev_z) / (s.z - prev_z);
648            let sx = prev_z + t * (s.x - prev_z); // approximate, good enough
649            crossings.push((s.x, s.y));
650            let _ = sx;
651        }
652        prev_z = s.z;
653        iterations += 1;
654    }
655    crossings
656}
657
658// ── Recurrence plot ────────────────────────────────────────────────────────────
659
660/// Compute a recurrence matrix for a sampled attractor trajectory.
661/// `matrix[i][j] = 1` if `|state_i - state_j| < threshold`.
662/// Returns a flat `n × n` bit vector.
663pub fn recurrence_plot(
664    attractor:  AttractorType,
665    n:          usize,
666    threshold:  f32,
667) -> Vec<bool> {
668    let trajectory = AttractorSampler::new(attractor)
669        .sample_trajectory(n);
670
671    let mut matrix = vec![false; n * n];
672    for i in 0..n {
673        for j in 0..n {
674            let d = (trajectory[i] - trajectory[j]).length();
675            matrix[i * n + j] = d < threshold;
676        }
677    }
678    matrix
679}
680
681// ── Attractor colour mapping ───────────────────────────────────────────────────
682
683/// Map an attractor velocity magnitude to a colour using a gradient.
684/// Returns `(r, g, b, a)` all in `[0.0, 1.0]`.
685pub fn velocity_color(velocity: Vec3, palette: AttractorPalette) -> glam::Vec4 {
686    let speed = velocity.length();
687    let t = (speed * 5.0).clamp(0.0, 1.0); // tune 5.0 as needed
688    match palette {
689        AttractorPalette::Plasma => {
690            // Plasma: purple → blue → green → yellow
691            let r = (0.5 + 0.5 * (t * std::f32::consts::TAU).sin()).clamp(0.0, 1.0);
692            let g = t.sqrt();
693            let b = (1.0 - t).powi(2);
694            glam::Vec4::new(r, g, b, 1.0)
695        }
696        AttractorPalette::Fire => {
697            let r = (t * 2.0).clamp(0.0, 1.0);
698            let g = ((t * 2.0) - 1.0).clamp(0.0, 1.0);
699            let b = 0.0;
700            glam::Vec4::new(r, g, b, 1.0)
701        }
702        AttractorPalette::Ice => {
703            let r = t * 0.2;
704            let g = t * 0.6;
705            let b = t;
706            glam::Vec4::new(r, g, b, 1.0)
707        }
708        AttractorPalette::Neon => {
709            let r = (1.0 - t) * 0.8;
710            let g = (1.0 - (t - 0.5).abs() * 2.0).max(0.0);
711            let b = t;
712            glam::Vec4::new(r, g, b, 1.0)
713        }
714        AttractorPalette::Greyscale => {
715            glam::Vec4::new(t, t, t, 1.0)
716        }
717    }
718}
719
720/// Colour palette for attractor visualisation.
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub enum AttractorPalette {
723    Plasma,
724    Fire,
725    Ice,
726    Neon,
727    Greyscale,
728}