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//! Candidates `tau` that can never be optimal again are pruned, which keeps
21//! the answer exact. For the Normal cost the rule is the inequality of
22//! Killick et al. (2012): drop `tau` once `F(tau) + C(y_{tau+1:t}) + K >= F(t)`.
23//! For the L2 cost on up to two channels it is functional (Maidstone et al.
24//! 2017): a candidate is dropped once no value of its last segment's mean is
25//! left where it wins.
26//!
27//! # Complexity
28//!
29//! L2 on one or two channels: near-linear in `n`, with or without changes.
30//! Otherwise (Normal, or L2 on three channels or more, where functional
31//! pruning costs more than it saves): expected O(n) when the number of
32//! changes grows with `n`; O(n^2) for a series without changes, where the
33//! inequality prunes nothing.
34//!
35//! # Cost Functions
36//!
37//! - [`CostFunction::L2`] — Detects changes in mean. Cost = sum of squared residuals
38//!   from segment mean. One parameter per segment.
39//! - [`CostFunction::Normal`] — Detects changes in mean and/or variance. Cost =
40//!   n * ln(MLE variance). Two parameters per segment.
41//!
42//! # Penalty Selection
43//!
44//! - **BIC** (default): `beta = p * ln(n)` where `p` is the number of parameters
45//!   per segment (1 for L2, 2 for Normal). Balances model complexity and fit.
46//!   For the L2 cost, a sum of squares in the data's units squared, it is
47//!   multiplied by the noise variance estimated from the data (the median of
48//!   the first differences, robust to the shifts being searched for), so the
49//!   same series gives the same changepoints in any unit.
50//! - **Custom**: User-specified penalty value.
51//!
52//! # References
53//!
54//! - Killick, R., Fearnhead, P., & Eckley, I.A. (2012). "Optimal Detection of
55//!   Changepoints with a Linear Computational Cost", *Journal of the American
56//!   Statistical Association* 107(500), pp. 1590-1598.
57//! - Maidstone, R., Hocking, T., Rigaill, G., & Fearnhead, P. (2017). "On
58//!   optimal multiple changepoint algorithms for large data", *Statistics and
59//!   Computing* 27(2), pp. 519-533.
60//! - Pishchagina, L., Rigaill, G., & Runge, V. (2023). "Geometric-based
61//!   pruning rules for change point detection in multiple independent time
62//!   series", arXiv preprint.
63//! - Schwarz, G. (1978). "Estimating the Dimension of a Model",
64//!   *Annals of Statistics* 6(2), pp. 461-464.
65
66/// Cost function for evaluating segment homogeneity.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum CostFunction {
69    /// Detects changes in mean only.
70    ///
71    /// Segment cost: `sum((y_i - y_bar)^2)` where `y_bar` is the segment mean.
72    /// Equivalent to minus log-likelihood of a normal model with known variance.
73    /// One parameter per segment (mean).
74    L2,
75    /// Detects changes in mean and/or variance.
76    ///
77    /// Segment cost: `n * ln(sigma^2_MLE)` where `sigma^2_MLE = sum((y_i - y_bar)^2) / n`.
78    /// Derived from the negative log-likelihood of a normal distribution
79    /// with both mean and variance estimated by MLE.
80    /// Two parameters per segment (mean, variance).
81    Normal,
82}
83
84impl CostFunction {
85    /// Number of estimated parameters per segment.
86    fn params_per_segment(self) -> usize {
87        match self {
88            CostFunction::L2 => 1,
89            CostFunction::Normal => 2,
90        }
91    }
92}
93
94/// Penalty selection for the PELT algorithm.
95#[derive(Debug, Clone, Copy)]
96pub enum Penalty {
97    /// BIC penalty: `p * ln(n)` where `p` = parameters per segment, times the
98    /// estimated noise variance of each channel for the L2 cost (whose
99    /// segments are costed in the data's units squared).
100    ///
101    /// Reference: Schwarz (1978). Automatically scales with data length.
102    Bic,
103    /// User-specified penalty value (must be positive and finite), in the
104    /// units of the cost: data units squared for L2.
105    Custom(f64),
106}
107
108/// PELT changepoint detector.
109///
110/// Implements the Pruned Exact Linear Time algorithm for detecting
111/// multiple changepoints in a univariate time series.
112///
113/// # Examples
114///
115/// ```
116/// use u_analytics::detection::{Pelt, CostFunction, Penalty};
117///
118/// // Data with a mean shift at index 50
119/// let mut data: Vec<f64> = vec![0.0; 50];
120/// data.extend(vec![5.0; 50]);
121///
122/// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
123/// let result = pelt.detect(&data);
124/// assert!(!result.changepoints.is_empty());
125/// // The detected changepoint should be near index 50
126/// assert!((result.changepoints[0] as i64 - 50).unsigned_abs() <= 2);
127/// ```
128///
129/// # References
130///
131/// Killick, R., Fearnhead, P., & Eckley, I.A. (2012). "Optimal Detection of
132/// Changepoints with a Linear Computational Cost", *JASA* 107(500), pp. 1590-1598.
133pub struct Pelt {
134    /// Cost function for segment evaluation.
135    cost: CostFunction,
136    /// Penalty per changepoint.
137    penalty: Penalty,
138    /// Minimum segment length (must be >= 2 for variance estimation).
139    min_segment_len: usize,
140}
141
142/// Result of PELT changepoint detection.
143#[derive(Debug, Clone)]
144pub struct PeltResult {
145    /// Detected changepoint indices (0-based). Each index marks the first
146    /// observation of a new segment.
147    ///
148    /// For example, if `changepoints = [50, 100]`, the segments are
149    /// `[0..50)`, `[50..100)`, `[100..n)`.
150    pub changepoints: Vec<usize>,
151}
152
153/// Result of multivariate PELT changepoint detection.
154#[derive(Debug, Clone)]
155pub struct MultiPeltResult {
156    /// Detected changepoint indices (0-based), shared across all channels.
157    pub changepoints: Vec<usize>,
158}
159
160impl Pelt {
161    /// Creates a new PELT detector with the given cost function and penalty.
162    ///
163    /// Uses a default minimum segment length of 2.
164    ///
165    /// # Returns
166    ///
167    /// `None` if a custom penalty is not positive or not finite.
168    ///
169    /// # Reference
170    ///
171    /// Killick et al. (2012), §2.2: penalty must be positive to avoid
172    /// trivial solutions (changepoint at every observation).
173    pub fn new(cost: CostFunction, penalty: Penalty) -> Option<Self> {
174        Self::with_min_segment_len(cost, penalty, 2)
175    }
176
177    /// Creates a PELT detector with a custom minimum segment length.
178    ///
179    /// # Parameters
180    ///
181    /// - `cost`: Cost function for segment evaluation
182    /// - `penalty`: Penalty per changepoint
183    /// - `min_segment_len`: Minimum number of observations per segment.
184    ///   Must be >= 2 (needed for variance estimation in Normal cost).
185    ///
186    /// # Returns
187    ///
188    /// `None` if parameters are invalid.
189    pub fn with_min_segment_len(
190        cost: CostFunction,
191        penalty: Penalty,
192        min_segment_len: usize,
193    ) -> Option<Self> {
194        if let Penalty::Custom(p) = penalty {
195            if !p.is_finite() || p <= 0.0 {
196                return None;
197            }
198        }
199        if min_segment_len < 2 {
200            return None;
201        }
202        Some(Self {
203            cost,
204            penalty,
205            min_segment_len,
206        })
207    }
208
209    /// Detects changepoints in the given data.
210    ///
211    /// Returns a [`PeltResult`] containing the detected changepoint indices.
212    /// If the data is too short (fewer than `2 * min_segment_len` observations),
213    /// no changepoints can be detected and an empty result is returned.
214    ///
215    /// Non-finite values (NaN, Infinity) are **not** supported in the input.
216    /// The data should be pre-cleaned; non-finite values will lead to
217    /// incorrect cost computations.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use u_analytics::detection::{Pelt, CostFunction, Penalty};
223    ///
224    /// // Two changepoints: shift up at 30, shift down at 70
225    /// let mut data = vec![0.0; 30];
226    /// data.extend(vec![3.0; 40]);
227    /// data.extend(vec![0.0; 30]);
228    ///
229    /// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
230    /// let result = pelt.detect(&data);
231    /// assert_eq!(result.changepoints.len(), 2);
232    /// ```
233    ///
234    /// # Complexity
235    ///
236    /// For the L2 cost, near-linear in `n` whether the series changes or not
237    /// (functional pruning — Maidstone et al. 2017). For the Normal cost,
238    /// expected O(n) when the number of changes grows with `n`, O(n^2) for a
239    /// series without changes (Killick et al. 2012).
240    pub fn detect(&self, data: &[f64]) -> PeltResult {
241        PeltResult {
242            changepoints: self.segment(&[data]),
243        }
244    }
245
246    /// Detects changepoints in multivariate (multi-signal) data.
247    ///
248    /// Each inner slice represents one signal channel. All channels must
249    /// have the same length. The cost function is applied independently
250    /// to each channel and summed — a single set of changepoints is
251    /// returned that applies to all channels simultaneously.
252    ///
253    /// The penalty scales with the number of channels: `penalty * n_channels`.
254    ///
255    /// # Parameters
256    ///
257    /// - `signals`: slice of signal channels, each of length `n`
258    ///
259    /// # Returns
260    ///
261    /// `None` if channels have inconsistent lengths.
262    /// Otherwise returns `Some(MultiPeltResult)`.
263    ///
264    /// # Examples
265    ///
266    /// ```
267    /// use u_analytics::detection::{Pelt, CostFunction, Penalty};
268    ///
269    /// let signal_a: Vec<f64> = [vec![0.0; 50], vec![5.0; 50]].concat();
270    /// let signal_b: Vec<f64> = [vec![0.0; 50], vec![3.0; 50]].concat();
271    ///
272    /// let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).unwrap();
273    /// let result = pelt.detect_multi(&[&signal_a, &signal_b]).unwrap();
274    /// assert!(!result.changepoints.is_empty());
275    /// ```
276    ///
277    /// # Reference
278    ///
279    /// Killick, R. & Eckley, I.A. (2014). "changepoint: An R Package for
280    /// Changepoint Analysis", *Journal of Statistical Software* 58(3).
281    ///
282    /// # Complexity
283    ///
284    /// For the L2 cost on one or two channels, near-linear in `n` whether the
285    /// series changes or not (functional pruning). Otherwise expected
286    /// O(n * k) when the number of changes grows with `n`, and O(n^2 * k) for
287    /// a series without changes, where k = number of channels.
288    pub fn detect_multi(&self, signals: &[&[f64]]) -> Option<MultiPeltResult> {
289        let n = signals.first().map_or(0, |s| s.len());
290        if signals.iter().any(|s| s.len() != n) {
291            return None;
292        }
293        Some(MultiPeltResult {
294            changepoints: self.segment(signals),
295        })
296    }
297
298    /// The optimal segmentation of `signals` (channels of one length, at
299    /// least one of them): the sorted first indices of every segment but the
300    /// first.
301    fn segment(&self, signals: &[&[f64]]) -> Vec<usize> {
302        let n = signals.first().map_or(0, |s| s.len());
303        if n < 2 * self.min_segment_len {
304            return Vec::new();
305        }
306
307        let penalty_value = self.resolve_penalty(n, signals);
308        if penalty_value <= 0.0 {
309            // A BIC penalty of nothing: the signal does not vary, so there is
310            // nothing to split.
311            return Vec::new();
312        }
313
314        // Cumulative sums per channel, for O(1) segment costs.
315        let channels: Vec<Channel> = signals.iter().map(|s| Channel::new(s)).collect();
316
317        let last_change = match self.cost {
318            CostFunction::L2 if channels.len() <= FUNCTIONAL_MAX_CHANNELS => {
319                self.partition_functional(&channels, n, penalty_value)
320            }
321            CostFunction::L2 | CostFunction::Normal => {
322                self.partition_pruned(&channels, n, penalty_value)
323            }
324        };
325
326        // Backtrack to extract changepoints.
327        let mut changepoints = Vec::new();
328        let mut t = n;
329        while t > 0 {
330            let tau = last_change[t];
331            if tau > 0 {
332                changepoints.push(tau);
333            }
334            t = tau;
335        }
336        changepoints.sort_unstable();
337        changepoints
338    }
339
340    /// The cost of the segment `[start, end)`, summed over the channels.
341    fn cost_of(&self, channels: &[Channel], start: usize, end: usize) -> f64 {
342        channels
343            .iter()
344            .map(|c| self.segment_cost(&c.cum_sum, &c.cum_sum_sq, start, end))
345            .sum()
346    }
347
348    /// The recurrence `F(t) = min_tau F(tau) + C(tau, t) + beta` over the
349    /// candidates `tau` that `prune` leaves, with `F(0) = -beta`. Returns the
350    /// minimising `tau` for each `t`; candidates are scanned in increasing
351    /// order and the first strict minimum wins.
352    ///
353    /// Candidates are pruned by inequality (Killick et al. 2012, Theorem
354    /// 3.1, with the conservative constant `K = beta`). Exact, but every
355    /// candidate survives on a series without changes, so the time is
356    /// quadratic there.
357    fn partition_pruned(&self, channels: &[Channel], n: usize, penalty_value: f64) -> Vec<usize> {
358        let mut f = vec![0.0_f64; n + 1];
359        f[0] = -penalty_value;
360        let mut last_change = vec![0_usize; n + 1];
361        let mut candidates: Vec<usize> = vec![0];
362        // `F(tau) + C(tau, t)` of each candidate at this `t`, computed once
363        // for both the minimum and the pruning.
364        let mut partial: Vec<f64> = Vec::new();
365
366        for t in self.min_segment_len..=n {
367            let mut best_cost = f64::INFINITY;
368            let mut best_tau = 0;
369            partial.clear();
370            for &tau in &candidates {
371                if t - tau < self.min_segment_len {
372                    partial.push(f64::NEG_INFINITY); // not yet evaluable — kept
373                    continue;
374                }
375                let part = f[tau] + self.cost_of(channels, tau, t);
376                partial.push(part);
377                let total = part + penalty_value;
378                if total < best_cost {
379                    best_cost = total;
380                    best_tau = tau;
381                }
382            }
383            f[t] = best_cost;
384            last_change[t] = best_tau;
385
386            let bound = f[t] + penalty_value;
387            let mut kept = 0;
388            for i in 0..candidates.len() {
389                if partial[i] < bound {
390                    candidates[kept] = candidates[i];
391                    kept += 1;
392                }
393            }
394            candidates.truncate(kept);
395            candidates.push(t);
396        }
397        last_change
398    }
399
400    /// The same recurrence as [`Self::partition_pruned`], for the L2 cost,
401    /// with candidates pruned on the segment mean instead of on the cost
402    /// (functional pruning: Maidstone et al. 2017 for one channel, and the
403    /// sphere-based rule of Pishchagina et al. 2023 for several).
404    ///
405    /// Candidate `a`'s cost to a later `t`, as a function of the mean `mu`
406    /// of its last segment, is `q_a(mu) = F(a) + beta + sum_{a<i<=t}
407    /// |y_i - mu|^2`. For two candidates `a < b`, `q_a - q_b` does not depend
408    /// on `t`, and `q_a <= q_b` exactly on the ball centred on the mean of
409    /// `y[a..b)` with squared radius `(F(b) - F(a) - C(a, b)) / (b - a)`. So
410    /// once `b` is a candidate, `a` can be optimal only inside that ball, and
411    /// `b` only outside it. A candidate keeps a box that holds the
412    /// intersection of the balls of the candidates after it (the region where
413    /// it can still win), cuts from it the balls of the candidates before it
414    /// (regions where it already loses), and is dropped when the box is
415    /// empty. The box starts as the range of the data: every segment mean lies
416    /// in it, so a candidate that loses there loses its minimum too.
417    ///
418    /// Exact — the same `F` as the unpruned recurrence — and, unlike
419    /// inequality pruning, it removes nearly every candidate on a series
420    /// without changes, so the time is near-linear there as well.
421    ///
422    /// A candidate `b` takes part from `t = b + min_segment_len` on, so the
423    /// balls it draws are applied at the end of step `t = b +
424    /// min_segment_len - 1`: before that, an older candidate may still be the
425    /// only one allowed to end at `t`.
426    fn partition_functional(
427        &self,
428        channels: &[Channel],
429        n: usize,
430        penalty_value: f64,
431    ) -> Vec<usize> {
432        let m = self.min_segment_len;
433        let dim = channels.len();
434        let mut f = vec![0.0_f64; n + 1];
435        f[0] = -penalty_value;
436        let mut last_change = vec![0_usize; n + 1];
437
438        let range_lo: Vec<f64> = channels.iter().map(|c| c.lo).collect();
439        let range_hi: Vec<f64> = channels.iter().map(|c| c.hi).collect();
440        let mut alive: Vec<Candidate> = vec![Candidate::new(0, &range_lo, &range_hi)];
441        let mut centre = vec![0.0_f64; dim];
442
443        for t in m..=n {
444            // Every alive candidate is eligible here: candidate `b` joins at
445            // the end of step `b + m - 1`.
446            let mut best_cost = f64::INFINITY;
447            let mut best_tau = 0;
448            for cand in &alive {
449                let tau = cand.tau;
450                let total = f[tau] + self.cost_of(channels, tau, t) + penalty_value;
451                if total < best_cost {
452                    best_cost = total;
453                    best_tau = tau;
454                }
455            }
456            f[t] = best_cost;
457            last_change[t] = best_tau;
458
459            // The candidate that becomes eligible at `t + 1`.
460            let Some(b) = (t + 1).checked_sub(m).filter(|&b| b >= m) else {
461                continue;
462            };
463            let mut born = Candidate::new(b, &range_lo, &range_hi);
464            for cand in &mut alive {
465                let a = cand.tau;
466                let len = (b - a) as f64;
467                let cost = self.cost_of(channels, a, b);
468                let gap = f[b] - f[a] - cost;
469                // Room for the rounding of the four terms of `gap`.
470                let slack = 1e-9 * (f[b].abs() + f[a].abs() + cost.abs() + penalty_value) / len;
471                for (k, c) in channels.iter().enumerate() {
472                    centre[k] = (c.cum_sum[b] - c.cum_sum[a]) / len;
473                }
474                // `a` can still win only inside the (slightly widened) ball.
475                cand.restrict(&centre, gap / len + slack);
476                // `b` already loses inside the (slightly narrowed) ball.
477                born.lose_inside(&centre, gap / len - slack);
478            }
479            for cand in &mut alive {
480                cand.cut_losing();
481            }
482            alive.retain(|cand| !cand.is_dominated());
483            born.cut_losing();
484            if !born.is_dominated() {
485                alive.push(born);
486            }
487        }
488        last_change
489    }
490
491    /// Resolves the penalty value for `signals`, the channels of length `n`
492    /// that are searched together.
493    ///
494    /// The BIC penalty of the [`CostFunction::L2`] cost is in the cost's own
495    /// units: the cost is a sum of squares, the model a normal one with a
496    /// *known* variance, so the penalty `p * ln(n)` is that of the model with
497    /// the noise variance of each channel put back in ([`noise_variance`]).
498    /// Without it the same series would show a different number of changes in
499    /// volts than in millivolts. The `Normal` cost is a log and needs none.
500    /// A custom penalty is taken as given, once per channel.
501    fn resolve_penalty(&self, n: usize, signals: &[&[f64]]) -> f64 {
502        let channels = signals.len() as f64;
503        match self.penalty {
504            Penalty::Bic => {
505                let p = self.cost.params_per_segment() as f64;
506                let per_channel = p * (n as f64).ln();
507                match self.cost {
508                    CostFunction::L2 => {
509                        per_channel * signals.iter().map(|s| noise_variance(s)).sum::<f64>()
510                    }
511                    CostFunction::Normal => per_channel * channels,
512                }
513            }
514            Penalty::Custom(val) => val * channels,
515        }
516    }
517
518    /// Computes the cost of the segment `data[start..end]` using cumulative sums.
519    ///
520    /// # Panics
521    ///
522    /// Panics if `end <= start`.
523    fn segment_cost(&self, cum_sum: &[f64], cum_sum_sq: &[f64], start: usize, end: usize) -> f64 {
524        let seg_len = (end - start) as f64;
525        let sum = cum_sum[end] - cum_sum[start];
526        let sum_sq = cum_sum_sq[end] - cum_sum_sq[start];
527        let mean = sum / seg_len;
528
529        match self.cost {
530            CostFunction::L2 => {
531                // sum((y_i - mean)^2) = sum(y_i^2) - n * mean^2
532                sum_sq - seg_len * mean * mean
533            }
534            CostFunction::Normal => {
535                // n * ln(variance) where variance = sum((y_i - mean)^2) / n
536                let variance = (sum_sq - seg_len * mean * mean) / seg_len;
537                if variance <= 0.0 {
538                    // Degenerate segment (constant values): assign a large negative
539                    // log-likelihood to make it favorable (perfect fit).
540                    seg_len * (f64::MIN_POSITIVE).ln()
541                } else {
542                    seg_len * variance.ln()
543                }
544            }
545        }
546    }
547}
548
549/// The variance of the noise in `signal`, estimated from the first
550/// differences: for independent noise of variance s² a difference has
551/// variance 2s², and its median magnitude is 0.6745·√2·s, a statistic a few
552/// level shifts hardly move. The estimate is floored at a millionth of the
553/// signal's range, so that a noiseless step stays a step and the penalty
554/// stays above the rounding of the cost.
555fn noise_variance(signal: &[f64]) -> f64 {
556    if signal.len() < 2 {
557        return 0.0;
558    }
559    let mut diffs: Vec<f64> = signal.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
560    diffs.sort_by(f64::total_cmp);
561    let median = if diffs.len() % 2 == 1 {
562        diffs[diffs.len() / 2]
563    } else {
564        0.5 * (diffs[diffs.len() / 2 - 1] + diffs[diffs.len() / 2])
565    };
566    let sigma = median / (0.6745 * std::f64::consts::SQRT_2);
567    let (lo, hi) = signal
568        .iter()
569        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
570            (lo.min(v), hi.max(v))
571        });
572    let floor = 1e-6 * (hi - lo);
573    sigma.max(floor).powi(2)
574}
575
576/// The cumulative sums of `signal` and of its squares, taken about the
577/// signal's mean.
578///
579/// A segment's cost is `sum(y^2) - n * mean^2` (a difference of two sums),
580/// which is unchanged by shifting the signal. Taken about zero, a series that
581/// sits far from it (a Unix timestamp in seconds, an instrument with a large
582/// offset) loses the variance in that subtraction and is split at nearly every
583/// point; about its own mean the two terms are the size of the variation.
584fn cumulative_sums(signal: &[f64]) -> (Vec<f64>, Vec<f64>, f64) {
585    let n = signal.len();
586    let center = signal.iter().sum::<f64>() / n as f64;
587    let mut cum_sum = vec![0.0_f64; n + 1];
588    let mut cum_sum_sq = vec![0.0_f64; n + 1];
589    for (i, &y) in signal.iter().enumerate() {
590        let y = y - center;
591        cum_sum[i + 1] = cum_sum[i] + y;
592        cum_sum_sq[i + 1] = cum_sum_sq[i] + y * y;
593    }
594    (cum_sum, cum_sum_sq, center)
595}
596/// One channel's cumulative sums (about its mean, see [`cumulative_sums`])
597/// and its range on the same scale.
598struct Channel {
599    cum_sum: Vec<f64>,
600    cum_sum_sq: Vec<f64>,
601    lo: f64,
602    hi: f64,
603}
604
605impl Channel {
606    fn new(signal: &[f64]) -> Self {
607        let (cum_sum, cum_sum_sq, center) = cumulative_sums(signal);
608        let (lo, hi) = signal
609            .iter()
610            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
611                (lo.min(v), hi.max(v))
612            });
613        Self {
614            cum_sum,
615            cum_sum_sq,
616            lo: lo - center,
617            hi: hi - center,
618        }
619    }
620}
621
622/// How many of the balls in which a candidate already loses it keeps (the
623/// widest). Any subset keeps the pruning exact; more prunes sooner, at a cost
624/// per candidate and step.
625const LOSING_BALLS: usize = 64;
626
627/// Up to how many channels the L2 cost is pruned functionally. The region a
628/// candidate can still win in shrinks quickly on one or two channels; on more
629/// it lingers (the boxes stay wide, and the candidates whose balls would close
630/// them wander slowly with the running segment mean), so the per-candidate
631/// work outgrows the pruning. Measured on 10 000 points without changes:
632/// 1 channel 24 ms vs 552 ms by inequality, 2: 268 vs 766 ms, 3: 6.3 vs
633/// 0.94 s, 4: 44 vs 1.1 s.
634const FUNCTIONAL_MAX_CHANNELS: usize = 2;
635
636/// A candidate last changepoint in [`Pelt::partition_functional`]: a box
637/// that holds every segment mean where it can still be optimal (Pishchagina
638/// et al. 2023, the rectangle rule), and balls where an older candidate beats
639/// it.
640struct Candidate {
641    tau: usize,
642    lo: Vec<f64>,
643    hi: Vec<f64>,
644    losing: Vec<(Vec<f64>, f64)>,
645}
646
647impl Candidate {
648    /// A candidate that can still win anywhere in the box `[lo, hi]`.
649    fn new(tau: usize, lo: &[f64], hi: &[f64]) -> Self {
650        Self {
651            tau,
652            lo: lo.to_vec(),
653            hi: hi.to_vec(),
654            losing: Vec::new(),
655        }
656    }
657
658    fn empty(&mut self) {
659        self.lo[0] = f64::INFINITY;
660        self.hi[0] = f64::NEG_INFINITY;
661    }
662
663    /// Narrows the box to the smallest box around its intersection with the
664    /// ball (`centre`, squared radius `r2`): along each axis, the values
665    /// whose slice of the box still meets the ball.
666    fn restrict(&mut self, centre: &[f64], r2: f64) {
667        if r2 < 0.0 {
668            self.empty();
669            return;
670        }
671        let gap2 = |k: usize, lo: &[f64], hi: &[f64]| {
672            let c = centre[k];
673            (lo[k] - c).max(c - hi[k]).max(0.0).powi(2)
674        };
675        let total: f64 = (0..centre.len()).map(|k| gap2(k, &self.lo, &self.hi)).sum();
676        if total > r2 {
677            self.empty();
678            return;
679        }
680        for (k, &c) in centre.iter().enumerate() {
681            let w = (r2 - (total - gap2(k, &self.lo, &self.hi))).max(0.0).sqrt();
682            self.lo[k] = self.lo[k].max(c - w);
683            self.hi[k] = self.hi[k].min(c + w);
684        }
685    }
686
687    /// Records that the candidate loses inside the ball (`centre`, squared
688    /// radius `r2`).
689    fn lose_inside(&mut self, centre: &[f64], r2: f64) {
690        if r2 <= 0.0 {
691            return;
692        }
693        if self.losing.len() == LOSING_BALLS {
694            let (narrowest, &(_, r2_min)) = self
695                .losing
696                .iter()
697                .enumerate()
698                .min_by(|x, y| x.1 .1.total_cmp(&y.1 .1))
699                .expect("the list is full, so not empty");
700            if r2 <= r2_min {
701                return;
702            }
703            self.losing.swap_remove(narrowest);
704        }
705        self.losing.push((centre.to_vec(), r2));
706    }
707
708    /// Cuts from the box every slab that lies inside a ball where the
709    /// candidate loses: a face of the box whose farthest point is in the ball
710    /// is in it entirely, and so is the box up to where that stops holding.
711    fn cut_losing(&mut self) {
712        for (centre, r2) in &self.losing {
713            if self.lo[0] > self.hi[0] {
714                return;
715            }
716            let far2 = |k: usize, lo: &[f64], hi: &[f64]| {
717                (lo[k] - centre[k]).powi(2).max((hi[k] - centre[k]).powi(2))
718            };
719            let total: f64 = (0..centre.len()).map(|k| far2(k, &self.lo, &self.hi)).sum();
720            for (k, &c) in centre.iter().enumerate() {
721                let rest = total - far2(k, &self.lo, &self.hi);
722                if rest > *r2 {
723                    continue;
724                }
725                let w = (r2 - rest).sqrt();
726                if (c - w..=c + w).contains(&self.lo[k]) {
727                    self.lo[k] = c + w;
728                }
729                if (c - w..=c + w).contains(&self.hi[k]) {
730                    self.hi[k] = c - w;
731                }
732            }
733        }
734    }
735
736    /// Whether no segment mean is left where the candidate can be optimal.
737    fn is_dominated(&self) -> bool {
738        self.lo.iter().zip(&self.hi).any(|(lo, hi)| lo > hi)
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    // --- Exactness against the unpruned recurrence ---
747
748    /// The optimal segmentation by the plain O(n^2) recurrence, no pruning.
749    fn brute_force(pelt: &Pelt, signals: &[&[f64]]) -> Vec<usize> {
750        let n = signals[0].len();
751        if n < 2 * pelt.min_segment_len {
752            return Vec::new();
753        }
754        let beta = pelt.resolve_penalty(n, signals);
755        if beta <= 0.0 {
756            return Vec::new();
757        }
758        let channels: Vec<Channel> = signals.iter().map(|s| Channel::new(s)).collect();
759        let m = pelt.min_segment_len;
760        let mut f = vec![f64::INFINITY; n + 1];
761        f[0] = -beta;
762        let mut last = vec![0_usize; n + 1];
763        for t in m..=n {
764            for tau in (0..=t - m).filter(|&tau| tau == 0 || tau >= m) {
765                let total = f[tau] + pelt.cost_of(&channels, tau, t) + beta;
766                if total < f[t] {
767                    f[t] = total;
768                    last[t] = tau;
769                }
770            }
771        }
772        let mut cps = Vec::new();
773        let mut t = n;
774        while t > 0 {
775            if last[t] > 0 {
776                cps.push(last[t]);
777            }
778            t = last[t];
779        }
780        cps.sort_unstable();
781        cps
782    }
783
784    /// A seeded series of `n` points: normal noise of sd 1 plus `jumps`
785    /// level shifts of size `step`.
786    fn noisy_series(seed: u64, n: usize, jumps: usize, step: f64) -> Vec<f64> {
787        // SplitMix64, so the test needs no generator crate.
788        let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03;
789        let mut uniform = move || {
790            state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
791            let mut z = state;
792            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
793            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
794            ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64
795        };
796        let mut level = 0.0;
797        let cut = n / (jumps + 1);
798        (0..n)
799            .map(|i| {
800                if jumps > 0 && i > 0 && i % cut == 0 && i / cut <= jumps {
801                    level += step;
802                }
803                // Box-Muller
804                let u1: f64 = uniform().max(1e-300);
805                let u2: f64 = uniform();
806                level + (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
807            })
808            .collect()
809    }
810
811    fn noise_series_third(seed: u64, n: usize, jumps: usize, step: f64) -> Vec<f64> {
812        noisy_series(seed + 2000, n, jumps, 0.5 * step)
813    }
814
815    #[test]
816    fn functional_pruning_matches_the_unpruned_recurrence() {
817        for seed in 0..40_u64 {
818            for &(n, jumps, step) in &[(200, 0, 0.0), (300, 3, 1.5), (250, 8, 0.8), (150, 2, 4.0)] {
819                for &m in &[2_usize, 5] {
820                    for penalty in [Penalty::Bic, Penalty::Custom(3.0)] {
821                        let pelt = Pelt::with_min_segment_len(CostFunction::L2, penalty, m)
822                            .expect("valid");
823                        let a = noisy_series(seed, n, jumps, step);
824                        let b = noisy_series(seed + 1000, n, jumps, -step);
825                        assert_eq!(
826                            pelt.detect(&a).changepoints,
827                            brute_force(&pelt, &[&a]),
828                            "seed {seed} n {n} jumps {jumps} m {m} {penalty:?}"
829                        );
830                        assert_eq!(
831                            pelt.detect_multi(&[&a, &b])
832                                .expect("same length")
833                                .changepoints,
834                            brute_force(&pelt, &[&a, &b]),
835                            "multi seed {seed} n {n} jumps {jumps} m {m} {penalty:?}"
836                        );
837                        // Three channels: past the functional limit.
838                        let c = noise_series_third(seed, n, jumps, step);
839                        assert_eq!(
840                            pelt.detect_multi(&[&a, &b, &c])
841                                .expect("same length")
842                                .changepoints,
843                            brute_force(&pelt, &[&a, &b, &c]),
844                            "three seed {seed} n {n} jumps {jumps} m {m} {penalty:?}"
845                        );
846                    }
847                }
848            }
849        }
850    }
851
852    #[test]
853    fn inequality_pruning_matches_the_unpruned_recurrence_for_the_normal_cost() {
854        for seed in 0..10_u64 {
855            for &m in &[2_usize, 5] {
856                let pelt = Pelt::with_min_segment_len(CostFunction::Normal, Penalty::Bic, m)
857                    .expect("valid");
858                let a = noisy_series(seed, 200, 3, 2.0);
859                assert_eq!(
860                    pelt.detect(&a).changepoints,
861                    brute_force(&pelt, &[&a]),
862                    "seed {seed} m {m}"
863                );
864            }
865        }
866    }
867
868    // --- Constructor validation ---
869
870    #[test]
871    fn test_pelt_valid_construction() {
872        assert!(Pelt::new(CostFunction::L2, Penalty::Bic).is_some());
873        assert!(Pelt::new(CostFunction::Normal, Penalty::Bic).is_some());
874        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(10.0)).is_some());
875    }
876
877    #[test]
878    fn test_pelt_invalid_custom_penalty() {
879        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(0.0)).is_none());
880        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(-1.0)).is_none());
881        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::NAN)).is_none());
882        assert!(Pelt::new(CostFunction::L2, Penalty::Custom(f64::INFINITY)).is_none());
883    }
884
885    #[test]
886    fn test_pelt_invalid_min_segment_len() {
887        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 0).is_none());
888        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 1).is_none());
889        assert!(Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 2).is_some());
890    }
891
892    // --- Empty and short data ---
893
894    #[test]
895    fn test_pelt_empty_data() {
896        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
897        let result = pelt.detect(&[]);
898        assert!(result.changepoints.is_empty());
899    }
900
901    #[test]
902    fn test_pelt_too_short_data() {
903        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
904        // min_segment_len=2, need at least 4 points for a changepoint
905        let result = pelt.detect(&[1.0, 2.0, 3.0]);
906        assert!(result.changepoints.is_empty());
907    }
908
909    // --- No changepoint scenarios ---
910
911    #[test]
912    fn test_pelt_constant_data_no_changepoint() {
913        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
914        let data = vec![5.0; 100];
915        let result = pelt.detect(&data);
916        assert!(
917            result.changepoints.is_empty(),
918            "constant data should have no changepoints, got {:?}",
919            result.changepoints
920        );
921    }
922
923    #[test]
924    fn test_pelt_normal_cost_constant_data() {
925        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
926        let data = vec![5.0; 100];
927        let result = pelt.detect(&data);
928        assert!(
929            result.changepoints.is_empty(),
930            "constant data should have no changepoints with Normal cost, got {:?}",
931            result.changepoints
932        );
933    }
934
935    // --- Single changepoint detection ---
936
937    #[test]
938    fn test_pelt_single_mean_shift_l2() {
939        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
940
941        let mut data = vec![0.0; 50];
942        data.extend(vec![5.0; 50]);
943
944        let result = pelt.detect(&data);
945        assert_eq!(
946            result.changepoints.len(),
947            1,
948            "expected 1 changepoint, got {:?}",
949            result.changepoints
950        );
951        assert!(
952            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
953            "changepoint should be near index 50, got {}",
954            result.changepoints[0]
955        );
956    }
957
958    #[test]
959    fn test_pelt_single_mean_shift_normal() {
960        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
961
962        let mut data = vec![0.0; 50];
963        data.extend(vec![5.0; 50]);
964
965        let result = pelt.detect(&data);
966        assert_eq!(
967            result.changepoints.len(),
968            1,
969            "expected 1 changepoint with Normal cost, got {:?}",
970            result.changepoints
971        );
972        assert!(
973            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
974            "changepoint should be near index 50, got {}",
975            result.changepoints[0]
976        );
977    }
978
979    // --- Multiple changepoints ---
980
981    #[test]
982    fn test_pelt_two_changepoints() {
983        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
984
985        let mut data = vec![0.0; 40];
986        data.extend(vec![5.0; 40]);
987        data.extend(vec![0.0; 40]);
988
989        let result = pelt.detect(&data);
990        assert_eq!(
991            result.changepoints.len(),
992            2,
993            "expected 2 changepoints, got {:?}",
994            result.changepoints
995        );
996
997        // Changepoints should be near 40 and 80
998        assert!(
999            (result.changepoints[0] as i64 - 40).unsigned_abs() <= 2,
1000            "first changepoint near 40, got {}",
1001            result.changepoints[0]
1002        );
1003        assert!(
1004            (result.changepoints[1] as i64 - 80).unsigned_abs() <= 2,
1005            "second changepoint near 80, got {}",
1006            result.changepoints[1]
1007        );
1008    }
1009
1010    #[test]
1011    fn test_pelt_three_changepoints() {
1012        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1013
1014        let mut data = vec![0.0; 30];
1015        data.extend(vec![4.0; 30]);
1016        data.extend(vec![-2.0; 30]);
1017        data.extend(vec![3.0; 30]);
1018
1019        let result = pelt.detect(&data);
1020        assert_eq!(
1021            result.changepoints.len(),
1022            3,
1023            "expected 3 changepoints, got {:?}",
1024            result.changepoints
1025        );
1026
1027        // Check ordering
1028        for i in 1..result.changepoints.len() {
1029            assert!(
1030                result.changepoints[i] > result.changepoints[i - 1],
1031                "changepoints should be strictly increasing"
1032            );
1033        }
1034    }
1035
1036    // --- Variance change detection ---
1037
1038    #[test]
1039    fn test_pelt_variance_change_normal_cost() {
1040        // Normal cost should detect a variance change even when mean is constant.
1041        let pelt = Pelt::new(CostFunction::Normal, Penalty::Bic).expect("valid");
1042
1043        // Low variance segment then high variance segment
1044        let mut data = Vec::with_capacity(200);
1045        // Segment 1: mean=0, low spread (deterministic zigzag)
1046        for i in 0..100 {
1047            data.push(if i % 2 == 0 { 0.1 } else { -0.1 });
1048        }
1049        // Segment 2: mean=0, high spread
1050        for i in 0..100 {
1051            data.push(if i % 2 == 0 { 5.0 } else { -5.0 });
1052        }
1053
1054        let result = pelt.detect(&data);
1055        assert!(
1056            !result.changepoints.is_empty(),
1057            "Normal cost should detect variance change"
1058        );
1059        // The changepoint should be near index 100
1060        let cp = result.changepoints[0];
1061        assert!(
1062            (cp as i64 - 100).unsigned_abs() <= 5,
1063            "variance changepoint should be near 100, got {}",
1064            cp
1065        );
1066    }
1067
1068    // --- Penalty sensitivity ---
1069
1070    #[test]
1071    fn test_pelt_higher_penalty_fewer_changepoints() {
1072        let mut data = vec![0.0; 30];
1073        data.extend(vec![2.0; 30]);
1074        data.extend(vec![0.0; 30]);
1075
1076        let pelt_low = Pelt::new(CostFunction::L2, Penalty::Custom(1.0)).expect("valid");
1077        let pelt_high = Pelt::new(CostFunction::L2, Penalty::Custom(100.0)).expect("valid");
1078
1079        let result_low = pelt_low.detect(&data);
1080        let result_high = pelt_high.detect(&data);
1081
1082        assert!(
1083            result_low.changepoints.len() >= result_high.changepoints.len(),
1084            "higher penalty should produce fewer or equal changepoints: low={}, high={}",
1085            result_low.changepoints.len(),
1086            result_high.changepoints.len()
1087        );
1088    }
1089
1090    // --- Custom minimum segment length ---
1091
1092    #[test]
1093    fn test_pelt_custom_min_segment_len() {
1094        let mut data = vec![0.0; 50];
1095        data.extend(vec![10.0; 50]);
1096
1097        let pelt = Pelt::with_min_segment_len(CostFunction::L2, Penalty::Bic, 10).expect("valid");
1098        let result = pelt.detect(&data);
1099        assert_eq!(
1100            result.changepoints.len(),
1101            1,
1102            "should detect changepoint with min_segment_len=10"
1103        );
1104
1105        // All segments should respect minimum length
1106        let mut boundaries = vec![0];
1107        boundaries.extend_from_slice(&result.changepoints);
1108        boundaries.push(data.len());
1109        for i in 1..boundaries.len() {
1110            let seg_len = boundaries[i] - boundaries[i - 1];
1111            assert!(
1112                seg_len >= 10,
1113                "segment length {} is less than min_segment_len=10",
1114                seg_len
1115            );
1116        }
1117    }
1118
1119    // --- Exact numeric verification ---
1120
1121    /// Verifies PELT on a simple 4-point example with known optimal solution.
1122    ///
1123    /// Data: [0, 0, 10, 10], penalty = 2*ln(4) ≈ 2.77
1124    ///
1125    /// Without changepoint: cost = sum((y_i - 5)^2) = 25+25+25+25 = 100
1126    /// With changepoint at 2: cost = 0 + 0 + 2*ln(4) = 2.77 (L2 cost of each segment = 0)
1127    ///
1128    /// PELT should find changepoint at index 2.
1129    #[test]
1130    fn test_pelt_exact_small_example() {
1131        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1132        let data = [0.0, 0.0, 10.0, 10.0];
1133        let result = pelt.detect(&data);
1134
1135        assert_eq!(
1136            result.changepoints.len(),
1137            1,
1138            "expected 1 changepoint in [0,0,10,10], got {:?}",
1139            result.changepoints
1140        );
1141        assert_eq!(
1142            result.changepoints[0], 2,
1143            "changepoint should be at index 2"
1144        );
1145    }
1146
1147    // --- Changepoints are sorted ---
1148
1149    #[test]
1150    fn test_pelt_changepoints_sorted() {
1151        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1152
1153        let mut data = vec![0.0; 25];
1154        data.extend(vec![5.0; 25]);
1155        data.extend(vec![-3.0; 25]);
1156        data.extend(vec![7.0; 25]);
1157
1158        let result = pelt.detect(&data);
1159        for i in 1..result.changepoints.len() {
1160            assert!(
1161                result.changepoints[i] > result.changepoints[i - 1],
1162                "changepoints must be strictly increasing: {:?}",
1163                result.changepoints
1164            );
1165        }
1166    }
1167
1168    // --- BIC penalty scales with n ---
1169
1170    #[test]
1171    fn test_pelt_bic_penalty_scales() {
1172        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1173
1174        // BIC for L2: penalty = ln(n). For n=100, penalty ≈ 4.6.
1175        // A small shift should not be detected with BIC.
1176        let mut data = vec![0.0; 50];
1177        data.extend(vec![0.5; 50]); // Very small shift
1178
1179        let result = pelt.detect(&data);
1180        // BIC should suppress this tiny shift
1181        // (0.5^2 * 50 = 12.5 for each segment reduction, but penalty is ~4.6)
1182        // This depends on exact cost, but a 0.5-unit shift in 100 points
1183        // may or may not be detected. We just verify it runs without panic.
1184        let _ = result;
1185    }
1186
1187    // --- Property: segments cover entire data ---
1188
1189    #[test]
1190    fn test_pelt_segments_cover_data() {
1191        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1192
1193        let mut data = vec![0.0; 30];
1194        data.extend(vec![5.0; 30]);
1195        data.extend(vec![0.0; 30]);
1196
1197        let result = pelt.detect(&data);
1198
1199        // Verify segments cover [0, n) without gaps
1200        let mut boundaries = vec![0];
1201        boundaries.extend_from_slice(&result.changepoints);
1202        boundaries.push(data.len());
1203
1204        for i in 1..boundaries.len() {
1205            assert!(
1206                boundaries[i] > boundaries[i - 1],
1207                "segments must not have zero length"
1208            );
1209        }
1210        assert_eq!(
1211            *boundaries.last().expect("non-empty boundaries"),
1212            data.len(),
1213            "segments must cover entire data"
1214        );
1215    }
1216
1217    // --- Downward shift ---
1218
1219    #[test]
1220    fn test_pelt_downward_shift() {
1221        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1222
1223        let mut data = vec![10.0; 50];
1224        data.extend(vec![2.0; 50]); // Downward shift
1225
1226        let result = pelt.detect(&data);
1227        assert_eq!(result.changepoints.len(), 1, "should detect downward shift");
1228        assert!(
1229            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
1230            "changepoint should be near index 50, got {}",
1231            result.changepoints[0]
1232        );
1233    }
1234
1235    // --- Cost function enum properties ---
1236
1237    #[test]
1238    fn test_cost_function_params() {
1239        assert_eq!(CostFunction::L2.params_per_segment(), 1);
1240        assert_eq!(CostFunction::Normal.params_per_segment(), 2);
1241    }
1242
1243    // --- Multi-signal tests ---
1244
1245    #[test]
1246    fn test_pelt_multi_single_channel_matches_univariate() {
1247        let pelt = Pelt::new(CostFunction::L2, Penalty::Custom(5.0)).expect("valid");
1248        let mut data = vec![0.0; 50];
1249        data.extend(vec![5.0; 50]);
1250
1251        let uni = pelt.detect(&data);
1252        let multi = pelt.detect_multi(&[&data]).expect("valid");
1253        assert_eq!(uni.changepoints, multi.changepoints);
1254    }
1255
1256    #[test]
1257    fn test_pelt_multi_two_channels() {
1258        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1259        let a: Vec<f64> = [vec![0.0; 50], vec![5.0; 50]].concat();
1260        let b: Vec<f64> = [vec![0.0; 50], vec![3.0; 50]].concat();
1261
1262        let result = pelt.detect_multi(&[&a, &b]).expect("valid");
1263        assert_eq!(
1264            result.changepoints.len(),
1265            1,
1266            "expected 1 changepoint, got {:?}",
1267            result.changepoints
1268        );
1269        assert!(
1270            (result.changepoints[0] as i64 - 50).unsigned_abs() <= 2,
1271            "changepoint near 50, got {}",
1272            result.changepoints[0]
1273        );
1274    }
1275
1276    #[test]
1277    fn test_pelt_multi_inconsistent_lengths() {
1278        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1279        let a = vec![0.0; 50];
1280        let b = vec![0.0; 30];
1281        assert!(pelt.detect_multi(&[&a[..], &b[..]]).is_none());
1282    }
1283
1284    #[test]
1285    fn test_pelt_multi_empty_signals() {
1286        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1287        let result = pelt.detect_multi(&[]).expect("valid");
1288        assert!(result.changepoints.is_empty());
1289    }
1290
1291    #[test]
1292    fn test_pelt_multi_three_channels_two_changepoints() {
1293        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1294        let a: Vec<f64> = [vec![0.0; 40], vec![5.0; 40], vec![0.0; 40]].concat();
1295        let b: Vec<f64> = [vec![0.0; 40], vec![3.0; 40], vec![0.0; 40]].concat();
1296        let c: Vec<f64> = [vec![0.0; 40], vec![4.0; 40], vec![0.0; 40]].concat();
1297
1298        let result = pelt.detect_multi(&[&a, &b, &c]).expect("valid");
1299        assert_eq!(
1300            result.changepoints.len(),
1301            2,
1302            "expected 2 changepoints, got {:?}",
1303            result.changepoints
1304        );
1305    }
1306
1307    #[test]
1308    fn test_pelt_multi_short_data() {
1309        let pelt = Pelt::new(CostFunction::L2, Penalty::Bic).expect("valid");
1310        let a = [1.0, 2.0];
1311        let b = [3.0, 4.0];
1312        let result = pelt.detect_multi(&[&a, &b]).expect("valid");
1313        assert!(result.changepoints.is_empty());
1314    }
1315}
1316
1317/// PELT against the optimum of its own objective -- the exact dynamic program
1318/// `F(t) = min over tau of F(tau) + C(tau, t) + beta` over every allowed last
1319/// changepoint, with segment costs from a two-pass mean and sum of squares --
1320/// written in Python 3 with NumPy 2.4.6. PELT's pruning must not change the
1321/// answer (Killick et al. 2012, Theorem 3.1).
1322#[cfg(test)]
1323mod exact_dp_reference {
1324    use super::*;
1325
1326    /// L2 BIC two mean shifts.
1327    const CASE_0: [f64; 60] = [
1328        1.02, -1.278, 0.209, -0.284, -0.226, -0.108, -1.01, -0.116, -0.433, 1.661, 0.113, -0.176,
1329        -0.141, -0.334, -0.528, -0.195, 0.241, -0.119, 0.479, -0.1, 3.012, 3.773, 3.273, 2.747,
1330        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,
1331        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,
1332        0.397, 0.037, 0.792, 0.791, 0.393, 0.109, 0.615, -0.747, 0.845, 0.746,
1333    ];
1334
1335    /// Normal BIC variance + mean shift, min seg 5.
1336    const CASE_1: [f64; 80] = [
1337        -0.656, 0.025, -0.386, 0.303, -0.814, -0.366, 0.284, 0.463, -0.863, -0.199, 0.131, -0.244,
1338        0.636, -0.476, 0.142, -0.419, 0.562, -0.009, -0.149, -0.687, 0.673, 0.301, 0.301, 0.455,
1339        0.14, -0.256, -0.32, -0.32, 0.548, -0.584, -0.239, -0.128, 0.09, 0.23, -0.5, -0.692,
1340        -0.002, 0.485, 0.303, 0.086, 0.366, 1.586, 0.513, 2.634, -0.589, 1.268, 0.778, 2.087,
1341        1.449, 6.1, 3.997, 3.993, -3.079, 0.319, -0.217, 2.065, -3.558, 3.349, 3.134, -1.604,
1342        -0.957, -0.602, 1.087, 2.282, 5.096, 0.605, 2.535, 1.311, 4.52, 2.484, 3.737, -1.155,
1343        0.616, -0.628, 4.01, 2.315, 0.39, 0.095, 1.969, -0.403,
1344    ];
1345
1346    /// L2 custom penalty 5, min seg 5.
1347    const CASE_2: [f64; 50] = [
1348        -0.931, 0.481, 2.463, -0.246, -0.556, -1.171, -1.335, 0.525, 0.851, 0.009, 0.333, 0.116,
1349        0.139, -1.526, -0.458, 0.111, -0.783, -0.476, -0.819, -0.333, 0.853, -0.407, -0.154, 0.814,
1350        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,
1351        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,
1352    ];
1353
1354    /// L2 BIC large offset 1e7.
1355    const CASE_3: [f64; 60] = [
1356        9999999.337,
1357        10000001.046,
1358        9999999.356,
1359        9999999.039,
1360        9999999.29,
1361        9999998.81,
1362        10000000.146,
1363        10000001.031,
1364        10000000.164,
1365        10000000.624,
1366        10000001.632,
1367        10000000.27,
1368        10000000.195,
1369        9999999.725,
1370        9999998.392,
1371        10000000.76,
1372        9999998.244,
1373        10000000.653,
1374        9999999.986,
1375        10000001.127,
1376        9999999.932,
1377        9999999.177,
1378        10000000.358,
1379        9999999.44,
1380        9999999.819,
1381        10000000.042,
1382        9999999.865,
1383        9999999.811,
1384        9999999.168,
1385        9999999.811,
1386        10000001.862,
1387        10000003.843,
1388        10000002.802,
1389        10000005.12,
1390        10000005.27,
1391        10000002.049,
1392        10000004.145,
1393        10000003.874,
1394        10000002.953,
1395        10000004.532,
1396        10000003.538,
1397        10000002.232,
1398        10000003.733,
1399        10000003.852,
1400        10000004.106,
1401        10000002.769,
1402        10000004.616,
1403        10000004.735,
1404        10000002.854,
1405        10000003.341,
1406        10000003.92,
1407        10000003.434,
1408        10000005.744,
1409        10000004.208,
1410        10000002.989,
1411        10000003.212,
1412        10000003.943,
1413        10000006.296,
1414        10000003.822,
1415        10000004.127,
1416    ];
1417
1418    fn detect(cost: CostFunction, penalty: Penalty, min_len: usize, data: &[f64]) -> Vec<usize> {
1419        Pelt::with_min_segment_len(cost, penalty, min_len)
1420            .expect("valid settings")
1421            .detect(data)
1422            .changepoints
1423    }
1424
1425    /// A mean shift at 40 on a level of 1.7e9 (a Unix timestamp in seconds).
1426    const LARGE_LEVEL: [f64; 80] = [
1427        1699999999.198,
1428        1699999998.676,
1429        1699999999.752,
1430        1700000000.42,
1431        1700000001.136,
1432        1700000000.11,
1433        1699999999.447,
1434        1699999999.215,
1435        1700000000.749,
1436        1700000001.635,
1437        1700000000.273,
1438        1699999998.767,
1439        1699999999.042,
1440        1700000001.6,
1441        1700000000.203,
1442        1699999998.268,
1443        1699999999.916,
1444        1699999998.837,
1445        1699999999.371,
1446        1699999999.512,
1447        1699999999.287,
1448        1700000000.553,
1449        1699999999.937,
1450        1699999999.411,
1451        1700000000.41,
1452        1700000000.83,
1453        1699999998.357,
1454        1699999999.743,
1455        1699999999.019,
1456        1699999999.827,
1457        1699999998.711,
1458        1700000000.021,
1459        1699999999.962,
1460        1699999999.696,
1461        1699999998.952,
1462        1699999999.604,
1463        1699999998.909,
1464        1699999998.645,
1465        1700000000.225,
1466        1699999998.891,
1467        1700000006.17,
1468        1700000005.717,
1469        1700000003.002,
1470        1700000005.272,
1471        1700000003.898,
1472        1700000005.033,
1473        1700000005.044,
1474        1700000003.012,
1475        1700000004.767,
1476        1700000004.744,
1477        1700000005.962,
1478        1700000003.819,
1479        1700000005.738,
1480        1700000003.901,
1481        1700000004.669,
1482        1700000004.16,
1483        1700000006.449,
1484        1700000005.568,
1485        1700000007.432,
1486        1700000005.642,
1487        1700000005.845,
1488        1700000005.841,
1489        1700000004.393,
1490        1700000004.93,
1491        1700000006.35,
1492        1700000004.603,
1493        1700000005.189,
1494        1700000004.979,
1495        1700000005.609,
1496        1700000004.635,
1497        1700000004.848,
1498        1700000005.242,
1499        1700000005.103,
1500        1700000004.135,
1501        1700000005.896,
1502        1700000003.702,
1503        1700000003.799,
1504        1700000003.718,
1505        1700000005.967,
1506        1700000004.639,
1507    ];
1508
1509    #[test]
1510    fn a_large_level_does_not_move_the_optimum() {
1511        // The same series minus 1.7e9: the segment costs are shift-invariant,
1512        // so the changepoints must be the same, [40, 56, 62] by the dynamic program.
1513        assert_eq!(
1514            detect(
1515                CostFunction::L2,
1516                Penalty::Custom((LARGE_LEVEL.len() as f64).ln()),
1517                2,
1518                &LARGE_LEVEL
1519            ),
1520            vec![40, 56, 62]
1521        );
1522    }
1523
1524    #[test]
1525    fn pruned_search_returns_the_exact_optimum() {
1526        // L2 BIC two mean shifts
1527        assert_eq!(
1528            detect(
1529                CostFunction::L2,
1530                Penalty::Custom((CASE_0.len() as f64).ln()),
1531                2,
1532                &CASE_0
1533            ),
1534            vec![20, 45]
1535        );
1536        // Normal BIC variance + mean shift, min seg 5
1537        assert_eq!(
1538            detect(CostFunction::Normal, Penalty::Bic, 5, &CASE_1),
1539            vec![41]
1540        );
1541        // L2 custom penalty 5, min seg 5
1542        assert_eq!(
1543            detect(CostFunction::L2, Penalty::Custom(5.0), 5, &CASE_2),
1544            vec![25]
1545        );
1546        // L2 BIC large offset 1e7
1547        assert_eq!(
1548            detect(
1549                CostFunction::L2,
1550                Penalty::Custom((CASE_3.len() as f64).ln()),
1551                2,
1552                &CASE_3
1553            ),
1554            vec![31]
1555        );
1556    }
1557}
1558
1559/// `detect_multi` against an exact dynamic program over every channel at once:
1560/// `F(t) = min over tau of F(tau) + sum over channels of cost(tau, t) + k * beta`,
1561/// with `F(0) = -k * beta` (the penalty the method documents, `penalty *
1562/// n_channels`), the segment costs taken in two passes. The reference is
1563/// Python 3 with NumPy; ties go to the smallest `tau`, as in the pruned search.
1564#[cfg(test)]
1565mod exact_dp_reference_multi {
1566    use super::*;
1567
1568    fn detect_multi(
1569        cost: CostFunction,
1570        penalty: Penalty,
1571        min_len: usize,
1572        channels: &[&[f64]],
1573    ) -> Vec<usize> {
1574        Pelt::with_min_segment_len(cost, penalty, min_len)
1575            .expect("valid settings")
1576            .detect_multi(channels)
1577            .expect("equal lengths")
1578            .changepoints
1579    }
1580
1581    const A_0: [f64; 60] = [
1582        -0.804, 0.801, -0.66, -0.813, 0.703, 1.326, -0.448, 2.263, -1.25, -2.127, 1.654, -0.384,
1583        0.038, -0.988, 1.559, -1.068, -0.242, -1.431, -1.253, -0.734, -0.485, 3.102, 2.171, -0.177,
1584        -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,
1585        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,
1586        1.201, 3.216, 1.34, 2.219, 3.623, 3.182, 5.136, 2.97, 1.461, 1.545,
1587    ];
1588
1589    const A_1: [f64; 60] = [
1590        1.04, -1.488, 0.963, -0.538, 1.736, 0.943, -0.191, 1.916, 0.606, -1.098, -0.764, 0.437,
1591        -0.276, 0.696, -1.029, -0.504, -0.287, -0.619, 0.587, -0.272, -1.753, 0.125, -2.015,
1592        -0.146, 1.821, 1.502, 1.776, -0.202, 1.056, 3.046, 2.47, 0.695, 1.365, 1.453, 0.705,
1593        -0.373, 1.967, 0.828, 1.686, -0.625, 0.406, 2.587, 1.235, -1.549, 1.573, -0.702, 2.101,
1594        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,
1595    ];
1596
1597    const B_0: [f64; 60] = [
1598        0.549, -0.705, -0.681, -0.052, 0.206, -0.084, -0.413, -0.562, -0.393, 0.281, 0.6, -1.232,
1599        -0.127, 0.072, 0.255, -0.094, -0.273, 0.606, -0.597, 0.615, 0.599, -0.193, 0.08, 1.066,
1600        -0.624, -0.284, 0.675, -0.924, -0.54, -0.564, -1.563, 2.435, -3.255, 0.942, 0.323, 1.455,
1601        -5.14, 1.305, -3.933, -0.982, 1.378, 2.145, -4.895, 0.009, -1.539, 3.119, -0.391, -2.202,
1602        5.382, 3.363, 2.322, -0.958, 1.278, -1.489, -0.671, -3.143, -0.972, 4.385, 1.683, -1.434,
1603    ];
1604
1605    const B_1: [f64; 60] = [
1606        -0.394, -0.653, 0.721, -0.457, -0.009, 0.161, -0.763, 0.533, -0.474, -0.655, -0.423, 0.734,
1607        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,
1608        -0.673, 0.033, 0.267, -0.507, 0.303, 5.49, -4.019, 0.665, 4.472, -0.633, -5.961, 2.979,
1609        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,
1610        0.382, 0.434, -2.124, 3.613, 2.052, 2.66, 2.733, 5.311, 0.163, 5.14,
1611    ];
1612
1613    const B_2: [f64; 60] = [
1614        0.13, -0.45, -0.281, 0.099, -0.849, -0.968, -0.102, 0.034, -0.244, -0.855, -1.248, 0.123,
1615        -0.757, -0.162, 0.304, -0.05, -0.153, 0.633, 0.197, -0.539, 0.398, -0.124, 0.021, 0.168,
1616        -0.909, -0.353, -0.161, -0.339, -0.212, 0.003, 0.043, -0.051, -0.087, -0.065, -0.55,
1617        -0.066, 0.756, 0.312, -0.21, -0.042, -0.518, -0.595, -0.717, -0.091, 0.24, -0.416, -0.179,
1618        0.611, -0.219, -0.528, -0.603, -1.164, -0.22, 0.875, -0.005, 1.255, -0.619, -0.851, 0.107,
1619        0.101,
1620    ];
1621
1622    const C_0: [f64; 60] = [
1623        0.954, 0.369, 0.316, 0.089, -0.008, -0.682, 0.736, -2.132, 2.099, -1.086, -0.896, -0.925,
1624        -0.576, -1.991, -0.338, 0.524, -0.182, 0.678, -0.767, 0.299, 1.874, 3.856, 2.051, 3.948,
1625        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,
1626        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,
1627        0.123, -0.145, -0.717, 0.334, 0.933, -1.102, 0.313, -2.129, 0.467, 0.762,
1628    ];
1629
1630    const C_1: [f64; 60] = [
1631        0.443, -0.016, 0.496, -0.191, 0.405, 0.124, 0.133, -0.853, -0.11, -1.616, -0.619, -0.114,
1632        0.05, 1.525, -1.539, 0.252, 0.402, 1.997, -0.821, 1.855, -3.542, -1.497, -2.465, 0.122,
1633        -3.354, -3.124, -2.577, -2.521, -1.298, -2.091, 0.046, -2.452, -1.855, -2.804, -2.243,
1634        -2.454, -1.28, -0.159, -1.652, -1.664, 0.177, -0.067, 0.107, -0.184, -0.051, 0.917, 1.159,
1635        1.173, 1.039, 0.66, 0.071, -0.928, 0.8, -0.388, 1.233, -0.091, 0.962, 0.423, -1.042,
1636        -0.113,
1637    ];
1638
1639    const D_0: [f64; 60] = [
1640        999998.994,
1641        999998.457,
1642        999997.649,
1643        999999.049,
1644        1000000.222,
1645        1000000.29,
1646        999999.019,
1647        1000000.062,
1648        1000000.761,
1649        999998.951,
1650        999999.22,
1651        999999.29,
1652        999999.563,
1653        1000000.661,
1654        1000000.006,
1655        999999.314,
1656        1000000.983,
1657        999998.466,
1658        1000000.97,
1659        1000002.599,
1660        999999.604,
1661        999998.518,
1662        1000001.488,
1663        1000001.909,
1664        999999.945,
1665        999998.831,
1666        999999.774,
1667        999999.966,
1668        999998.892,
1669        999998.914,
1670        1000003.175,
1671        1000004.168,
1672        1000004.646,
1673        1000003.017,
1674        1000001.941,
1675        1000004.399,
1676        1000003.577,
1677        1000002.784,
1678        1000003.348,
1679        1000002.429,
1680        1000002.519,
1681        1000001.615,
1682        1000002.515,
1683        1000001.867,
1684        1000003.272,
1685        1000004.196,
1686        1000000.7,
1687        1000003.487,
1688        1000003.406,
1689        1000002.157,
1690        1000003.654,
1691        1000002.441,
1692        1000002.837,
1693        1000003.022,
1694        1000003.252,
1695        1000003.838,
1696        1000003.735,
1697        1000002.762,
1698        1000003.664,
1699        1000003.312,
1700    ];
1701
1702    const D_1: [f64; 60] = [
1703        0.323, 1.153, -0.737, -0.765, -0.387, -0.132, -0.38, -0.017, 0.039, -1.855, 1.465, 2.111,
1704        -1.086, 0.568, 0.859, 1.543, 1.12, 0.916, -0.591, -1.247, -0.131, -0.231, -0.639, -0.829,
1705        0.322, -0.727, -1.789, -0.935, -0.092, 0.186, 1.729, 3.461, 2.688, 1.473, 3.507, 1.722,
1706        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,
1707        0.915, 2.022, 2.275, 1.484, 2.071, 3.911, -0.234, 2.667, 1.866, 2.315, 2.401,
1708    ];
1709
1710    const D_2: [f64; 60] = [
1711        -50.107, -50.532, -50.842, -48.33, -49.442, -49.761, -51.681, -52.824, -50.637, -51.821,
1712        -53.9, -49.553, -48.224, -49.598, -48.92, -51.311, -48.414, -46.838, -47.13, -49.746,
1713        -47.412, -51.215, -47.565, -50.883, -50.071, -53.527, -49.043, -48.191, -51.551, -49.062,
1714        -48.796, -49.414, -46.466, -48.3, -49.039, -48.988, -48.978, -48.995, -52.944, -51.869,
1715        -51.183, -47.484, -49.5, -48.72, -48.391, -50.368, -47.708, -47.899, -50.359, -50.786,
1716        -49.405, -49.132, -46.105, -56.126, -49.201, -49.857, -51.277, -51.729, -50.852, -49.224,
1717    ];
1718
1719    #[test]
1720    fn the_pruned_multi_channel_search_returns_the_exact_optimum() {
1721        assert_eq!(
1722            detect_multi(
1723                CostFunction::L2,
1724                Penalty::Custom((A_0.len() as f64).ln()),
1725                2,
1726                &[&A_0, &A_1]
1727            ),
1728            vec![21, 23, 26]
1729        );
1730        assert_eq!(
1731            detect_multi(CostFunction::Normal, Penalty::Bic, 5, &[&B_0, &B_1, &B_2]),
1732            vec![30]
1733        );
1734        assert_eq!(
1735            detect_multi(CostFunction::L2, Penalty::Custom(8.0), 5, &[&C_0, &C_1]),
1736            vec![20, 40]
1737        );
1738        assert_eq!(
1739            detect_multi(
1740                CostFunction::L2,
1741                Penalty::Custom((D_0.len() as f64).ln()),
1742                2,
1743                &[&D_0, &D_1, &D_2]
1744            ),
1745            vec![6, 11, 21, 30, 38, 41, 53]
1746        );
1747    }
1748}