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    let predicted = data[n - 1 - m] + slope * m as f64;
527    extended.extend(std::iter::repeat_n(predicted, EXTENSION));
528
529    let spectrum = fft(&extended
530        .iter()
531        .map(|&x| Complex::real(x))
532        .collect::<Vec<_>>());
533    let len = spectrum.len();
534    let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
535    // Moving average of width q over the preceding bins (cumulative form).
536    let mut avg = vec![0.0; len];
537    let mut cum = 0.0;
538    let mut cums = Vec::with_capacity(len + 1);
539    cums.push(0.0);
540    for &v in &log_amp {
541        cum += v;
542        cums.push(cum);
543    }
544    for i in 0..len {
545        let from = (i + 1).saturating_sub(q);
546        avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
547    }
548    let residual_spectrum: Vec<Complex> = spectrum
549        .iter()
550        .enumerate()
551        .map(|(i, z)| {
552            let r = (log_amp[i] - avg[i]).exp();
553            let phase = z.arg();
554            Complex::new(r * phase.cos(), r * phase.sin())
555        })
556        .collect();
557    ifft(&residual_spectrum)
558        .into_iter()
559        .take(n)
560        .map(|z| z.norm())
561        .collect()
562}
563
564/// The series with each flagged point replaced by the mean of its nearest
565/// unflagged neighbours, reconstructed from its low-frequency components:
566/// the middle band of the spectrum (bins from 3/8 to 5/8 of the length) is
567/// zeroed, as in the Microsoft service's expected value.
568fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
569    let n = data.len();
570    let mut cleaned = data.to_vec();
571    for i in 0..n {
572        if !flagged[i] {
573            continue;
574        }
575        let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
576        let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
577        cleaned[i] = match (left, right) {
578            (Some(a), Some(b)) => (a + b) / 2.0,
579            (Some(a), None) | (None, Some(a)) => a,
580            (None, None) => data[i],
581        };
582    }
583    let mut spectrum = fft(&cleaned
584        .iter()
585        .map(|&x| Complex::real(x))
586        .collect::<Vec<_>>());
587    let lo = n * 3 / 8;
588    let hi = n * 5 / 8;
589    for (i, z) in spectrum.iter_mut().enumerate() {
590        if i > lo && i < hi {
591            *z = Complex::default();
592        }
593    }
594    ifft(&spectrum).into_iter().map(|z| z.re).collect()
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    fn noise(seed: u64, n: usize) -> Vec<f64> {
602        let mut state = seed;
603        (0..n)
604            .map(|_| {
605                state = state
606                    .wrapping_mul(6364136223846793005)
607                    .wrapping_add(1442695040888963407);
608                ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
609            })
610            .collect()
611    }
612
613    fn check_shape(points: &[SrPoint], n: usize) {
614        assert_eq!(points.len(), n);
615        for (i, p) in points.iter().enumerate() {
616            assert_eq!(p.index, i);
617            assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
618            assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
619            assert!(
620                p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
621                "{p:?}"
622            );
623        }
624    }
625
626    #[test]
627    fn a_spike_on_a_sine_is_the_anomaly() {
628        let mut series: Vec<f64> = noise(3, 120)
629            .iter()
630            .enumerate()
631            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
632            .collect();
633        series[80] += 5.0;
634        let points = SpectralResidual::new().analyze(&series).unwrap();
635        check_shape(&points, 120);
636        assert!(points[80].is_anomaly, "{:?}", points[80]);
637        let flagged: Vec<usize> = points
638            .iter()
639            .filter(|p| p.is_anomaly)
640            .map(|p| p.index)
641            .collect();
642        assert!(flagged.len() <= 3, "flagged {flagged:?}");
643        assert!(points[80].value > points[80].upper);
644        // The expected value is not dragged up by the spike.
645        assert!(
646            (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
647            "{:?}",
648            points[80]
649        );
650    }
651
652    #[test]
653    fn a_step_is_flagged_where_it_happens() {
654        let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
655        let points = SpectralResidual::new().analyze(&series).unwrap();
656        check_shape(&points, 100);
657        let first = points.iter().position(|p| p.is_anomaly);
658        assert_eq!(
659            first,
660            Some(60),
661            "{:?}",
662            points
663                .iter()
664                .filter(|p| p.is_anomaly)
665                .map(|p| p.index)
666                .collect::<Vec<_>>()
667        );
668    }
669
670    #[test]
671    fn a_constant_and_plain_noise_raise_no_anomaly() {
672        let constant = vec![3.0; 50];
673        let points = SpectralResidual::new().analyze(&constant).unwrap();
674        check_shape(&points, 50);
675        assert!(points.iter().all(|p| !p.is_anomaly));
676        assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
677
678        let series = noise(9, 200);
679        let points = SpectralResidual::new().analyze(&series).unwrap();
680        check_shape(&points, 200);
681        let flagged = points.iter().filter(|p| p.is_anomaly).count();
682        assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
683    }
684
685    #[test]
686    fn scoring_is_deterministic_and_batching_keeps_the_shape() {
687        let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
688        series[150] = 20.0;
689        series[260] = -5.0;
690        let sr = SpectralResidual::new();
691        let a = sr.analyze(&series).unwrap();
692        let b = sr.analyze(&series).unwrap();
693        assert_eq!(a, b);
694        let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
695        check_shape(&batched, 300);
696        assert!(batched[150].is_anomaly && batched[260].is_anomaly);
697        // Each batch is scored on its own: the first point of a batch has no
698        // history but the shape is unchanged and indices are global.
699        assert_eq!(batched[200].index, 200);
700    }
701
702    #[test]
703    fn the_band_widens_with_sensitivity() {
704        let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
705        let narrow = SpectralResidual::new()
706            .with_sensitivity(50.0)
707            .analyze(&series)
708            .unwrap();
709        let wide = SpectralResidual::new()
710            .with_sensitivity(99.0)
711            .analyze(&series)
712            .unwrap();
713        for (a, b) in narrow.iter().zip(&wide) {
714            assert!(b.upper - b.lower > a.upper - a.lower);
715            assert_eq!(a.expected, b.expected);
716        }
717    }
718
719    /// The transform's boundary handling reaches both ends, so the marker
720    /// does too -- a tail-only flag would leave half the affected points
721    /// unmarked.
722    #[test]
723    fn points_near_either_end_of_a_batch_are_marked() {
724        let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
725        let points = SpectralResidual::new().analyze(&series).expect("ok");
726        let marked: Vec<usize> = points
727            .iter()
728            .filter(|p| p.near_edge)
729            .map(|p| p.index)
730            .collect();
731        assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
732
733        // Batching moves the boundaries, so it moves the marks with them.
734        let batched = SpectralResidual::new()
735            .with_batch_size(Some(50))
736            .analyze(&series)
737            .expect("ok");
738        let marked: Vec<usize> = batched
739            .iter()
740            .filter(|p| p.near_edge)
741            .map(|p| p.index)
742            .collect();
743        // 49 and 50 straddle a batch boundary, so both are near an edge; 75
744        // sits in the body of a batch and is not.
745        assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
746        assert!(!marked.contains(&75), "{marked:?}");
747        assert_eq!(
748            marked.len(),
749            4 * 10,
750            "four batches, ten marks each: {marked:?}"
751        );
752
753        // It marks a position, it does not suppress a detection: a spike
754        // planted in the last few points is still an anomaly.
755        let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
756        spiked[57] += 30.0;
757        let points = SpectralResidual::new().analyze(&spiked).expect("ok");
758        assert!(points[57].is_anomaly);
759        assert!(points[57].near_edge);
760    }
761
762    /// Documented, not corrected (see the module docs): the flag at a level
763    /// change can land on the point before it as well.
764    #[test]
765    fn a_step_can_flag_the_point_before_the_transition() {
766        let step: Vec<f64> = (0..120)
767            .map(|i| {
768                let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
769                (if i < 60 { 1.0 } else { 6.0 }) + jitter
770            })
771            .collect();
772        let points = SpectralResidual::new().analyze(&step).expect("ok");
773        let flagged: Vec<usize> = points
774            .iter()
775            .filter(|p| p.is_anomaly && !p.near_edge)
776            .map(|p| p.index)
777            .collect();
778        assert!(!flagged.is_empty(), "the transition is detected");
779        assert!(
780            flagged.iter().all(|&i| (59..=61).contains(&i)),
781            "detections sit at the transition or its neighbour: {flagged:?}"
782        );
783    }
784
785    /// A refusal names the one condition that failed, so a consumer can point
786    /// at the setting to change instead of restating every rule.
787    #[test]
788    fn a_refusal_names_the_condition_that_failed() {
789        let ok = vec![1.0; 20];
790        use SpectralResidualError as E;
791
792        assert_eq!(
793            SpectralResidual::new().analyze(&[1.0; 11]),
794            Err(SpectralResidualError::TooFewObservations {
795                needed: MIN_OBSERVATIONS,
796                got: 11
797            })
798        );
799
800        let mut series = vec![1.0; 20];
801        series[7] = f64::INFINITY;
802        assert_eq!(
803            SpectralResidual::new().analyze(&series),
804            Err(SpectralResidualError::ValueNotFinite { index: 7 })
805        );
806
807        // Every option, each naming itself, with the value given and the
808        // bounds of its domain -- inclusive as `min`, exclusive as
809        // `exclusive_min` / `exclusive_max`.
810        assert_eq!(
811            SpectralResidual::new().with_threshold(0.0).analyze(&ok),
812            Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
813        );
814        let sensitivity = |got| {
815            Err(E::not_between(
816                "sensitivity",
817                "a finite number strictly between 0 and 100",
818                got,
819                0.0,
820                100.0,
821            ))
822        };
823        assert_eq!(
824            SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
825            sensitivity(100.0)
826        );
827        assert_eq!(
828            SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
829            sensitivity(0.0)
830        );
831        assert_eq!(
832            SpectralResidual::new()
833                .with_batch_size(Some(5))
834                .analyze(&ok),
835            Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
836        );
837        assert_eq!(
838            SpectralResidual::new()
839                .with_averaging_window(0)
840                .analyze(&ok),
841            Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
842        );
843        assert_eq!(
844            SpectralResidual::new()
845                .with_judgement_window(0)
846                .analyze(&ok),
847            Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
848        );
849        assert_eq!(
850            SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
851            Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
852        );
853        // NaN is refused too; `got` is the NaN itself, so match the rest.
854        assert!(matches!(
855            SpectralResidual::new()
856                .with_threshold(f64::NAN)
857                .analyze(&ok),
858            Err(E::OptionOutOfRange {
859                option: "threshold",
860                min: None,
861                exclusive_min,
862                max: None,
863                exclusive_max: None,
864                got,
865                ..
866            }) if got.is_nan() && exclusive_min == Some(0.0)
867        ));
868
869        // The message a transport shows repeats the option, not the rulebook.
870        let message = SpectralResidual::new()
871            .with_threshold(0.0)
872            .analyze(&ok)
873            .expect_err("refused")
874            .to_string();
875        assert_eq!(message, "threshold must be a finite number > 0");
876        assert!(!message.contains("sensitivity"));
877
878        // `min_zscore = 0` disables the gate; it is not out of range.
879        assert!(SpectralResidual::new()
880            .with_min_zscore(0.0)
881            .analyze(&ok)
882            .is_ok());
883    }
884}