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    let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
537    // Moving average of width q over the preceding bins (cumulative form).
538    let mut avg = vec![0.0; len];
539    let mut cum = 0.0;
540    let mut cums = Vec::with_capacity(len + 1);
541    cums.push(0.0);
542    for &v in &log_amp {
543        cum += v;
544        cums.push(cum);
545    }
546    for i in 0..len {
547        let from = (i + 1).saturating_sub(q);
548        avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
549    }
550    let residual_spectrum: Vec<Complex> = spectrum
551        .iter()
552        .enumerate()
553        .map(|(i, z)| {
554            let r = (log_amp[i] - avg[i]).exp();
555            let phase = z.arg();
556            Complex::new(r * phase.cos(), r * phase.sin())
557        })
558        .collect();
559    ifft(&residual_spectrum)
560        .into_iter()
561        .take(n)
562        .map(|z| z.norm())
563        .collect()
564}
565
566/// The series with each flagged point replaced by the mean of its nearest
567/// unflagged neighbours, reconstructed from its low-frequency components:
568/// the middle band of the spectrum (bins from 3/8 to 5/8 of the length) is
569/// zeroed, as in the Microsoft service's expected value.
570fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
571    let n = data.len();
572    let mut cleaned = data.to_vec();
573    for i in 0..n {
574        if !flagged[i] {
575            continue;
576        }
577        let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
578        let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
579        cleaned[i] = match (left, right) {
580            (Some(a), Some(b)) => (a + b) / 2.0,
581            (Some(a), None) | (None, Some(a)) => a,
582            (None, None) => data[i],
583        };
584    }
585    let mut spectrum = fft(&cleaned
586        .iter()
587        .map(|&x| Complex::real(x))
588        .collect::<Vec<_>>());
589    let lo = n * 3 / 8;
590    let hi = n * 5 / 8;
591    for (i, z) in spectrum.iter_mut().enumerate() {
592        if i > lo && i < hi {
593            *z = Complex::default();
594        }
595    }
596    ifft(&spectrum).into_iter().map(|z| z.re).collect()
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn noise(seed: u64, n: usize) -> Vec<f64> {
604        let mut state = seed;
605        (0..n)
606            .map(|_| {
607                state = state
608                    .wrapping_mul(6364136223846793005)
609                    .wrapping_add(1442695040888963407);
610                ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
611            })
612            .collect()
613    }
614
615    fn check_shape(points: &[SrPoint], n: usize) {
616        assert_eq!(points.len(), n);
617        for (i, p) in points.iter().enumerate() {
618            assert_eq!(p.index, i);
619            assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
620            assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
621            assert!(
622                p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
623                "{p:?}"
624            );
625        }
626    }
627
628    #[test]
629    fn a_spike_on_a_sine_is_the_anomaly() {
630        let mut series: Vec<f64> = noise(3, 120)
631            .iter()
632            .enumerate()
633            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
634            .collect();
635        series[80] += 5.0;
636        let points = SpectralResidual::new().analyze(&series).unwrap();
637        check_shape(&points, 120);
638        assert!(points[80].is_anomaly, "{:?}", points[80]);
639        let flagged: Vec<usize> = points
640            .iter()
641            .filter(|p| p.is_anomaly)
642            .map(|p| p.index)
643            .collect();
644        assert!(flagged.len() <= 3, "flagged {flagged:?}");
645        assert!(points[80].value > points[80].upper);
646        // The expected value is not dragged up by the spike.
647        assert!(
648            (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
649            "{:?}",
650            points[80]
651        );
652    }
653
654    #[test]
655    fn a_step_is_flagged_where_it_happens() {
656        let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
657        let points = SpectralResidual::new().analyze(&series).unwrap();
658        check_shape(&points, 100);
659        let first = points.iter().position(|p| p.is_anomaly);
660        assert_eq!(
661            first,
662            Some(60),
663            "{:?}",
664            points
665                .iter()
666                .filter(|p| p.is_anomaly)
667                .map(|p| p.index)
668                .collect::<Vec<_>>()
669        );
670    }
671
672    #[test]
673    fn a_constant_and_plain_noise_raise_no_anomaly() {
674        let constant = vec![3.0; 50];
675        let points = SpectralResidual::new().analyze(&constant).unwrap();
676        check_shape(&points, 50);
677        assert!(points.iter().all(|p| !p.is_anomaly));
678        assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
679
680        let series = noise(9, 200);
681        let points = SpectralResidual::new().analyze(&series).unwrap();
682        check_shape(&points, 200);
683        let flagged = points.iter().filter(|p| p.is_anomaly).count();
684        assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
685    }
686
687    #[test]
688    fn scoring_is_deterministic_and_batching_keeps_the_shape() {
689        let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
690        series[150] = 20.0;
691        series[260] = -5.0;
692        let sr = SpectralResidual::new();
693        let a = sr.analyze(&series).unwrap();
694        let b = sr.analyze(&series).unwrap();
695        assert_eq!(a, b);
696        let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
697        check_shape(&batched, 300);
698        assert!(batched[150].is_anomaly && batched[260].is_anomaly);
699        // Each batch is scored on its own: the first point of a batch has no
700        // history but the shape is unchanged and indices are global.
701        assert_eq!(batched[200].index, 200);
702    }
703
704    #[test]
705    fn the_band_widens_with_sensitivity() {
706        let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
707        let narrow = SpectralResidual::new()
708            .with_sensitivity(50.0)
709            .analyze(&series)
710            .unwrap();
711        let wide = SpectralResidual::new()
712            .with_sensitivity(99.0)
713            .analyze(&series)
714            .unwrap();
715        for (a, b) in narrow.iter().zip(&wide) {
716            assert!(b.upper - b.lower > a.upper - a.lower);
717            assert_eq!(a.expected, b.expected);
718        }
719    }
720
721    /// The transform's boundary handling reaches both ends, so the marker
722    /// does too -- a tail-only flag would leave half the affected points
723    /// unmarked.
724    #[test]
725    fn points_near_either_end_of_a_batch_are_marked() {
726        let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
727        let points = SpectralResidual::new().analyze(&series).expect("ok");
728        let marked: Vec<usize> = points
729            .iter()
730            .filter(|p| p.near_edge)
731            .map(|p| p.index)
732            .collect();
733        assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
734
735        // Batching moves the boundaries, so it moves the marks with them.
736        let batched = SpectralResidual::new()
737            .with_batch_size(Some(50))
738            .analyze(&series)
739            .expect("ok");
740        let marked: Vec<usize> = batched
741            .iter()
742            .filter(|p| p.near_edge)
743            .map(|p| p.index)
744            .collect();
745        // 49 and 50 straddle a batch boundary, so both are near an edge; 75
746        // sits in the body of a batch and is not.
747        assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
748        assert!(!marked.contains(&75), "{marked:?}");
749        assert_eq!(
750            marked.len(),
751            4 * 10,
752            "four batches, ten marks each: {marked:?}"
753        );
754
755        // It marks a position, it does not suppress a detection: a spike
756        // planted in the last few points is still an anomaly.
757        let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
758        spiked[57] += 30.0;
759        let points = SpectralResidual::new().analyze(&spiked).expect("ok");
760        assert!(points[57].is_anomaly);
761        assert!(points[57].near_edge);
762    }
763
764    /// Documented, not corrected (see the module docs): the flag at a level
765    /// change can land on the point before it as well.
766    #[test]
767    fn a_step_can_flag_the_point_before_the_transition() {
768        let step: Vec<f64> = (0..120)
769            .map(|i| {
770                let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
771                (if i < 60 { 1.0 } else { 6.0 }) + jitter
772            })
773            .collect();
774        let points = SpectralResidual::new().analyze(&step).expect("ok");
775        let flagged: Vec<usize> = points
776            .iter()
777            .filter(|p| p.is_anomaly && !p.near_edge)
778            .map(|p| p.index)
779            .collect();
780        assert!(!flagged.is_empty(), "the transition is detected");
781        assert!(
782            flagged.iter().all(|&i| (59..=61).contains(&i)),
783            "detections sit at the transition or its neighbour: {flagged:?}"
784        );
785    }
786
787    /// A refusal names the one condition that failed, so a consumer can point
788    /// at the setting to change instead of restating every rule.
789    #[test]
790    fn a_refusal_names_the_condition_that_failed() {
791        let ok = vec![1.0; 20];
792        use SpectralResidualError as E;
793
794        assert_eq!(
795            SpectralResidual::new().analyze(&[1.0; 11]),
796            Err(SpectralResidualError::TooFewObservations {
797                needed: MIN_OBSERVATIONS,
798                got: 11
799            })
800        );
801
802        let mut series = vec![1.0; 20];
803        series[7] = f64::INFINITY;
804        assert_eq!(
805            SpectralResidual::new().analyze(&series),
806            Err(SpectralResidualError::ValueNotFinite { index: 7 })
807        );
808
809        // Every option, each naming itself, with the value given and the
810        // bounds of its domain -- inclusive as `min`, exclusive as
811        // `exclusive_min` / `exclusive_max`.
812        assert_eq!(
813            SpectralResidual::new().with_threshold(0.0).analyze(&ok),
814            Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
815        );
816        let sensitivity = |got| {
817            Err(E::not_between(
818                "sensitivity",
819                "a finite number strictly between 0 and 100",
820                got,
821                0.0,
822                100.0,
823            ))
824        };
825        assert_eq!(
826            SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
827            sensitivity(100.0)
828        );
829        assert_eq!(
830            SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
831            sensitivity(0.0)
832        );
833        assert_eq!(
834            SpectralResidual::new()
835                .with_batch_size(Some(5))
836                .analyze(&ok),
837            Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
838        );
839        assert_eq!(
840            SpectralResidual::new()
841                .with_averaging_window(0)
842                .analyze(&ok),
843            Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
844        );
845        assert_eq!(
846            SpectralResidual::new()
847                .with_judgement_window(0)
848                .analyze(&ok),
849            Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
850        );
851        assert_eq!(
852            SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
853            Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
854        );
855        // NaN is refused too; `got` is the NaN itself, so match the rest.
856        assert!(matches!(
857            SpectralResidual::new()
858                .with_threshold(f64::NAN)
859                .analyze(&ok),
860            Err(E::OptionOutOfRange {
861                option: "threshold",
862                min: None,
863                exclusive_min,
864                max: None,
865                exclusive_max: None,
866                got,
867                ..
868            }) if got.is_nan() && exclusive_min == Some(0.0)
869        ));
870
871        // The message a transport shows repeats the option, not the rulebook.
872        let message = SpectralResidual::new()
873            .with_threshold(0.0)
874            .analyze(&ok)
875            .expect_err("refused")
876            .to_string();
877        assert_eq!(message, "threshold must be a finite number > 0");
878        assert!(!message.contains("sensitivity"));
879
880        // `min_zscore = 0` disables the gate; it is not out of range.
881        assert!(SpectralResidual::new()
882            .with_min_zscore(0.0)
883            .analyze(&ok)
884            .is_ok());
885    }
886}
887
888/// The saliency map and the scores against a NumPy implementation of the
889/// published method (Ren et al. 2019, section 4.1): the series extended by
890/// kappa = 5 copies of x_{n+1} = x_{n-m+1} + g * m (equations 8 and 9, with
891/// m = 5), the log amplitude spectrum less its trailing average over q = 3
892/// bins, the original phase, the inverse transform. One sine of period 16 with
893/// seeded noise and a spike of 4 at point 30. The reference is a restatement
894/// of the paper's equations, not Microsoft's code, whose extension leaves out
895/// the last point.
896#[cfg(test)]
897mod numpy_reference {
898    use super::*;
899
900    const SERIES: [f64; 64] = [
901        0.061, 0.585, 0.52, 1.134, 1.216, 0.847, 0.806, 0.369, -0.109, -0.265, -0.526, -1.008,
902        -1.139, -0.838, -0.869, -0.672, -0.068, 0.575, 0.594, 0.806, 0.924, 0.716, 0.839, 0.411,
903        0.217, -0.158, -0.583, -1.146, -1.238, -0.975, 3.168, -0.527, 0.231, 0.418, 0.492, 1.129,
904        0.898, 1.212, 0.745, 0.17, -0.134, -0.308, -0.865, -0.867, -0.867, -0.913, -0.746, -0.503,
905        0.119, 0.423, 0.811, 0.793, 0.878, 1.089, 0.49, 0.568, 0.154, -0.484, -0.677, -1.11,
906        -0.991, -0.84, -0.504, -0.44,
907    ];
908    const SALIENCY: [f64; 64] = [
909        0.0369614318108603,
910        0.0796307697333925,
911        0.0271892659280146,
912        0.152493866186463,
913        0.166141948303089,
914        0.0816656918320778,
915        0.107174248120332,
916        0.0556332303417036,
917        0.0251300576938508,
918        0.00299138253005822,
919        0.00594392958654813,
920        0.106106695034459,
921        0.119126565861323,
922        0.0424769851751049,
923        0.0893964151003082,
924        0.0920927965046129,
925        0.0198418341809379,
926        0.0882438287553024,
927        0.0549823475501431,
928        0.067869463097445,
929        0.113994918150788,
930        0.105398733553807,
931        0.119888725456503,
932        0.0746552451898384,
933        0.109038966340197,
934        0.0173152365850345,
935        0.0229006048210958,
936        0.132971301432821,
937        0.205056469142638,
938        0.150938526823712,
939        0.830517907514111,
940        0.128639067122104,
941        0.0093039215691647,
942        0.0611436432409572,
943        0.0458141563355618,
944        0.143893509514701,
945        0.129454085999137,
946        0.179180669344178,
947        0.105280578308881,
948        0.0679545755367448,
949        0.0275280318011067,
950        0.0125531569333055,
951        0.0908993020495842,
952        0.0747621046819059,
953        0.0497022369555018,
954        0.0789105027673606,
955        0.0665323274213843,
956        0.0500154087434401,
957        0.0336638535386336,
958        0.0524451689724362,
959        0.0975047615363696,
960        0.0534062948099107,
961        0.0728496986560913,
962        0.133492464750329,
963        0.0136337226253535,
964        0.068520339745976,
965        0.0280483371477404,
966        0.102625433672842,
967        0.106450145288677,
968        0.183534057756558,
969        0.135468059382387,
970        0.0877555778478272,
971        0.0278059231072768,
972        0.0277019054815613,
973    ];
974    const SCORE: [f64; 64] = [
975        0.0,
976        0.365970771264122,
977        0.0,
978        1.05881285226553,
979        0.796450465672768,
980        0.0,
981        0.15195611134601,
982        0.0,
983        0.0,
984        0.0,
985        0.0,
986        0.503171625125652,
987        0.602838806095091,
988        0.0,
989        0.22119300766101,
990        0.238060922935183,
991        0.0,
992        0.223493163232391,
993        0.0,
994        0.0,
995        0.559450528883167,
996        0.413466232376651,
997        0.566393797404165,
998        0.0,
999        0.402200065392971,
1000        0.0,
1001        0.0,
1002        0.758500430509727,
1003        1.5606529881759,
1004        0.830851346829295,
1005        6.79293349706241,
1006        0.199289129287858,
1007        0.0,
1008        0.0,
1009        0.0,
1010        0.402864454850786,
1011        0.253212692235468,
1012        0.701706509860138,
1013        0.0,
1014        0.0,
1015        0.0,
1016        0.0,
1017        0.0,
1018        0.0,
1019        0.0,
1020        0.0,
1021        0.0,
1022        0.0,
1023        0.0,
1024        0.0,
1025        0.0,
1026        0.0,
1027        0.0,
1028        0.315186058686334,
1029        0.0,
1030        0.0,
1031        0.0,
1032        0.0305605858088266,
1033        0.0553323044375994,
1034        0.76882472342237,
1035        0.298864697731819,
1036        0.0,
1037        0.0,
1038        0.0,
1039    ];
1040
1041    fn close(got: f64, want: f64) -> bool {
1042        (got - want).abs() <= 1e-8 * want.abs().max(1.0)
1043    }
1044
1045    #[test]
1046    fn saliency_and_scores_reproduce_the_published_method() {
1047        let points = SpectralResidual::new()
1048            .analyze(&SERIES)
1049            .expect("a valid series");
1050        for (i, p) in points.iter().enumerate() {
1051            assert!(
1052                close(p.saliency, SALIENCY[i]),
1053                "saliency[{i}]: got {}, reference {}",
1054                p.saliency,
1055                SALIENCY[i]
1056            );
1057            assert!(
1058                close(p.score, SCORE[i]),
1059                "score[{i}]: got {}, reference {}",
1060                p.score,
1061                SCORE[i]
1062            );
1063        }
1064        let flagged: Vec<usize> = points
1065            .iter()
1066            .filter(|p| p.is_anomaly)
1067            .map(|p| p.index)
1068            .collect();
1069        assert_eq!(flagged, vec![30]);
1070    }
1071}