Skip to main content

sc_neurocore_engine/scpn/
kuramoto.rs

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