Skip to main content

u_analytics/detection/
spectral_residual.rs

1//! Spectral residual saliency for one-shot anomaly scoring of a series.
2//!
3//! The spectral residual transform (Hou & Zhang 2007, brought to time series
4//! by Ren et al. 2019) takes the log amplitude spectrum of the series,
5//! subtracts its local average, and transforms back with the original phase.
6//! What is left — the saliency map — is large exactly where the series has a
7//! feature its spectrum cannot explain as regular structure: a spike, a
8//! step, a dropout. No model is trained and no period is assumed.
9//!
10//! Each point is scored relative to the saliency of the points before it,
11//! `score = (S_i − mean(S over the judgement window)) / mean(...)`; a point
12//! is an anomaly when its score exceeds the threshold (Ren et al. use τ = 3)
13//! and, so that a bump of a few thousandths on a flat series is not one, its
14//! value stands at least `min_zscore` standard deviations from the level of
15//! the window before it.
16//!
17//! The result also carries an **expected value** — the series with the
18//! anomalies replaced by their neighbours, reconstructed from its
19//! low-frequency components — and a band around it whose half-width is the
20//! normal quantile for `sensitivity` percent coverage times the robust
21//! spread of the residuals. The band is information for a chart, not the
22//! anomaly decision, which is the saliency score.
23//!
24//! # What the saliency does at a boundary, and at a step
25//!
26//! Two properties of the method are worth knowing before reading its output.
27//!
28//! **Near either end of a batch**, the saliency is moved by the transform's
29//! own boundary handling: κ = 5 points along the trailing slope are appended
30//! before the transform, and since the transform is circular that block is
31//! adjacent to the first points as well as the last. Points there carry
32//! [`SrPoint::near_edge`], so a flag that appears only at an end can be told
33//! from one in the body of the series.
34//!
35//! **At a step**, the flag can fall on the point before the transition as well
36//! as on the transition itself -- `[59, 60]` rather than `[60]` for a level
37//! change between 59 and 60. Saliency is a measure of local spectral
38//! irregularity, not an edge detector: the irregularity a step creates is
39//! spread over the points around it, and which of them clear the threshold
40//! depends on the noise. This is the method as published, not a defect, and it
41//! is why a consumer matching detections against known transitions should
42//! allow a place on either side rather than demand an exact index.
43//!
44//! # References
45//! - Ren, H., Xu, B., Wang, Y., Yi, C., Huang, C., Kou, X., Xing, T., Yang,
46//!   M., Tong, J. & Zhang, Q. (2019). "Time-Series Anomaly Detection Service
47//!   at Microsoft." *KDD 2019*, 3009–3017.
48//! - Hou, X. & Zhang, L. (2007). "Saliency Detection: A Spectral Residual
49//!   Approach." *CVPR 2007*.
50
51use u_numflow::fourier::{fft, ifft, Complex};
52use u_numflow::special::inverse_normal_cdf;
53use u_numflow::stats::median;
54
55/// Fewest observations the transform is applied to at once.
56pub const MIN_OBSERVATIONS: usize = 12;
57
58// A refusal has to read as a sentence to whoever typed the option, so the
59// `batch_size` message spells the number rather than naming the constant.
60// This keeps the two from drifting apart.
61const _: () = assert!(
62    MIN_OBSERVATIONS == 12,
63    "the batch_size requirement message spells this number"
64);
65
66/// Points appended before the transform to soften its boundary effect, and
67/// the number of preceding points their slope is estimated from (κ = m = 5
68/// in Ren et al.).
69const EXTENSION: usize = 5;
70
71/// Why a [`SpectralResidual`] run was refused.
72///
73/// One condition, not the rulebook. A consumer has to tell its user which
74/// setting to change, and a refusal that lists every rule leaves it
75/// re-validating the options the crate already checks -- which is how the
76/// same rules end up written twice, in two places that can drift.
77#[derive(Debug, Clone, Copy, PartialEq)]
78#[non_exhaustive]
79pub enum SpectralResidualError {
80    /// An option is outside its domain.
81    ///
82    /// Each end of the domain is given once: `min` when the lower bound is
83    /// inclusive, `exclusive_min` when it is exclusive, neither when there is
84    /// none (likewise `max` / `exclusive_max`).
85    OptionOutOfRange {
86        /// The option's name, spelled as the builder and the wire formats
87        /// spell it (`threshold`, `sensitivity`, ...).
88        option: &'static str,
89        /// What that option has to satisfy, for a message a consumer can show.
90        requirement: &'static str,
91        /// The value given (a count option's value as a float).
92        got: f64,
93        /// Inclusive lower bound.
94        min: Option<f64>,
95        /// Exclusive lower bound.
96        exclusive_min: Option<f64>,
97        /// Inclusive upper bound.
98        max: Option<f64>,
99        /// Exclusive upper bound.
100        exclusive_max: Option<f64>,
101    },
102    /// Fewer observations than the transform is applied to at once.
103    TooFewObservations {
104        /// [`MIN_OBSERVATIONS`].
105        needed: usize,
106        /// How many were given.
107        got: usize,
108    },
109    /// The series holds a NaN or an infinity.
110    ValueNotFinite {
111        /// Position of the first such value.
112        index: usize,
113    },
114}
115
116impl core::fmt::Display for SpectralResidualError {
117    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118        match self {
119            SpectralResidualError::OptionOutOfRange {
120                option,
121                requirement,
122                ..
123            } => write!(f, "{option} must be {requirement}"),
124            SpectralResidualError::TooFewObservations { needed, got } => {
125                write!(f, "needs at least {needed} observations, got {got}")
126            }
127            SpectralResidualError::ValueNotFinite { index } => {
128                write!(f, "data[{index}] is not a finite number")
129            }
130        }
131    }
132}
133
134impl std::error::Error for SpectralResidualError {}
135
136/// The options that count something, each with its floor.
137///
138/// The builder takes them as `usize`, but a wire format reads a number, which
139/// can be negative; checking that number here, against the same floor the
140/// builder's value is checked against, keeps `-1` and `0` one refusal.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub(crate) enum CountOption {
143    AveragingWindow,
144    JudgementWindow,
145    BatchSize,
146}
147
148impl CountOption {
149    /// `got` (a whole number) against this option's floor.
150    pub(crate) fn check(self, got: f64) -> Result<(), SpectralResidualError> {
151        let (option, requirement, min) = match self {
152            CountOption::AveragingWindow => ("averaging_window", ">= 1", 1),
153            CountOption::JudgementWindow => ("judgement_window", ">= 1", 1),
154            CountOption::BatchSize => ("batch_size", "unset, or at least 12", MIN_OBSERVATIONS),
155        };
156        if got < min as f64 {
157            Err(SpectralResidualError::below(
158                option,
159                requirement,
160                got,
161                min as f64,
162            ))
163        } else {
164            Ok(())
165        }
166    }
167}
168
169impl SpectralResidualError {
170    /// `option` below its inclusive lower bound `min`.
171    fn below(option: &'static str, requirement: &'static str, got: f64, min: f64) -> Self {
172        Self::OptionOutOfRange {
173            option,
174            requirement,
175            got,
176            min: Some(min),
177            exclusive_min: None,
178            max: None,
179            exclusive_max: None,
180        }
181    }
182
183    /// `option` not above its exclusive lower bound.
184    fn not_above(
185        option: &'static str,
186        requirement: &'static str,
187        got: f64,
188        exclusive_min: f64,
189    ) -> Self {
190        Self::OptionOutOfRange {
191            option,
192            requirement,
193            got,
194            min: None,
195            exclusive_min: Some(exclusive_min),
196            max: None,
197            exclusive_max: None,
198        }
199    }
200
201    /// `option` not strictly between `exclusive_min` and `exclusive_max`.
202    fn not_between(
203        option: &'static str,
204        requirement: &'static str,
205        got: f64,
206        exclusive_min: f64,
207        exclusive_max: f64,
208    ) -> Self {
209        Self::OptionOutOfRange {
210            option,
211            requirement,
212            got,
213            min: None,
214            exclusive_min: Some(exclusive_min),
215            max: None,
216            exclusive_max: Some(exclusive_max),
217        }
218    }
219}
220
221/// Spectral residual anomaly scorer.
222///
223/// Built with [`SpectralResidual::new`] (the defaults of Ren et al. 2019) and
224/// adjusted with the `with_*` methods; run with [`SpectralResidual::analyze`].
225#[derive(Debug, Clone, PartialEq)]
226pub struct SpectralResidual {
227    /// Width of the moving average applied to the log amplitude spectrum
228    /// (`q` in Ren et al.; default 3).
229    pub averaging_window: usize,
230    /// How many preceding saliency values a point is compared against
231    /// (`z`; default 40).
232    pub judgement_window: usize,
233    /// Score above which a point is an anomaly (`τ`; default 3).
234    pub threshold: f64,
235    /// Minimum `|value − mean| / std` of the point against the judgement
236    /// window before it, for it to be an anomaly (default 1.5), so that
237    /// saliency alone does not flag noise on a flat series. A window with no
238    /// spread passes any departure from its level.
239    pub min_zscore: f64,
240    /// Coverage of the band around the expected value, in percent (default
241    /// 70): its half-width is the normal quantile for that two-sided coverage
242    /// times the robust spread of the non-anomalous residuals.
243    pub sensitivity: f64,
244    /// Process the series in consecutive batches of this many points instead
245    /// of at once (default `None`). Long series with slow drift are scored
246    /// against their local context this way; the last batch absorbs a short
247    /// remainder.
248    pub batch_size: Option<usize>,
249}
250
251impl Default for SpectralResidual {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257impl SpectralResidual {
258    /// The defaults of Ren et al. (2019): q = 3, z = 40, τ = 3, z-score gate
259    /// 1.5, 70% band, no batching.
260    pub fn new() -> Self {
261        Self {
262            averaging_window: 3,
263            judgement_window: 40,
264            threshold: 3.0,
265            min_zscore: 1.5,
266            sensitivity: 70.0,
267            batch_size: None,
268        }
269    }
270
271    /// Sets the spectral averaging window (≥ 1).
272    pub fn with_averaging_window(mut self, q: usize) -> Self {
273        self.averaging_window = q;
274        self
275    }
276
277    /// Sets the judgement window (≥ 1).
278    pub fn with_judgement_window(mut self, z: usize) -> Self {
279        self.judgement_window = z;
280        self
281    }
282
283    /// Sets the anomaly threshold on the score (> 0).
284    pub fn with_threshold(mut self, threshold: f64) -> Self {
285        self.threshold = threshold;
286        self
287    }
288
289    /// Sets the z-score gate (≥ 0; 0 disables it).
290    pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
291        self.min_zscore = min_zscore;
292        self
293    }
294
295    /// Sets the band coverage in percent (0 < s < 100).
296    pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
297        self.sensitivity = sensitivity;
298        self
299    }
300
301    /// Sets the batch size (≥ [`MIN_OBSERVATIONS`]), or `None` for one batch.
302    pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
303        self.batch_size = batch_size;
304        self
305    }
306
307    /// The first option that is outside its domain, in a fixed order.
308    fn check_options(&self) -> Result<(), SpectralResidualError> {
309        use SpectralResidualError as E;
310        CountOption::AveragingWindow.check(self.averaging_window as f64)?;
311        CountOption::JudgementWindow.check(self.judgement_window as f64)?;
312        if !self.threshold.is_finite() || self.threshold <= 0.0 {
313            return Err(E::not_above(
314                "threshold",
315                "a finite number > 0",
316                self.threshold,
317                0.0,
318            ));
319        }
320        if !self.min_zscore.is_finite() || self.min_zscore < 0.0 {
321            return Err(E::below(
322                "min_zscore",
323                "a finite number >= 0",
324                self.min_zscore,
325                0.0,
326            ));
327        }
328        if !self.sensitivity.is_finite() || self.sensitivity <= 0.0 || self.sensitivity >= 100.0 {
329            return Err(E::not_between(
330                "sensitivity",
331                "a finite number strictly between 0 and 100",
332                self.sensitivity,
333                0.0,
334                100.0,
335            ));
336        }
337        if let Some(b) = self.batch_size {
338            CountOption::BatchSize.check(b as f64)?;
339        }
340        Ok(())
341    }
342
343    /// Scores every point of the series.
344    ///
345    /// The result has one [`SrPoint`] per input point, in order.
346    ///
347    /// # Errors
348    ///
349    /// Returns the **one** condition that was not met — the option that is out
350    /// of its domain, the shortfall in observations, or the position of the
351    /// first non-finite value — rather than a refusal a caller has to match
352    /// against the whole rulebook to interpret.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use u_analytics::detection::{SpectralResidual, SpectralResidualError};
358    ///
359    /// let mut series: Vec<f64> = (0..60).map(|t| (t as f64 * 0.3).sin()).collect();
360    /// series[40] += 4.0; // a spike
361    /// let points = SpectralResidual::new().analyze(&series).unwrap();
362    /// assert!(points[40].is_anomaly);
363    /// assert!(points[40].score > points[20].score);
364    ///
365    /// // A refusal names the option it is about, the value and its domain.
366    /// let refused = SpectralResidual::new().with_threshold(0.0).analyze(&series);
367    /// assert_eq!(
368    ///     refused,
369    ///     Err(SpectralResidualError::OptionOutOfRange {
370    ///         option: "threshold",
371    ///         requirement: "a finite number > 0",
372    ///         got: 0.0,
373    ///         min: None,
374    ///         exclusive_min: Some(0.0),
375    ///         max: None,
376    ///         exclusive_max: None,
377    ///     })
378    /// );
379    /// ```
380    pub fn analyze(&self, series: &[f64]) -> Result<Vec<SrPoint>, SpectralResidualError> {
381        self.check_options()?;
382        if series.len() < MIN_OBSERVATIONS {
383            return Err(SpectralResidualError::TooFewObservations {
384                needed: MIN_OBSERVATIONS,
385                got: series.len(),
386            });
387        }
388        if let Some(index) = series.iter().position(|v| !v.is_finite()) {
389            return Err(SpectralResidualError::ValueNotFinite { index });
390        }
391        let n = series.len();
392        let batch = self.batch_size.unwrap_or(n).min(n);
393        let mut points = Vec::with_capacity(n);
394        let mut start = 0;
395        while start < n {
396            // The last batch absorbs a remainder shorter than a batch.
397            let end = if n - start < 2 * batch {
398                n
399            } else {
400                start + batch
401            };
402            points.extend(self.analyze_batch(&series[start..end], start));
403            start = end;
404        }
405        Ok(points)
406    }
407
408    fn analyze_batch(&self, data: &[f64], offset: usize) -> Vec<SrPoint> {
409        let n = data.len();
410        let saliency = saliency_map(data, self.averaging_window);
411
412        // Score against the preceding judgement window (the point included,
413        // as in Ren et al.'s implementation), relative to that average.
414        let scores: Vec<f64> = (0..n)
415            .map(|i| {
416                let from = i.saturating_sub(self.judgement_window - 1);
417                let avg = saliency[from..=i].iter().sum::<f64>() / (i + 1 - from) as f64;
418                let avg = avg.max(1e-8);
419                ((saliency[i] - avg) / avg).max(0.0)
420            })
421            .collect();
422
423        // The z-score gate is local: the point against the mean and spread of
424        // the judgement window before it, so a step is judged against the
425        // level it departs from, not against a mean that already straddles it.
426        let flagged: Vec<bool> = (0..n)
427            .map(|i| {
428                if scores[i] <= self.threshold {
429                    return false;
430                }
431                if self.min_zscore == 0.0 || i == 0 {
432                    return true;
433                }
434                let from = i.saturating_sub(self.judgement_window);
435                let window = &data[from..i];
436                let mean = window.iter().sum::<f64>() / window.len() as f64;
437                let std = (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
438                    / window.len() as f64)
439                    .sqrt();
440                if std > 0.0 {
441                    (data[i] - mean).abs() / std >= self.min_zscore
442                } else {
443                    data[i] != mean
444                }
445            })
446            .collect();
447
448        let expected = expected_values(data, &flagged);
449        let residuals: Vec<f64> = (0..n)
450            .filter(|&i| !flagged[i])
451            .map(|i| (data[i] - expected[i]).abs())
452            .collect();
453        // Robust spread: 1.4826 · median absolute residual (the MAD of a
454        // centred residual), then the two-sided normal quantile for the
455        // requested coverage.
456        let spread = median(&residuals).unwrap_or(0.0) * 1.4826;
457        let z = inverse_normal_cdf(0.5 + self.sensitivity / 200.0);
458        let margin = z * spread;
459
460        (0..n)
461            .map(|i| SrPoint {
462                index: offset + i,
463                value: data[i],
464                saliency: saliency[i],
465                score: scores[i],
466                expected: expected[i],
467                lower: expected[i] - margin,
468                upper: expected[i] + margin,
469                is_anomaly: flagged[i],
470                near_edge: i < EXTENSION || i + EXTENSION >= n,
471            })
472            .collect()
473    }
474}
475
476/// One scored point.
477#[derive(Debug, Clone, PartialEq)]
478pub struct SrPoint {
479    /// Position in the input series.
480    pub index: usize,
481    /// The observed value.
482    pub value: f64,
483    /// Spectral residual saliency of the point (≥ 0).
484    pub saliency: f64,
485    /// Saliency relative to the preceding judgement window (≥ 0).
486    pub score: f64,
487    /// Low-frequency reconstruction of the series with anomalies removed.
488    pub expected: f64,
489    /// `expected − margin`.
490    pub lower: f64,
491    /// `expected + margin`.
492    pub upper: f64,
493    /// `score > threshold` and the value clears the z-score gate.
494    pub is_anomaly: bool,
495    /// The point lies within κ = 5 places of an end of its batch, where the
496    /// transform's own boundary handling moves the saliency most.
497    ///
498    /// The method extends the series by κ points before transforming, and the
499    /// transform is circular, so that appended block sits next to the last
500    /// points *and* the first ones. Measured on a noisy sawtooth of 200
501    /// points, replacing the predicted extension with the series' real
502    /// continuation moves the saliency of the five points at either end about
503    /// eight times as much as it moves the middle -- and the two ends by the
504    /// same amount, which is why this is not a tail-only flag.
505    ///
506    /// It is a property of the position, not a verdict: a real anomaly near an
507    /// end is still reported as one. What it says is that a lone flag here is
508    /// the one worth a second look, and an anomaly count is worth reporting
509    /// with and without these.
510    pub near_edge: bool,
511}
512
513/// The saliency map of Ren et al. (2019): extend the series by κ = 5 points
514/// along the slope of its last m = 5, take the FFT, average the log
515/// amplitude over `q` bins, keep the residual with the original phase,
516/// transform back, and drop the extension.
517fn saliency_map(data: &[f64], q: usize) -> Vec<f64> {
518    let n = data.len();
519    let mut extended = data.to_vec();
520    let m = EXTENSION.min(n - 1);
521    let last = data[n - 1];
522    let slope = (1..=m)
523        .map(|i| (last - data[n - 1 - i]) / i as f64)
524        .sum::<f64>()
525        / m as f64;
526    // x_{n+1} = x_{n-m+1} + g * m (Ren et al., eq. 9): the point m places before
527    // the one being predicted, so `data[n - m]` with the last point `data[n - 1]`.
528    let predicted = data[n - m] + slope * m as f64;
529    extended.extend(std::iter::repeat_n(predicted, EXTENSION));
530
531    let spectrum = fft(&extended
532        .iter()
533        .map(|&x| Complex::real(x))
534        .collect::<Vec<_>>());
535    let len = spectrum.len();
536    // The floor that keeps the log finite at a zero bin is relative to the
537    // spectrum's own size: a fixed one is nothing for a series in units of 1
538    // and everything for the same series in nanoamperes (Ren et al.'s 1e-8 is
539    // for data of order 1).
540    let peak = spectrum.iter().map(|z| z.norm()).fold(0.0_f64, f64::max);
541    let floor = if peak > 0.0 { 1e-8 * peak } else { 1e-8 };
542    let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + floor).ln()).collect();
543    // Moving average of width q over the preceding bins (cumulative form).
544    let mut avg = vec![0.0; len];
545    let mut cum = 0.0;
546    let mut cums = Vec::with_capacity(len + 1);
547    cums.push(0.0);
548    for &v in &log_amp {
549        cum += v;
550        cums.push(cum);
551    }
552    for i in 0..len {
553        let from = (i + 1).saturating_sub(q);
554        avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
555    }
556    let residual_spectrum: Vec<Complex> = spectrum
557        .iter()
558        .enumerate()
559        .map(|(i, z)| {
560            let r = (log_amp[i] - avg[i]).exp();
561            let phase = z.arg();
562            Complex::new(r * phase.cos(), r * phase.sin())
563        })
564        .collect();
565    ifft(&residual_spectrum)
566        .into_iter()
567        .take(n)
568        .map(|z| z.norm())
569        .collect()
570}
571
572/// The series with each flagged point replaced by the mean of its nearest
573/// unflagged neighbours, reconstructed from its low-frequency components:
574/// the middle band of the spectrum (bins from 3/8 to 5/8 of the length) is
575/// zeroed, as in the Microsoft service's expected value.
576fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
577    let n = data.len();
578    let mut cleaned = data.to_vec();
579    for i in 0..n {
580        if !flagged[i] {
581            continue;
582        }
583        let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
584        let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
585        cleaned[i] = match (left, right) {
586            (Some(a), Some(b)) => (a + b) / 2.0,
587            (Some(a), None) | (None, Some(a)) => a,
588            (None, None) => data[i],
589        };
590    }
591    let mut spectrum = fft(&cleaned
592        .iter()
593        .map(|&x| Complex::real(x))
594        .collect::<Vec<_>>());
595    let lo = n * 3 / 8;
596    let hi = n * 5 / 8;
597    for (i, z) in spectrum.iter_mut().enumerate() {
598        if i > lo && i < hi {
599            *z = Complex::default();
600        }
601    }
602    ifft(&spectrum).into_iter().map(|z| z.re).collect()
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    fn noise(seed: u64, n: usize) -> Vec<f64> {
610        let mut state = seed;
611        (0..n)
612            .map(|_| {
613                state = state
614                    .wrapping_mul(6364136223846793005)
615                    .wrapping_add(1442695040888963407);
616                ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
617            })
618            .collect()
619    }
620
621    fn check_shape(points: &[SrPoint], n: usize) {
622        assert_eq!(points.len(), n);
623        for (i, p) in points.iter().enumerate() {
624            assert_eq!(p.index, i);
625            assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
626            assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
627            assert!(
628                p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
629                "{p:?}"
630            );
631        }
632    }
633
634    /// The same series in another unit is the same series: saliency, score and
635    /// the anomalies are dimensionless, the expected value and band scale with
636    /// the data.
637    #[test]
638    fn the_unit_of_the_series_does_not_change_the_answer() {
639        let mut series: Vec<f64> = noise(3, 120)
640            .iter()
641            .enumerate()
642            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
643            .collect();
644        series[80] += 5.0;
645        let base = SpectralResidual::new().analyze(&series).unwrap();
646        for k in [1e-15, 1e-12, 1e-9, 1e-6, 1e6, 1e12] {
647            let scaled: Vec<f64> = series.iter().map(|v| v * k).collect();
648            let got = SpectralResidual::new().analyze(&scaled).unwrap();
649            for (g, b) in got.iter().zip(&base) {
650                assert_eq!(g.is_anomaly, b.is_anomaly, "scale {k:e}, point {}", b.index);
651                assert!(
652                    (g.saliency - b.saliency).abs() <= 1e-6 * b.saliency.max(1e-3),
653                    "scale {k:e}, point {}: saliency {} vs {}",
654                    b.index,
655                    g.saliency,
656                    b.saliency
657                );
658                assert!(
659                    (g.expected / k - b.expected).abs() <= 1e-6 * b.expected.abs().max(1.0),
660                    "scale {k:e}, point {}: expected",
661                    b.index
662                );
663            }
664        }
665    }
666
667    #[test]
668    fn a_spike_on_a_sine_is_the_anomaly() {
669        let mut series: Vec<f64> = noise(3, 120)
670            .iter()
671            .enumerate()
672            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
673            .collect();
674        series[80] += 5.0;
675        let points = SpectralResidual::new().analyze(&series).unwrap();
676        check_shape(&points, 120);
677        assert!(points[80].is_anomaly, "{:?}", points[80]);
678        let flagged: Vec<usize> = points
679            .iter()
680            .filter(|p| p.is_anomaly)
681            .map(|p| p.index)
682            .collect();
683        assert!(flagged.len() <= 3, "flagged {flagged:?}");
684        assert!(points[80].value > points[80].upper);
685        // The expected value is not dragged up by the spike.
686        assert!(
687            (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
688            "{:?}",
689            points[80]
690        );
691    }
692
693    #[test]
694    fn a_step_is_flagged_where_it_happens() {
695        let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
696        let points = SpectralResidual::new().analyze(&series).unwrap();
697        check_shape(&points, 100);
698        let first = points.iter().position(|p| p.is_anomaly);
699        assert_eq!(
700            first,
701            Some(60),
702            "{:?}",
703            points
704                .iter()
705                .filter(|p| p.is_anomaly)
706                .map(|p| p.index)
707                .collect::<Vec<_>>()
708        );
709    }
710
711    #[test]
712    fn a_constant_and_plain_noise_raise_no_anomaly() {
713        let constant = vec![3.0; 50];
714        let points = SpectralResidual::new().analyze(&constant).unwrap();
715        check_shape(&points, 50);
716        assert!(points.iter().all(|p| !p.is_anomaly));
717        assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
718
719        let series = noise(9, 200);
720        let points = SpectralResidual::new().analyze(&series).unwrap();
721        check_shape(&points, 200);
722        let flagged = points.iter().filter(|p| p.is_anomaly).count();
723        assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
724    }
725
726    #[test]
727    fn scoring_is_deterministic_and_batching_keeps_the_shape() {
728        let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
729        series[150] = 20.0;
730        series[260] = -5.0;
731        let sr = SpectralResidual::new();
732        let a = sr.analyze(&series).unwrap();
733        let b = sr.analyze(&series).unwrap();
734        assert_eq!(a, b);
735        let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
736        check_shape(&batched, 300);
737        assert!(batched[150].is_anomaly && batched[260].is_anomaly);
738        // Each batch is scored on its own: the first point of a batch has no
739        // history but the shape is unchanged and indices are global.
740        assert_eq!(batched[200].index, 200);
741    }
742
743    #[test]
744    fn the_band_widens_with_sensitivity() {
745        let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
746        let narrow = SpectralResidual::new()
747            .with_sensitivity(50.0)
748            .analyze(&series)
749            .unwrap();
750        let wide = SpectralResidual::new()
751            .with_sensitivity(99.0)
752            .analyze(&series)
753            .unwrap();
754        for (a, b) in narrow.iter().zip(&wide) {
755            assert!(b.upper - b.lower > a.upper - a.lower);
756            assert_eq!(a.expected, b.expected);
757        }
758    }
759
760    /// The transform's boundary handling reaches both ends, so the marker
761    /// does too -- a tail-only flag would leave half the affected points
762    /// unmarked.
763    #[test]
764    fn points_near_either_end_of_a_batch_are_marked() {
765        let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
766        let points = SpectralResidual::new().analyze(&series).expect("ok");
767        let marked: Vec<usize> = points
768            .iter()
769            .filter(|p| p.near_edge)
770            .map(|p| p.index)
771            .collect();
772        assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
773
774        // Batching moves the boundaries, so it moves the marks with them.
775        let batched = SpectralResidual::new()
776            .with_batch_size(Some(50))
777            .analyze(&series)
778            .expect("ok");
779        let marked: Vec<usize> = batched
780            .iter()
781            .filter(|p| p.near_edge)
782            .map(|p| p.index)
783            .collect();
784        // 49 and 50 straddle a batch boundary, so both are near an edge; 75
785        // sits in the body of a batch and is not.
786        assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
787        assert!(!marked.contains(&75), "{marked:?}");
788        assert_eq!(
789            marked.len(),
790            4 * 10,
791            "four batches, ten marks each: {marked:?}"
792        );
793
794        // It marks a position, it does not suppress a detection: a spike
795        // planted in the last few points is still an anomaly.
796        let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
797        spiked[57] += 30.0;
798        let points = SpectralResidual::new().analyze(&spiked).expect("ok");
799        assert!(points[57].is_anomaly);
800        assert!(points[57].near_edge);
801    }
802
803    /// Documented, not corrected (see the module docs): the flag at a level
804    /// change can land on the point before it as well.
805    #[test]
806    fn a_step_can_flag_the_point_before_the_transition() {
807        let step: Vec<f64> = (0..120)
808            .map(|i| {
809                let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
810                (if i < 60 { 1.0 } else { 6.0 }) + jitter
811            })
812            .collect();
813        let points = SpectralResidual::new().analyze(&step).expect("ok");
814        let flagged: Vec<usize> = points
815            .iter()
816            .filter(|p| p.is_anomaly && !p.near_edge)
817            .map(|p| p.index)
818            .collect();
819        assert!(!flagged.is_empty(), "the transition is detected");
820        assert!(
821            flagged.iter().all(|&i| (59..=61).contains(&i)),
822            "detections sit at the transition or its neighbour: {flagged:?}"
823        );
824    }
825
826    /// A refusal names the one condition that failed, so a consumer can point
827    /// at the setting to change instead of restating every rule.
828    #[test]
829    fn a_refusal_names_the_condition_that_failed() {
830        let ok = vec![1.0; 20];
831        use SpectralResidualError as E;
832
833        assert_eq!(
834            SpectralResidual::new().analyze(&[1.0; 11]),
835            Err(SpectralResidualError::TooFewObservations {
836                needed: MIN_OBSERVATIONS,
837                got: 11
838            })
839        );
840
841        let mut series = vec![1.0; 20];
842        series[7] = f64::INFINITY;
843        assert_eq!(
844            SpectralResidual::new().analyze(&series),
845            Err(SpectralResidualError::ValueNotFinite { index: 7 })
846        );
847
848        // Every option, each naming itself, with the value given and the
849        // bounds of its domain -- inclusive as `min`, exclusive as
850        // `exclusive_min` / `exclusive_max`.
851        assert_eq!(
852            SpectralResidual::new().with_threshold(0.0).analyze(&ok),
853            Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
854        );
855        let sensitivity = |got| {
856            Err(E::not_between(
857                "sensitivity",
858                "a finite number strictly between 0 and 100",
859                got,
860                0.0,
861                100.0,
862            ))
863        };
864        assert_eq!(
865            SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
866            sensitivity(100.0)
867        );
868        assert_eq!(
869            SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
870            sensitivity(0.0)
871        );
872        assert_eq!(
873            SpectralResidual::new()
874                .with_batch_size(Some(5))
875                .analyze(&ok),
876            Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
877        );
878        assert_eq!(
879            SpectralResidual::new()
880                .with_averaging_window(0)
881                .analyze(&ok),
882            Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
883        );
884        assert_eq!(
885            SpectralResidual::new()
886                .with_judgement_window(0)
887                .analyze(&ok),
888            Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
889        );
890        assert_eq!(
891            SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
892            Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
893        );
894        // NaN is refused too; `got` is the NaN itself, so match the rest.
895        assert!(matches!(
896            SpectralResidual::new()
897                .with_threshold(f64::NAN)
898                .analyze(&ok),
899            Err(E::OptionOutOfRange {
900                option: "threshold",
901                min: None,
902                exclusive_min,
903                max: None,
904                exclusive_max: None,
905                got,
906                ..
907            }) if got.is_nan() && exclusive_min == Some(0.0)
908        ));
909
910        // The message a transport shows repeats the option, not the rulebook.
911        let message = SpectralResidual::new()
912            .with_threshold(0.0)
913            .analyze(&ok)
914            .expect_err("refused")
915            .to_string();
916        assert_eq!(message, "threshold must be a finite number > 0");
917        assert!(!message.contains("sensitivity"));
918
919        // `min_zscore = 0` disables the gate; it is not out of range.
920        assert!(SpectralResidual::new()
921            .with_min_zscore(0.0)
922            .analyze(&ok)
923            .is_ok());
924    }
925}
926
927/// The saliency map and the scores against a NumPy implementation of the
928/// published method (Ren et al. 2019, section 4.1): the series extended by
929/// kappa = 5 copies of x_{n+1} = x_{n-m+1} + g * m (equations 8 and 9, with
930/// m = 5), the log amplitude spectrum less its trailing average over q = 3
931/// bins, the original phase, the inverse transform. One sine of period 16 with
932/// seeded noise and a spike of 4 at point 30. The reference is a restatement
933/// of the paper's equations, not Microsoft's code, whose extension leaves out
934/// the last point.
935#[cfg(test)]
936mod numpy_reference {
937    use super::*;
938
939    const SERIES: [f64; 64] = [
940        0.061, 0.585, 0.52, 1.134, 1.216, 0.847, 0.806, 0.369, -0.109, -0.265, -0.526, -1.008,
941        -1.139, -0.838, -0.869, -0.672, -0.068, 0.575, 0.594, 0.806, 0.924, 0.716, 0.839, 0.411,
942        0.217, -0.158, -0.583, -1.146, -1.238, -0.975, 3.168, -0.527, 0.231, 0.418, 0.492, 1.129,
943        0.898, 1.212, 0.745, 0.17, -0.134, -0.308, -0.865, -0.867, -0.867, -0.913, -0.746, -0.503,
944        0.119, 0.423, 0.811, 0.793, 0.878, 1.089, 0.49, 0.568, 0.154, -0.484, -0.677, -1.11,
945        -0.991, -0.84, -0.504, -0.44,
946    ];
947    const SALIENCY: [f64; 64] = [
948        0.0369614318108603,
949        0.0796307697333925,
950        0.0271892659280146,
951        0.152493866186463,
952        0.166141948303089,
953        0.0816656918320778,
954        0.107174248120332,
955        0.0556332303417036,
956        0.0251300576938508,
957        0.00299138253005822,
958        0.00594392958654813,
959        0.106106695034459,
960        0.119126565861323,
961        0.0424769851751049,
962        0.0893964151003082,
963        0.0920927965046129,
964        0.0198418341809379,
965        0.0882438287553024,
966        0.0549823475501431,
967        0.067869463097445,
968        0.113994918150788,
969        0.105398733553807,
970        0.119888725456503,
971        0.0746552451898384,
972        0.109038966340197,
973        0.0173152365850345,
974        0.0229006048210958,
975        0.132971301432821,
976        0.205056469142638,
977        0.150938526823712,
978        0.830517907514111,
979        0.128639067122104,
980        0.0093039215691647,
981        0.0611436432409572,
982        0.0458141563355618,
983        0.143893509514701,
984        0.129454085999137,
985        0.179180669344178,
986        0.105280578308881,
987        0.0679545755367448,
988        0.0275280318011067,
989        0.0125531569333055,
990        0.0908993020495842,
991        0.0747621046819059,
992        0.0497022369555018,
993        0.0789105027673606,
994        0.0665323274213843,
995        0.0500154087434401,
996        0.0336638535386336,
997        0.0524451689724362,
998        0.0975047615363696,
999        0.0534062948099107,
1000        0.0728496986560913,
1001        0.133492464750329,
1002        0.0136337226253535,
1003        0.068520339745976,
1004        0.0280483371477404,
1005        0.102625433672842,
1006        0.106450145288677,
1007        0.183534057756558,
1008        0.135468059382387,
1009        0.0877555778478272,
1010        0.0278059231072768,
1011        0.0277019054815613,
1012    ];
1013    const SCORE: [f64; 64] = [
1014        0.0,
1015        0.365970771264122,
1016        0.0,
1017        1.05881285226553,
1018        0.796450465672768,
1019        0.0,
1020        0.15195611134601,
1021        0.0,
1022        0.0,
1023        0.0,
1024        0.0,
1025        0.503171625125652,
1026        0.602838806095091,
1027        0.0,
1028        0.22119300766101,
1029        0.238060922935183,
1030        0.0,
1031        0.223493163232391,
1032        0.0,
1033        0.0,
1034        0.559450528883167,
1035        0.413466232376651,
1036        0.566393797404165,
1037        0.0,
1038        0.402200065392971,
1039        0.0,
1040        0.0,
1041        0.758500430509727,
1042        1.5606529881759,
1043        0.830851346829295,
1044        6.79293349706241,
1045        0.199289129287858,
1046        0.0,
1047        0.0,
1048        0.0,
1049        0.402864454850786,
1050        0.253212692235468,
1051        0.701706509860138,
1052        0.0,
1053        0.0,
1054        0.0,
1055        0.0,
1056        0.0,
1057        0.0,
1058        0.0,
1059        0.0,
1060        0.0,
1061        0.0,
1062        0.0,
1063        0.0,
1064        0.0,
1065        0.0,
1066        0.0,
1067        0.315186058686334,
1068        0.0,
1069        0.0,
1070        0.0,
1071        0.0305605858088266,
1072        0.0553323044375994,
1073        0.76882472342237,
1074        0.298864697731819,
1075        0.0,
1076        0.0,
1077        0.0,
1078    ];
1079
1080    /// The published method floors the amplitude at an absolute 1e-8; this
1081    /// crate at 1e-8 of the spectrum's peak (the same for data of order one,
1082    /// independent of the unit otherwise), which moves this series' values by
1083    /// less than 1e-7.
1084    fn close(got: f64, want: f64) -> bool {
1085        (got - want).abs() <= 1e-6 * want.abs().max(1.0)
1086    }
1087
1088    #[test]
1089    fn saliency_and_scores_reproduce_the_published_method() {
1090        let points = SpectralResidual::new()
1091            .analyze(&SERIES)
1092            .expect("a valid series");
1093        for (i, p) in points.iter().enumerate() {
1094            assert!(
1095                close(p.saliency, SALIENCY[i]),
1096                "saliency[{i}]: got {}, reference {}",
1097                p.saliency,
1098                SALIENCY[i]
1099            );
1100            assert!(
1101                close(p.score, SCORE[i]),
1102                "score[{i}]: got {}, reference {}",
1103                p.score,
1104                SCORE[i]
1105            );
1106        }
1107        let flagged: Vec<usize> = points
1108            .iter()
1109            .filter(|p| p.is_anomaly)
1110            .map(|p| p.index)
1111            .collect();
1112        assert_eq!(flagged, vec![30]);
1113    }
1114}