Skip to main content

fdars_core/
kshape.rs

1//! k-Shape clustering of curve sets through the Shape-Based Distance (SBD).
2//!
3//! k-Shape (Paparrizos & Gravano, *k-Shape*, SIGMOD 2015) is a partitional
4//! clustering algorithm for time series that is invariant to amplitude scaling
5//! and phase (circular) shift. It alternates two steps, Lloyd-style, until
6//! convergence:
7//!
8//! 1. **Assignment.** Each z-normalized series is assigned to the cluster whose
9//!    centroid minimizes the [`sbd`] distance. The optimal SBD *shift* from that
10//!    same call is stored — it is needed to shift-align the member before the
11//!    centroid update (a common transcription bug is to discard it).
12//! 2. **Refinement (shape extraction).** Each centroid is recomputed as the
13//!    **top eigenvector** of a shift-aligned, mean-centered cross-product matrix
14//!    — *not* the arithmetic mean (that would be k-means, not k-Shape). See
15//!    [`shape_extraction`] for the exact matrix algebra.
16//!
17//! ## Shape extraction (the only genuinely-new numerical piece)
18//!
19//! For a cluster with members `X` (`n_k × m`, each row shift-aligned to the
20//! current centroid and z-normalized):
21//!
22//! ```text
23//! S = XᵀX                        (m × m)
24//! Q = I_m − (1/m)·O_m            (centering over the TIME dim; m = series length)
25//! M = Qᵀ S Q                     (symmetric m × m)
26//! μ = argmax eigenvector of M    (LARGEST eigenvalue)
27//! ```
28//!
29//! The eigenvector is defined up to sign: the sign that minimizes the total SBD
30//! to the members is chosen, then `μ` is z-normalized. Two subtle points that
31//! silently corrupt k-Shape if wrong: the centering divisor is `m` (the series
32//! length), **not** `n_k`; and nalgebra returns eigenvalues **ascending**, so the
33//! centroid is the eigenvector at the *largest* eigenvalue (argmax), not index 0.
34//!
35//! ## Robustness (mirrors [`crate::kernel_kmeans`])
36//!
37//! - **Init:** `n_init` *random-partition* restarts, each seeded
38//!   `seed_from_u64(seed + restart_idx)`; the lowest-total-inertia restart wins.
39//!   The default `n_init = 10` (an fdars convention exceeding tslearn's 1).
40//! - **Empty clusters:** a cluster that empties mid-iteration is reseeded in
41//!   place from the series currently farthest (max SBD) from its centroid — a
42//!   documented divergence from tslearn's full restart. The algorithm never
43//!   panics; `k > natural clusters` returns valid labels.
44//! - **Determinism:** the same `seed` yields byte-identical labels and inertia;
45//!   sequential and `parallel` builds agree (SBD is RNG-free).
46//!
47//! ## Out-of-sample prediction
48//!
49//! [`KShapeResult::predict`] z-normalizes each new series, computes [`sbd`] to
50//! every stored (already-z-normalized) centroid, and takes the argmin — the
51//! centroids are used as-is, so `predict(train_data)` reproduces the training
52//! labels.
53
54use crate::alignment::{kmedoids_from_distances, KMedoidsConfig, KMedoidsResult};
55use crate::error::FdarError;
56use crate::matrix::FdMatrix;
57use crate::metric::sbd::{sbd, sbd_distance_matrix};
58use crate::shapelet::z_normalize_window;
59use nalgebra::{DMatrix, SymmetricEigen};
60use rand::prelude::*;
61
62/// Configuration for k-Shape clustering ([`kshape_fd`]).
63///
64/// Defaults: `n_clusters = 2`, `n_init = 10` (robustness over tslearn's default
65/// of 1 — k-Shape is sensitive to initialization), `max_iter = 100`,
66/// `tol = 1e-6`, `seed = 0`.
67#[derive(Debug, Clone, PartialEq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69#[non_exhaustive]
70pub struct KShapeConfig {
71    /// Number of clusters `k` (≥ 1, ≤ number of series).
72    pub n_clusters: usize,
73    /// Number of random-partition restarts; the lowest-inertia run is returned (≥ 1).
74    pub n_init: usize,
75    /// Maximum Lloyd iterations per restart.
76    pub max_iter: usize,
77    /// Convergence tolerance on the absolute inertia decrease.
78    pub tol: f64,
79    /// Base RNG seed; restart `r` is seeded `seed + r`.
80    pub seed: u64,
81}
82
83impl Default for KShapeConfig {
84    fn default() -> Self {
85        Self {
86            n_clusters: 2,
87            n_init: 10,
88            max_iter: 100,
89            tol: 1e-6,
90            seed: 0,
91        }
92    }
93}
94
95impl KShapeConfig {
96    /// Construct a config for `n_clusters`, keeping the other defaults.
97    #[must_use]
98    pub fn new(n_clusters: usize) -> Self {
99        Self {
100            n_clusters,
101            ..Self::default()
102        }
103    }
104}
105
106/// Result of [`kshape_fd`].
107///
108/// Carries the fitted centroids (k × m, already z-normalized) plus cluster
109/// assignments and fit diagnostics. [`KShapeResult::predict`] reuses the stored
110/// centroids directly — nothing is re-estimated on new data.
111#[derive(Debug, Clone, PartialEq)]
112#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
113#[non_exhaustive]
114pub struct KShapeResult {
115    /// Cluster centroids as a `k × m` [`FdMatrix`] (each row a z-normalized shape prototype).
116    pub centroids: FdMatrix,
117    /// Cluster assignment for each training series (values in `0..n_clusters`).
118    pub cluster: Vec<usize>,
119    /// Total SBD inertia `Σ_i SBD(series_i, centroid[cluster[i]])` of the returned run.
120    pub inertia: f64,
121    /// Number of Lloyd iterations the winning restart ran.
122    pub iter: usize,
123    /// Whether the winning restart converged before hitting `max_iter`.
124    pub converged: bool,
125    /// Index of the restart (0-based) that produced this result.
126    pub n_init_best: usize,
127}
128
129impl KShapeResult {
130    /// The fitted centroids (`k × m`, each row a z-normalized shape prototype).
131    #[must_use]
132    pub fn centroids(&self) -> &FdMatrix {
133        &self.centroids
134    }
135
136    /// The per-series cluster assignments.
137    #[must_use]
138    pub fn cluster(&self) -> &[usize] {
139        &self.cluster
140    }
141
142    /// Total SBD inertia of the returned run.
143    #[must_use]
144    pub fn inertia(&self) -> f64 {
145        self.inertia
146    }
147
148    /// The number of clusters (`k`).
149    #[must_use]
150    pub fn n_clusters(&self) -> usize {
151        self.centroids.nrows()
152    }
153
154    /// Assign new (out-of-sample) series to the fitted clusters.
155    ///
156    /// Each new series is z-normalized and compared by [`sbd`] against every
157    /// stored (already-z-normalized) centroid; the argmin cluster is returned.
158    /// The centroids are used as-is, so `predict(train_data)` reproduces the
159    /// training labels.
160    ///
161    /// # Errors
162    /// Returns [`FdarError::InvalidDimension`] if `new_data` is empty or its
163    /// evaluation-grid width differs from the fitted centroids' length `m`.
164    pub fn predict(&self, new_data: &FdMatrix) -> Result<Vec<usize>, FdarError> {
165        let p = new_data.nrows();
166        let m = new_data.ncols();
167        let k = self.centroids.nrows();
168        let cm = self.centroids.ncols();
169        if p == 0 || m == 0 {
170            return Err(FdarError::InvalidDimension {
171                parameter: "new_data",
172                expected: "non-empty matrix (nrows > 0, ncols > 0)".to_string(),
173                actual: format!("{p}x{m}"),
174            });
175        }
176        if m != cm {
177            return Err(FdarError::InvalidDimension {
178                parameter: "new_data",
179                expected: format!("series length m={cm} matching fitted centroids"),
180                actual: format!("m={m}"),
181            });
182        }
183
184        // Materialize the stored centroids as contiguous rows once.
185        let mut centroid_rows: Vec<Vec<f64>> = Vec::with_capacity(k);
186        for c in 0..k {
187            centroid_rows.push(self.centroids.row(c));
188        }
189
190        let mut labels = vec![0usize; p];
191        let mut row = vec![0.0f64; m];
192        for t in 0..p {
193            new_data.row_to_buf(t, &mut row);
194            let z = z_normalize_window(&row);
195            let mut best = 0usize;
196            let mut best_d = f64::INFINITY;
197            for (c, cent) in centroid_rows.iter().enumerate() {
198                let d = sbd(&z, cent).map(|r| r.distance).unwrap_or(1.0);
199                if d < best_d {
200                    best_d = d;
201                    best = c;
202                }
203            }
204            labels[t] = best;
205        }
206        Ok(labels)
207    }
208}
209
210/// Cluster a curve set with k-Shape.
211///
212/// Runs `config.n_init` random-partition restarts (each seeded
213/// `config.seed + restart_idx`), keeping the lowest-total-inertia run. Every
214/// series is z-normalized once up front; each restart iterates SBD assignment +
215/// shape-extraction centroid refinement to convergence or `max_iter`. Empty
216/// clusters are recovered in place by farthest-point reassignment; the algorithm
217/// never panics.
218///
219/// # Errors
220/// - [`FdarError::InvalidDimension`] if `data` is empty (no series or no points).
221/// - [`FdarError::InvalidParameter`] if `n_clusters < 1`, `n_clusters > n`, or
222///   `n_init < 1`.
223///
224/// # Examples
225/// ```
226/// use fdars_core::kshape::{kshape_fd, KShapeConfig};
227/// use fdars_core::FdMatrix;
228/// // Two shape groups: rising ramps vs. falling ramps (column-major FdMatrix).
229/// let rows = [
230///     vec![0.0, 1.0, 2.0, 3.0, 4.0],
231///     vec![0.1, 1.1, 2.0, 3.1, 3.9],
232///     vec![4.0, 3.0, 2.0, 1.0, 0.0],
233///     vec![3.9, 3.1, 2.0, 0.9, 0.1],
234/// ];
235/// let (n, m) = (4, 5);
236/// let mut data = vec![0.0; n * m];
237/// for (i, r) in rows.iter().enumerate() {
238///     for (j, &v) in r.iter().enumerate() {
239///         data[i + j * n] = v; // column-major
240///     }
241/// }
242/// let data = FdMatrix::from_slice(&data, n, m).unwrap();
243///
244/// let cfg = KShapeConfig::new(2);
245/// let res = kshape_fd(&data, &cfg).unwrap();
246/// assert_eq!(res.cluster.len(), 4);
247/// // The two rising ramps share a cluster; the two falling ramps share the other.
248/// assert_eq!(res.cluster[0], res.cluster[1]);
249/// assert_eq!(res.cluster[2], res.cluster[3]);
250/// assert_ne!(res.cluster[0], res.cluster[2]);
251/// ```
252#[must_use = "expensive computation whose result should not be discarded"]
253pub fn kshape_fd(data: &FdMatrix, config: &KShapeConfig) -> Result<KShapeResult, FdarError> {
254    let n = data.nrows();
255    let m = data.ncols();
256    if n == 0 || m == 0 {
257        return Err(FdarError::InvalidDimension {
258            parameter: "data",
259            expected: "non-empty matrix (nrows > 0, ncols > 0)".to_string(),
260            actual: format!("{n}x{m}"),
261        });
262    }
263    let k = config.n_clusters;
264    if k < 1 {
265        return Err(FdarError::InvalidParameter {
266            parameter: "n_clusters",
267            message: "number of clusters must be >= 1".to_string(),
268        });
269    }
270    if k > n {
271        return Err(FdarError::InvalidParameter {
272            parameter: "n_clusters",
273            message: format!("n_clusters={k} exceeds number of series n={n}"),
274        });
275    }
276    if config.n_init < 1 {
277        return Err(FdarError::InvalidParameter {
278            parameter: "n_init",
279            message: "n_init must be >= 1".to_string(),
280        });
281    }
282
283    // Z-normalize every series ONCE up front; all assignment + shape extraction
284    // operate on these z-normed rows.
285    let mut series: Vec<Vec<f64>> = Vec::with_capacity(n);
286    let mut row = vec![0.0f64; m];
287    for i in 0..n {
288        data.row_to_buf(i, &mut row);
289        series.push(z_normalize_window(&row));
290    }
291
292    let mut best: Option<RestartOutcome> = None;
293    for restart in 0..config.n_init {
294        let mut rng = StdRng::seed_from_u64(config.seed.wrapping_add(restart as u64));
295        let outcome = run_restart(
296            &series,
297            n,
298            m,
299            k,
300            config.max_iter,
301            config.tol,
302            &mut rng,
303            restart,
304        );
305        let take = match &best {
306            None => true,
307            Some(b) => outcome.inertia < b.inertia,
308        };
309        if take {
310            best = Some(outcome);
311        }
312    }
313
314    // n_init >= 1 guarantees at least one restart ran.
315    let RestartOutcome {
316        cluster,
317        centroids,
318        inertia,
319        iter,
320        converged,
321        restart_idx,
322    } = best.expect("n_init >= 1 guarantees a restart outcome");
323
324    // Assemble the k × m centroid matrix (rows already z-normalized).
325    let mut cmat = FdMatrix::zeros(k, m);
326    for (c, cent) in centroids.iter().enumerate() {
327        for (j, &v) in cent.iter().enumerate() {
328            cmat[(c, j)] = v;
329        }
330    }
331
332    Ok(KShapeResult {
333        centroids: cmat,
334        cluster,
335        inertia,
336        iter,
337        converged,
338        n_init_best: restart_idx,
339    })
340}
341
342/// Cluster a curve set with **k-medoids over the Shape-Based Distance**.
343///
344/// A shape-based clustering path distinct from [`kshape_fd`]: instead of
345/// estimating shape-extraction centroids, this builds the full n×n SBD distance
346/// matrix ([`sbd_distance_matrix`]) and feeds it to the existing
347/// [`kmedoids_from_distances`] solver. The returned medoids are *actual input
348/// series* (indices into `data`), which makes the result directly
349/// interpretable. Because the backend distance is SBD, the clustering is
350/// invariant to amplitude scaling and circular phase shift — unlike an L2- or
351/// DTW-backed k-medoids.
352///
353/// Reuses [`KMedoidsConfig`] / [`KMedoidsResult`] unchanged.
354///
355/// # Errors
356/// Propagates any error from [`sbd_distance_matrix`] (e.g. empty `data`) or from
357/// [`kmedoids_from_distances`] (`config.k < 1`, `config.k > n`).
358///
359/// # Examples
360/// ```
361/// use fdars_core::{sbd_kmedoids, sbd_distance_matrix, KMedoidsConfig, FdMatrix};
362/// use fdars_core::alignment::kmedoids_from_distances;
363///
364/// // Two shape groups: rising ramps vs. falling ramps (column-major FdMatrix).
365/// let rows = [
366///     vec![0.0, 1.0, 2.0, 3.0, 4.0],
367///     vec![0.1, 1.1, 2.0, 3.1, 3.9],
368///     vec![4.0, 3.0, 2.0, 1.0, 0.0],
369///     vec![3.9, 3.1, 2.0, 0.9, 0.1],
370/// ];
371/// let (n, m) = (4, 5);
372/// let mut flat = vec![0.0; n * m];
373/// for (i, r) in rows.iter().enumerate() {
374///     for (j, &v) in r.iter().enumerate() {
375///         flat[i + j * n] = v; // column-major
376///     }
377/// }
378/// let data = FdMatrix::from_slice(&flat, n, m).unwrap();
379///
380/// let cfg = KMedoidsConfig { k: 2, ..Default::default() };
381/// let res = sbd_kmedoids(&data, &cfg).unwrap();
382/// assert_eq!(res.labels.len(), 4);
383/// assert_eq!(res.medoid_indices.len(), 2);
384///
385/// // Equivalent to the explicit SBD-matrix → k-medoids flow:
386/// let dist = sbd_distance_matrix(&data).unwrap();
387/// let manual = kmedoids_from_distances(&dist, &cfg).unwrap();
388/// assert_eq!(res.labels, manual.labels);
389/// ```
390#[must_use = "expensive computation whose result should not be discarded"]
391pub fn sbd_kmedoids(data: &FdMatrix, config: &KMedoidsConfig) -> Result<KMedoidsResult, FdarError> {
392    let dist = sbd_distance_matrix(data)?;
393    kmedoids_from_distances(&dist, config)
394}
395
396/// Outcome of a single random-partition restart.
397struct RestartOutcome {
398    cluster: Vec<usize>,
399    centroids: Vec<Vec<f64>>,
400    inertia: f64,
401    iter: usize,
402    converged: bool,
403    restart_idx: usize,
404}
405
406/// Run one k-Shape restart (Lloyd iterations) on the z-normalized series.
407#[allow(clippy::too_many_arguments)]
408fn run_restart(
409    series: &[Vec<f64>],
410    n: usize,
411    m: usize,
412    k: usize,
413    max_iter: usize,
414    tol: f64,
415    rng: &mut StdRng,
416    restart_idx: usize,
417) -> RestartOutcome {
418    // Random-partition init, repaired so every cluster starts non-empty.
419    let mut cluster: Vec<usize> = (0..n).map(|_| rng.gen_range(0..k)).collect();
420    ensure_no_empty_random(&mut cluster, n, k, rng);
421
422    // Centroids: initialized to zero; the first refinement fills them from the
423    // random partition (assignment before the first refinement is skipped).
424    let mut centroids: Vec<Vec<f64>> = vec![vec![0.0f64; m]; k];
425    refine_centroids(series, &cluster, k, m, &mut centroids);
426
427    let mut prev_inertia = f64::INFINITY;
428    let mut iter = 0usize;
429    let mut converged = false;
430    let mut inertia = f64::INFINITY;
431
432    while iter < max_iter {
433        iter += 1;
434
435        // --- Assignment: min-SBD centroid, storing the SBD shift per series. ---
436        let mut new_cluster = vec![0usize; n];
437        let mut dist_to_own = vec![0.0f64; n];
438        for i in 0..n {
439            let mut best_c = 0usize;
440            let mut best_d = f64::INFINITY;
441            for (c, cent) in centroids.iter().enumerate() {
442                let d = sbd(&series[i], cent).map(|r| r.distance).unwrap_or(1.0);
443                if d < best_d {
444                    best_d = d;
445                    best_c = c;
446                }
447            }
448            new_cluster[i] = best_c;
449            dist_to_own[i] = best_d;
450        }
451
452        // Empty-cluster recovery: reseed each empty cluster from the series
453        // currently farthest (max SBD) from its assigned centroid.
454        recover_empty_clusters(&mut new_cluster, &dist_to_own, n, k);
455
456        // --- Refinement: shape-extraction centroids. ---
457        refine_centroids(series, &new_cluster, k, m, &mut centroids);
458
459        // --- Inertia (after refinement, against the just-assigned labels). ---
460        inertia = 0.0;
461        for i in 0..n {
462            let d = sbd(&series[i], &centroids[new_cluster[i]])
463                .map(|r| r.distance)
464                .unwrap_or(1.0);
465            inertia += d;
466        }
467
468        let changed = new_cluster != cluster;
469        cluster = new_cluster;
470
471        if !changed || (prev_inertia - inertia).abs() < tol {
472            converged = true;
473            break;
474        }
475        prev_inertia = inertia;
476    }
477
478    RestartOutcome {
479        cluster,
480        centroids,
481        inertia,
482        iter,
483        converged,
484        restart_idx,
485    }
486}
487
488/// Recompute every centroid via shape extraction from its current members.
489///
490/// An empty cluster keeps its previous centroid (recovery happens in the
491/// assignment step); a non-empty cluster's centroid is overwritten in place.
492fn refine_centroids(
493    series: &[Vec<f64>],
494    cluster: &[usize],
495    k: usize,
496    m: usize,
497    centroids: &mut [Vec<f64>],
498) {
499    for c in 0..k {
500        let members: Vec<usize> = (0..series.len()).filter(|&i| cluster[i] == c).collect();
501        if members.is_empty() {
502            continue;
503        }
504        centroids[c] = shape_extraction(series, &members, &centroids[c], m);
505    }
506}
507
508/// Shape-extraction centroid for one cluster (decision 5 — the k-Shape crux).
509///
510/// Each member is aligned to `centroid` by its SBD optimal shift, stacked into
511/// `X` (`n_k × m`); then `S = XᵀX`, `Q = I_m − O_m/m` (centering over the TIME
512/// dimension, divisor `m` = series length), `M = QᵀSQ`. The centroid is the
513/// eigenvector of `M` at the **largest** eigenvalue (nalgebra returns ascending,
514/// so argmax), sign-fixed to minimize total SBD to the members, then
515/// z-normalized.
516fn shape_extraction(
517    series: &[Vec<f64>],
518    members: &[usize],
519    centroid: &[f64],
520    m: usize,
521) -> Vec<f64> {
522    let n_k = members.len();
523
524    // Align each member to the current centroid by its SBD shift, then
525    // re-z-normalize the shifted vector. If the centroid is all-zero (first
526    // refinement, before any assignment produced a shape), SBD's constant-series
527    // guard returns shift 0 — the members are used unshifted, which is correct
528    // for a random-partition seed.
529    let mut x_aligned: Vec<Vec<f64>> = Vec::with_capacity(n_k);
530    for &i in members {
531        let shift = sbd(centroid, &series[i]).map(|r| r.shift).unwrap_or(0);
532        let shifted = circular_shift(&series[i], shift);
533        x_aligned.push(z_normalize_window(&shifted));
534    }
535
536    // S = XᵀX  (m × m). S[a][b] = Σ_i X[i][a] · X[i][b].
537    let mut s = DMatrix::<f64>::zeros(m, m);
538    for row_vec in &x_aligned {
539        for a in 0..m {
540            let va = row_vec[a];
541            if va == 0.0 {
542                continue;
543            }
544            for b in 0..m {
545                s[(a, b)] += va * row_vec[b];
546            }
547        }
548    }
549
550    // M = Qᵀ S Q with Q = I_m − O_m/m (centering over time; divisor m).
551    // Q is symmetric, so M = Q S Q. Compute QS then (QS)Q.
552    // (Q A)[a][b] = A[a][b] − mean_over_a(A[:,b]); (B Q)[a][b] = B[a][b] − mean_over_b(B[a][:]).
553    let inv_m = 1.0 / m as f64;
554    // QS: subtract, from each column, that column's mean over rows.
555    let mut qs = s.clone();
556    for b in 0..m {
557        let mut col_mean = 0.0;
558        for a in 0..m {
559            col_mean += s[(a, b)];
560        }
561        col_mean *= inv_m;
562        for a in 0..m {
563            qs[(a, b)] -= col_mean;
564        }
565    }
566    // (QS)Q: subtract, from each row, that row's mean over columns.
567    let mut mmat = qs.clone();
568    for a in 0..m {
569        let mut row_mean = 0.0;
570        for b in 0..m {
571            row_mean += qs[(a, b)];
572        }
573        row_mean *= inv_m;
574        for b in 0..m {
575            mmat[(a, b)] -= row_mean;
576        }
577    }
578    // Symmetrize defensively (M is symmetric in exact arithmetic).
579    for a in 0..m {
580        for b in (a + 1)..m {
581            let avg = 0.5 * (mmat[(a, b)] + mmat[(b, a)]);
582            mmat[(a, b)] = avg;
583            mmat[(b, a)] = avg;
584        }
585    }
586
587    // Top eigenvector: nalgebra returns eigenvalues ASCENDING → take argmax.
588    let eig = SymmetricEigen::new(mmat);
589    let mut arg = 0usize;
590    let mut best_eval = f64::NEG_INFINITY;
591    for (i, &ev) in eig.eigenvalues.iter().enumerate() {
592        if ev > best_eval {
593            best_eval = ev;
594            arg = i;
595        }
596    }
597    let mut v: Vec<f64> = eig.eigenvectors.column(arg).iter().copied().collect();
598
599    // Sign fix: choose ±v minimizing Σ SBD(±v, member).
600    let neg: Vec<f64> = v.iter().map(|x| -x).collect();
601    let mut sum_pos = 0.0;
602    let mut sum_neg = 0.0;
603    for row_vec in &x_aligned {
604        sum_pos += sbd(&v, row_vec).map(|r| r.distance).unwrap_or(1.0);
605        sum_neg += sbd(&neg, row_vec).map(|r| r.distance).unwrap_or(1.0);
606    }
607    if sum_neg < sum_pos {
608        v = neg;
609    }
610
611    // z-normalize the centroid (mean 0, std 1) for the next iteration's SBD.
612    z_normalize_window(&v)
613}
614
615/// Circularly shift `x` by `shift` positions (positive = right / later).
616fn circular_shift(x: &[f64], shift: isize) -> Vec<f64> {
617    let n = x.len();
618    if n == 0 {
619        return Vec::new();
620    }
621    let n_i = n as isize;
622    let s = ((shift % n_i) + n_i) % n_i; // normalize to 0..n
623    let mut out = vec![0.0f64; n];
624    for (i, &v) in x.iter().enumerate() {
625        let j = ((i as isize + s) % n_i) as usize;
626        out[j] = v;
627    }
628    out
629}
630
631/// Ensure a random-partition init leaves no cluster empty by moving distinct
632/// series into empty clusters (deterministic given the RNG state).
633fn ensure_no_empty_random(cluster: &mut [usize], n: usize, k: usize, rng: &mut StdRng) {
634    loop {
635        let mut sizes = vec![0usize; k];
636        for &c in cluster.iter() {
637            sizes[c] += 1;
638        }
639        let empty: Vec<usize> = (0..k).filter(|&c| sizes[c] == 0).collect();
640        if empty.is_empty() {
641            return;
642        }
643        for c in empty {
644            let donors: Vec<usize> = (0..n).filter(|&i| sizes[cluster[i]] > 1).collect();
645            if donors.is_empty() {
646                return; // k == n edge; assignment loop tolerates this.
647            }
648            let pick = donors[rng.gen_range(0..donors.len())];
649            sizes[cluster[pick]] -= 1;
650            cluster[pick] = c;
651            sizes[c] += 1;
652        }
653    }
654}
655
656/// Recover empty clusters after a reassignment by moving the series currently
657/// farthest (max SBD to its centroid) into each empty cluster. Never panics.
658fn recover_empty_clusters(cluster: &mut [usize], dist_to_own: &[f64], n: usize, k: usize) {
659    loop {
660        let mut sizes = vec![0usize; k];
661        for &c in cluster.iter() {
662            sizes[c] += 1;
663        }
664        let Some(empty) = (0..k).find(|&c| sizes[c] == 0) else {
665            return;
666        };
667        let mut best_i = None;
668        let mut best_d = f64::NEG_INFINITY;
669        for i in 0..n {
670            if sizes[cluster[i]] <= 1 {
671                continue;
672            }
673            let d = dist_to_own[i];
674            if d > best_d {
675                best_d = d;
676                best_i = Some(i);
677            }
678        }
679        match best_i {
680            Some(i) => cluster[i] = empty,
681            None => return, // no movable series (k == n); leave as-is.
682        }
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use std::f64::consts::PI;
690
691    /// Build an FdMatrix from row-major curves (each inner Vec is one series/row).
692    fn matrix_from_rows(rows: &[Vec<f64>]) -> FdMatrix {
693        let n = rows.len();
694        let m = rows[0].len();
695        let mut data = vec![0.0; n * m];
696        for (i, r) in rows.iter().enumerate() {
697            for (j, &v) in r.iter().enumerate() {
698                data[i + j * n] = v; // column-major
699            }
700        }
701        FdMatrix::from_slice(&data, n, m).unwrap()
702    }
703
704    /// Permutation-invariant purity of `labels` against `truth`.
705    fn purity(labels: &[usize], truth: &[usize], k: usize) -> f64 {
706        let n = labels.len();
707        let n_truth = truth.iter().copied().max().unwrap_or(0) + 1;
708        let mut correct = 0usize;
709        for c in 0..k {
710            let mut counts = vec![0usize; n_truth];
711            for i in 0..n {
712                if labels[i] == c {
713                    counts[truth[i]] += 1;
714                }
715            }
716            correct += counts.iter().copied().max().unwrap_or(0);
717        }
718        correct as f64 / n as f64
719    }
720
721    /// Two shape groups with random per-series CIRCULAR shifts + light noise.
722    /// Group 0 is a single-period sine; group 1 is a single-period cosine-like
723    /// (double-frequency) bump — distinct base shapes.
724    fn shifted_groups(seed: u64) -> (FdMatrix, Vec<usize>) {
725        let m = 40usize;
726        let mut rng = StdRng::seed_from_u64(seed);
727        let mut rows = Vec::new();
728        let mut truth = Vec::new();
729        let base_a: Vec<f64> = (0..m)
730            .map(|j| (2.0 * PI * j as f64 / m as f64).sin())
731            .collect();
732        let base_b: Vec<f64> = (0..m)
733            .map(|j| (4.0 * PI * j as f64 / m as f64).sin())
734            .collect();
735        for (label, base) in [(0usize, &base_a), (1usize, &base_b)] {
736            for _ in 0..8 {
737                let shift = rng.gen_range(0..m) as isize;
738                let shifted = circular_shift(base, shift);
739                let noisy: Vec<f64> = shifted
740                    .iter()
741                    .map(|&v| v + (rng.gen::<f64>() - 0.5) * 0.05)
742                    .collect();
743                rows.push(noisy);
744                truth.push(label);
745            }
746        }
747        (matrix_from_rows(&rows), truth)
748    }
749
750    #[test]
751    fn test_kshape_recovers_shifted_groups() {
752        let (data, truth) = shifted_groups(7);
753        let cfg = KShapeConfig {
754            n_clusters: 2,
755            n_init: 10,
756            seed: 3,
757            ..Default::default()
758        };
759        let res = kshape_fd(&data, &cfg).unwrap();
760        assert_eq!(res.cluster.len(), 16);
761        let p = purity(&res.cluster, &truth, 2);
762        assert!((p - 1.0).abs() < 1e-12, "purity {p} != 1.0");
763        assert_eq!(res.n_clusters(), 2);
764        // Centroids are z-normalized (mean ~0).
765        for c in 0..2 {
766            let row = res.centroids.row(c);
767            let mean: f64 = row.iter().sum::<f64>() / row.len() as f64;
768            assert!(mean.abs() < 1e-8, "centroid {c} not zero-mean: {mean}");
769        }
770    }
771
772    #[test]
773    fn test_kshape_centroid_sign() {
774        // A clean single-shape cluster: 6 identical (up to tiny noise) sine curves,
775        // no shifts. The extracted centroid must correlate POSITIVELY with members.
776        let m = 32usize;
777        let base: Vec<f64> = (0..m)
778            .map(|j| (2.0 * PI * j as f64 / m as f64).sin())
779            .collect();
780        let mut rng = StdRng::seed_from_u64(1);
781        let rows: Vec<Vec<f64>> = (0..6)
782            .map(|_| {
783                base.iter()
784                    .map(|&v| v + (rng.gen::<f64>() - 0.5) * 0.01)
785                    .collect()
786            })
787            .collect();
788        let data = matrix_from_rows(&rows);
789        let cfg = KShapeConfig::new(1);
790        let res = kshape_fd(&data, &cfg).unwrap();
791        let cent = res.centroids.row(0);
792        let base_z = z_normalize_window(&base);
793        let cent_z = z_normalize_window(&cent);
794        // Pearson correlation (both zero-mean, unit-std) = mean of products.
795        let corr: f64 = cent_z
796            .iter()
797            .zip(base_z.iter())
798            .map(|(a, b)| a * b)
799            .sum::<f64>()
800            / m as f64;
801        assert!(
802            corr > 0.99,
803            "centroid must correlate positively, corr={corr}"
804        );
805    }
806
807    #[test]
808    fn test_kshape_empty_cluster_recovery() {
809        // k = 5 but only two natural groups. Must not panic; all clusters non-empty.
810        let (data, _) = shifted_groups(11);
811        let cfg = KShapeConfig {
812            n_clusters: 5,
813            n_init: 3,
814            seed: 2,
815            ..Default::default()
816        };
817        let res = kshape_fd(&data, &cfg).unwrap();
818        assert_eq!(res.cluster.len(), 16);
819        assert!(res.cluster.iter().all(|&c| c < 5));
820        let mut sizes = vec![0usize; 5];
821        for &c in &res.cluster {
822            sizes[c] += 1;
823        }
824        assert!(
825            sizes.iter().all(|&s| s >= 1),
826            "an empty cluster survived: {sizes:?}"
827        );
828    }
829
830    #[test]
831    fn test_kshape_deterministic() {
832        let (data, _) = shifted_groups(5);
833        let cfg = KShapeConfig {
834            n_clusters: 2,
835            n_init: 5,
836            seed: 42,
837            ..Default::default()
838        };
839        let a = kshape_fd(&data, &cfg).unwrap();
840        let b = kshape_fd(&data, &cfg).unwrap();
841        assert_eq!(a.cluster, b.cluster, "same seed must give identical labels");
842        assert_eq!(a.inertia.to_bits(), b.inertia.to_bits());
843        assert_eq!(a.n_init_best, b.n_init_best);
844        // Centroids byte-identical too (sequential==parallel: SBD is RNG-free).
845        let n = a.centroids.nrows();
846        let m = a.centroids.ncols();
847        for i in 0..n {
848            for j in 0..m {
849                assert_eq!(a.centroids[(i, j)].to_bits(), b.centroids[(i, j)].to_bits());
850            }
851        }
852    }
853
854    #[test]
855    fn test_kshape_best_of_n_init() {
856        // n_init > 1 must return inertia no worse than a single-init baseline on
857        // the same base seed.
858        let (data, _) = shifted_groups(9);
859        let multi = KShapeConfig {
860            n_clusters: 2,
861            n_init: 10,
862            seed: 4,
863            ..Default::default()
864        };
865        let single = KShapeConfig {
866            n_init: 1,
867            ..multi.clone()
868        };
869        let rm = kshape_fd(&data, &multi).unwrap();
870        let rs = kshape_fd(&data, &single).unwrap();
871        assert!(
872            rm.inertia <= rs.inertia + 1e-12,
873            "multi-init inertia {} worse than single-init {}",
874            rm.inertia,
875            rs.inertia
876        );
877    }
878
879    #[test]
880    fn test_kshape_predict() {
881        let (data, _) = shifted_groups(13);
882        let cfg = KShapeConfig {
883            n_clusters: 2,
884            n_init: 10,
885            seed: 6,
886            ..Default::default()
887        };
888        let res = kshape_fd(&data, &cfg).unwrap();
889
890        // predict on the training data reproduces the training labels exactly.
891        let preds = res.predict(&data).unwrap();
892        assert_eq!(preds, res.cluster, "predict(train) must reproduce cluster");
893
894        // A new series near group A (a shifted copy of series 0) routes to A.
895        let m = data.ncols();
896        let src = data.row(0);
897        let novel = circular_shift(&src, 7);
898        let test = matrix_from_rows(&[novel]);
899        let p = res.predict(&test).unwrap();
900        assert_eq!(p.len(), 1);
901        assert_eq!(
902            p[0], res.cluster[0],
903            "shifted copy of series 0 should route to its cluster"
904        );
905        let _ = m;
906    }
907
908    #[test]
909    fn test_kshape_validation() {
910        let (data, _) = shifted_groups(1);
911        // n_clusters = 0.
912        let cfg0 = KShapeConfig::new(0);
913        assert!(matches!(
914            kshape_fd(&data, &cfg0),
915            Err(FdarError::InvalidParameter { .. })
916        ));
917        // n_clusters > n.
918        let cfg_big = KShapeConfig::new(999);
919        assert!(matches!(
920            kshape_fd(&data, &cfg_big),
921            Err(FdarError::InvalidParameter { .. })
922        ));
923        // n_init = 0.
924        let cfg_ni = KShapeConfig {
925            n_init: 0,
926            ..KShapeConfig::new(2)
927        };
928        assert!(matches!(
929            kshape_fd(&data, &cfg_ni),
930            Err(FdarError::InvalidParameter { .. })
931        ));
932        // Empty data.
933        let empty = FdMatrix::zeros(0, 0);
934        assert!(matches!(
935            kshape_fd(&empty, &KShapeConfig::new(2)),
936            Err(FdarError::InvalidDimension { .. })
937        ));
938        // predict dimension mismatch.
939        let res = kshape_fd(&data, &KShapeConfig::new(2)).unwrap();
940        let wrong = matrix_from_rows(&[vec![1.0, 2.0, 3.0]]);
941        assert!(matches!(
942            res.predict(&wrong),
943            Err(FdarError::InvalidDimension { .. })
944        ));
945    }
946
947    #[test]
948    fn test_sbd_kmedoids_recovers_groups() {
949        // Two shifted-shape groups → k-medoids over SBD recovers them at high
950        // purity, proving it uses the shape-invariant SBD matrix (an L2/DTW
951        // backend would be fooled by the circular shifts).
952        let (data, truth) = shifted_groups(7);
953        let cfg = KMedoidsConfig {
954            k: 2,
955            max_iter: 100,
956            seed: 3,
957        };
958        let res = sbd_kmedoids(&data, &cfg).unwrap();
959        assert_eq!(res.labels.len(), 16);
960        assert_eq!(res.medoid_indices.len(), 2);
961        let p = purity(&res.labels, &truth, 2);
962        assert!(p >= 0.9, "SBD k-medoids purity {p} too low (< 0.9)");
963    }
964
965    #[test]
966    fn test_sbd_kmedoids_uses_sbd_matrix() {
967        // sbd_kmedoids == manual composition sbd_distance_matrix + kmedoids_from_distances
968        // (same seed → identical labels and medoids).
969        let (data, _) = shifted_groups(5);
970        let cfg = KMedoidsConfig {
971            k: 2,
972            max_iter: 100,
973            seed: 42,
974        };
975        let res = sbd_kmedoids(&data, &cfg).unwrap();
976        let dist = sbd_distance_matrix(&data).unwrap();
977        let manual = kmedoids_from_distances(&dist, &cfg).unwrap();
978        assert_eq!(
979            res.labels, manual.labels,
980            "labels must match manual composition"
981        );
982        assert_eq!(
983            res.medoid_indices, manual.medoid_indices,
984            "medoids must match manual composition"
985        );
986        assert_eq!(
987            res.total_within_distance.to_bits(),
988            manual.total_within_distance.to_bits()
989        );
990    }
991
992    #[test]
993    fn test_sbd_kmedoids_validation() {
994        let (data, _) = shifted_groups(1);
995        // k = 0 → error (propagated from kmedoids_from_distances).
996        let cfg0 = KMedoidsConfig {
997            k: 0,
998            ..Default::default()
999        };
1000        assert!(matches!(
1001            sbd_kmedoids(&data, &cfg0),
1002            Err(FdarError::InvalidParameter { .. })
1003        ));
1004        // k > n → error.
1005        let cfg_big = KMedoidsConfig {
1006            k: 999,
1007            ..Default::default()
1008        };
1009        assert!(matches!(
1010            sbd_kmedoids(&data, &cfg_big),
1011            Err(FdarError::InvalidParameter { .. })
1012        ));
1013    }
1014
1015    /// Crate-root re-exports for the full v0.34.0 SBD + k-Shape surface resolve.
1016    ///
1017    /// Uses `crate::` paths (the same items published at the crate root); a full
1018    /// external `use fdars_core::{...}` resolution is additionally covered by the
1019    /// `sbd_kmedoids` doctest, which is compiled as an out-of-crate binary.
1020    #[test]
1021    fn test_kshape_reexports() {
1022        use crate::{
1023            kshape_fd, sbd, sbd_distance_matrix, sbd_kmedoids, KMedoidsConfig, KMedoidsResult,
1024            KShapeConfig, KShapeResult, SbdResult,
1025        };
1026        // Reference each item so an unresolved name fails to compile.
1027        let _f: fn(&FdMatrix, &KShapeConfig) -> Result<KShapeResult, FdarError> = kshape_fd;
1028        let _k: fn(&FdMatrix, &KMedoidsConfig) -> Result<KMedoidsResult, FdarError> = sbd_kmedoids;
1029        let _s: fn(&[f64], &[f64]) -> Result<SbdResult, FdarError> = sbd;
1030        let _m: fn(&FdMatrix) -> Result<FdMatrix, FdarError> = sbd_distance_matrix;
1031    }
1032}