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