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}