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