Skip to main content

sc_neurocore_engine/scpn/
kuramoto.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Kuramoto Solver
8
9//! # Kuramoto Solver
10//!
11//! High-performance Kuramoto oscillator integration for SCPN Layer-4 and SSGF loops.
12//! The solver keeps all scratch buffers preallocated to avoid per-step allocations.
13
14use rand::{RngExt, SeedableRng};
15use rand_chacha::ChaCha8Rng;
16use rayon::prelude::*;
17
18const TWO_PI: f64 = std::f64::consts::TAU;
19
20/// High-performance Kuramoto oscillator solver.
21///
22/// Baseline equation:
23/// `dθ_n/dt = ω_n + Σ_m K_nm sin(θ_m - θ_n) + noise`
24///
25/// SSGF extension (`step_ssgf`) adds geometry/PGBO/field terms:
26/// `+ σ_g Σ_m W_nm sin(θ_m - θ_n) + pgbo_w Σ_m h_nm sin(θ_m - θ_n) + F cos(θ_n)`.
27pub struct KuramotoSolver {
28    /// Number of oscillators.
29    pub n: usize,
30    /// Precomputed 1/N.
31    pub n_inv: f64,
32    /// Natural frequencies `ω_n`, shape `(n,)`.
33    pub omega: Vec<f64>,
34    /// Baseline coupling matrix `K_nm`, row-major shape `(n*n)`.
35    pub coupling: Vec<f64>,
36    /// Current phase vector `θ_n`, shape `(n,)`.
37    pub phases: Vec<f64>,
38    /// Gaussian noise amplitude.
39    pub noise_amp: f64,
40    /// Field pressure strength `F` used by `step_ssgf`.
41    pub field_pressure: f64,
42    /// Scratch: per-oscillator phase derivative.
43    dtheta: Vec<f64>,
44    /// Scratch: `sin(θ_m - θ_n)` matrix, row-major `(n*n)`.
45    sin_diff: Vec<f64>,
46    /// Scratch: per-step standard normal draws.
47    noise: Vec<f64>,
48    /// Scratch: `cos(θ_n)` vector for field-pressure term.
49    cos_theta: Vec<f64>,
50    /// Scratch: geometry coupling contribution per oscillator.
51    geo_coupling: Vec<f64>,
52    /// Scratch: PGBO coupling contribution per oscillator.
53    pgbo_coupling: Vec<f64>,
54}
55
56impl KuramotoSolver {
57    /// Create a new solver with preallocated scratch buffers.
58    pub fn new(
59        omega: Vec<f64>,
60        coupling_flat: Vec<f64>,
61        initial_phases: Vec<f64>,
62        noise_amp: f64,
63    ) -> Self {
64        let n = omega.len();
65        assert!(n > 0, "omega must not be empty");
66        assert_eq!(
67            initial_phases.len(),
68            n,
69            "initial_phases length mismatch: got {}, expected {}",
70            initial_phases.len(),
71            n
72        );
73        assert_eq!(
74            coupling_flat.len(),
75            n * n,
76            "coupling length mismatch: got {}, expected {}",
77            coupling_flat.len(),
78            n * n
79        );
80        assert_all_finite("omega", &omega);
81        assert_all_finite("coupling", &coupling_flat);
82        assert_all_finite("initial_phases", &initial_phases);
83        assert!(
84            noise_amp.is_finite() && noise_amp >= 0.0,
85            "noise_amp must be finite and non-negative"
86        );
87
88        Self {
89            n,
90            n_inv: 1.0 / n as f64,
91            omega,
92            coupling: coupling_flat,
93            phases: initial_phases,
94            noise_amp,
95            field_pressure: 0.0,
96            dtheta: vec![0.0; n],
97            sin_diff: vec![0.0; n * n],
98            noise: vec![0.0; n],
99            cos_theta: vec![0.0; n],
100            geo_coupling: vec![0.0; n],
101            pgbo_coupling: vec![0.0; n],
102        }
103    }
104
105    /// Set external field pressure `F` for SSGF mode.
106    pub fn set_field_pressure(&mut self, f: f64) {
107        assert!(f.is_finite(), "field_pressure must be finite");
108        self.field_pressure = f;
109    }
110
111    /// Advance one baseline Euler step.
112    ///
113    /// Returns Kuramoto order parameter `R ∈ [0, 1]`.
114    pub fn step(&mut self, dt: f64, seed: u64) -> f64 {
115        assert_dt(dt);
116        let n = self.n;
117        let phases = &self.phases;
118
119        self.sin_diff
120            .par_chunks_mut(n)
121            .enumerate()
122            .for_each(|(row_idx, row)| {
123                let theta_n = phases[row_idx];
124                for (col_idx, value) in row.iter_mut().enumerate() {
125                    *value = (phases[col_idx] - theta_n).sin();
126                }
127            });
128
129        if seed == 0 || self.noise_amp == 0.0 {
130            self.noise.fill(0.0);
131        } else {
132            fill_standard_normals(&mut self.noise, seed);
133        }
134
135        self.dtheta
136            .par_iter_mut()
137            .enumerate()
138            .for_each(|(row_idx, dtheta_n)| {
139                let coupling_row = &self.coupling[row_idx * n..(row_idx + 1) * n];
140                let sin_row = &self.sin_diff[row_idx * n..(row_idx + 1) * n];
141                let coupling_sum = crate::simd::dot_f64_dispatch(coupling_row, sin_row);
142                // 1/N normalization per Kuramoto (1984)
143                *dtheta_n = self.omega[row_idx]
144                    + coupling_sum * self.n_inv
145                    + self.noise_amp * self.noise[row_idx];
146            });
147
148        for (phase, dtheta) in self.phases.iter_mut().zip(self.dtheta.iter()) {
149            *phase = (*phase + dtheta * dt).rem_euclid(TWO_PI);
150        }
151
152        self.order_parameter()
153    }
154
155    /// Advance N baseline steps and return `R` after each step.
156    pub fn run(&mut self, n_steps: usize, dt: f64, seed: u64) -> Vec<f64> {
157        assert_dt(dt);
158        let mut order_values = Vec::with_capacity(n_steps);
159        for step_idx in 0..n_steps {
160            let step_seed = if seed == 0 {
161                0
162            } else {
163                seed.wrapping_add(step_idx as u64)
164            };
165            order_values.push(self.step(dt, step_seed));
166        }
167        order_values
168    }
169
170    /// SSGF-compatible step with geometry and PGBO coupling.
171    ///
172    /// `w_flat`: row-major geometry matrix `W` (`n*n`). Empty slice disables geometry term.
173    /// `sigma_g`: geometry coupling gain.
174    /// `h_flat`: row-major PGBO tensor `h` (`n*n`). Empty slice disables PGBO term.
175    /// `pgbo_weight`: PGBO coupling gain.
176    ///
177    /// Returns Kuramoto order parameter `R ∈ [0, 1]`.
178    #[allow(clippy::too_many_arguments)]
179    pub fn step_ssgf(
180        &mut self,
181        dt: f64,
182        seed: u64,
183        w_flat: &[f64],
184        sigma_g: f64,
185        h_flat: &[f64],
186        pgbo_weight: f64,
187    ) -> f64 {
188        assert_dt(dt);
189        assert!(sigma_g.is_finite(), "sigma_g must be finite");
190        assert!(pgbo_weight.is_finite(), "pgbo_weight must be finite");
191        let n = self.n;
192        let phases = &self.phases;
193
194        // 1) Shared sin-difference matrix.
195        self.sin_diff
196            .par_chunks_mut(n)
197            .enumerate()
198            .for_each(|(row_idx, row)| {
199                let theta_n = phases[row_idx];
200                for (col_idx, value) in row.iter_mut().enumerate() {
201                    *value = (phases[col_idx] - theta_n).sin();
202                }
203            });
204
205        // 2) Noise vector.
206        if seed == 0 || self.noise_amp == 0.0 {
207            self.noise.fill(0.0);
208        } else {
209            fill_standard_normals(&mut self.noise, seed);
210        }
211
212        // 3) Geometry term: sigma_g * Σ_m W_nm sin(diff).
213        let has_geo = !w_flat.is_empty() && sigma_g != 0.0;
214        if has_geo {
215            assert_eq!(
216                w_flat.len(),
217                n * n,
218                "w_flat length mismatch: got {}, expected {}",
219                w_flat.len(),
220                n * n
221            );
222            assert_all_finite("w_flat", w_flat);
223            self.geo_coupling
224                .par_iter_mut()
225                .enumerate()
226                .for_each(|(row_idx, geo_n)| {
227                    let w_row = &w_flat[row_idx * n..(row_idx + 1) * n];
228                    let sin_row = &self.sin_diff[row_idx * n..(row_idx + 1) * n];
229                    *geo_n = sigma_g
230                        * w_row
231                            .iter()
232                            .zip(sin_row.iter())
233                            .map(|(w, s)| w * s)
234                            .sum::<f64>();
235                });
236        } else {
237            self.geo_coupling.fill(0.0);
238        }
239
240        // 4) PGBO term: pgbo_weight * Σ_m h_nm sin(diff).
241        let has_pgbo = !h_flat.is_empty() && pgbo_weight != 0.0;
242        if has_pgbo {
243            assert_eq!(
244                h_flat.len(),
245                n * n,
246                "h_flat length mismatch: got {}, expected {}",
247                h_flat.len(),
248                n * n
249            );
250            assert_all_finite("h_flat", h_flat);
251            self.pgbo_coupling
252                .par_iter_mut()
253                .enumerate()
254                .for_each(|(row_idx, pgbo_n)| {
255                    let h_row = &h_flat[row_idx * n..(row_idx + 1) * n];
256                    let sin_row = &self.sin_diff[row_idx * n..(row_idx + 1) * n];
257                    *pgbo_n = pgbo_weight
258                        * h_row
259                            .iter()
260                            .zip(sin_row.iter())
261                            .map(|(h, s)| h * s)
262                            .sum::<f64>();
263                });
264        } else {
265            self.pgbo_coupling.fill(0.0);
266        }
267
268        // 5) Field pressure term input.
269        if self.field_pressure != 0.0 {
270            for (c, &theta) in self.cos_theta.iter_mut().zip(phases.iter()) {
271                *c = theta.cos();
272            }
273        } else {
274            self.cos_theta.fill(0.0);
275        }
276
277        // 6) Assemble dtheta from all enabled terms.
278        self.dtheta
279            .par_iter_mut()
280            .enumerate()
281            .for_each(|(i, dtheta_n)| {
282                let coupling_row = &self.coupling[i * n..(i + 1) * n];
283                let sin_row = &self.sin_diff[i * n..(i + 1) * n];
284                let coupling_sum = crate::simd::dot_f64_dispatch(coupling_row, sin_row);
285
286                *dtheta_n = self.omega[i]
287                    + coupling_sum * self.n_inv
288                    + self.geo_coupling[i]
289                    + self.pgbo_coupling[i]
290                    + self.field_pressure * self.cos_theta[i]
291                    + self.noise_amp * self.noise[i];
292            });
293
294        for (phase, dtheta) in self.phases.iter_mut().zip(self.dtheta.iter()) {
295            *phase = (*phase + dtheta * dt).rem_euclid(TWO_PI);
296        }
297
298        self.order_parameter()
299    }
300
301    /// Run N SSGF-compatible steps and return `R` after each step.
302    #[allow(clippy::too_many_arguments)]
303    pub fn run_ssgf(
304        &mut self,
305        n_steps: usize,
306        dt: f64,
307        seed: u64,
308        w_flat: &[f64],
309        sigma_g: f64,
310        h_flat: &[f64],
311        pgbo_weight: f64,
312    ) -> Vec<f64> {
313        assert_dt(dt);
314        assert!(sigma_g.is_finite(), "sigma_g must be finite");
315        assert!(pgbo_weight.is_finite(), "pgbo_weight must be finite");
316        if !w_flat.is_empty() {
317            assert_eq!(
318                w_flat.len(),
319                self.n * self.n,
320                "w_flat length mismatch: got {}, expected {}",
321                w_flat.len(),
322                self.n * self.n
323            );
324            assert_all_finite("w_flat", w_flat);
325        }
326        if !h_flat.is_empty() {
327            assert_eq!(
328                h_flat.len(),
329                self.n * self.n,
330                "h_flat length mismatch: got {}, expected {}",
331                h_flat.len(),
332                self.n * self.n
333            );
334            assert_all_finite("h_flat", h_flat);
335        }
336        let mut order_values = Vec::with_capacity(n_steps);
337        for step_idx in 0..n_steps {
338            let step_seed = if seed == 0 {
339                0
340            } else {
341                seed.wrapping_add(step_idx as u64)
342            };
343            order_values.push(self.step_ssgf(dt, step_seed, w_flat, sigma_g, h_flat, pgbo_weight));
344        }
345        order_values
346    }
347
348    /// Compute Kuramoto order parameter
349    /// `R = sqrt(mean(cos θ)^2 + mean(sin θ)^2)`.
350    pub fn order_parameter(&self) -> f64 {
351        if self.phases.is_empty() {
352            return 0.0;
353        }
354
355        let n_inv = 1.0 / self.phases.len() as f64;
356        let mean_cos = self.phases.iter().map(|theta| theta.cos()).sum::<f64>() * n_inv;
357        let mean_sin = self.phases.iter().map(|theta| theta.sin()).sum::<f64>() * n_inv;
358        (mean_cos * mean_cos + mean_sin * mean_sin).sqrt()
359    }
360
361    /// Borrow current phase vector.
362    pub fn get_phases(&self) -> &[f64] {
363        &self.phases
364    }
365
366    /// Replace phase vector.
367    pub fn set_phases(&mut self, phases: Vec<f64>) {
368        assert_eq!(
369            phases.len(),
370            self.n,
371            "phases length mismatch: got {}, expected {}",
372            phases.len(),
373            self.n
374        );
375        assert_all_finite("phases", &phases);
376        self.phases = phases;
377    }
378
379    /// Replace baseline coupling matrix.
380    pub fn set_coupling(&mut self, coupling_flat: Vec<f64>) {
381        assert_eq!(
382            coupling_flat.len(),
383            self.n * self.n,
384            "coupling length mismatch: got {}, expected {}",
385            coupling_flat.len(),
386            self.n * self.n
387        );
388        assert_all_finite("coupling", &coupling_flat);
389        self.coupling = coupling_flat;
390    }
391}
392
393fn assert_dt(dt: f64) {
394    assert!(dt.is_finite() && dt > 0.0, "dt must be finite and positive");
395}
396
397fn assert_all_finite(name: &str, values: &[f64]) {
398    assert!(
399        values.iter().all(|value| value.is_finite()),
400        "{name} values must be finite"
401    );
402}
403
404fn fill_standard_normals(out: &mut [f64], seed: u64) {
405    let mut rng = ChaCha8Rng::seed_from_u64(seed);
406    let mut i = 0usize;
407
408    while i + 1 < out.len() {
409        let u1 = rng.random::<f64>().max(f64::MIN_POSITIVE);
410        let u2 = rng.random::<f64>();
411        let r = (-2.0 * u1.ln()).sqrt();
412        let theta = TWO_PI * u2;
413        out[i] = r * theta.cos();
414        out[i + 1] = r * theta.sin();
415        i += 2;
416    }
417
418    if i < out.len() {
419        let u1 = rng.random::<f64>().max(f64::MIN_POSITIVE);
420        let u2 = rng.random::<f64>();
421        let r = (-2.0 * u1.ln()).sqrt();
422        out[i] = r * (TWO_PI * u2).cos();
423    }
424}