Skip to main content

u_analytics/detection/
pelt.rs

1//! Pruned Exact Linear Time (PELT) algorithm for offline changepoint detection.
2//!
3//! # Algorithm
4//!
5//! PELT solves the penalized cost minimization problem:
6//!
7//! ```text
8//! minimize  sum_{j=1}^{m+1} C(y_{tau_{j-1}+1 : tau_j}) + m * beta
9//! ```
10//!
11//! where `C` is a segment cost function, `m` is the number of changepoints,
12//! and `beta` is a penalty per changepoint.
13//!
14//! The dynamic programming recurrence is:
15//!
16//! ```text
17//! F(t) = min_{tau in R_t} [ F(tau) + C(y_{tau+1:t}) + beta ]
18//! ```
19//!
20//! with pruning rule: remove `tau` from candidates if `F(tau) + C(y_{tau+1:t}) >= F(t)`,
21//! since such `tau` can never be optimal for any future `s > t`.
22//!
23//! # Complexity
24//!
25//! Expected O(n) under mild conditions on the cost function.
26//! Worst case O(n^2) (no pruning effective).
27//!
28//! # Cost Functions
29//!
30//! - [`CostFunction::L2`] — Detects changes in mean. Cost = sum of squared residuals
31//!   from segment mean. One parameter per segment.
32//! - [`CostFunction::Normal`] — Detects changes in mean and/or variance. Cost =
33//!   n * ln(MLE variance). Two parameters per segment.
34//!
35//! # Penalty Selection
36//!
37//! - **BIC** (default): `beta = p * ln(n)` where `p` is the number of parameters
38//!   per segment (1 for L2, 2 for Normal). Balances model complexity and fit.
39//!   For the L2 cost, a sum of squares in the data's units squared, it is
40//!   multiplied by the noise variance estimated from the data (the median of
41//!   the first differences, robust to the shifts being searched for), so the
42//!   same series gives the same changepoints in any unit.
43//! - **Custom**: User-specified penalty value.
44//!
45//! # References
46//!
47//! - Killick, R., Fearnhead, P., & Eckley, I.A. (2012). "Optimal Detection of
48//!   Changepoints with a Linear Computational Cost", *Journal of the American
49//!   Statistical Association* 107(500), pp. 1590-1598.
50//! - Schwarz, G. (1978). "Estimating the Dimension of a Model",
51//!   *Annals of Statistics* 6(2), pp. 461-464.
52
53/// Cost function for evaluating segment homogeneity.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum CostFunction {
56    /// Detects changes in mean only.
57    ///
58    /// Segment cost: `sum((y_i - y_bar)^2)` where `y_bar` is the segment mean.
59    /// Equivalent to minus log-likelihood of a normal model with known variance.
60    /// One parameter per segment (mean).
61    L2,
62    /// Detects changes in mean and/or variance.
63    ///
64    /// Segment cost: `n * ln(sigma^2_MLE)` where `sigma^2_MLE = sum((y_i - y_bar)^2) / n`.
65    /// Derived from the negative log-likelihood of a normal distribution
66    /// with both mean and variance estimated by MLE.
67    /// Two parameters per segment (mean, variance).
68    Normal,
69}
70
71impl CostFunction {
72    /// Number of estimated parameters per segment.
73    fn params_per_segment(self) -> usize {
74        match self {
75            CostFunction::L2 => 1,
76            CostFunction::Normal => 2,
77        }
78    }
79}
80
81/// Penalty selection for the PELT algorithm.
82#[derive(Debug, Clone, Copy)]
83pub enum Penalty {
84    /// BIC penalty: `p * ln(n)` where `p` = parameters per segment, times the
85    /// estimated noise variance of each channel for the L2 cost (whose
86    /// segments are costed in the data's units squared).
87    ///
88    /// Reference: Schwarz (1978). Automatically scales with data length.
89    Bic,
90    /// User-specified penalty value (must be positive and finite), in the
91    /// units of the cost: data units squared for L2.
92    Custom(f64),
93}
94
95/// PELT changepoint detector.
96///
97/// Implements the Pruned Exact Linear Time algorithm for detecting
98/// multiple changepoints in a univariate time series.
99///
100/// # Examples
101///
102/// ```
103/// use u_analytics::detection::{Pelt, CostFunction, Penalty};
104///
105/// // Data with a mean shift at index 50
106/// let mut data: Vec<f64> = vec![0.0; 50];
107/// data.extend(vec![5.0; 50]);
108///
109/// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
110/// let result = pelt.detect(&data);
111/// assert!(!result.changepoints.is_empty());
112/// // The detected changepoint should be near index 50
113/// assert!((result.changepoints[0] as i64 - 50).unsigned_abs() <= 2);
114/// ```
115///
116/// # References
117///
118/// Killick, R., Fearnhead, P., & Eckley, I.A. (2012). "Optimal Detection of
119/// Changepoints with a Linear Computational Cost", *JASA* 107(500), pp. 1590-1598.
120pub struct Pelt {
121    /// Cost function for segment evaluation.
122    cost: CostFunction,
123    /// Penalty per changepoint.
124    penalty: Penalty,
125    /// Minimum segment length (must be >= 2 for variance estimation).
126    min_segment_len: usize,
127}
128
129/// Result of PELT changepoint detection.
130#[derive(Debug, Clone)]
131pub struct PeltResult {
132    /// Detected changepoint indices (0-based). Each index marks the first
133    /// observation of a new segment.
134    ///
135    /// For example, if `changepoints = [50, 100]`, the segments are
136    /// `[0..50)`, `[50..100)`, `[100..n)`.
137    pub changepoints: Vec<usize>,
138}
139
140/// Result of multivariate PELT changepoint detection.
141#[derive(Debug, Clone)]
142pub struct MultiPeltResult {
143    /// Detected changepoint indices (0-based), shared across all channels.
144    pub changepoints: Vec<usize>,
145}
146
147impl Pelt {
148    /// Creates a new PELT detector with the given cost function and penalty.
149    ///
150    /// Uses a default minimum segment length of 2.
151    ///
152    /// # Returns
153    ///
154    /// `None` if a custom penalty is not positive or not finite.
155    ///
156    /// # Reference
157    ///
158    /// Killick et al. (2012), §2.2: penalty must be positive to avoid
159    /// trivial solutions (changepoint at every observation).
160    pub fn new(cost: CostFunction, penalty: Penalty) -> Option<Self> {
161        Self::with_min_segment_len(cost, penalty, 2)
162    }
163
164    /// Creates a PELT detector with a custom minimum segment length.
165    ///
166    /// # Parameters
167    ///
168    /// - `cost`: Cost function for segment evaluation
169    /// - `penalty`: Penalty per changepoint
170    /// - `min_segment_len`: Minimum number of observations per segment.
171    ///   Must be >= 2 (needed for variance estimation in Normal cost).
172    ///
173    /// # Returns
174    ///
175    /// `None` if parameters are invalid.
176    pub fn with_min_segment_len(
177        cost: CostFunction,
178        penalty: Penalty,
179        min_segment_len: usize,
180    ) -> Option<Self> {
181        if let Penalty::Custom(p) = penalty {
182            if !p.is_finite() || p <= 0.0 {
183                return None;
184            }
185        }
186        if min_segment_len < 2 {
187            return None;
188        }
189        Some(Self {
190            cost,
191            penalty,
192            min_segment_len,
193        })
194    }
195
196    /// Detects changepoints in the given data.
197    ///
198    /// Returns a [`PeltResult`] containing the detected changepoint indices.
199    /// If the data is too short (fewer than `2 * min_segment_len` observations),
200    /// no changepoints can be detected and an empty result is returned.
201    ///
202    /// Non-finite values (NaN, Infinity) are **not** supported in the input.
203    /// The data should be pre-cleaned; non-finite values will lead to
204    /// incorrect cost computations.
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use u_analytics::detection::{Pelt, CostFunction, Penalty};
210    ///
211    /// // Two changepoints: shift up at 30, shift down at 70
212    /// let mut data = vec![0.0; 30];
213    /// data.extend(vec![3.0; 40]);
214    /// data.extend(vec![0.0; 30]);
215    ///
216    /// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
217    /// let result = pelt.detect(&data);
218    /// assert_eq!(result.changepoints.len(), 2);
219    /// ```
220    ///
221    /// # Complexity
222    ///
223    /// Expected O(n), worst case O(n^2).
224    pub fn detect(&self, data: &[f64]) -> PeltResult {
225        let n = data.len();
226
227        if n < 2 * self.min_segment_len {
228            return PeltResult {
229                changepoints: Vec::new(),
230            };
231        }
232
233        let penalty_value = self.resolve_penalty(n, &[data]);
234        if penalty_value <= 0.0 {
235            // A BIC penalty of nothing: the signal does not vary, so there is
236            // nothing to split.
237            return PeltResult {
238                changepoints: Vec::new(),
239            };
240        }
241
242        // Precompute cumulative sums for O(1) segment cost evaluation.
243        // cum_sum[i] = sum(data[0..i])
244        // cum_sum_sq[i] = sum(data[0..i]^2)
245        let (cum_sum, cum_sum_sq) = cumulative_sums(data);
246
247        // F[t] = optimal cost for data[0..t]
248        // F[0] = -penalty (so that the first segment cost + penalty = cost alone)
249        let mut f = vec![0.0_f64; n + 1];
250        f[0] = -penalty_value;
251
252        // last_change[t] = last changepoint index for optimal segmentation of data[0..t]
253        let mut last_change = vec![0_usize; n + 1];
254
255        // Candidate set R: indices tau such that tau could be the last changepoint before t
256        let mut candidates: Vec<usize> = vec![0];
257
258        for t in self.min_segment_len..=n {
259            // Find the optimal last changepoint for position t
260            let mut best_cost = f64::INFINITY;
261            let mut best_tau = 0;
262
263            for &tau in &candidates {
264                let seg_len = t - tau;
265                if seg_len < self.min_segment_len {
266                    continue;
267                }
268
269                let cost = self.segment_cost(&cum_sum, &cum_sum_sq, tau, t);
270                let total = f[tau] + cost + penalty_value;
271
272                if total < best_cost {
273                    best_cost = total;
274                    best_tau = tau;
275                }
276            }
277
278            f[t] = best_cost;
279            last_change[t] = best_tau;
280
281            // PELT pruning: remove candidates that can never be optimal
282            // Killick et al. (2012), Theorem 3.1:
283            // If F(tau) + C(y_{tau+1:t}) >= F(t), then tau can be pruned.
284            candidates.retain(|&tau| {
285                let seg_len = t - tau;
286                if seg_len < self.min_segment_len {
287                    return true; // Keep — not yet evaluable
288                }
289                let cost = self.segment_cost(&cum_sum, &cum_sum_sq, tau, t);
290                f[tau] + cost < f[t] + penalty_value
291            });
292
293            candidates.push(t);
294        }
295
296        // Backtrack to extract changepoints
297        let mut changepoints = Vec::new();
298        let mut t = n;
299        while t > 0 {
300            let tau = last_change[t];
301            if tau > 0 {
302                changepoints.push(tau);
303            }
304            t = tau;
305        }
306
307        changepoints.sort_unstable();
308
309        PeltResult { changepoints }
310    }
311
312    /// Detects changepoints in multivariate (multi-signal) data.
313    ///
314    /// Each inner slice represents one signal channel. All channels must
315    /// have the same length. The cost function is applied independently
316    /// to each channel and summed — a single set of changepoints is
317    /// returned that applies to all channels simultaneously.
318    ///
319    /// The penalty scales with the number of channels: `penalty * n_channels`.
320    ///
321    /// # Parameters
322    ///
323    /// - `signals`: slice of signal channels, each of length `n`
324    ///
325    /// # Returns
326    ///
327    /// `None` if channels have inconsistent lengths.
328    /// Otherwise returns `Some(MultiPeltResult)`.
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use u_analytics::detection::{Pelt, CostFunction, Penalty};
334    ///
335    /// let signal_a: Vec<f64> = [vec![0.0; 50], vec![5.0; 50]].concat();
336    /// let signal_b: Vec<f64> = [vec![0.0; 50], vec![3.0; 50]].concat();
337    ///
338    /// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
339    /// let result = pelt.detect_multi(&[&signal_a, &signal_b]).unwrap();
340    /// assert!(!result.changepoints.is_empty());
341    /// ```
342    ///
343    /// # Reference
344    ///
345    /// Killick, R. & Eckley, I.A. (2014). "changepoint: An R Package for
346    /// Changepoint Analysis", *Journal of Statistical Software* 58(3).
347    ///
348    /// # Complexity
349    ///
350    /// Expected O(n * k), worst case O(n^2 * k) where k = number of channels.
351    pub fn detect_multi(&self, signals: &[&[f64]]) -> Option<MultiPeltResult> {
352        if signals.is_empty() {
353            return Some(MultiPeltResult {
354                changepoints: Vec::new(),
355            });
356        }
357
358        let n = signals[0].len();
359        if signals.iter().any(|s| s.len() != n) {
360            return None;
361        }
362
363        if n < 2 * self.min_segment_len {
364            return Some(MultiPeltResult {
365                changepoints: Vec::new(),
366            });
367        }
368
369        let n_channels = signals.len();
370        let penalty_value = self.resolve_penalty(n, signals);
371        if penalty_value <= 0.0 {
372            return Some(MultiPeltResult {
373                changepoints: Vec::new(),
374            });
375        }
376
377        // Precompute cumulative sums per channel for O(1) segment cost.
378        let mut cum_sums: Vec<Vec<f64>> = Vec::with_capacity(n_channels);
379        let mut cum_sum_sqs: Vec<Vec<f64>> = Vec::with_capacity(n_channels);
380
381        for signal in signals {
382            let (cs, css) = cumulative_sums(signal);
383            cum_sums.push(cs);
384            cum_sum_sqs.push(css);
385        }
386
387        let mut f = vec![0.0_f64; n + 1];
388        f[0] = -penalty_value;
389        let mut last_change = vec![0_usize; n + 1];
390        let mut candidates: Vec<usize> = vec![0];
391
392        for t in self.min_segment_len..=n {
393            let mut best_cost = f64::INFINITY;
394            let mut best_tau = 0;
395
396            for &tau in &candidates {
397                let seg_len = t - tau;
398                if seg_len < self.min_segment_len {
399                    continue;
400                }
401
402                let cost: f64 = (0..n_channels)
403                    .map(|ch| self.segment_cost(&cum_sums[ch], &cum_sum_sqs[ch], tau, t))
404                    .sum();
405                let total = f[tau] + cost + penalty_value;
406
407                if total < best_cost {
408                    best_cost = total;
409                    best_tau = tau;
410                }
411            }
412
413            f[t] = best_cost;
414            last_change[t] = best_tau;
415
416            candidates.retain(|&tau| {
417                let seg_len = t - tau;
418                if seg_len < self.min_segment_len {
419                    return true;
420                }
421                let cost: f64 = (0..n_channels)
422                    .map(|ch| self.segment_cost(&cum_sums[ch], &cum_sum_sqs[ch], tau, t))
423                    .sum();
424                f[tau] + cost < f[t] + penalty_value
425            });
426
427            candidates.push(t);
428        }
429
430        let mut changepoints = Vec::new();
431        let mut t = n;
432        while t > 0 {
433            let tau = last_change[t];
434            if tau > 0 {
435                changepoints.push(tau);
436            }
437            t = tau;
438        }
439        changepoints.sort_unstable();
440
441        Some(MultiPeltResult { changepoints })
442    }
443
444    /// Resolves the penalty value for `signals`, the channels of length `n`
445    /// that are searched together.
446    ///
447    /// The BIC penalty of the [`CostFunction::L2`] cost is in the cost's own
448    /// units: the cost is a sum of squares, the model a normal one with a
449    /// *known* variance, so the penalty `p * ln(n)` is that of the model with
450    /// the noise variance of each channel put back in ([`noise_variance`]).
451    /// Without it the same series would show a different number of changes in
452    /// volts than in millivolts. The `Normal` cost is a log and needs none.
453    /// A custom penalty is taken as given, once per channel.
454    fn resolve_penalty(&self, n: usize, signals: &[&[f64]]) -> f64 {
455        let channels = signals.len() as f64;
456        match self.penalty {
457            Penalty::Bic => {
458                let p = self.cost.params_per_segment() as f64;
459                let per_channel = p * (n as f64).ln();
460                match self.cost {
461                    CostFunction::L2 => {
462                        per_channel * signals.iter().map(|s| noise_variance(s)).sum::<f64>()
463                    }
464                    CostFunction::Normal => per_channel * channels,
465                }
466            }
467            Penalty::Custom(val) => val * channels,
468        }
469    }
470
471    /// Computes the cost of the segment `data[start..end]` using cumulative sums.
472    ///
473    /// # Panics
474    ///
475    /// Panics if `end <= start`.
476    fn segment_cost(&self, cum_sum: &[f64], cum_sum_sq: &[f64], start: usize, end: usize) -> f64 {
477        let seg_len = (end - start) as f64;
478        let sum = cum_sum[end] - cum_sum[start];
479        let sum_sq = cum_sum_sq[end] - cum_sum_sq[start];
480        let mean = sum / seg_len;
481
482        match self.cost {
483            CostFunction::L2 => {
484                // sum((y_i - mean)^2) = sum(y_i^2) - n * mean^2
485                sum_sq - seg_len * mean * mean
486            }
487            CostFunction::Normal => {
488                // n * ln(variance) where variance = sum((y_i - mean)^2) / n
489                let variance = (sum_sq - seg_len * mean * mean) / seg_len;
490                if variance <= 0.0 {
491                    // Degenerate segment (constant values): assign a large negative
492                    // log-likelihood to make it favorable (perfect fit).
493                    seg_len * (f64::MIN_POSITIVE).ln()
494                } else {
495                    seg_len * variance.ln()
496                }
497            }
498        }
499    }
500}
501
502/// The variance of the noise in `signal`, estimated from the first
503/// differences: for independent noise of variance s² a difference has
504/// variance 2s², and its median magnitude is 0.6745·√2·s, a statistic a few
505/// level shifts hardly move. The estimate is floored at a millionth of the
506/// signal's range, so that a noiseless step stays a step and the penalty
507/// stays above the rounding of the cost.
508fn noise_variance(signal: &[f64]) -> f64 {
509    if signal.len() < 2 {
510        return 0.0;
511    }
512    let mut diffs: Vec<f64> = signal.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
513    diffs.sort_by(f64::total_cmp);
514    let median = if diffs.len() % 2 == 1 {
515        diffs[diffs.len() / 2]
516    } else {
517        0.5 * (diffs[diffs.len() / 2 - 1] + diffs[diffs.len() / 2])
518    };
519    let sigma = median / (0.6745 * std::f64::consts::SQRT_2);
520    let (lo, hi) = signal
521        .iter()
522        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
523            (lo.min(v), hi.max(v))
524        });
525    let floor = 1e-6 * (hi - lo);
526    sigma.max(floor).powi(2)
527}
528
529/// The cumulative sums of `signal` and of its squares, taken about the
530/// signal's mean.
531///
532/// A segment's cost is `sum(y^2) - n * mean^2` (a difference of two sums),
533/// which is unchanged by shifting the signal. Taken about zero, a series that
534/// sits far from it (a Unix timestamp in seconds, an instrument with a large
535/// offset) loses the variance in that subtraction and is split at nearly every
536/// point; about its own mean the two terms are the size of the variation.
537fn cumulative_sums(signal: &[f64]) -> (Vec<f64>, Vec<f64>) {
538    let n = signal.len();
539    let center = signal.iter().sum::<f64>() / n as f64;
540    let mut cum_sum = vec![0.0_f64; n + 1];
541    let mut cum_sum_sq = vec![0.0_f64; n + 1];
542    for (i, &y) in signal.iter().enumerate() {
543        let y = y - center;
544        cum_sum[i + 1] = cum_sum[i] + y;
545        cum_sum_sq[i + 1] = cum_sum_sq[i] + y * y;
546    }
547    (cum_sum, cum_sum_sq)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    // --- Constructor validation ---
555
556    #[test]
557    fn test_pelt_valid_construction() {
558        assert!(Pelt::new(CostFunction::L2, Penalty::Bic).is_some());
559        assert!(Pelt::new(CostFunction::Normal, Penalty::Bic).is_some());
560        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(10.0)).is_some());
561    }
562
563    #[test]
564    fn test_pelt_invalid_custom_penalty() {
565        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(0.0)).is_none());
566        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(-1.0)).is_none());
567        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::NAN)).is_none());
568        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::INFINITY)).is_none());
569    }
570
571    #[test]
572    fn test_pelt_invalid_min_segment_len() {
573        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 0).is_none());
574        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 1).is_none());
575        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 2).is_some());
576    }
577
578    // --- Empty and short data ---
579
580    #[test]
581    fn test_pelt_empty_data() {
582        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
583        let result = pelt.detect(&[]);
584        assert!(result.changepoints.is_empty());
585    }
586
587    #[test]
588    fn test_pelt_too_short_data() {
589        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
590        // min_segment_len=2, need at least 4 points for a changepoint
591        let result = pelt.detect(&[1.0, 2.0, 3.0]);
592        assert!(result.changepoints.is_empty());
593    }
594
595    // --- No changepoint scenarios ---
596
597    #[test]
598    fn test_pelt_constant_data_no_changepoint() {
599        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
600        let data = vec![5.0; 100];
601        let result = pelt.detect(&data);
602        assert!(
603            result.changepoints.is_empty(),
604            "constant data should have no changepoints, got {:?}",
605            result.changepoints
606        );
607    }
608
609    #[test]
610    fn test_pelt_normal_cost_constant_data() {
611        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
612        let data = vec![5.0; 100];
613        let result = pelt.detect(&data);
614        assert!(
615            result.changepoints.is_empty(),
616            "constant data should have no changepoints with Normal cost, got {:?}",
617            result.changepoints
618        );
619    }
620
621    // --- Single changepoint detection ---
622
623    #[test]
624    fn test_pelt_single_mean_shift_l2() {
625        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
626
627        let mut data = vec![0.0; 50];
628        data.extend(vec![5.0; 50]);
629
630        let result = pelt.detect(&data);
631        assert_eq!(
632            result.changepoints.len(),
633            1,
634            "expected 1 changepoint, got {:?}",
635            result.changepoints
636        );
637        assert!(
638            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
639            "changepoint should be near index 50, got {}",
640            result.changepoints[0]
641        );
642    }
643
644    #[test]
645    fn test_pelt_single_mean_shift_normal() {
646        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
647
648        let mut data = vec![0.0; 50];
649        data.extend(vec![5.0; 50]);
650
651        let result = pelt.detect(&data);
652        assert_eq!(
653            result.changepoints.len(),
654            1,
655            "expected 1 changepoint with Normal cost, got {:?}",
656            result.changepoints
657        );
658        assert!(
659            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
660            "changepoint should be near index 50, got {}",
661            result.changepoints[0]
662        );
663    }
664
665    // --- Multiple changepoints ---
666
667    #[test]
668    fn test_pelt_two_changepoints() {
669        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
670
671        let mut data = vec![0.0; 40];
672        data.extend(vec![5.0; 40]);
673        data.extend(vec![0.0; 40]);
674
675        let result = pelt.detect(&data);
676        assert_eq!(
677            result.changepoints.len(),
678            2,
679            "expected 2 changepoints, got {:?}",
680            result.changepoints
681        );
682
683        // Changepoints should be near 40 and 80
684        assert!(
685            (result.changepoints[0] as i64 - 40).unsigned_abs() <= 2,
686            "first changepoint near 40, got {}",
687            result.changepoints[0]
688        );
689        assert!(
690            (result.changepoints[1] as i64 - 80).unsigned_abs() <= 2,
691            "second changepoint near 80, got {}",
692            result.changepoints[1]
693        );
694    }
695
696    #[test]
697    fn test_pelt_three_changepoints() {
698        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
699
700        let mut data = vec![0.0; 30];
701        data.extend(vec![4.0; 30]);
702        data.extend(vec![-2.0; 30]);
703        data.extend(vec![3.0; 30]);
704
705        let result = pelt.detect(&data);
706        assert_eq!(
707            result.changepoints.len(),
708            3,
709            "expected 3 changepoints, got {:?}",
710            result.changepoints
711        );
712
713        // Check ordering
714        for i in 1..result.changepoints.len() {
715            assert!(
716                result.changepoints[i] > result.changepoints[i - 1],
717                "changepoints should be strictly increasing"
718            );
719        }
720    }
721
722    // --- Variance change detection ---
723
724    #[test]
725    fn test_pelt_variance_change_normal_cost() {
726        // Normal cost should detect a variance change even when mean is constant.
727        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
728
729        // Low variance segment then high variance segment
730        let mut data = Vec::with_capacity(200);
731        // Segment 1: mean=0, low spread (deterministic zigzag)
732        for i in 0..100 {
733            data.push(if i % 2 == 0 { 0.1 } else { -0.1 });
734        }
735        // Segment 2: mean=0, high spread
736        for i in 0..100 {
737            data.push(if i % 2 == 0 { 5.0 } else { -5.0 });
738        }
739
740        let result = pelt.detect(&data);
741        assert!(
742            !result.changepoints.is_empty(),
743            "Normal cost should detect variance change"
744        );
745        // The changepoint should be near index 100
746        let cp = result.changepoints[0];
747        assert!(
748            (cp as i64 - 100).unsigned_abs() <= 5,
749            "variance changepoint should be near 100, got {}",
750            cp
751        );
752    }
753
754    // --- Penalty sensitivity ---
755
756    #[test]
757    fn test_pelt_higher_penalty_fewer_changepoints() {
758        let mut data = vec![0.0; 30];
759        data.extend(vec![2.0; 30]);
760        data.extend(vec![0.0; 30]);
761
762        let pelt_low = Pelt::new(CostFunction::L2, Penalty::Custom(1.0)).expect("valid");
763        let pelt_high = Pelt::new(CostFunction::L2, Penalty::Custom(100.0)).expect("valid");
764
765        let result_low = pelt_low.detect(&data);
766        let result_high = pelt_high.detect(&data);
767
768        assert!(
769            result_low.changepoints.len() >= result_high.changepoints.len(),
770            "higher penalty should produce fewer or equal changepoints: low={}, high={}",
771            result_low.changepoints.len(),
772            result_high.changepoints.len()
773        );
774    }
775
776    // --- Custom minimum segment length ---
777
778    #[test]
779    fn test_pelt_custom_min_segment_len() {
780        let mut data = vec![0.0; 50];
781        data.extend(vec![10.0; 50]);
782
783        let pelt = Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 10).expect("valid");
784        let result = pelt.detect(&data);
785        assert_eq!(
786            result.changepoints.len(),
787            1,
788            "should detect changepoint with min_segment_len=10"
789        );
790
791        // All segments should respect minimum length
792        let mut boundaries = vec![0];
793        boundaries.extend_from_slice(&result.changepoints);
794        boundaries.push(data.len());
795        for i in 1..boundaries.len() {
796            let seg_len = boundaries[i] - boundaries[i - 1];
797            assert!(
798                seg_len >= 10,
799                "segment length {} is less than min_segment_len=10",
800                seg_len
801            );
802        }
803    }
804
805    // --- Exact numeric verification ---
806
807    /// Verifies PELT on a simple 4-point example with known optimal solution.
808    ///
809    /// Data: [0, 0, 10, 10], penalty = 2*ln(4) ≈ 2.77
810    ///
811    /// Without changepoint: cost = sum((y_i - 5)^2) = 25+25+25+25 = 100
812    /// With changepoint at 2: cost = 0 + 0 + 2*ln(4) = 2.77 (L2 cost of each segment = 0)
813    ///
814    /// PELT should find changepoint at index 2.
815    #[test]
816    fn test_pelt_exact_small_example() {
817        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
818        let data = [0.0, 0.0, 10.0, 10.0];
819        let result = pelt.detect(&data);
820
821        assert_eq!(
822            result.changepoints.len(),
823            1,
824            "expected 1 changepoint in [0,0,10,10], got {:?}",
825            result.changepoints
826        );
827        assert_eq!(
828            result.changepoints[0], 2,
829            "changepoint should be at index 2"
830        );
831    }
832
833    // --- Changepoints are sorted ---
834
835    #[test]
836    fn test_pelt_changepoints_sorted() {
837        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
838
839        let mut data = vec![0.0; 25];
840        data.extend(vec![5.0; 25]);
841        data.extend(vec![-3.0; 25]);
842        data.extend(vec![7.0; 25]);
843
844        let result = pelt.detect(&data);
845        for i in 1..result.changepoints.len() {
846            assert!(
847                result.changepoints[i] > result.changepoints[i - 1],
848                "changepoints must be strictly increasing: {:?}",
849                result.changepoints
850            );
851        }
852    }
853
854    // --- BIC penalty scales with n ---
855
856    #[test]
857    fn test_pelt_bic_penalty_scales() {
858        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
859
860        // BIC for L2: penalty = ln(n). For n=100, penalty ≈ 4.6.
861        // A small shift should not be detected with BIC.
862        let mut data = vec![0.0; 50];
863        data.extend(vec![0.5; 50]); // Very small shift
864
865        let result = pelt.detect(&data);
866        // BIC should suppress this tiny shift
867        // (0.5^2 * 50 = 12.5 for each segment reduction, but penalty is ~4.6)
868        // This depends on exact cost, but a 0.5-unit shift in 100 points
869        // may or may not be detected. We just verify it runs without panic.
870        let _ = result;
871    }
872
873    // --- Property: segments cover entire data ---
874
875    #[test]
876    fn test_pelt_segments_cover_data() {
877        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
878
879        let mut data = vec![0.0; 30];
880        data.extend(vec![5.0; 30]);
881        data.extend(vec![0.0; 30]);
882
883        let result = pelt.detect(&data);
884
885        // Verify segments cover [0, n) without gaps
886        let mut boundaries = vec![0];
887        boundaries.extend_from_slice(&result.changepoints);
888        boundaries.push(data.len());
889
890        for i in 1..boundaries.len() {
891            assert!(
892                boundaries[i] > boundaries[i - 1],
893                "segments must not have zero length"
894            );
895        }
896        assert_eq!(
897            *boundaries.last().expect("non-empty boundaries"),
898            data.len(),
899            "segments must cover entire data"
900        );
901    }
902
903    // --- Downward shift ---
904
905    #[test]
906    fn test_pelt_downward_shift() {
907        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
908
909        let mut data = vec![10.0; 50];
910        data.extend(vec![2.0; 50]); // Downward shift
911
912        let result = pelt.detect(&data);
913        assert_eq!(result.changepoints.len(), 1, "should detect downward shift");
914        assert!(
915            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
916            "changepoint should be near index 50, got {}",
917            result.changepoints[0]
918        );
919    }
920
921    // --- Cost function enum properties ---
922
923    #[test]
924    fn test_cost_function_params() {
925        assert_eq!(CostFunction::L2.params_per_segment(), 1);
926        assert_eq!(CostFunction::Normal.params_per_segment(), 2);
927    }
928
929    // --- Multi-signal tests ---
930
931    #[test]
932    fn test_pelt_multi_single_channel_matches_univariate() {
933        let pelt = Pelt::new(CostFunction::L2, Penalty::Custom(5.0)).expect("valid");
934        let mut data = vec![0.0; 50];
935        data.extend(vec![5.0; 50]);
936
937        let uni = pelt.detect(&data);
938        let multi = pelt.detect_multi(&[&data]).expect("valid");
939        assert_eq!(uni.changepoints, multi.changepoints);
940    }
941
942    #[test]
943    fn test_pelt_multi_two_channels() {
944        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
945        let a: Vec<f64> = [vec![0.0; 50], vec![5.0; 50]].concat();
946        let b: Vec<f64> = [vec![0.0; 50], vec![3.0; 50]].concat();
947
948        let result = pelt.detect_multi(&[&a, &b]).expect("valid");
949        assert_eq!(
950            result.changepoints.len(),
951            1,
952            "expected 1 changepoint, got {:?}",
953            result.changepoints
954        );
955        assert!(
956            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
957            "changepoint near 50, got {}",
958            result.changepoints[0]
959        );
960    }
961
962    #[test]
963    fn test_pelt_multi_inconsistent_lengths() {
964        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
965        let a = vec![0.0; 50];
966        let b = vec![0.0; 30];
967        assert!(pelt.detect_multi(&[&a[..], &b[..]]).is_none());
968    }
969
970    #[test]
971    fn test_pelt_multi_empty_signals() {
972        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
973        let result = pelt.detect_multi(&[]).expect("valid");
974        assert!(result.changepoints.is_empty());
975    }
976
977    #[test]
978    fn test_pelt_multi_three_channels_two_changepoints() {
979        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
980        let a: Vec<f64> = [vec![0.0; 40], vec![5.0; 40], vec![0.0; 40]].concat();
981        let b: Vec<f64> = [vec![0.0; 40], vec![3.0; 40], vec![0.0; 40]].concat();
982        let c: Vec<f64> = [vec![0.0; 40], vec![4.0; 40], vec![0.0; 40]].concat();
983
984        let result = pelt.detect_multi(&[&a, &b, &c]).expect("valid");
985        assert_eq!(
986            result.changepoints.len(),
987            2,
988            "expected 2 changepoints, got {:?}",
989            result.changepoints
990        );
991    }
992
993    #[test]
994    fn test_pelt_multi_short_data() {
995        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
996        let a = [1.0, 2.0];
997        let b = [3.0, 4.0];
998        let result = pelt.detect_multi(&[&a, &b]).expect("valid");
999        assert!(result.changepoints.is_empty());
1000    }
1001}
1002
1003/// PELT against the optimum of its own objective -- the exact dynamic program
1004/// `F(t) = min over tau of F(tau) + C(tau, t) + beta` over every allowed last
1005/// changepoint, with segment costs from a two-pass mean and sum of squares --
1006/// written in Python 3 with NumPy 2.4.6. PELT's pruning must not change the
1007/// answer (Killick et al. 2012, Theorem 3.1).
1008#[cfg(test)]
1009mod exact_dp_reference {
1010    use super::*;
1011
1012    /// L2 BIC two mean shifts.
1013    const CASE_0: [f64; 60] = [
1014        1.02, -1.278, 0.209, -0.284, -0.226, -0.108, -1.01, -0.116, -0.433, 1.661, 0.113, -0.176,
1015        -0.141, -0.334, -0.528, -0.195, 0.241, -0.119, 0.479, -0.1, 3.012, 3.773, 3.273, 2.747,
1016        2.909, 3.27, 3.968, 2.865, 2.878, 3.501, 2.557, 2.854, 3.441, 3.29, 3.046, 3.335, 1.586,
1017        3.511, 2.52, 2.166, 3.138, 3.35, 2.778, 2.462, 3.013, 0.474, 1.203, 0.874, 0.597, 1.056,
1018        0.397, 0.037, 0.792, 0.791, 0.393, 0.109, 0.615, -0.747, 0.845, 0.746,
1019    ];
1020
1021    /// Normal BIC variance + mean shift, min seg 5.
1022    const CASE_1: [f64; 80] = [
1023        -0.656, 0.025, -0.386, 0.303, -0.814, -0.366, 0.284, 0.463, -0.863, -0.199, 0.131, -0.244,
1024        0.636, -0.476, 0.142, -0.419, 0.562, -0.009, -0.149, -0.687, 0.673, 0.301, 0.301, 0.455,
1025        0.14, -0.256, -0.32, -0.32, 0.548, -0.584, -0.239, -0.128, 0.09, 0.23, -0.5, -0.692,
1026        -0.002, 0.485, 0.303, 0.086, 0.366, 1.586, 0.513, 2.634, -0.589, 1.268, 0.778, 2.087,
1027        1.449, 6.1, 3.997, 3.993, -3.079, 0.319, -0.217, 2.065, -3.558, 3.349, 3.134, -1.604,
1028        -0.957, -0.602, 1.087, 2.282, 5.096, 0.605, 2.535, 1.311, 4.52, 2.484, 3.737, -1.155,
1029        0.616, -0.628, 4.01, 2.315, 0.39, 0.095, 1.969, -0.403,
1030    ];
1031
1032    /// L2 custom penalty 5, min seg 5.
1033    const CASE_2: [f64; 50] = [
1034        -0.931, 0.481, 2.463, -0.246, -0.556, -1.171, -1.335, 0.525, 0.851, 0.009, 0.333, 0.116,
1035        0.139, -1.526, -0.458, 0.111, -0.783, -0.476, -0.819, -0.333, 0.853, -0.407, -0.154, 0.814,
1036        0.645, 2.895, -0.89, 2.057, 0.718, 1.335, 2.038, 2.283, 2.239, 1.355, 2.81, 0.917, 1.059,
1037        1.999, 0.649, 3.361, 2.219, 3.376, 1.173, 0.817, 1.367, 1.935, 0.613, 1.58, 1.183, 2.816,
1038    ];
1039
1040    /// L2 BIC large offset 1e7.
1041    const CASE_3: [f64; 60] = [
1042        9999999.337,
1043        10000001.046,
1044        9999999.356,
1045        9999999.039,
1046        9999999.29,
1047        9999998.81,
1048        10000000.146,
1049        10000001.031,
1050        10000000.164,
1051        10000000.624,
1052        10000001.632,
1053        10000000.27,
1054        10000000.195,
1055        9999999.725,
1056        9999998.392,
1057        10000000.76,
1058        9999998.244,
1059        10000000.653,
1060        9999999.986,
1061        10000001.127,
1062        9999999.932,
1063        9999999.177,
1064        10000000.358,
1065        9999999.44,
1066        9999999.819,
1067        10000000.042,
1068        9999999.865,
1069        9999999.811,
1070        9999999.168,
1071        9999999.811,
1072        10000001.862,
1073        10000003.843,
1074        10000002.802,
1075        10000005.12,
1076        10000005.27,
1077        10000002.049,
1078        10000004.145,
1079        10000003.874,
1080        10000002.953,
1081        10000004.532,
1082        10000003.538,
1083        10000002.232,
1084        10000003.733,
1085        10000003.852,
1086        10000004.106,
1087        10000002.769,
1088        10000004.616,
1089        10000004.735,
1090        10000002.854,
1091        10000003.341,
1092        10000003.92,
1093        10000003.434,
1094        10000005.744,
1095        10000004.208,
1096        10000002.989,
1097        10000003.212,
1098        10000003.943,
1099        10000006.296,
1100        10000003.822,
1101        10000004.127,
1102    ];
1103
1104    fn detect(cost: CostFunction, penalty: Penalty, min_len: usize, data: &[f64]) -> Vec<usize> {
1105        Pelt::with_min_segment_len(cost, penalty, min_len)
1106            .expect("valid settings")
1107            .detect(data)
1108            .changepoints
1109    }
1110
1111    /// A mean shift at 40 on a level of 1.7e9 (a Unix timestamp in seconds).
1112    const LARGE_LEVEL: [f64; 80] = [
1113        1699999999.198,
1114        1699999998.676,
1115        1699999999.752,
1116        1700000000.42,
1117        1700000001.136,
1118        1700000000.11,
1119        1699999999.447,
1120        1699999999.215,
1121        1700000000.749,
1122        1700000001.635,
1123        1700000000.273,
1124        1699999998.767,
1125        1699999999.042,
1126        1700000001.6,
1127        1700000000.203,
1128        1699999998.268,
1129        1699999999.916,
1130        1699999998.837,
1131        1699999999.371,
1132        1699999999.512,
1133        1699999999.287,
1134        1700000000.553,
1135        1699999999.937,
1136        1699999999.411,
1137        1700000000.41,
1138        1700000000.83,
1139        1699999998.357,
1140        1699999999.743,
1141        1699999999.019,
1142        1699999999.827,
1143        1699999998.711,
1144        1700000000.021,
1145        1699999999.962,
1146        1699999999.696,
1147        1699999998.952,
1148        1699999999.604,
1149        1699999998.909,
1150        1699999998.645,
1151        1700000000.225,
1152        1699999998.891,
1153        1700000006.17,
1154        1700000005.717,
1155        1700000003.002,
1156        1700000005.272,
1157        1700000003.898,
1158        1700000005.033,
1159        1700000005.044,
1160        1700000003.012,
1161        1700000004.767,
1162        1700000004.744,
1163        1700000005.962,
1164        1700000003.819,
1165        1700000005.738,
1166        1700000003.901,
1167        1700000004.669,
1168        1700000004.16,
1169        1700000006.449,
1170        1700000005.568,
1171        1700000007.432,
1172        1700000005.642,
1173        1700000005.845,
1174        1700000005.841,
1175        1700000004.393,
1176        1700000004.93,
1177        1700000006.35,
1178        1700000004.603,
1179        1700000005.189,
1180        1700000004.979,
1181        1700000005.609,
1182        1700000004.635,
1183        1700000004.848,
1184        1700000005.242,
1185        1700000005.103,
1186        1700000004.135,
1187        1700000005.896,
1188        1700000003.702,
1189        1700000003.799,
1190        1700000003.718,
1191        1700000005.967,
1192        1700000004.639,
1193    ];
1194
1195    #[test]
1196    fn a_large_level_does_not_move_the_optimum() {
1197        // The same series minus 1.7e9: the segment costs are shift-invariant,
1198        // so the changepoints must be the same, [40, 56, 62] by the dynamic program.
1199        assert_eq!(
1200            detect(
1201                CostFunction::L2,
1202                Penalty::Custom((LARGE_LEVEL.len() as f64).ln()),
1203                2,
1204                &LARGE_LEVEL
1205            ),
1206            vec![40, 56, 62]
1207        );
1208    }
1209
1210    #[test]
1211    fn pruned_search_returns_the_exact_optimum() {
1212        // L2 BIC two mean shifts
1213        assert_eq!(
1214            detect(
1215                CostFunction::L2,
1216                Penalty::Custom((CASE_0.len() as f64).ln()),
1217                2,
1218                &CASE_0
1219            ),
1220            vec![20, 45]
1221        );
1222        // Normal BIC variance + mean shift, min seg 5
1223        assert_eq!(
1224            detect(CostFunction::Normal, Penalty::Bic, 5, &CASE_1),
1225            vec![41]
1226        );
1227        // L2 custom penalty 5, min seg 5
1228        assert_eq!(
1229            detect(CostFunction::L2, Penalty::Custom(5.0), 5, &CASE_2),
1230            vec![25]
1231        );
1232        // L2 BIC large offset 1e7
1233        assert_eq!(
1234            detect(
1235                CostFunction::L2,
1236                Penalty::Custom((CASE_3.len() as f64).ln()),
1237                2,
1238                &CASE_3
1239            ),
1240            vec![31]
1241        );
1242    }
1243}
1244
1245/// `detect_multi` against an exact dynamic program over every channel at once:
1246/// `F(t) = min over tau of F(tau) + sum over channels of cost(tau, t) + k * beta`,
1247/// with `F(0) = -k * beta` (the penalty the method documents, `penalty *
1248/// n_channels`), the segment costs taken in two passes. The reference is
1249/// Python 3 with NumPy; ties go to the smallest `tau`, as in the pruned search.
1250#[cfg(test)]
1251mod exact_dp_reference_multi {
1252    use super::*;
1253
1254    fn detect_multi(
1255        cost: CostFunction,
1256        penalty: Penalty,
1257        min_len: usize,
1258        channels: &[&[f64]],
1259    ) -> Vec<usize> {
1260        Pelt::with_min_segment_len(cost, penalty, min_len)
1261            .expect("valid settings")
1262            .detect_multi(channels)
1263            .expect("equal lengths")
1264            .changepoints
1265    }
1266
1267    const A_0: [f64; 60] = [
1268        -0.804, 0.801, -0.66, -0.813, 0.703, 1.326, -0.448, 2.263, -1.25, -2.127, 1.654, -0.384,
1269        0.038, -0.988, 1.559, -1.068, -0.242, -1.431, -1.253, -0.734, -0.485, 3.102, 2.171, -0.177,
1270        -1.051, 0.913, 3.976, 2.577, 1.26, 2.31, 4.25, 2.975, 2.82, 1.342, 3.658, 2.263, 4.546,
1271        0.756, 1.575, 0.993, 2.598, 3.666, 3.249, 2.378, 2.049, 3.232, 2.173, 1.678, 2.103, 2.626,
1272        1.201, 3.216, 1.34, 2.219, 3.623, 3.182, 5.136, 2.97, 1.461, 1.545,
1273    ];
1274
1275    const A_1: [f64; 60] = [
1276        1.04, -1.488, 0.963, -0.538, 1.736, 0.943, -0.191, 1.916, 0.606, -1.098, -0.764, 0.437,
1277        -0.276, 0.696, -1.029, -0.504, -0.287, -0.619, 0.587, -0.272, -1.753, 0.125, -2.015,
1278        -0.146, 1.821, 1.502, 1.776, -0.202, 1.056, 3.046, 2.47, 0.695, 1.365, 1.453, 0.705,
1279        -0.373, 1.967, 0.828, 1.686, -0.625, 0.406, 2.587, 1.235, -1.549, 1.573, -0.702, 2.101,
1280        2.461, 2.297, 1.882, 2.46, 2.129, 2.153, 3.022, -0.409, 0.904, 2.0, 2.557, 0.326, 1.061,
1281    ];
1282
1283    const B_0: [f64; 60] = [
1284        0.549, -0.705, -0.681, -0.052, 0.206, -0.084, -0.413, -0.562, -0.393, 0.281, 0.6, -1.232,
1285        -0.127, 0.072, 0.255, -0.094, -0.273, 0.606, -0.597, 0.615, 0.599, -0.193, 0.08, 1.066,
1286        -0.624, -0.284, 0.675, -0.924, -0.54, -0.564, -1.563, 2.435, -3.255, 0.942, 0.323, 1.455,
1287        -5.14, 1.305, -3.933, -0.982, 1.378, 2.145, -4.895, 0.009, -1.539, 3.119, -0.391, -2.202,
1288        5.382, 3.363, 2.322, -0.958, 1.278, -1.489, -0.671, -3.143, -0.972, 4.385, 1.683, -1.434,
1289    ];
1290
1291    const B_1: [f64; 60] = [
1292        -0.394, -0.653, 0.721, -0.457, -0.009, 0.161, -0.763, 0.533, -0.474, -0.655, -0.423, 0.734,
1293        0.24, 0.621, -0.094, -0.334, 0.193, 0.01, 0.27, 0.499, 0.814, -1.284, 0.793, 0.584, -0.022,
1294        -0.673, 0.033, 0.267, -0.507, 0.303, 5.49, -4.019, 0.665, 4.472, -0.633, -5.961, 2.979,
1295        1.608, 2.534, -0.708, -2.226, 1.057, 7.403, 1.97, 2.913, 3.112, 1.576, 4.229, 3.941, 1.798,
1296        0.382, 0.434, -2.124, 3.613, 2.052, 2.66, 2.733, 5.311, 0.163, 5.14,
1297    ];
1298
1299    const B_2: [f64; 60] = [
1300        0.13, -0.45, -0.281, 0.099, -0.849, -0.968, -0.102, 0.034, -0.244, -0.855, -1.248, 0.123,
1301        -0.757, -0.162, 0.304, -0.05, -0.153, 0.633, 0.197, -0.539, 0.398, -0.124, 0.021, 0.168,
1302        -0.909, -0.353, -0.161, -0.339, -0.212, 0.003, 0.043, -0.051, -0.087, -0.065, -0.55,
1303        -0.066, 0.756, 0.312, -0.21, -0.042, -0.518, -0.595, -0.717, -0.091, 0.24, -0.416, -0.179,
1304        0.611, -0.219, -0.528, -0.603, -1.164, -0.22, 0.875, -0.005, 1.255, -0.619, -0.851, 0.107,
1305        0.101,
1306    ];
1307
1308    const C_0: [f64; 60] = [
1309        0.954, 0.369, 0.316, 0.089, -0.008, -0.682, 0.736, -2.132, 2.099, -1.086, -0.896, -0.925,
1310        -0.576, -1.991, -0.338, 0.524, -0.182, 0.678, -0.767, 0.299, 1.874, 3.856, 2.051, 3.948,
1311        3.904, 2.532, 1.485, 2.418, 4.804, 3.011, 3.46, 3.401, 3.144, 4.83, 2.15, 1.129, 1.494,
1312        5.09, 3.588, 3.541, 0.143, -1.055, 1.09, 1.263, 0.046, -0.552, 1.53, 1.03, 1.19, -0.342,
1313        0.123, -0.145, -0.717, 0.334, 0.933, -1.102, 0.313, -2.129, 0.467, 0.762,
1314    ];
1315
1316    const C_1: [f64; 60] = [
1317        0.443, -0.016, 0.496, -0.191, 0.405, 0.124, 0.133, -0.853, -0.11, -1.616, -0.619, -0.114,
1318        0.05, 1.525, -1.539, 0.252, 0.402, 1.997, -0.821, 1.855, -3.542, -1.497, -2.465, 0.122,
1319        -3.354, -3.124, -2.577, -2.521, -1.298, -2.091, 0.046, -2.452, -1.855, -2.804, -2.243,
1320        -2.454, -1.28, -0.159, -1.652, -1.664, 0.177, -0.067, 0.107, -0.184, -0.051, 0.917, 1.159,
1321        1.173, 1.039, 0.66, 0.071, -0.928, 0.8, -0.388, 1.233, -0.091, 0.962, 0.423, -1.042,
1322        -0.113,
1323    ];
1324
1325    const D_0: [f64; 60] = [
1326        999998.994,
1327        999998.457,
1328        999997.649,
1329        999999.049,
1330        1000000.222,
1331        1000000.29,
1332        999999.019,
1333        1000000.062,
1334        1000000.761,
1335        999998.951,
1336        999999.22,
1337        999999.29,
1338        999999.563,
1339        1000000.661,
1340        1000000.006,
1341        999999.314,
1342        1000000.983,
1343        999998.466,
1344        1000000.97,
1345        1000002.599,
1346        999999.604,
1347        999998.518,
1348        1000001.488,
1349        1000001.909,
1350        999999.945,
1351        999998.831,
1352        999999.774,
1353        999999.966,
1354        999998.892,
1355        999998.914,
1356        1000003.175,
1357        1000004.168,
1358        1000004.646,
1359        1000003.017,
1360        1000001.941,
1361        1000004.399,
1362        1000003.577,
1363        1000002.784,
1364        1000003.348,
1365        1000002.429,
1366        1000002.519,
1367        1000001.615,
1368        1000002.515,
1369        1000001.867,
1370        1000003.272,
1371        1000004.196,
1372        1000000.7,
1373        1000003.487,
1374        1000003.406,
1375        1000002.157,
1376        1000003.654,
1377        1000002.441,
1378        1000002.837,
1379        1000003.022,
1380        1000003.252,
1381        1000003.838,
1382        1000003.735,
1383        1000002.762,
1384        1000003.664,
1385        1000003.312,
1386    ];
1387
1388    const D_1: [f64; 60] = [
1389        0.323, 1.153, -0.737, -0.765, -0.387, -0.132, -0.38, -0.017, 0.039, -1.855, 1.465, 2.111,
1390        -1.086, 0.568, 0.859, 1.543, 1.12, 0.916, -0.591, -1.247, -0.131, -0.231, -0.639, -0.829,
1391        0.322, -0.727, -1.789, -0.935, -0.092, 0.186, 1.729, 3.461, 2.688, 1.473, 3.507, 1.722,
1392        1.093, 0.833, 1.715, 1.832, 3.849, 1.504, 1.669, 3.159, 1.839, 2.265, 2.845, 1.986, 1.832,
1393        0.915, 2.022, 2.275, 1.484, 2.071, 3.911, -0.234, 2.667, 1.866, 2.315, 2.401,
1394    ];
1395
1396    const D_2: [f64; 60] = [
1397        -50.107, -50.532, -50.842, -48.33, -49.442, -49.761, -51.681, -52.824, -50.637, -51.821,
1398        -53.9, -49.553, -48.224, -49.598, -48.92, -51.311, -48.414, -46.838, -47.13, -49.746,
1399        -47.412, -51.215, -47.565, -50.883, -50.071, -53.527, -49.043, -48.191, -51.551, -49.062,
1400        -48.796, -49.414, -46.466, -48.3, -49.039, -48.988, -48.978, -48.995, -52.944, -51.869,
1401        -51.183, -47.484, -49.5, -48.72, -48.391, -50.368, -47.708, -47.899, -50.359, -50.786,
1402        -49.405, -49.132, -46.105, -56.126, -49.201, -49.857, -51.277, -51.729, -50.852, -49.224,
1403    ];
1404
1405    #[test]
1406    fn the_pruned_multi_channel_search_returns_the_exact_optimum() {
1407        assert_eq!(
1408            detect_multi(
1409                CostFunction::L2,
1410                Penalty::Custom((A_0.len() as f64).ln()),
1411                2,
1412                &[&A_0, &A_1]
1413            ),
1414            vec![21, 23, 26]
1415        );
1416        assert_eq!(
1417            detect_multi(CostFunction::Normal, Penalty::Bic, 5, &[&B_0, &B_1, &B_2]),
1418            vec![30]
1419        );
1420        assert_eq!(
1421            detect_multi(CostFunction::L2, Penalty::Custom(8.0), 5, &[&C_0, &C_1]),
1422            vec![20, 40]
1423        );
1424        assert_eq!(
1425            detect_multi(
1426                CostFunction::L2,
1427                Penalty::Custom((D_0.len() as f64).ln()),
1428                2,
1429                &[&D_0, &D_1, &D_2]
1430            ),
1431            vec![6, 11, 21, 30, 38, 41, 53]
1432        );
1433    }
1434}