Skip to main content

scx_pandemonium/
chaos.rs

1// PANDEMONIUM CHAOS PRIMITIVES
2// PURE-RUST RAW-WINDOW STATISTICS USED BY THE ADAPTIVE LAYER.
3//
4// EVERY PUBLIC ITEM IN THIS MODULE IS PART OF THE CHAOS API SURFACE:
5// SOME ARE CONSUMED BY THE BINARY (adaptive.rs), SOME BY TESTS, AND
6// SOME ARE EXPORTED CONSTANTS FOR DIAGNOSTICS / FUTURE EXPANSION.
7// CARGO'S DEAD-CODE LINT FIRES PER COMPILATION TARGET; SILENCE IT.
8#![allow(dead_code)]
9
10// NO EWMA, NO SCHMITT, NO CUSUM. EVERY MEASURE IS COMPUTED FROM
11// THE RAW SAMPLE WINDOW EACH CALL.
12//
13// HVG MEAN DEGREE / HVG ENTROPY (LUQUE-LACASA 2009): TWO STATISTICS OF
14// THE HORIZONTAL VISIBILITY GRAPH'S DEGREE DISTRIBUTION.
15//   - MEAN DEGREE LAMBDA = <k>. IID RANDOM SEQUENCES SATURATE AT 4 - 2/N
16//     (-> 4 IN THE LIMIT). PURELY PERIODIC SEQUENCES STAY NEAR 2.
17//   - SHANNON ENTROPY S OVER THE DEGREE DISTRIBUTION. IID HAS THE EXACT
18//     CLOSED FORM P(k) = (1/3)*(2/3)^(k-2), k >= 2, GIVING
19//     S_IID = LN(3) + 2*LN(3/2) ~= 1.910.
20// LN(3/2) IS THE CHARACTERISTIC EXPONENT OF THE IID DEGREE DISTRIBUTION,
21// NOT THE ENTROPY THRESHOLD. WE EXPOSE LAMBDA AS THE PRIMARY REGIME
22// DISCRIMINATOR (DIRECTLY INTERPRETABLE) AND ENTROPY AS A CORROBORATOR.
23//
24// BANDT-POMPE PERMUTATION ENTROPY (D=3) (BANDT-POMPE 2002): SHANNON
25// ENTROPY OF THE ORDINAL-PATTERN DISTRIBUTION OF LENGTH-3 SUB-WINDOWS,
26// NORMALIZED TO [0, 1] BY LN(6). 0 = PERFECTLY MONOTONIC / PERIODIC,
27// 1 = MAXIMALLY DISORDERED.
28//
29// THESE TWO PRIMITIVES ARE COMPLEMENTARY: HVG ENTROPY IS AMPLITUDE-
30// SENSITIVE (TWO SEQUENCES WITH IDENTICAL ORDINAL STRUCTURE BUT
31// DIFFERENT VALUES CAN DIFFER IN HVG-S), BANDT-POMPE IS AMPLITUDE-
32// INVARIANT (CAPTURES PURE ORDINAL DYNAMICS).
33
34use std::sync::atomic::{AtomicU64, Ordering};
35
36// CRITICAL VALUES
37
38// LN(3/2). IID HVG DEGREE-DISTRIBUTION CHARACTERISTIC EXPONENT.
39// EXPOSED FOR DIAGNOSTICS / FUTURE USE; NOT USED AS A DIRECT THRESHOLD.
40pub const HVG_LN_3_2: f64 = 0.405_465_108_108_164_4;
41
42// HVG MEAN-DEGREE THRESHOLDS. IID RANDOM SATURATES AT 4 - 2/N; PURELY
43// PERIODIC SEQUENCES STAY NEAR 2. THE TWO THRESHOLDS BELOW DEFINE A
44// DEAD ZONE FOR THE MIXED REGIME WITHOUT HYSTERESIS SCHMITT LOGIC.
45pub const HVG_LAMBDA_PERIODIC_MAX: f64 = 2.6;
46pub const HVG_LAMBDA_CHAOTIC_MIN: f64 = 3.4;
47
48// IID-ASYMPTOTE HVG ENTROPY: LN(3) + 2*LN(3/2) ~= 1.910.
49pub const HVG_S_IID: f64 = 1.910_543_686_807_036;
50
51// BANDT-POMPE D=3 PATTERN COUNT.
52pub const BP_D3_PATTERNS: usize = 6;
53
54// LN(6): NORMALIZATION FACTOR FOR BP D=3 PERMUTATION ENTROPY.
55const LN_BP_D3: f64 = 1.791_759_469_228_055;
56
57// HIGH PERMUTATION-ENTROPY THRESHOLD. ABOVE THIS THE ORDINAL DYNAMICS
58// LOOK MAXIMALLY DISORDERED ON THE WINDOW; THE ADAPTIVE LAYER USES IT
59// AS A "WORKLOAD IS UNPREDICTABLE THIS WINDOW" SIGNAL.
60pub const BP_H_HIGH: f64 = 0.85;
61
62// RQA (RECURRENCE QUANTIFICATION ANALYSIS) DETERMINISM.
63// DET IS THE FRACTION OF RECURRENCE POINTS THAT LIE ON DIAGONAL LINE
64// SEGMENTS. A DETERMINISTIC / STEADY SIGNAL REVISITS PHASE-SPACE
65// NEIGHBORHOODS ALONG DIAGONALS (DET -> 1); AN IID SIGNAL SCATTERS
66// RECURRENCE POINTS WITH NO DIAGONAL STRUCTURE (DET -> 0).
67// THE ADAPTIVE QUIESCENCE GATE PAIRS HIGH DET WITH HVG LAMBDA IN THE
68// PERIODIC BAND TO DETECT "STOP RETUNING" STEADY STATE.
69
70// DELAY-EMBEDDING DIMENSION. 3-D VECTORS WITH UNIT DELAY -- MATCHES
71// THE D=3 ORDINAL SCALE USED BY BANDT-POMPE.
72pub const RQA_EMBED_DIM: usize = 3;
73
74// RECURRENCE THRESHOLD AS A FRACTION OF THE WINDOW STANDARD DEVIATION:
75// eps = RQA_THRESH_STD_FRAC * sigma. 0.20 IS THE STANDARD RQA DEFAULT
76// BAND FOR SHORT SERIES.
77pub const RQA_THRESH_STD_FRAC: f64 = 0.20;
78
79// MINIMUM DIAGONAL-LINE LENGTH COUNTED AS DETERMINISM (RQA l_min).
80pub const RQA_LMIN: usize = 2;
81
82// DET AT OR ABOVE THIS = DETERMINISTIC / STEADY. CONSUMED BY THE
83// ADAPTIVE QUIESCENCE GATE.
84pub const RQA_DET_STEADY_MIN: f64 = 0.90;
85
86// BELOW THIS WINDOW FILL, rqa_det RETURNS None (INSUFFICIENT DATA --
87// NEVER LET THE GATE FREEZE ON A HALF-FILLED WINDOW).
88pub const RQA_MIN_SAMPLES: usize = 8;
89
90// RAW WINDOW
91// FIXED-SIZE RING BUFFER OF f64 SAMPLES. NO HEAP ALLOC AT STEADY STATE.
92// SEMANTICS:
93//   - PUSH IS O(1)
94//   - SAMPLES ARE READ IN INSERTION ORDER (OLDEST FIRST)
95//   - UNFILLED SLOTS ARE NOT YIELDED
96//   - len() == 0 UNTIL FIRST PUSH; AT MOST N AFTER N PUSHES
97
98#[derive(Clone, Debug)]
99pub struct RawWindow<const N: usize> {
100    buf: [f64; N],
101    head: usize,
102    filled: usize,
103}
104
105impl<const N: usize> Default for RawWindow<N> {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111impl<const N: usize> RawWindow<N> {
112    pub const fn new() -> Self {
113        Self {
114            buf: [0.0; N],
115            head: 0,
116            filled: 0,
117        }
118    }
119
120    pub fn push(&mut self, x: f64) {
121        self.buf[self.head] = x;
122        self.head = (self.head + 1) % N;
123        if self.filled < N {
124            self.filled += 1;
125        }
126    }
127
128    pub fn len(&self) -> usize {
129        self.filled
130    }
131
132    pub fn is_empty(&self) -> bool {
133        self.filled == 0
134    }
135
136    pub fn capacity(&self) -> usize {
137        N
138    }
139
140    // YIELD SAMPLES IN INSERTION ORDER (OLDEST -> NEWEST).
141    pub fn iter(&self) -> RawWindowIter<'_, N> {
142        let start = if self.filled < N { 0 } else { self.head };
143        RawWindowIter {
144            win: self,
145            pos: 0,
146            start,
147        }
148    }
149
150    pub fn last(&self) -> Option<f64> {
151        if self.filled == 0 {
152            None
153        } else {
154            let i = (self.head + N - 1) % N;
155            Some(self.buf[i])
156        }
157    }
158}
159
160pub struct RawWindowIter<'a, const N: usize> {
161    win: &'a RawWindow<N>,
162    pos: usize,
163    start: usize,
164}
165
166impl<'a, const N: usize> Iterator for RawWindowIter<'a, N> {
167    type Item = f64;
168    fn next(&mut self) -> Option<f64> {
169        if self.pos >= self.win.filled {
170            return None;
171        }
172        let idx = (self.start + self.pos) % N;
173        self.pos += 1;
174        Some(self.win.buf[idx])
175    }
176}
177
178// MEAN / MIN / MAX HELPERS
179
180#[allow(dead_code)]
181pub fn mean<const N: usize>(w: &RawWindow<N>) -> f64 {
182    if w.filled == 0 {
183        return 0.0;
184    }
185    let mut s = 0.0;
186    for x in w.iter() {
187        s += x;
188    }
189    s / w.filled as f64
190}
191
192// HORIZONTAL VISIBILITY GRAPH
193//
194// FOR A SEQUENCE x_1..x_N, NODES i AND j (i < j) ARE HVG-CONNECTED IFF
195// x_k < min(x_i, x_j) FOR ALL i < k < j. ADJACENT NODES (j = i+1) ARE
196// ALWAYS CONNECTED.
197//
198// hvg_degrees BUILDS THE DEGREE VECTOR ONCE; hvg_stats DERIVES BOTH
199// LAMBDA AND ENTROPY FROM IT IN A SINGLE O(N^2) PASS.
200//
201// BRUTE-FORCE O(N^2). AT N <= 128 (>1-MINUTE WINDOW AT 1HZ) THIS IS
202// UNDER 16K COMPARISONS, DONE ONCE PER SECOND.
203
204fn hvg_degrees<const N: usize>(w: &RawWindow<N>) -> Option<([u32; N], usize)> {
205    let n = w.filled;
206    if n < 3 {
207        return None;
208    }
209
210    let mut s: [f64; N] = [0.0; N];
211    let mut k = 0;
212    for x in w.iter() {
213        s[k] = x;
214        k += 1;
215    }
216
217    let mut deg: [u32; N] = [0; N];
218    for i in 0..n {
219        let mut blocker = f64::NEG_INFINITY;
220        for j in (i + 1)..n {
221            let limit = s[i].min(s[j]);
222            if j == i + 1 || blocker < limit {
223                deg[i] += 1;
224                deg[j] += 1;
225            }
226            if s[j] > blocker {
227                blocker = s[j];
228            }
229        }
230    }
231    Some((deg, n))
232}
233
234// AMORTIZED LAMBDA + ENTROPY. ONE O(N^2) PASS BUILDS THE DEGREE VECTOR;
235// BOTH STATISTICS DERIVE FROM IT.
236pub fn hvg_stats<const N: usize>(w: &RawWindow<N>) -> (f64, f64) {
237    let (deg, n) = match hvg_degrees(w) {
238        Some(v) => v,
239        None => return (0.0, 0.0),
240    };
241
242    let mut sum: u64 = 0;
243    let mut hist: [u32; N] = [0; N];
244    for d in deg.iter().take(n) {
245        sum += *d as u64;
246        let bucket = (*d as usize).min(n - 1);
247        hist[bucket] += 1;
248    }
249    let lambda = sum as f64 / n as f64;
250
251    let total = n as f64;
252    let mut entropy = 0.0;
253    for c in hist.iter().take(n) {
254        if *c == 0 {
255            continue;
256        }
257        let p = *c as f64 / total;
258        entropy -= p * p.ln();
259    }
260    (lambda, entropy)
261}
262
263// BANDT-POMPE PERMUTATION ENTROPY (D=3)
264//
265// SLIDE A LENGTH-3 WINDOW. FOR EACH (a, b, c) MAP TO ONE OF SIX ORDINAL
266// PATTERNS BY THE RANK ORDER. BUILD THE EMPIRICAL DISTRIBUTION AND
267// RETURN H / LN(6) IN [0, 1].
268//
269// TIES ARE BROKEN BY POSITION (b > a IFF b STRICTLY GREATER, ELSE a > b).
270// IID RANDOM SAMPLES YIELD H ~= 1; PERIODIC OR MONOTONIC YIELD H << 1.
271pub fn bandt_pompe_d3<const N: usize>(w: &RawWindow<N>) -> f64 {
272    let n = w.filled;
273    if n < 3 {
274        return 0.0;
275    }
276
277    // INLINED RING WALK: WE NEED THREE CONSECUTIVE SAMPLES.
278    let mut counts = [0u32; BP_D3_PATTERNS];
279    let mut total = 0u32;
280
281    // COLLECT INTO LINEAR BUFFER ONCE; SAFE FOR N <= 128.
282    let mut s: [f64; N] = [0.0; N];
283    let mut k = 0;
284    for x in w.iter() {
285        s[k] = x;
286        k += 1;
287    }
288
289    for i in 0..(n - 2) {
290        let a = s[i];
291        let b = s[i + 1];
292        let c = s[i + 2];
293        // BREAK TIES BY POSITION (POSITIONAL ORDER WHEN VALUES EQUAL).
294        let pattern = match (a < b, b < c, a < c) {
295            (true, true, true) => 0,    // a < b < c
296            (true, false, true) => 1,   // a < c <= b
297            (true, false, false) => 2,  // c <= a < b
298            (false, true, true) => 3,   // b <= a < c
299            (false, true, false) => 4,  // b < c <= a
300            (false, false, false) => 5, // c <= b <= a
301            (true, true, false) => 1,   // DEGENERATE TIE: TREAT AS PATTERN 1
302            (false, false, true) => 4,  // DEGENERATE TIE: TREAT AS PATTERN 4
303        };
304        counts[pattern] += 1;
305        total += 1;
306    }
307
308    if total == 0 {
309        return 0.0;
310    }
311
312    let denom = total as f64;
313    let mut h = 0.0;
314    for c in counts.iter() {
315        if *c == 0 {
316            continue;
317        }
318        let p = *c as f64 / denom;
319        h -= p * p.ln();
320    }
321    h / LN_BP_D3
322}
323
324// RQA DETERMINISM (DET)
325//
326// DELAY-EMBED THE WINDOW INTO RQA_EMBED_DIM-D VECTORS WITH UNIT DELAY,
327// BUILD THE RECURRENCE MATRIX UNDER A CHEBYSHEV (L-INFINITY) BALL OF
328// RADIUS eps = RQA_THRESH_STD_FRAC * sigma, AND RETURN THE FRACTION OF
329// OFF-DIAGONAL RECURRENCE POINTS THAT LIE ON DIAGONAL RUNS OF LENGTH
330// >= RQA_LMIN.
331//
332// RETURNS Some(DET) IN [0, 1], OR None WHEN THE WINDOW HAS FEWER THAN
333// RQA_MIN_SAMPLES FILLED SLOTS -- THE QUIESCENCE GATE MUST NOT FREEZE
334// ON A HALF-FILLED WINDOW.
335//
336// BRUTE-FORCE O(N^2), SAME COST CLASS AS hvg_degrees. AT N <= 64 THIS
337// IS UNDER 4K COMPARISONS, DONE ONCE PER SECOND.
338
339// CHEBYSHEV (L-INFINITY) DISTANCE BETWEEN TWO 3-D EMBEDDED POINTS.
340fn chebyshev3(a: &[f64; RQA_EMBED_DIM], b: &[f64; RQA_EMBED_DIM]) -> f64 {
341    let mut m = 0.0;
342    for k in 0..RQA_EMBED_DIM {
343        let d = (a[k] - b[k]).abs();
344        if d > m {
345            m = d;
346        }
347    }
348    m
349}
350
351pub fn rqa_det<const N: usize>(w: &RawWindow<N>) -> Option<f64> {
352    let n = w.filled;
353    if n < RQA_MIN_SAMPLES {
354        return None;
355    }
356
357    // COPY WINDOW INTO ORDER-PRESERVING SLICE FOR INDEXED ACCESS.
358    let mut s: [f64; N] = [0.0; N];
359    let mut k = 0;
360    for x in w.iter() {
361        s[k] = x;
362        k += 1;
363    }
364
365    // MEAN AND STANDARD DEVIATION OVER THE n FILLED SAMPLES.
366    let nf = n as f64;
367    let mut sum = 0.0;
368    for v in s.iter().take(n) {
369        sum += *v;
370    }
371    let mean = sum / nf;
372    let mut var = 0.0;
373    for v in s.iter().take(n) {
374        let d = *v - mean;
375        var += d * d;
376    }
377    let sigma = (var / nf).sqrt();
378
379    // FLAT-WINDOW SPECIAL CASE. A PERFECTLY STEADY SIGNAL HAS sigma = 0;
380    // EVERY EMBEDDED POINT RECURS WITH EVERY OTHER. THAT IS FULLY
381    // DETERMINISTIC -- RETURN 1.0 DIRECTLY (AND AVOID eps = 0 / A
382    // DEGENERATE RECURRENCE MATRIX). idle_pct IS INTEGER-PERCENT CAST
383    // TO f64, SO A STEADY COMPUTE WORKLOAD PRODUCES AN EXACTLY-FLAT
384    // WINDOW AND MUST READ AS QUIESCENT.
385    if sigma < 1e-9 {
386        return Some(1.0);
387    }
388
389    let eps = RQA_THRESH_STD_FRAC * sigma;
390
391    // DELAY-EMBED: m = n - (RQA_EMBED_DIM - 1) VECTORS.
392    let m = n - (RQA_EMBED_DIM - 1);
393    if m < 2 {
394        return None;
395    }
396    let mut emb: [[f64; RQA_EMBED_DIM]; N] = [[0.0; RQA_EMBED_DIM]; N];
397    for i in 0..m {
398        for d in 0..RQA_EMBED_DIM {
399            emb[i][d] = s[i + d];
400        }
401    }
402
403    // RECURRENCE MATRIX OVER THE m EMBEDDED POINTS.
404    let mut rec: [[bool; N]; N] = [[false; N]; N];
405    for i in 0..m {
406        for j in 0..m {
407            rec[i][j] = chebyshev3(&emb[i], &emb[j]) <= eps;
408        }
409    }
410
411    // WALK EVERY OFF-MAIN DIAGONAL. COUNT TOTAL RECURRENCE POINTS AND
412    // THE POINTS THAT BELONG TO DIAGONAL RUNS OF LENGTH >= RQA_LMIN.
413    let mut total_rec: u64 = 0;
414    let mut diag_rec: u64 = 0;
415    // OFFSET o > 0: PAIRS (i, i + o). OFFSET o < 0 IS THE SYMMETRIC
416    // MIRROR; THE RECURRENCE MATRIX IS SYMMETRIC SO WE WALK o IN
417    // [1, m) AND DOUBLE-COUNT NOTHING BY COUNTING BOTH (i,j) AND (j,i)
418    // VIA THE 2x FACTOR -- INSTEAD WE JUST WALK BOTH UPPER AND LOWER
419    // EXPLICITLY TO KEEP total_rec CONSISTENT WITH diag_rec.
420    for o in 1..m {
421        // UPPER DIAGONAL: (i, i + o).
422        let mut run: usize = 0;
423        for i in 0..(m - o) {
424            if rec[i][i + o] {
425                total_rec += 1;
426                run += 1;
427            } else {
428                if run >= RQA_LMIN {
429                    diag_rec += run as u64;
430                }
431                run = 0;
432            }
433        }
434        if run >= RQA_LMIN {
435            diag_rec += run as u64;
436        }
437        // LOWER DIAGONAL: (i + o, i).
438        run = 0;
439        for i in 0..(m - o) {
440            if rec[i + o][i] {
441                total_rec += 1;
442                run += 1;
443            } else {
444                if run >= RQA_LMIN {
445                    diag_rec += run as u64;
446                }
447                run = 0;
448            }
449        }
450        if run >= RQA_LMIN {
451            diag_rec += run as u64;
452        }
453    }
454
455    if total_rec == 0 {
456        return Some(0.0);
457    }
458    Some(diag_rec as f64 / total_rec as f64)
459}
460
461// CHAOS COUNTER (DIAGNOSTIC)
462//
463// MONOTONIC COUNTER OF "WINDOW IS CHAOTIC" CROSSINGS. INCREMENT WHEN
464// HVG ENTROPY CROSSES LN(3/2) UPWARD OR WHEN PERMUTATION ENTROPY
465// CROSSES BP_H_HIGH UPWARD. EXPOSED FOR THE TELEMETRY LINE AND
466// FOR THE COMMITTED MWU PATHWAY THAT GATES OFF CROSSINGS.
467#[derive(Default, Debug)]
468pub struct ChaosCounter(AtomicU64);
469
470impl ChaosCounter {
471    pub const fn new() -> Self {
472        Self(AtomicU64::new(0))
473    }
474
475    pub fn bump(&self) {
476        self.0.fetch_add(1, Ordering::Relaxed);
477    }
478
479    pub fn load(&self) -> u64 {
480        self.0.load(Ordering::Relaxed)
481    }
482}