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
81        Self {
82            n,
83            n_inv: 1.0 / n as f64,
84            omega,
85            coupling: coupling_flat,
86            phases: initial_phases,
87            noise_amp,
88            field_pressure: 0.0,
89            dtheta: vec![0.0; n],
90            sin_diff: vec![0.0; n * n],
91            noise: vec![0.0; n],
92            cos_theta: vec![0.0; n],
93            geo_coupling: vec![0.0; n],
94            pgbo_coupling: vec![0.0; n],
95        }
96    }
97
98    /// Set external field pressure `F` for SSGF mode.
99    pub fn set_field_pressure(&mut self, f: f64) {
100        self.field_pressure = f;
101    }
102
103    /// Advance one baseline Euler step.
104    ///
105    /// Returns Kuramoto order parameter `R ∈ [0, 1]`.
106    pub fn step(&mut self, dt: f64, seed: u64) -> f64 {
107        let n = self.n;
108        let phases = &self.phases;
109
110        self.sin_diff
111            .par_chunks_mut(n)
112            .enumerate()
113            .for_each(|(row_idx, row)| {
114                let theta_n = phases[row_idx];
115                for (col_idx, value) in row.iter_mut().enumerate() {
116                    *value = (phases[col_idx] - theta_n).sin();
117                }
118            });
119
120        if seed == 0 || self.noise_amp == 0.0 {
121            self.noise.fill(0.0);
122        } else {
123            fill_standard_normals(&mut self.noise, seed);
124        }
125
126        self.dtheta
127            .par_iter_mut()
128            .enumerate()
129            .for_each(|(row_idx, dtheta_n)| {
130                let coupling_row = &self.coupling[row_idx * n..(row_idx + 1) * n];
131                let sin_row = &self.sin_diff[row_idx * n..(row_idx + 1) * n];
132                let coupling_sum = crate::simd::dot_f64_dispatch(coupling_row, sin_row);
133                // 1/N normalization per Kuramoto (1984)
134                *dtheta_n = self.omega[row_idx]
135                    + coupling_sum * self.n_inv
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 = crate::simd::dot_f64_dispatch(coupling_row, sin_row);
270
271                *dtheta_n = self.omega[i]
272                    + coupling_sum * self.n_inv
273                    + self.geo_coupling[i]
274                    + self.pgbo_coupling[i]
275                    + self.field_pressure * self.cos_theta[i]
276                    + self.noise_amp * self.noise[i];
277            });
278
279        for (phase, dtheta) in self.phases.iter_mut().zip(self.dtheta.iter()) {
280            *phase = (*phase + dtheta * dt).rem_euclid(TWO_PI);
281        }
282
283        self.order_parameter()
284    }
285
286    /// Run N SSGF-compatible steps and return `R` after each step.
287    #[allow(clippy::too_many_arguments)]
288    pub fn run_ssgf(
289        &mut self,
290        n_steps: usize,
291        dt: f64,
292        seed: u64,
293        w_flat: &[f64],
294        sigma_g: f64,
295        h_flat: &[f64],
296        pgbo_weight: f64,
297    ) -> Vec<f64> {
298        let mut order_values = Vec::with_capacity(n_steps);
299        for step_idx in 0..n_steps {
300            let step_seed = if seed == 0 {
301                0
302            } else {
303                seed.wrapping_add(step_idx as u64)
304            };
305            order_values.push(self.step_ssgf(dt, step_seed, w_flat, sigma_g, h_flat, pgbo_weight));
306        }
307        order_values
308    }
309
310    /// Compute Kuramoto order parameter
311    /// `R = sqrt(mean(cos θ)^2 + mean(sin θ)^2)`.
312    pub fn order_parameter(&self) -> f64 {
313        if self.phases.is_empty() {
314            return 0.0;
315        }
316
317        let n_inv = 1.0 / self.phases.len() as f64;
318        let mean_cos = self.phases.iter().map(|theta| theta.cos()).sum::<f64>() * n_inv;
319        let mean_sin = self.phases.iter().map(|theta| theta.sin()).sum::<f64>() * n_inv;
320        (mean_cos * mean_cos + mean_sin * mean_sin).sqrt()
321    }
322
323    /// Borrow current phase vector.
324    pub fn get_phases(&self) -> &[f64] {
325        &self.phases
326    }
327
328    /// Replace phase vector.
329    pub fn set_phases(&mut self, phases: Vec<f64>) {
330        assert_eq!(
331            phases.len(),
332            self.n,
333            "phases length mismatch: got {}, expected {}",
334            phases.len(),
335            self.n
336        );
337        self.phases = phases;
338    }
339
340    /// Replace baseline coupling matrix.
341    pub fn set_coupling(&mut self, coupling_flat: Vec<f64>) {
342        assert_eq!(
343            coupling_flat.len(),
344            self.n * self.n,
345            "coupling length mismatch: got {}, expected {}",
346            coupling_flat.len(),
347            self.n * self.n
348        );
349        self.coupling = coupling_flat;
350    }
351}
352
353fn fill_standard_normals(out: &mut [f64], seed: u64) {
354    let mut rng = ChaCha8Rng::seed_from_u64(seed);
355    let mut i = 0usize;
356
357    while i + 1 < out.len() {
358        let u1 = rng.random::<f64>().max(f64::MIN_POSITIVE);
359        let u2 = rng.random::<f64>();
360        let r = (-2.0 * u1.ln()).sqrt();
361        let theta = TWO_PI * u2;
362        out[i] = r * theta.cos();
363        out[i + 1] = r * theta.sin();
364        i += 2;
365    }
366
367    if i < out.len() {
368        let u1 = rng.random::<f64>().max(f64::MIN_POSITIVE);
369        let u2 = rng.random::<f64>();
370        let r = (-2.0 * u1.ln()).sqrt();
371        out[i] = r * (TWO_PI * u2).cos();
372    }
373}