use crate::math::{mean_f32, sqrt_f32};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoiseAttractorState {
StochasticBall,
StructuredOrbit,
CollapsedAttractor,
}
#[derive(Debug, Clone, Copy)]
pub struct AttractorResult {
pub correlation_dimension: f32,
pub koopman_proxy: f32,
pub state: NoiseAttractorState,
pub n_pairs: usize,
}
pub struct DelayEmbedding<const W: usize> {
buf: [f32; W],
head: usize,
count: usize,
pub tau: usize,
}
impl<const W: usize> DelayEmbedding<W> {
pub const fn new(tau: usize) -> Self {
Self { buf: [0.0; W], head: 0, count: 0, tau }
}
pub fn push(&mut self, norm: f32) {
self.buf[self.head] = norm;
self.head = (self.head + 1) % W;
if self.count < W { self.count += 1; }
}
pub fn len(&self) -> usize { self.count }
pub fn is_ready(&self) -> bool { self.count >= W }
pub fn reset(&mut self) {
self.buf = [0.0; W];
self.head = 0;
self.count = 0;
}
fn sample(&self, i: usize) -> f32 {
let idx = (self.head + W - 1 - i) % W;
self.buf[idx]
}
pub fn analyse(&self, r1: f32, r2: f32) -> Option<AttractorResult> {
if self.count < self.tau + 4 { return None; }
let n = self.count.min(W);
let usable = if n > self.tau { n - self.tau } else { return None; };
if usable < 3 { return None; }
let n_pairs = usable;
let (c_r1, c_r2, total_pairs) = self.count_pairs_in_radii(n_pairs, r1, r2);
let d2 = correlation_dimension_gp(c_r1, c_r2, total_pairs, r1, r2);
let koopman_proxy = self.koopman_variance_to_mean(n);
let state = classify_attractor_state(d2, koopman_proxy);
Some(AttractorResult {
correlation_dimension: d2,
koopman_proxy,
state,
n_pairs,
})
}
fn count_pairs_in_radii(&self, n_pairs: usize, r1: f32, r2: f32) -> (u32, u32, u32) {
let mut c_r1: u32 = 0;
let mut c_r2: u32 = 0;
let mut total_pairs: u32 = 0;
for i in 0..n_pairs {
let xi = self.sample(i);
let xi_d = self.sample(i + self.tau);
for j in (i + 1)..n_pairs {
let xj = self.sample(j);
let xj_d = self.sample(j + self.tau);
let dx = xi - xj;
let dy = xi_d - xj_d;
let dist = sqrt_f32(dx * dx + dy * dy);
if dist < r1 { c_r1 += 1; c_r2 += 1; }
else if dist < r2 { c_r2 += 1; }
total_pairs += 1;
}
}
(c_r1, c_r2, total_pairs)
}
fn koopman_variance_to_mean(&self, n: usize) -> f32 {
let mut norms_buf = [0.0_f32; W];
for i in 0..n {
norms_buf[i] = self.sample(i);
}
let slice = &norms_buf[..n];
let m = mean_f32(slice);
let var = slice.iter().map(|&x| (x - m) * (x - m)).sum::<f32>() / n as f32;
if m > 1e-6 { var / m } else { 0.0 }
}
}
fn correlation_dimension_gp(c_r1: u32, c_r2: u32, total_pairs: u32, r1: f32, r2: f32) -> f32 {
if total_pairs == 0 { return 2.0; }
if c_r1 == total_pairs { return 0.0; }
if c_r1 < 2 || c_r2 < 2 || r1 <= 0.0 || r2 <= r1 { return 2.0; }
let fr1 = c_r1 as f32 / total_pairs as f32;
let fr2 = c_r2 as f32 / total_pairs as f32;
if fr1 <= 0.0 || fr2 <= fr1 { return 2.0; }
let log_ratio_c = log2_approx(fr2 / fr1);
let log_ratio_r = log2_approx(r2 / r1);
(log_ratio_c / log_ratio_r).max(0.0).min(4.0)
}
fn classify_attractor_state(d2: f32, koopman_proxy: f32) -> NoiseAttractorState {
if d2 < 0.6 {
NoiseAttractorState::CollapsedAttractor
} else if d2 < 1.4 && koopman_proxy < 0.5 {
NoiseAttractorState::StructuredOrbit
} else {
NoiseAttractorState::StochasticBall
}
}
impl<const W: usize> Default for DelayEmbedding<W> {
fn default() -> Self { Self::new(2) }
}
fn log2_approx(x: f32) -> f32 {
if x <= 0.0 { return -100.0; }
let mut y = x;
let mut exp = 0i32;
for _ in 0..300 {
if y < 2.0 { break; }
y *= 0.5; exp += 1;
}
for _ in 0..300 {
if y >= 1.0 { break; }
y *= 2.0; exp -= 1;
}
let u = (y - 1.0) / (y + 1.0);
let u2 = u * u;
let ln_y = 2.0 * u * (1.0 + u2 / 3.0 + u2 * u2 / 5.0);
let ln2 = 0.693_147_f32;
exp as f32 + ln_y / ln2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_fills_buffer() {
let mut emb = DelayEmbedding::<8>::new(2);
assert!(!emb.is_ready());
for i in 0..8 { emb.push(i as f32 * 0.01); }
assert!(emb.is_ready());
}
#[test]
fn reset_clears_buffer() {
let mut emb = DelayEmbedding::<8>::new(2);
for _ in 0..8 { emb.push(1.0); }
emb.reset();
assert_eq!(emb.len(), 0);
assert!(!emb.is_ready());
}
#[test]
fn constant_signal_collapsed_attractor() {
let mut emb = DelayEmbedding::<32>::new(2);
for _ in 0..32 { emb.push(0.1_f32); }
let res = emb.analyse(0.05, 0.2).unwrap();
assert_eq!(res.state, NoiseAttractorState::CollapsedAttractor,
"constant signal: D₂={:.3}", res.correlation_dimension);
}
#[test]
fn stochastic_signal_fills_space() {
let mut emb = DelayEmbedding::<32>::new(2);
let mut s: u32 = 12345;
for _ in 0..32 {
s = s.wrapping_mul(1664525).wrapping_add(1013904223);
let v = (s >> 16) as f32 / 65535.0;
emb.push(v);
}
let res = emb.analyse(0.05, 0.3).unwrap();
assert_ne!(res.state, NoiseAttractorState::CollapsedAttractor,
"random norms: D₂={:.3}", res.correlation_dimension);
}
#[test]
fn not_enough_samples_returns_none() {
let emb = DelayEmbedding::<32>::new(2);
assert!(emb.analyse(0.05, 0.2).is_none());
}
#[test]
fn log2_approx_accuracy() {
assert!((log2_approx(1.0) - 0.0).abs() < 0.01, "log₂(1)=0");
assert!((log2_approx(2.0) - 1.0).abs() < 0.02, "log₂(2)=1");
assert!((log2_approx(4.0) - 2.0).abs() < 0.02, "log₂(4)=2");
assert!((log2_approx(0.5) + 1.0).abs() < 0.02, "log₂(0.5)=-1");
}
}