Skip to main content

antecedent_data/
transforms.rs

1//! Column transforms for discovery / symbolic CI .
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
6
7use crate::error::DataError;
8
9fn require_finite(col: &[f64], what: &str) -> Result<(), DataError> {
10    if let Some(i) = col.iter().position(|v| !v.is_finite()) {
11        return Err(DataError::InvalidArgument {
12            message: format!("{what}: non-finite value at index {i}"),
13        });
14    }
15    Ok(())
16}
17
18/// Equal-width binning of a float column into `n_bins` integer codes in `0..n_bins`.
19///
20/// Non-finite values are rejected (no silent map to bin 0).
21///
22/// # Errors
23///
24/// Empty `n_bins`, length mismatch, or non-finite input.
25pub fn equal_width_bin(col: &[f64], n_bins: usize, out: &mut [f64]) -> Result<(), DataError> {
26    if n_bins == 0 {
27        return Err(DataError::InvalidArgument { message: "n_bins must be > 0".into() });
28    }
29    if col.len() != out.len() {
30        return Err(DataError::InvalidArgument { message: "out length != col length".into() });
31    }
32    if col.is_empty() {
33        return Ok(());
34    }
35    require_finite(col, "equal_width_bin")?;
36    let mut min_v = f64::INFINITY;
37    let mut max_v = f64::NEG_INFINITY;
38    for &v in col {
39        min_v = min_v.min(v);
40        max_v = max_v.max(v);
41    }
42    let width = (max_v - min_v).max(1e-12);
43    let last_bin = (n_bins - 1) as f64;
44    for (slot, &v) in out.iter_mut().zip(col.iter()) {
45        let b = ((v - min_v) / width * n_bins as f64).floor();
46        *slot = b.clamp(0.0, last_bin);
47    }
48    Ok(())
49}
50
51/// Ordinal pattern of embedding dimension `m` with delay `tau` (Bandt–Pompe).
52///
53/// Writes one pattern code per valid window into `out` (length `col.len() - (m-1)*tau`).
54/// Non-finite values in any window are rejected (no silent tie treatment of NaN).
55///
56/// # Errors
57///
58/// Bad shape or non-finite input.
59pub fn ordinal_patterns(
60    col: &[f64],
61    m: usize,
62    tau: usize,
63    out: &mut [f64],
64) -> Result<usize, DataError> {
65    if m < 2 || tau == 0 {
66        return Err(DataError::InvalidArgument { message: "need m>=2 and tau>=1".into() });
67    }
68    let need = col.len().saturating_sub((m - 1) * tau);
69    if out.len() < need {
70        return Err(DataError::InvalidArgument { message: "out buffer too short".into() });
71    }
72    require_finite(col, "ordinal_patterns")?;
73    let mut idx = vec![0usize; m];
74    for t in 0..need {
75        for (k, slot) in idx.iter_mut().enumerate() {
76            *slot = k;
77        }
78        idx.sort_by(|&a, &b| {
79            col[t + a * tau].partial_cmp(&col[t + b * tau]).unwrap_or(std::cmp::Ordering::Equal)
80        });
81        // Lehmer code
82        let mut code = 0usize;
83        for i in 0..m {
84            let mut smaller = 0usize;
85            for j in (i + 1)..m {
86                if idx[j] < idx[i] {
87                    smaller += 1;
88                }
89            }
90            code = code * (m - i) + smaller;
91        }
92        out[t] = code as f64;
93    }
94    Ok(need)
95}
96
97/// Simple moving-average smoother (odd window).
98///
99/// # Errors
100///
101/// Even/zero window, shape mismatch, or non-finite input.
102pub fn moving_average(col: &[f64], window: usize, out: &mut [f64]) -> Result<(), DataError> {
103    if window == 0 || window % 2 == 0 {
104        return Err(DataError::InvalidArgument { message: "window must be odd and > 0".into() });
105    }
106    if col.len() != out.len() {
107        return Err(DataError::InvalidArgument { message: "out length != col length".into() });
108    }
109    require_finite(col, "moving_average")?;
110    let half = window / 2;
111    let n = col.len();
112    for (i, slot) in out.iter_mut().enumerate() {
113        let lo = i.saturating_sub(half);
114        let hi = (i + half + 1).min(n);
115        let s: f64 = col[lo..hi].iter().sum();
116        *slot = s / (hi - lo) as f64;
117    }
118    Ok(())
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn binning_two_bins() {
127        let col = [0.0, 1.0, 2.0, 3.0];
128        let mut out = [0.0; 4];
129        equal_width_bin(&col, 2, &mut out).unwrap();
130        assert!((out[0] - 0.0).abs() < f64::EPSILON);
131        assert!((out[3] - 1.0).abs() < f64::EPSILON);
132    }
133
134    #[test]
135    fn binning_rejects_nan() {
136        let col = [0.0, f64::NAN, 2.0];
137        let mut out = [0.0; 3];
138        assert!(equal_width_bin(&col, 2, &mut out).is_err());
139    }
140
141    #[test]
142    fn ordinal_runs() {
143        let col: Vec<f64> = (0..20).map(|i| f64::from(i).sin()).collect();
144        let mut out = vec![0.0; 20];
145        let n = ordinal_patterns(&col, 3, 1, &mut out).unwrap();
146        assert!(n > 0);
147    }
148
149    #[test]
150    fn ordinal_rejects_nan() {
151        let col = [0.0, 1.0, f64::NAN, 3.0, 4.0];
152        let mut out = vec![0.0; 5];
153        assert!(ordinal_patterns(&col, 3, 1, &mut out).is_err());
154    }
155}