Skip to main content

crackle_runtime/
information.rs

1//! Information-theoretic foundations for pattern detection.
2//!
3//! Grounds the pattern detection in actual information theory, providing
4//! principled measures of uncertainty, dependence, distribution shift, and
5//! information flow.
6//!
7//! # References
8//!
9//! - Shannon (1948), "A Mathematical Theory of Communication"
10//! - Cover & Thomas (2006), "Elements of Information Theory"
11//! - Kullback & Leibler (1951)
12//! - Schreiber (2000), "Measuring Information Transfer"
13//! - Bandt & Pompe (2002), "Permutation Entropy"
14
15/// Discretize continuous values into `bins` equal-width bins and return histogram counts.
16fn histogram(values: &[f64], bins: usize) -> Vec<usize> {
17    if values.is_empty() || bins == 0 {
18        return vec![];
19    }
20    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
21    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
22
23    if (max - min).abs() < f64::EPSILON {
24        // All values identical — one bin gets everything
25        let mut counts = vec![0usize; bins];
26        if bins > 0 {
27            counts[0] = values.len();
28        }
29        return counts;
30    }
31
32    let width = (max - min) / bins as f64;
33    let mut counts = vec![0usize; bins];
34
35    for &v in values {
36        let idx = ((v - min) / width).floor() as usize;
37        // Clamp to last bin for the max value
38        let idx = idx.min(bins - 1);
39        counts[idx] += 1;
40    }
41
42    counts
43}
44
45/// Discretize two variables jointly into a 2D histogram.
46fn histogram_2d(x: &[f64], y: &[f64], bins: usize) -> Vec<Vec<usize>> {
47    let n = x.len().min(y.len());
48    if n == 0 || bins == 0 {
49        return vec![];
50    }
51
52    let x_min = x[..n].iter().cloned().fold(f64::INFINITY, f64::min);
53    let x_max = x[..n].iter().cloned().fold(f64::NEG_INFINITY, f64::max);
54    let y_min = y[..n].iter().cloned().fold(f64::INFINITY, f64::min);
55    let y_max = y[..n].iter().cloned().fold(f64::NEG_INFINITY, f64::max);
56
57    let x_width = if (x_max - x_min).abs() < f64::EPSILON {
58        1.0
59    } else {
60        (x_max - x_min) / bins as f64
61    };
62    let y_width = if (y_max - y_min).abs() < f64::EPSILON {
63        1.0
64    } else {
65        (y_max - y_min) / bins as f64
66    };
67
68    let mut counts = vec![vec![0usize; bins]; bins];
69
70    for i in 0..n {
71        let xi = if (x_max - x_min).abs() < f64::EPSILON {
72            0
73        } else {
74            (((x[i] - x_min) / x_width).floor() as usize).min(bins - 1)
75        };
76        let yi = if (y_max - y_min).abs() < f64::EPSILON {
77            0
78        } else {
79            (((y[i] - y_min) / y_width).floor() as usize).min(bins - 1)
80        };
81        counts[xi][yi] += 1;
82    }
83
84    counts
85}
86
87/// Compute Shannon entropy of a set of continuous values.
88///
89/// Discretizes values into `bins` equal-width bins, then computes:
90/// `H(X) = -Σ p(x) log₂ p(x)`
91///
92/// High entropy indicates unpredictability; low entropy indicates regular patterns.
93///
94/// # Arguments
95///
96/// * `values` - The continuous values to analyze
97/// * `bins` - Number of bins for discretization (typically 10–50)
98///
99/// # Returns
100///
101/// Entropy in bits. Returns 0.0 for empty input.
102///
103/// # Reference
104///
105/// Shannon (1948), "A Mathematical Theory of Communication"
106pub fn entropy(values: &[f64], bins: usize) -> f64 {
107    if values.is_empty() || bins == 0 {
108        return 0.0;
109    }
110
111    let counts = histogram(values, bins);
112    let total = values.len() as f64;
113
114    let mut h = 0.0;
115    for &c in &counts {
116        if c > 0 {
117            let p = c as f64 / total;
118            h -= p * p.log2();
119        }
120    }
121
122    h
123}
124
125/// Compute joint entropy of two variables.
126///
127/// `H(X,Y) = -Σ p(x,y) log₂ p(x,y)`
128pub fn joint_entropy(x: &[f64], y: &[f64], bins: usize) -> f64 {
129    let n = x.len().min(y.len());
130    if n == 0 || bins == 0 {
131        return 0.0;
132    }
133
134    let counts = histogram_2d(x, y, bins);
135    let total = n as f64;
136
137    let mut h = 0.0;
138    for row in &counts {
139        for &c in row {
140            if c > 0 {
141                let p = c as f64 / total;
142                h -= p * p.log2();
143            }
144        }
145    }
146
147    h
148}
149
150/// Compute mutual information between two variables.
151///
152/// `I(X;Y) = H(X) + H(Y) - H(X,Y)`
153///
154/// Captures non-linear dependencies that Pearson correlation misses.
155/// Returns 0 if X and Y are independent; higher values indicate stronger dependence.
156///
157/// # Arguments
158///
159/// * `x` - First variable
160/// * `y` - Second variable
161/// * `bins` - Number of bins for discretization
162///
163/// # Returns
164///
165/// Mutual information in bits (non-negative).
166///
167/// # Reference
168///
169/// Cover & Thomas (2006), "Elements of Information Theory"
170pub fn mutual_information(x: &[f64], y: &[f64], bins: usize) -> f64 {
171    let n = x.len().min(y.len());
172    if n == 0 || bins == 0 {
173        return 0.0;
174    }
175
176    let hx = entropy(x, bins);
177    let hy = entropy(y, bins);
178    let hxy = joint_entropy(x, y, bins);
179
180    // MI is non-negative; numerical issues can make it slightly negative
181    (hx + hy - hxy).max(0.0)
182}
183
184/// Compute Kullback-Leibler divergence between two distributions.
185///
186/// `D_KL(P || Q) = Σ P(i) log(P(i)/Q(i))`
187///
188/// Measures how much information is lost when Q is used to approximate P.
189/// This is the principled measure for distribution shift detection.
190///
191/// # Arguments
192///
193/// * `current` - The "current" distribution P
194/// * `baseline` - The "baseline" distribution Q
195/// * `bins` - Number of bins for discretization
196///
197/// # Returns
198///
199/// KL divergence in bits. Returns `f64::INFINITY` if P has support where Q doesn't.
200///
201/// # Reference
202///
203/// Kullback & Leibler (1951)
204pub fn kl_divergence(current: &[f64], baseline: &[f64], bins: usize) -> f64 {
205    if current.is_empty() || baseline.is_empty() || bins == 0 {
206        return 0.0;
207    }
208
209    let p_counts = histogram(current, bins);
210    let q_counts = histogram(baseline, bins);
211
212    let p_total = current.len() as f64;
213    let q_total = baseline.len() as f64;
214
215    let mut kl = 0.0;
216    for i in 0..bins {
217        let p = p_counts[i] as f64 / p_total;
218        let q = q_counts[i] as f64 / q_total;
219
220        if p > 0.0 && q > 0.0 {
221            kl += p * (p / q).log2();
222        } else if p > 0.0 && q <= 0.0 {
223            return f64::INFINITY;
224        }
225        // If p == 0, contribution is 0 regardless of q
226    }
227
228    kl
229}
230
231/// Compute Jensen-Shannon divergence between two distributions.
232///
233/// `JSD(P,Q) = ½ D_KL(P||M) + ½ D_KL(Q||M)` where `M = (P+Q)/2`
234///
235/// Symmetric, always finite, and its square root is a proper metric.
236/// Use for detecting ANY distribution shift (not just mean shift).
237///
238/// # Arguments
239///
240/// * `p` - First distribution's values
241/// * `q` - Second distribution's values
242/// * `bins` - Number of bins for discretization
243///
244/// # Returns
245///
246/// JSD in bits (symmetric, bounded by `log₂(bins)`).
247pub fn jsd(p: &[f64], q: &[f64], bins: usize) -> f64 {
248    if p.is_empty() || q.is_empty() || bins == 0 {
249        return 0.0;
250    }
251
252    let p_counts = histogram(p, bins);
253    let q_counts = histogram(q, bins);
254
255    let p_total = p.len() as f64;
256    let q_total = q.len() as f64;
257
258    let mut jsd_val = 0.0;
259
260    for i in 0..bins {
261        let pi = p_counts[i] as f64 / p_total;
262        let qi = q_counts[i] as f64 / q_total;
263        let mi = (pi + qi) / 2.0;
264
265        if pi > 0.0 && mi > 0.0 {
266            jsd_val += 0.5 * pi * (pi / mi).log2();
267        }
268        if qi > 0.0 && mi > 0.0 {
269            jsd_val += 0.5 * qi * (qi / mi).log2();
270        }
271    }
272
273    jsd_val
274}
275
276/// Compute transfer entropy from X to Y.
277///
278/// `TE(X→Y) = I(Y_{t+1}; X_t | Y_t)`
279///
280/// Measures whether X's past helps predict Y beyond Y's own past.
281/// This detects directional influence (information flow) between metrics.
282///
283/// # Arguments
284///
285/// * `x` - Source variable (potential cause)
286/// * `y` - Target variable (potential effect)
287/// * `lag` - Time lag to consider (typically 1)
288/// * `bins` - Number of bins for discretization
289///
290/// # Returns
291///
292/// Transfer entropy in bits. Non-negative; higher values indicate stronger
293/// directional information flow from X to Y.
294///
295/// # Reference
296///
297/// Schreiber (2000), "Measuring Information Transfer"
298pub fn transfer_entropy(x: &[f64], y: &[f64], lag: usize, bins: usize) -> f64 {
299    let n = x.len().min(y.len());
300    if n <= lag + 1 || bins == 0 {
301        return 0.0;
302    }
303
304    // Build triplets: (y_{t+1}, x_t, y_t) for t = 0..n-lag-1
305    let m = n - lag;
306    let y_future: Vec<f64> = (lag..n).map(|t| y[t]).collect();
307    let x_past: Vec<f64> = (0..m).map(|t| x[t]).collect();
308    let y_past: Vec<f64> = (0..m).map(|t| y[t]).collect();
309
310    // TE = H(Y_{t+1}, X_t, Y_t) - H(X_t, Y_t) - H(Y_{t+1}, Y_t) + H(Y_t)
311    // We compute this via 3D and 2D histograms
312    let h_yxy = entropy_3d(&y_future, &x_past, &y_past, bins);
313    let h_xy = joint_entropy(&x_past, &y_past, bins);
314    let h_yy = joint_entropy(&y_future, &y_past, bins);
315    let h_y = entropy(&y_past, bins);
316
317    (h_yxy - h_xy - h_yy + h_y).max(0.0)
318}
319
320/// Compute 3D joint entropy for three variables.
321fn entropy_3d(a: &[f64], b: &[f64], c: &[f64], bins: usize) -> f64 {
322    let n = a.len().min(b.len()).min(c.len());
323    if n == 0 || bins == 0 {
324        return 0.0;
325    }
326
327    // Discretize each variable
328    let a_disc = discretize(&a[..n], bins);
329    let b_disc = discretize(&b[..n], bins);
330    let c_disc = discretize(&c[..n], bins);
331
332    // Count joint occurrences using a flat map
333    let mut counts = std::collections::HashMap::new();
334    for i in 0..n {
335        let key = (a_disc[i], b_disc[i], c_disc[i]);
336        *counts.entry(key).or_insert(0usize) += 1;
337    }
338
339    let total = n as f64;
340    let mut h = 0.0;
341    for &c in counts.values() {
342        let p = c as f64 / total;
343        h -= p * p.log2();
344    }
345
346    h
347}
348
349/// Discretize values into bin indices.
350fn discretize(values: &[f64], bins: usize) -> Vec<usize> {
351    if values.is_empty() || bins == 0 {
352        return vec![];
353    }
354
355    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
356    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
357
358    if (max - min).abs() < f64::EPSILON {
359        return vec![0; values.len()];
360    }
361
362    let width = (max - min) / bins as f64;
363    values
364        .iter()
365        .map(|&v| ((v - min) / width).floor() as usize).map(|idx| idx.min(bins - 1))
366        .collect()
367}
368
369/// Compute permutation entropy of a time series.
370///
371/// Ordinal pattern analysis captures temporal structure that static entropy misses.
372/// Measures the complexity of the time series based on the ordering of consecutive values.
373///
374/// # Arguments
375///
376/// * `values` - The time series to analyze
377/// * `order` - Embedding dimension (typically 3–7). Must be >= 2.
378///
379/// # Returns
380///
381/// Normalized permutation entropy in [0, 1].
382/// 0 = perfectly regular (single ordinal pattern), 1 = random (all patterns equally likely).
383///
384/// # Reference
385///
386/// Bandt & Pompe (2002), "Permutation Entropy"
387pub fn permutation_entropy(values: &[f64], order: usize) -> f64 {
388    if values.len() < order || order < 2 {
389        return 0.0;
390    }
391
392    let n_patterns = factorial(order);
393    let mut pattern_counts = vec![0usize; n_patterns];
394
395    let n = values.len() - order + 1;
396    for i in 0..n {
397        let window = &values[i..i + order];
398        let idx = ordinal_pattern_index(window);
399        pattern_counts[idx] += 1;
400    }
401
402    let total = n as f64;
403    let mut h = 0.0;
404    for &c in &pattern_counts {
405        if c > 0 {
406            let p = c as f64 / total;
407            h -= p * p.log2();
408        }
409    }
410
411    // Normalize by log2(n_patterns!)
412    let max_h = (n_patterns as f64).log2();
413    if max_h > 0.0 {
414        h / max_h
415    } else {
416        0.0
417    }
418}
419
420/// Compute the ordinal pattern index for a window of values.
421///
422/// Maps the permutation of indices sorted by value to a unique integer.
423fn ordinal_pattern_index(window: &[f64]) -> usize {
424    let order = window.len();
425    let mut indices: Vec<usize> = (0..order).collect();
426
427    // Sort indices by corresponding values (stable sort for ties)
428    indices.sort_by(|&a, &b| {
429        window[a]
430            .partial_cmp(&window[b])
431            .unwrap_or(std::cmp::Ordering::Equal)
432    });
433
434    // Compute the Lehmer code (factorial number system)
435    let mut code = 0usize;
436    for i in 0..order {
437        let mut count = 0;
438        for j in (i + 1)..order {
439            if indices[j] < indices[i] {
440                count += 1;
441            }
442        }
443        code += count * factorial(order - i - 1);
444    }
445
446    code
447}
448
449/// Compute factorial (small values only, used for ordinal patterns).
450fn factorial(n: usize) -> usize {
451    if n <= 1 {
452        1
453    } else {
454        (2..=n).product()
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    // ── Histogram tests ──
463
464    #[test]
465    fn histogram_basic() {
466        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
467        let counts = histogram(&values, 5);
468        assert_eq!(counts.iter().sum::<usize>(), 5);
469    }
470
471    #[test]
472    fn histogram_empty() {
473        let counts = histogram(&[], 10);
474        assert!(counts.is_empty());
475    }
476
477    #[test]
478    fn histogram_single_value() {
479        let counts = histogram(&[5.0; 10], 5);
480        assert_eq!(counts[0], 10);
481        assert_eq!(counts.iter().sum::<usize>(), 10);
482    }
483
484    #[test]
485    fn histogram_2d_basic() {
486        let x = vec![1.0, 2.0, 3.0];
487        let y = vec![1.0, 2.0, 3.0];
488        let counts = histogram_2d(&x, &y, 3);
489        let total: usize = counts.iter().flat_map(|r| r.iter()).sum();
490        assert_eq!(total, 3);
491    }
492
493    #[test]
494    fn histogram_2d_empty() {
495        let counts = histogram_2d(&[], &[], 3);
496        assert!(counts.is_empty());
497    }
498
499    // ── Shannon entropy tests ──
500
501    #[test]
502    fn entropy_uniform_distribution() {
503        // Uniform over 4 bins should give log2(4) = 2.0 bits
504        let values: Vec<f64> = (0..100).map(|i| (i % 4) as f64).collect();
505        let h = entropy(&values, 4);
506        assert!((h - 2.0).abs() < 0.05, "expected ~2.0, got {}", h);
507    }
508
509    #[test]
510    fn entropy_single_value() {
511        let h = entropy(&[5.0; 100], 10);
512        assert!(h.abs() < 0.001, "expected ~0.0, got {}", h);
513    }
514
515    #[test]
516    fn entropy_empty() {
517        assert_eq!(entropy(&[], 10), 0.0);
518    }
519
520    #[test]
521    fn entropy_zero_bins() {
522        assert_eq!(entropy(&[1.0, 2.0], 0), 0.0);
523    }
524
525    #[test]
526    fn entropy_two_values_different() {
527        let h = entropy(&[1.0, 2.0], 2);
528        // Two values in different bins: log2(2) = 1.0
529        assert!((h - 1.0).abs() < 0.01, "expected ~1.0, got {}", h);
530    }
531
532    #[test]
533    fn entropy_monotonic_in_bins() {
534        // More bins shouldn't decrease entropy for uniform data
535        let values: Vec<f64> = (0..1000).map(|i| (i % 10) as f64).collect();
536        let h5 = entropy(&values, 5);
537        let h20 = entropy(&values, 20);
538        // With uniform data, more bins up to the number of distinct values gives higher entropy
539        assert!(h20 >= h5 - 0.1);
540    }
541
542    // ── Joint entropy tests ──
543
544    #[test]
545    fn joint_entropy_independent() {
546        let x: Vec<f64> = (0..100).map(|i| (i % 2) as f64).collect();
547        let y: Vec<f64> = (0..100).map(|i| (i % 2) as f64).collect();
548        let hxy = joint_entropy(&x, &y, 2);
549        let hx = entropy(&x, 2);
550        let hy = entropy(&y, 2);
551        // Identical variables: joint entropy = marginal entropy
552        assert!((hxy - hx).abs() < 0.1);
553        let _ = hy; // suppress unused warning
554    }
555
556    #[test]
557    fn joint_entropy_empty() {
558        assert_eq!(joint_entropy(&[], &[], 10), 0.0);
559    }
560
561    #[test]
562    fn joint_entropy_increases_with_independence() {
563        // Perfectly correlated
564        let x: Vec<f64> = (0..100).map(|i| (i % 4) as f64).collect();
565        let y_same: Vec<f64> = x.clone();
566        let y_indep: Vec<f64> = (0..100).map(|i| ((i + 2) % 4) as f64).collect();
567
568        let h_corr = joint_entropy(&x, &y_same, 4);
569        let h_indep = joint_entropy(&x, &y_indep, 4);
570        // Independent variables have higher joint entropy than identical
571        assert!(h_indep >= h_corr);
572    }
573
574    // ── Mutual information tests ──
575
576    #[test]
577    fn mi_identical_variables() {
578        let x: Vec<f64> = (0..100).map(|i| (i % 4) as f64).collect();
579        let mi = mutual_information(&x, &x, 4);
580        let hx = entropy(&x, 4);
581        // MI of a variable with itself = H(X)
582        assert!((mi - hx).abs() < 0.1, "MI = {}, H(X) = {}", mi, hx);
583    }
584
585    #[test]
586    fn mi_independent_variables() {
587        // Alternating patterns that are independent
588        let x: Vec<f64> = (0..100).map(|i| (i % 2) as f64).collect();
589        let y: Vec<f64> = (0..100).map(|i| ((i / 2) % 2) as f64).collect();
590        let mi = mutual_information(&x, &y, 2);
591        // Should be low (not perfectly 0 due to discretization)
592        assert!(mi < 0.5, "expected low MI, got {}", mi);
593    }
594
595    #[test]
596    fn mi_empty() {
597        assert_eq!(mutual_information(&[], &[], 10), 0.0);
598    }
599
600    #[test]
601    fn mi_nonlinear_dependence() {
602        // Y = X^2 — nonlinear relation that Pearson might miss
603        let x: Vec<f64> = (0..50).map(|i| (i as f64 - 25.0) / 5.0).collect();
604        let y: Vec<f64> = x.iter().map(|v| v * v).collect();
605        let mi = mutual_information(&x, &y, 10);
606        // Should detect the dependence
607        assert!(mi > 0.5, "MI should detect nonlinear dependence, got {}", mi);
608    }
609
610    #[test]
611    fn mi_non_negative() {
612        let x = vec![1.0, 2.0, 3.0, 4.0];
613        let y = vec![4.0, 3.0, 2.0, 1.0];
614        let mi = mutual_information(&x, &y, 4);
615        assert!(mi >= 0.0);
616    }
617
618    // ── KL divergence tests ──
619
620    #[test]
621    fn kl_identical_distributions() {
622        let p = vec![1.0, 2.0, 3.0, 4.0, 5.0];
623        let q = vec![1.0, 2.0, 3.0, 4.0, 5.0];
624        let kl = kl_divergence(&p, &q, 5);
625        assert!(kl.abs() < 0.01, "expected ~0.0, got {}", kl);
626    }
627
628    #[test]
629    fn kl_different_distributions() {
630        let p: Vec<f64> = (0..100).map(|_| 1.0).collect();
631        let q: Vec<f64> = (0..100).map(|i| if i < 50 { 1.0 } else { 10.0 }).collect();
632        let kl = kl_divergence(&p, &q, 10);
633        assert!(kl > 0.0);
634    }
635
636    #[test]
637    fn kl_empty() {
638        assert_eq!(kl_divergence(&[], &[1.0], 10), 0.0);
639        assert_eq!(kl_divergence(&[1.0], &[], 10), 0.0);
640    }
641
642    #[test]
643    fn kl_asymmetric() {
644        let p = vec![1.0, 1.0, 1.0, 10.0];
645        let q = vec![1.0, 1.0, 1.0, 1.0];
646        let kl_pq = kl_divergence(&p, &q, 4);
647        let kl_qp = kl_divergence(&q, &p, 4);
648        // KL is asymmetric: D(P||Q) != D(Q||P) in general
649        assert!((kl_pq - kl_qp).abs() > 0.01 || (kl_pq.is_infinite() || kl_qp.is_infinite()));
650    }
651
652    #[test]
653    fn kl_non_negative() {
654        let p = vec![1.0, 2.0, 3.0];
655        let q = vec![3.0, 2.0, 1.0];
656        let kl = kl_divergence(&p, &q, 3);
657        assert!(kl >= 0.0);
658    }
659
660    // ── JSD tests ──
661
662    #[test]
663    fn jsd_identical() {
664        let p = vec![1.0, 2.0, 3.0, 4.0];
665        let js = jsd(&p, &p, 4);
666        assert!(js.abs() < 0.01, "expected ~0.0, got {}", js);
667    }
668
669    #[test]
670    fn jsd_symmetric() {
671        let p = vec![1.0, 1.0, 1.0, 10.0];
672        let q = vec![1.0, 1.0, 1.0, 1.0];
673        let js_pq = jsd(&p, &q, 4);
674        let js_qp = jsd(&q, &p, 4);
675        assert!((js_pq - js_qp).abs() < 0.01);
676    }
677
678    #[test]
679    fn jsd_different_distributions() {
680        let p: Vec<f64> = (0..100).map(|_| 1.0).collect();
681        let q: Vec<f64> = (0..100).map(|i| if i < 50 { 1.0 } else { 10.0 }).collect();
682        let js = jsd(&p, &q, 10);
683        assert!(js > 0.0);
684    }
685
686    #[test]
687    fn jsd_empty() {
688        assert_eq!(jsd(&[], &[], 10), 0.0);
689    }
690
691    #[test]
692    fn jsd_bounded() {
693        let p = vec![1.0, 1.0, 1.0];
694        let q = vec![10.0, 10.0, 10.0];
695        let js = jsd(&p, &q, 2);
696        // JSD is bounded by log2(bins) / 2 at most in practice
697        assert!(js.is_finite());
698        assert!(js >= 0.0);
699    }
700
701    // ── Transfer entropy tests ──
702
703    #[test]
704    fn te_empty() {
705        assert_eq!(transfer_entropy(&[], &[], 1, 10), 0.0);
706    }
707
708    #[test]
709    fn te_too_short() {
710        assert_eq!(transfer_entropy(&[1.0], &[1.0], 1, 10), 0.0);
711    }
712
713    #[test]
714    fn te_causal_direction() {
715        // X causes Y with lag 1: Y[t+1] = X[t]
716        let x: Vec<f64> = (0..50).map(|i| (i % 4) as f64).collect();
717        let mut y = vec![0.0; 50];
718        for i in 1..50 {
719            y[i] = x[i - 1];
720        }
721        let te_xy = transfer_entropy(&x, &y, 1, 4);
722        let te_yx = transfer_entropy(&y, &x, 1, 4);
723        // X→Y should be stronger than Y→X
724        assert!(te_xy >= te_yx, "X→Y = {}, Y→X = {}", te_xy, te_yx);
725    }
726
727    #[test]
728    fn te_independent() {
729        // Independent variables: transfer entropy should be near 0
730        let x: Vec<f64> = (0..100).map(|i| (i % 2) as f64).collect();
731        let y: Vec<f64> = (0..100).map(|i| ((i * 7 + 3) % 5) as f64).collect();
732        let te = transfer_entropy(&x, &y, 1, 5);
733        // Should be small
734        assert!(te < 1.0, "expected small TE, got {}", te);
735    }
736
737    #[test]
738    fn te_non_negative() {
739        let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
740        let y: Vec<f64> = (0..20).map(|i| (i * 2) as f64).collect();
741        let te = transfer_entropy(&x, &y, 1, 5);
742        assert!(te >= 0.0);
743    }
744
745    #[test]
746    fn te_lag_2() {
747        // X causes Y with lag 2: Y[t] = X[t-2] + noise
748        let x: Vec<f64> = (0..100).map(|i| (i as f64 * 0.3).sin()).collect();
749        let mut y = vec![0.0; 100];
750        for i in 2..100 {
751            y[i] = x[i - 2] + 0.01 * (i as f64).cos();
752        }
753        let te_xy = transfer_entropy(&x, &y, 2, 5);
754        let te_yx = transfer_entropy(&y, &x, 2, 5);
755        // X→Y should be stronger than Y→X
756        assert!(te_xy > 0.0 || te_xy >= te_yx, "X→Y = {}, Y→X = {}", te_xy, te_yx);
757    }
758
759    // ── Permutation entropy tests ──
760
761    #[test]
762    fn pe_constant_series() {
763        let values = vec![5.0; 100];
764        let pe = permutation_entropy(&values, 3);
765        // Constant series: only one ordinal pattern possible
766        assert!(pe.abs() < 0.01, "expected ~0.0, got {}", pe);
767    }
768
769    #[test]
770    fn pe_increasing_series() {
771        let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
772        let pe = permutation_entropy(&values, 3);
773        // Strictly increasing: only one ordinal pattern (0,1,2)
774        assert!(pe.abs() < 0.01, "expected ~0.0, got {}", pe);
775    }
776
777    #[test]
778    fn pe_random_like() {
779        // A series that visits many ordinal patterns
780        let values: Vec<f64> = (0..100).map(|i| ((i * 17 + 3) % 97) as f64).collect();
781        let pe = permutation_entropy(&values, 3);
782        // Should be closer to 1.0 (high complexity)
783        assert!(pe > 0.4, "expected high PE for pseudo-random, got {}", pe);
784    }
785
786    #[test]
787    fn pe_empty() {
788        assert_eq!(permutation_entropy(&[], 3), 0.0);
789    }
790
791    #[test]
792    fn pe_order_too_large() {
793        let values = vec![1.0, 2.0];
794        assert_eq!(permutation_entropy(&values, 5), 0.0);
795    }
796
797    #[test]
798    fn pe_order_1() {
799        assert_eq!(permutation_entropy(&[1.0, 2.0, 3.0], 1), 0.0);
800    }
801
802    #[test]
803    fn pe_normalized_between_0_and_1() {
804        let values: Vec<f64> = (0..50).map(|i| (i as f64).sin()).collect();
805        let pe = permutation_entropy(&values, 4);
806        assert!(pe >= 0.0 && pe <= 1.0);
807    }
808
809    #[test]
810    fn pe_sinusoidal() {
811        // Sinusoidal should have some structure but not be maximally complex
812        let values: Vec<f64> = (0..200).map(|i| (i as f64 * 0.1).sin()).collect();
813        let pe = permutation_entropy(&values, 3);
814        assert!(pe > 0.0 && pe < 1.0);
815    }
816
817    // ── Factorial tests ──
818
819    #[test]
820    fn factorial_values() {
821        assert_eq!(factorial(0), 1);
822        assert_eq!(factorial(1), 1);
823        assert_eq!(factorial(2), 2);
824        assert_eq!(factorial(3), 6);
825        assert_eq!(factorial(4), 24);
826        assert_eq!(factorial(5), 120);
827    }
828
829    // ── Discretize tests ──
830
831    #[test]
832    fn discretize_basic() {
833        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
834        let disc = discretize(&values, 5);
835        assert_eq!(disc.len(), 5);
836        // All indices should be valid
837        for &d in &disc {
838            assert!(d < 5);
839        }
840    }
841
842    #[test]
843    fn discretize_constant() {
844        let disc = discretize(&[5.0; 10], 5);
845        assert!(disc.iter().all(|&d| d == 0));
846    }
847
848    // ── Ordinal pattern tests ──
849
850    #[test]
851    fn ordinal_pattern_increasing() {
852        let idx = ordinal_pattern_index(&[1.0, 2.0, 3.0]);
853        // Increasing: indices (0,1,2), Lehmer code = 0
854        assert_eq!(idx, 0);
855    }
856
857    #[test]
858    fn ordinal_pattern_decreasing() {
859        let idx = ordinal_pattern_index(&[3.0, 2.0, 1.0]);
860        // Decreasing: indices (2,1,0), Lehmer code should be max (5 for order 3)
861        assert_eq!(idx, 5);
862    }
863
864    // ── 3D entropy test ──
865
866    #[test]
867    fn entropy_3d_basic() {
868        let a = vec![1.0, 2.0, 3.0, 4.0];
869        let b = vec![1.0, 2.0, 3.0, 4.0];
870        let c = vec![1.0, 2.0, 3.0, 4.0];
871        let h = entropy_3d(&a, &b, &c, 4);
872        // All unique combos: log2(4) = 2.0
873        assert!(h > 0.0);
874        assert!(h.is_finite());
875    }
876
877    // ── Integration: entropy vs distribution shape ──
878
879    #[test]
880    fn entropy_peaked_vs_flat() {
881        // Peaked: most values in one bin
882        let peaked: Vec<f64> = (0..100).map(|_| 5.0).chain(vec![1.0, 2.0, 8.0, 9.0]).collect();
883        // Flat: spread across bins
884        let flat: Vec<f64> = (0..100).map(|i| i as f64).collect();
885
886        let h_peaked = entropy(&peaked, 10);
887        let h_flat = entropy(&flat, 10);
888        assert!(h_peaked < h_flat);
889    }
890
891    #[test]
892    fn entropy_bits_units() {
893        // 8 uniform bins → log2(8) = 3 bits
894        let values: Vec<f64> = (0..800).map(|i| (i % 8) as f64).collect();
895        let h = entropy(&values, 8);
896        assert!((h - 3.0).abs() < 0.05);
897    }
898}