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, Eq)]
78#[non_exhaustive]
79pub enum SpectralResidualError {
80    /// An option is outside its domain.
81    OptionOutOfRange {
82        /// The option's name, spelled as the builder and the wire formats
83        /// spell it (`threshold`, `sensitivity`, ...).
84        option: &'static str,
85        /// What that option has to satisfy, for a message a consumer can show.
86        requirement: &'static str,
87    },
88    /// Fewer observations than the transform is applied to at once.
89    TooFewObservations {
90        /// [`MIN_OBSERVATIONS`].
91        needed: usize,
92        /// How many were given.
93        got: usize,
94    },
95    /// The series holds a NaN or an infinity.
96    ValueNotFinite {
97        /// Position of the first such value.
98        index: usize,
99    },
100}
101
102impl core::fmt::Display for SpectralResidualError {
103    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
104        match self {
105            SpectralResidualError::OptionOutOfRange {
106                option,
107                requirement,
108            } => write!(f, "{option} must be {requirement}"),
109            SpectralResidualError::TooFewObservations { needed, got } => {
110                write!(f, "needs at least {needed} observations, got {got}")
111            }
112            SpectralResidualError::ValueNotFinite { index } => {
113                write!(f, "data[{index}] is not a finite number")
114            }
115        }
116    }
117}
118
119impl std::error::Error for SpectralResidualError {}
120
121/// Spectral residual anomaly scorer.
122///
123/// Built with [`SpectralResidual::new`] (the defaults of Ren et al. 2019) and
124/// adjusted with the `with_*` methods; run with [`SpectralResidual::analyze`].
125#[derive(Debug, Clone, PartialEq)]
126pub struct SpectralResidual {
127    /// Width of the moving average applied to the log amplitude spectrum
128    /// (`q` in Ren et al.; default 3).
129    pub averaging_window: usize,
130    /// How many preceding saliency values a point is compared against
131    /// (`z`; default 40).
132    pub judgement_window: usize,
133    /// Score above which a point is an anomaly (`τ`; default 3).
134    pub threshold: f64,
135    /// Minimum `|value − mean| / std` of the point against the judgement
136    /// window before it, for it to be an anomaly (default 1.5), so that
137    /// saliency alone does not flag noise on a flat series. A window with no
138    /// spread passes any departure from its level.
139    pub min_zscore: f64,
140    /// Coverage of the band around the expected value, in percent (default
141    /// 70): its half-width is the normal quantile for that two-sided coverage
142    /// times the robust spread of the non-anomalous residuals.
143    pub sensitivity: f64,
144    /// Process the series in consecutive batches of this many points instead
145    /// of at once (default `None`). Long series with slow drift are scored
146    /// against their local context this way; the last batch absorbs a short
147    /// remainder.
148    pub batch_size: Option<usize>,
149}
150
151impl Default for SpectralResidual {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl SpectralResidual {
158    /// The defaults of Ren et al. (2019): q = 3, z = 40, τ = 3, z-score gate
159    /// 1.5, 70% band, no batching.
160    pub fn new() -> Self {
161        Self {
162            averaging_window: 3,
163            judgement_window: 40,
164            threshold: 3.0,
165            min_zscore: 1.5,
166            sensitivity: 70.0,
167            batch_size: None,
168        }
169    }
170
171    /// Sets the spectral averaging window (≥ 1).
172    pub fn with_averaging_window(mut self, q: usize) -> Self {
173        self.averaging_window = q;
174        self
175    }
176
177    /// Sets the judgement window (≥ 1).
178    pub fn with_judgement_window(mut self, z: usize) -> Self {
179        self.judgement_window = z;
180        self
181    }
182
183    /// Sets the anomaly threshold on the score (> 0).
184    pub fn with_threshold(mut self, threshold: f64) -> Self {
185        self.threshold = threshold;
186        self
187    }
188
189    /// Sets the z-score gate (≥ 0; 0 disables it).
190    pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
191        self.min_zscore = min_zscore;
192        self
193    }
194
195    /// Sets the band coverage in percent (0 < s < 100).
196    pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
197        self.sensitivity = sensitivity;
198        self
199    }
200
201    /// Sets the batch size (≥ [`MIN_OBSERVATIONS`]), or `None` for one batch.
202    pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
203        self.batch_size = batch_size;
204        self
205    }
206
207    /// The first option that is outside its domain, in a fixed order.
208    fn check_options(&self) -> Result<(), SpectralResidualError> {
209        let out_of_range = |option, requirement| {
210            Err(SpectralResidualError::OptionOutOfRange {
211                option,
212                requirement,
213            })
214        };
215        if self.averaging_window < 1 {
216            return out_of_range("averaging_window", ">= 1");
217        }
218        if self.judgement_window < 1 {
219            return out_of_range("judgement_window", ">= 1");
220        }
221        if !self.threshold.is_finite() || self.threshold <= 0.0 {
222            return out_of_range("threshold", "a finite number > 0");
223        }
224        if !self.min_zscore.is_finite() || self.min_zscore < 0.0 {
225            return out_of_range("min_zscore", "a finite number >= 0");
226        }
227        if !self.sensitivity.is_finite() || self.sensitivity <= 0.0 || self.sensitivity >= 100.0 {
228            return out_of_range("sensitivity", "a finite number strictly between 0 and 100");
229        }
230        if self.batch_size.is_some_and(|b| b < MIN_OBSERVATIONS) {
231            return out_of_range("batch_size", "unset, or at least 12");
232        }
233        Ok(())
234    }
235
236    /// Scores every point of the series.
237    ///
238    /// The result has one [`SrPoint`] per input point, in order.
239    ///
240    /// # Errors
241    ///
242    /// Returns the **one** condition that was not met — the option that is out
243    /// of its domain, the shortfall in observations, or the position of the
244    /// first non-finite value — rather than a refusal a caller has to match
245    /// against the whole rulebook to interpret.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use u_analytics::detection::{SpectralResidual, SpectralResidualError};
251    ///
252    /// let mut series: Vec<f64> = (0..60).map(|t| (t as f64 * 0.3).sin()).collect();
253    /// series[40] += 4.0; // a spike
254    /// let points = SpectralResidual::new().analyze(&series).unwrap();
255    /// assert!(points[40].is_anomaly);
256    /// assert!(points[40].score > points[20].score);
257    ///
258    /// // A refusal names the option it is about.
259    /// let refused = SpectralResidual::new().with_threshold(0.0).analyze(&series);
260    /// assert_eq!(
261    ///     refused,
262    ///     Err(SpectralResidualError::OptionOutOfRange {
263    ///         option: "threshold",
264    ///         requirement: "a finite number > 0",
265    ///     })
266    /// );
267    /// ```
268    pub fn analyze(&self, series: &[f64]) -> Result<Vec<SrPoint>, SpectralResidualError> {
269        self.check_options()?;
270        if series.len() < MIN_OBSERVATIONS {
271            return Err(SpectralResidualError::TooFewObservations {
272                needed: MIN_OBSERVATIONS,
273                got: series.len(),
274            });
275        }
276        if let Some(index) = series.iter().position(|v| !v.is_finite()) {
277            return Err(SpectralResidualError::ValueNotFinite { index });
278        }
279        let n = series.len();
280        let batch = self.batch_size.unwrap_or(n).min(n);
281        let mut points = Vec::with_capacity(n);
282        let mut start = 0;
283        while start < n {
284            // The last batch absorbs a remainder shorter than a batch.
285            let end = if n - start < 2 * batch {
286                n
287            } else {
288                start + batch
289            };
290            points.extend(self.analyze_batch(&series[start..end], start));
291            start = end;
292        }
293        Ok(points)
294    }
295
296    fn analyze_batch(&self, data: &[f64], offset: usize) -> Vec<SrPoint> {
297        let n = data.len();
298        let saliency = saliency_map(data, self.averaging_window);
299
300        // Score against the preceding judgement window (the point included,
301        // as in Ren et al.'s implementation), relative to that average.
302        let scores: Vec<f64> = (0..n)
303            .map(|i| {
304                let from = i.saturating_sub(self.judgement_window - 1);
305                let avg = saliency[from..=i].iter().sum::<f64>() / (i + 1 - from) as f64;
306                let avg = avg.max(1e-8);
307                ((saliency[i] - avg) / avg).max(0.0)
308            })
309            .collect();
310
311        // The z-score gate is local: the point against the mean and spread of
312        // the judgement window before it, so a step is judged against the
313        // level it departs from, not against a mean that already straddles it.
314        let flagged: Vec<bool> = (0..n)
315            .map(|i| {
316                if scores[i] <= self.threshold {
317                    return false;
318                }
319                if self.min_zscore == 0.0 || i == 0 {
320                    return true;
321                }
322                let from = i.saturating_sub(self.judgement_window);
323                let window = &data[from..i];
324                let mean = window.iter().sum::<f64>() / window.len() as f64;
325                let std = (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
326                    / window.len() as f64)
327                    .sqrt();
328                if std > 0.0 {
329                    (data[i] - mean).abs() / std >= self.min_zscore
330                } else {
331                    data[i] != mean
332                }
333            })
334            .collect();
335
336        let expected = expected_values(data, &flagged);
337        let residuals: Vec<f64> = (0..n)
338            .filter(|&i| !flagged[i])
339            .map(|i| (data[i] - expected[i]).abs())
340            .collect();
341        // Robust spread: 1.4826 · median absolute residual (the MAD of a
342        // centred residual), then the two-sided normal quantile for the
343        // requested coverage.
344        let spread = median(&residuals).unwrap_or(0.0) * 1.4826;
345        let z = inverse_normal_cdf(0.5 + self.sensitivity / 200.0);
346        let margin = z * spread;
347
348        (0..n)
349            .map(|i| SrPoint {
350                index: offset + i,
351                value: data[i],
352                saliency: saliency[i],
353                score: scores[i],
354                expected: expected[i],
355                lower: expected[i] - margin,
356                upper: expected[i] + margin,
357                is_anomaly: flagged[i],
358                near_edge: i < EXTENSION || i + EXTENSION >= n,
359            })
360            .collect()
361    }
362}
363
364/// One scored point.
365#[derive(Debug, Clone, PartialEq)]
366pub struct SrPoint {
367    /// Position in the input series.
368    pub index: usize,
369    /// The observed value.
370    pub value: f64,
371    /// Spectral residual saliency of the point (≥ 0).
372    pub saliency: f64,
373    /// Saliency relative to the preceding judgement window (≥ 0).
374    pub score: f64,
375    /// Low-frequency reconstruction of the series with anomalies removed.
376    pub expected: f64,
377    /// `expected − margin`.
378    pub lower: f64,
379    /// `expected + margin`.
380    pub upper: f64,
381    /// `score > threshold` and the value clears the z-score gate.
382    pub is_anomaly: bool,
383    /// The point lies within κ = 5 places of an end of its batch, where the
384    /// transform's own boundary handling moves the saliency most.
385    ///
386    /// The method extends the series by κ points before transforming, and the
387    /// transform is circular, so that appended block sits next to the last
388    /// points *and* the first ones. Measured on a noisy sawtooth of 200
389    /// points, replacing the predicted extension with the series' real
390    /// continuation moves the saliency of the five points at either end about
391    /// eight times as much as it moves the middle -- and the two ends by the
392    /// same amount, which is why this is not a tail-only flag.
393    ///
394    /// It is a property of the position, not a verdict: a real anomaly near an
395    /// end is still reported as one. What it says is that a lone flag here is
396    /// the one worth a second look, and an anomaly count is worth reporting
397    /// with and without these.
398    pub near_edge: bool,
399}
400
401/// The saliency map of Ren et al. (2019): extend the series by κ = 5 points
402/// along the slope of its last m = 5, take the FFT, average the log
403/// amplitude over `q` bins, keep the residual with the original phase,
404/// transform back, and drop the extension.
405fn saliency_map(data: &[f64], q: usize) -> Vec<f64> {
406    let n = data.len();
407    let mut extended = data.to_vec();
408    let m = EXTENSION.min(n - 1);
409    let last = data[n - 1];
410    let slope = (1..=m)
411        .map(|i| (last - data[n - 1 - i]) / i as f64)
412        .sum::<f64>()
413        / m as f64;
414    let predicted = data[n - 1 - m] + slope * m as f64;
415    extended.extend(std::iter::repeat_n(predicted, EXTENSION));
416
417    let spectrum = fft(&extended
418        .iter()
419        .map(|&x| Complex::real(x))
420        .collect::<Vec<_>>());
421    let len = spectrum.len();
422    let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
423    // Moving average of width q over the preceding bins (cumulative form).
424    let mut avg = vec![0.0; len];
425    let mut cum = 0.0;
426    let mut cums = Vec::with_capacity(len + 1);
427    cums.push(0.0);
428    for &v in &log_amp {
429        cum += v;
430        cums.push(cum);
431    }
432    for i in 0..len {
433        let from = (i + 1).saturating_sub(q);
434        avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
435    }
436    let residual_spectrum: Vec<Complex> = spectrum
437        .iter()
438        .enumerate()
439        .map(|(i, z)| {
440            let r = (log_amp[i] - avg[i]).exp();
441            let phase = z.arg();
442            Complex::new(r * phase.cos(), r * phase.sin())
443        })
444        .collect();
445    ifft(&residual_spectrum)
446        .into_iter()
447        .take(n)
448        .map(|z| z.norm())
449        .collect()
450}
451
452/// The series with each flagged point replaced by the mean of its nearest
453/// unflagged neighbours, reconstructed from its low-frequency components:
454/// the middle band of the spectrum (bins from 3/8 to 5/8 of the length) is
455/// zeroed, as in the Microsoft service's expected value.
456fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
457    let n = data.len();
458    let mut cleaned = data.to_vec();
459    for i in 0..n {
460        if !flagged[i] {
461            continue;
462        }
463        let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
464        let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
465        cleaned[i] = match (left, right) {
466            (Some(a), Some(b)) => (a + b) / 2.0,
467            (Some(a), None) | (None, Some(a)) => a,
468            (None, None) => data[i],
469        };
470    }
471    let mut spectrum = fft(&cleaned
472        .iter()
473        .map(|&x| Complex::real(x))
474        .collect::<Vec<_>>());
475    let lo = n * 3 / 8;
476    let hi = n * 5 / 8;
477    for (i, z) in spectrum.iter_mut().enumerate() {
478        if i > lo && i < hi {
479            *z = Complex::default();
480        }
481    }
482    ifft(&spectrum).into_iter().map(|z| z.re).collect()
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    fn noise(seed: u64, n: usize) -> Vec<f64> {
490        let mut state = seed;
491        (0..n)
492            .map(|_| {
493                state = state
494                    .wrapping_mul(6364136223846793005)
495                    .wrapping_add(1442695040888963407);
496                ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
497            })
498            .collect()
499    }
500
501    fn check_shape(points: &[SrPoint], n: usize) {
502        assert_eq!(points.len(), n);
503        for (i, p) in points.iter().enumerate() {
504            assert_eq!(p.index, i);
505            assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
506            assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
507            assert!(
508                p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
509                "{p:?}"
510            );
511        }
512    }
513
514    #[test]
515    fn a_spike_on_a_sine_is_the_anomaly() {
516        let mut series: Vec<f64> = noise(3, 120)
517            .iter()
518            .enumerate()
519            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
520            .collect();
521        series[80] += 5.0;
522        let points = SpectralResidual::new().analyze(&series).unwrap();
523        check_shape(&points, 120);
524        assert!(points[80].is_anomaly, "{:?}", points[80]);
525        let flagged: Vec<usize> = points
526            .iter()
527            .filter(|p| p.is_anomaly)
528            .map(|p| p.index)
529            .collect();
530        assert!(flagged.len() <= 3, "flagged {flagged:?}");
531        assert!(points[80].value > points[80].upper);
532        // The expected value is not dragged up by the spike.
533        assert!(
534            (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
535            "{:?}",
536            points[80]
537        );
538    }
539
540    #[test]
541    fn a_step_is_flagged_where_it_happens() {
542        let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
543        let points = SpectralResidual::new().analyze(&series).unwrap();
544        check_shape(&points, 100);
545        let first = points.iter().position(|p| p.is_anomaly);
546        assert_eq!(
547            first,
548            Some(60),
549            "{:?}",
550            points
551                .iter()
552                .filter(|p| p.is_anomaly)
553                .map(|p| p.index)
554                .collect::<Vec<_>>()
555        );
556    }
557
558    #[test]
559    fn a_constant_and_plain_noise_raise_no_anomaly() {
560        let constant = vec![3.0; 50];
561        let points = SpectralResidual::new().analyze(&constant).unwrap();
562        check_shape(&points, 50);
563        assert!(points.iter().all(|p| !p.is_anomaly));
564        assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
565
566        let series = noise(9, 200);
567        let points = SpectralResidual::new().analyze(&series).unwrap();
568        check_shape(&points, 200);
569        let flagged = points.iter().filter(|p| p.is_anomaly).count();
570        assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
571    }
572
573    #[test]
574    fn scoring_is_deterministic_and_batching_keeps_the_shape() {
575        let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
576        series[150] = 20.0;
577        series[260] = -5.0;
578        let sr = SpectralResidual::new();
579        let a = sr.analyze(&series).unwrap();
580        let b = sr.analyze(&series).unwrap();
581        assert_eq!(a, b);
582        let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
583        check_shape(&batched, 300);
584        assert!(batched[150].is_anomaly && batched[260].is_anomaly);
585        // Each batch is scored on its own: the first point of a batch has no
586        // history but the shape is unchanged and indices are global.
587        assert_eq!(batched[200].index, 200);
588    }
589
590    #[test]
591    fn the_band_widens_with_sensitivity() {
592        let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
593        let narrow = SpectralResidual::new()
594            .with_sensitivity(50.0)
595            .analyze(&series)
596            .unwrap();
597        let wide = SpectralResidual::new()
598            .with_sensitivity(99.0)
599            .analyze(&series)
600            .unwrap();
601        for (a, b) in narrow.iter().zip(&wide) {
602            assert!(b.upper - b.lower > a.upper - a.lower);
603            assert_eq!(a.expected, b.expected);
604        }
605    }
606
607    /// The transform's boundary handling reaches both ends, so the marker
608    /// does too -- a tail-only flag would leave half the affected points
609    /// unmarked.
610    #[test]
611    fn points_near_either_end_of_a_batch_are_marked() {
612        let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
613        let points = SpectralResidual::new().analyze(&series).expect("ok");
614        let marked: Vec<usize> = points
615            .iter()
616            .filter(|p| p.near_edge)
617            .map(|p| p.index)
618            .collect();
619        assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
620
621        // Batching moves the boundaries, so it moves the marks with them.
622        let batched = SpectralResidual::new()
623            .with_batch_size(Some(50))
624            .analyze(&series)
625            .expect("ok");
626        let marked: Vec<usize> = batched
627            .iter()
628            .filter(|p| p.near_edge)
629            .map(|p| p.index)
630            .collect();
631        // 49 and 50 straddle a batch boundary, so both are near an edge; 75
632        // sits in the body of a batch and is not.
633        assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
634        assert!(!marked.contains(&75), "{marked:?}");
635        assert_eq!(
636            marked.len(),
637            4 * 10,
638            "four batches, ten marks each: {marked:?}"
639        );
640
641        // It marks a position, it does not suppress a detection: a spike
642        // planted in the last few points is still an anomaly.
643        let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
644        spiked[57] += 30.0;
645        let points = SpectralResidual::new().analyze(&spiked).expect("ok");
646        assert!(points[57].is_anomaly);
647        assert!(points[57].near_edge);
648    }
649
650    /// Documented, not corrected (see the module docs): the flag at a level
651    /// change can land on the point before it as well.
652    #[test]
653    fn a_step_can_flag_the_point_before_the_transition() {
654        let step: Vec<f64> = (0..120)
655            .map(|i| {
656                let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
657                (if i < 60 { 1.0 } else { 6.0 }) + jitter
658            })
659            .collect();
660        let points = SpectralResidual::new().analyze(&step).expect("ok");
661        let flagged: Vec<usize> = points
662            .iter()
663            .filter(|p| p.is_anomaly && !p.near_edge)
664            .map(|p| p.index)
665            .collect();
666        assert!(!flagged.is_empty(), "the transition is detected");
667        assert!(
668            flagged.iter().all(|&i| (59..=61).contains(&i)),
669            "detections sit at the transition or its neighbour: {flagged:?}"
670        );
671    }
672
673    /// A refusal names the one condition that failed, so a consumer can point
674    /// at the setting to change instead of restating every rule.
675    #[test]
676    fn a_refusal_names_the_condition_that_failed() {
677        let ok = vec![1.0; 20];
678        let out_of_range = |option, requirement| {
679            Err(SpectralResidualError::OptionOutOfRange {
680                option,
681                requirement,
682            })
683        };
684
685        assert_eq!(
686            SpectralResidual::new().analyze(&[1.0; 11]),
687            Err(SpectralResidualError::TooFewObservations {
688                needed: MIN_OBSERVATIONS,
689                got: 11
690            })
691        );
692
693        let mut series = vec![1.0; 20];
694        series[7] = f64::INFINITY;
695        assert_eq!(
696            SpectralResidual::new().analyze(&series),
697            Err(SpectralResidualError::ValueNotFinite { index: 7 })
698        );
699
700        // Every option, each naming itself — the four the consumer report
701        // reached through one indistinguishable message, and the two beside
702        // them.
703        assert_eq!(
704            SpectralResidual::new().with_threshold(0.0).analyze(&ok),
705            out_of_range("threshold", "a finite number > 0")
706        );
707        assert_eq!(
708            SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
709            out_of_range("sensitivity", "a finite number strictly between 0 and 100")
710        );
711        assert_eq!(
712            SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
713            out_of_range("sensitivity", "a finite number strictly between 0 and 100")
714        );
715        assert_eq!(
716            SpectralResidual::new()
717                .with_batch_size(Some(5))
718                .analyze(&ok),
719            out_of_range("batch_size", "unset, or at least 12")
720        );
721        assert_eq!(
722            SpectralResidual::new()
723                .with_averaging_window(0)
724                .analyze(&ok),
725            out_of_range("averaging_window", ">= 1")
726        );
727        assert_eq!(
728            SpectralResidual::new()
729                .with_judgement_window(0)
730                .analyze(&ok),
731            out_of_range("judgement_window", ">= 1")
732        );
733        assert_eq!(
734            SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
735            out_of_range("min_zscore", "a finite number >= 0")
736        );
737        assert_eq!(
738            SpectralResidual::new()
739                .with_threshold(f64::NAN)
740                .analyze(&ok),
741            out_of_range("threshold", "a finite number > 0")
742        );
743
744        // The message a transport shows repeats the option, not the rulebook.
745        let message = SpectralResidual::new()
746            .with_threshold(0.0)
747            .analyze(&ok)
748            .expect_err("refused")
749            .to_string();
750        assert_eq!(message, "threshold must be a finite number > 0");
751        assert!(!message.contains("sensitivity"));
752
753        // `min_zscore = 0` disables the gate; it is not out of range.
754        assert!(SpectralResidual::new()
755            .with_min_zscore(0.0)
756            .analyze(&ok)
757            .is_ok());
758    }
759}