#![allow(dead_code)]
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pattern {
LogShaped,
Burst,
Stable,
Declining,
Unknown,
}
impl Pattern {
pub fn as_str(self) -> &'static str {
match self {
Pattern::LogShaped => "log_shaped",
Pattern::Burst => "burst",
Pattern::Stable => "stable",
Pattern::Declining => "declining",
Pattern::Unknown => "unknown",
}
}
}
impl fmt::Display for Pattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
pub const DECLINING_SLOPE_FRAC: f64 = -0.01;
pub const DECLINING_R2_MIN: f64 = 0.5;
pub const SPIKE_RATIO: f64 = 4.0;
pub const STABLE_RATIO: f64 = 3.0;
pub const FIRST_THIRD_DIV: usize = 3;
pub const MIN_SAMPLES: usize = 3;
pub fn classify_pattern(samples: &[(i64, u64)]) -> Pattern {
if samples.len() < MIN_SAMPLES {
return Pattern::Unknown;
}
let n = samples.len() as f64;
let xs: Vec<f64> = samples.iter().map(|(t, _)| *t as f64).collect();
let ys: Vec<f64> = samples.iter().map(|(_, b)| *b as f64).collect();
let sum_x: f64 = xs.iter().sum();
let sum_y: f64 = ys.iter().sum();
let mean_x = sum_x / n;
let mean_y = sum_y / n;
let mut s_xy = 0.0;
let mut s_xx = 0.0;
let mut s_yy = 0.0;
for i in 0..samples.len() {
let dx = xs[i] - mean_x;
let dy = ys[i] - mean_y;
s_xy += dx * dy;
s_xx += dx * dx;
s_yy += dy * dy;
}
let slope = if s_xx > 0.0 { s_xy / s_xx } else { 0.0 };
let r2 = if s_xx > 0.0 && s_yy > 0.0 {
(s_xy * s_xy) / (s_xx * s_yy)
} else {
0.0
};
let span = xs[xs.len() - 1] - xs[0];
let mean_step = if span > 0.0 { span / (n - 1.0) } else { 1.0 };
let per_step_slope = slope * mean_step;
if mean_y > 0.0 && per_step_slope / mean_y <= DECLINING_SLOPE_FRAC && r2 >= DECLINING_R2_MIN {
return Pattern::Declining;
}
let mut strides: Vec<i128> = Vec::with_capacity(samples.len() - 1);
for w in samples.windows(2) {
strides.push(w[1].1 as i128 - w[0].1 as i128);
}
let mut abs_strides: Vec<u128> = strides.iter().map(|d| d.unsigned_abs()).collect();
let max_stride = *abs_strides.iter().max().unwrap_or(&0);
let max_idx = strides
.iter()
.enumerate()
.max_by_key(|(_, d)| d.unsigned_abs())
.map(|(i, _)| i)
.unwrap_or(0);
abs_strides.sort_unstable();
let mid = abs_strides.len() / 2;
let median = if abs_strides.len() % 2 == 1 {
abs_strides[mid] as f64
} else {
(abs_strides[mid - 1] as f64 + abs_strides[mid] as f64) / 2.0
};
if max_stride == 0 {
return Pattern::Stable;
}
if median == 0.0 {
return spike_pattern(max_idx, strides.len());
}
let ratio = max_stride as f64 / median;
if ratio > SPIKE_RATIO {
return spike_pattern(max_idx, strides.len());
}
if ratio < STABLE_RATIO {
return Pattern::Stable;
}
Pattern::Unknown
}
fn spike_pattern(spike_idx: usize, n_strides: usize) -> Pattern {
let cutoff = n_strides / FIRST_THIRD_DIV;
if spike_idx < cutoff {
Pattern::LogShaped
} else {
Pattern::Burst
}
}
#[cfg(test)]
mod tests {
use super::*;
fn series(bytes: &[u64]) -> Vec<(i64, u64)> {
bytes
.iter()
.enumerate()
.map(|(i, b)| (1000 + 60 * i as i64, *b))
.collect()
}
#[test]
fn too_few_samples_is_unknown() {
assert_eq!(classify_pattern(&[]), Pattern::Unknown);
assert_eq!(classify_pattern(&series(&[100])), Pattern::Unknown);
assert_eq!(classify_pattern(&series(&[100, 200])), Pattern::Unknown);
}
#[test]
fn log_shaped_big_initial_growth_then_flat() {
let s = series(&[
10_000_000,
100_000_000,
101_000_000,
101_500_000,
102_000_000,
102_400_000,
]);
assert_eq!(classify_pattern(&s), Pattern::LogShaped);
}
#[test]
fn burst_late_spike() {
let s = series(&[
10_000_000,
10_500_000,
11_000_000,
11_500_000,
12_000_000,
500_000_000,
]);
assert_eq!(classify_pattern(&s), Pattern::Burst);
}
#[test]
fn stable_flat_series() {
let s = series(&[
1_000_000, 1_005_000, 1_002_000, 1_004_000, 1_003_000, 1_001_500,
]);
assert_eq!(classify_pattern(&s), Pattern::Stable);
}
#[test]
fn stable_exactly_constant() {
let s = series(&[5_000_000; 6]);
assert_eq!(classify_pattern(&s), Pattern::Stable);
}
#[test]
fn declining_clear_downtrend() {
let s = series(&[
10_000_000, 9_000_000, 8_000_000, 7_000_000, 6_000_000, 5_000_000,
]);
assert_eq!(classify_pattern(&s), Pattern::Declining);
}
#[test]
fn declining_beats_burst_when_trend_dominates() {
let s = series(&[
10_000_000, 9_500_000, 8_800_000, 8_000_000, 7_500_000, 6_900_000,
]);
assert_eq!(classify_pattern(&s), Pattern::Declining);
}
#[test]
fn unknown_mixed_noise() {
let s = series(&[
10_000_000, 11_000_000, 10_500_000, 11_200_000, 10_800_000, 11_100_000,
]);
let got = classify_pattern(&s);
assert!(
matches!(got, Pattern::Unknown | Pattern::Stable),
"expected unknown/stable, got {got:?}"
);
}
#[test]
fn pattern_json_strings_are_snake_case() {
assert_eq!(Pattern::LogShaped.as_str(), "log_shaped");
assert_eq!(Pattern::Burst.as_str(), "burst");
assert_eq!(Pattern::Stable.as_str(), "stable");
assert_eq!(Pattern::Declining.as_str(), "declining");
assert_eq!(Pattern::Unknown.as_str(), "unknown");
}
#[test]
fn first_third_boundary_n5_only_stride_zero_is_log() {
let log_s = series(&[
10_000_000,
200_000_000,
201_000_000,
201_500_000,
202_000_000,
]);
assert_eq!(classify_pattern(&log_s), Pattern::LogShaped);
let burst_s = series(&[
10_000_000,
11_000_000,
200_000_000,
200_500_000,
201_000_000,
]);
assert_eq!(classify_pattern(&burst_s), Pattern::Burst);
}
}