use super::funejson::Value; fn isqrt(n: i128) -> i128 { if n < 2 { return n; } let mut x = n; let mut y = (x + 1) / 2; while y < x { x = y; y = (x + n / x) / 2; } x } // Half-up with halves away from zero, as math.round-div rounds. fn half_up(n: i128, d: i128) -> i128 { let q = (2 * n.abs() + d) / (2 * d); if n < 0 { -q } else { q } } fn median_twice(sorted: &[i128]) -> i128 { let m = sorted.len() / 2; if sorted.len() % 2 == 1 { 2 * sorted[m] } else { sorted[m - 1] + sorted[m] } } /// Whether `value` is an outlier against `history`, by z-score (`stddev`) or /// by Iglewicz and Hoaglin's modified z-score (`mad`). Exact integer /// arithmetic: the threshold comparison never depends on float rounding. /// /// # Panics /// Panics on a history under 2 values, a threshold under 1 or an unknown method. pub fn detect_anomaly(history: &[i64], value: i64, method: &str, threshold_hundredths: i64) -> Anomaly { if history.len() < 2 { panic!("history must have at least 2 values, received {}", history.len()); } if threshold_hundredths < 1 { panic!("thresholdHundredths must be at least 1, received {}", threshold_hundredths); } let xs: Vec = history.iter().map(|&x| x as i128).collect(); let n = xs.len() as i128; let v = value as i128; let t = threshold_hundredths as i128; let (anomaly, signed, center, spread, score) = match method { "stddev" => { let s: i128 = xs.iter().sum(); let ss: i128 = xs.iter().map(|x| x * x).sum(); // n^2 * variance and n * (value - mean), both whole numbers. let q = n * ss - s * s; let signed = n * v - s; let center = half_up(s * 1000, n); let spread = isqrt(1_000_000 * q) / n; if q == 0 { (signed != 0, signed, center, spread, None) } else { let anomaly = 10000 * signed * signed > t * t * q; (anomaly, signed, center, spread, Some(isqrt(10000 * signed * signed / q))) } } "mad" => { // Twice the median and four times the MAD are whole numbers. let mut sorted = xs.clone(); sorted.sort(); let med2 = median_twice(&sorted); let mut devs: Vec = xs.iter().map(|x| (2 * x - med2).abs()).collect(); devs.sort(); let mad4 = median_twice(&devs); let signed = 2 * v - med2; if mad4 == 0 { (signed != 0, signed, med2 * 500, 0, None) } else { // |M| = 0.6745 * |2v - med2| / 2 / (mad4 / 4) = 1349 * |signed| / (1000 * mad4) let anomaly = 1349 * signed.abs() * 100 > t * 1000 * mad4; let score = 1349 * signed.abs() * 100 / (1000 * mad4); (anomaly, signed, med2 * 500, mad4 * 250, Some(score)) } } other => panic!("unknown anomaly method: {}", other), }; let direction = if !anomaly { "normal" } else if signed > 0 { "high" } else { "low" }; Anomaly { anomaly, direction: direction.to_string(), center_milli: center as i64, spread_milli: spread as i64, score_hundredths: score.map(|s| s as i64), } } pub fn anomaly_to_value(a: &Anomaly) -> Value { Value::obj(vec![ ("anomaly", Value::Bool(a.anomaly)), ("direction", Value::str(&a.direction)), ("centerMilli", Value::Int(a.center_milli)), ("spreadMilli", Value::Int(a.spread_milli)), ("scoreHundredths", match a.score_hundredths { Some(s) => Value::Int(s), None => Value::Null, }), ]) } pub fn fune_vector(args: &[Value]) -> Value { let history: Vec = args[0].as_arr().iter().map(|x| x.as_i64()).collect(); anomaly_to_value(&detect_anomaly(&history, args[1].as_i64(), args[2].as_str(), args[3].as_i64())) }