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 mut cfg = KMedoidsConfig::default();
381/// cfg.k = 2;
382/// let res = sbd_kmedoids(&data, &cfg).unwrap();
383/// assert_eq!(res.labels.len(), 4);
384/// assert_eq!(res.medoid_indices.len(), 2);
385///
386/// // Equivalent to the explicit SBD-matrix → k-medoids flow:
387/// let dist = sbd_distance_matrix(&data).unwrap();
388/// let manual = kmedoids_from_distances(&dist, &cfg).unwrap();
389/// assert_eq!(res.labels, manual.labels);
390/// ```
391#[must_use = "expensive computation whose result should not be discarded"]
392pub fn sbd_kmedoids(data: &FdMatrix, config: &KMedoidsConfig) -> Result<KMedoidsResult, FdarError> {
393    let dist = sbd_distance_matrix(data)?;
394    kmedoids_from_distances(&dist, config)
395}
396
397/// Outcome of a single random-partition restart.
398struct RestartOutcome {
399    cluster: Vec<usize>,
400    centroids: Vec<Vec<f64>>,
401    inertia: f64,
402    iter: usize,
403    converged: bool,
404    restart_idx: usize,
405}
406
407/// Run one k-Shape restart (Lloyd iterations) on the z-normalized series.
408#[allow(clippy::too_many_arguments)]
409fn run_restart(
410    series: &[Vec<f64>],
411    n: usize,
412    m: usize,
413    k: usize,
414    max_iter: usize,
415    tol: f64,
416    rng: &mut StdRng,
417    restart_idx: usize,
418) -> RestartOutcome {
419    // Random-partition init, repaired so every cluster starts non-empty.
420    let mut cluster: Vec<usize> = (0..n).map(|_| rng.gen_range(0..k)).collect();
421    ensure_no_empty_random(&mut cluster, n, k, rng);
422
423    // Centroids: initialized to zero; the first refinement fills them from the
424    // random partition (assignment before the first refinement is skipped).
425    let mut centroids: Vec<Vec<f64>> = vec![vec![0.0f64; m]; k];
426    refine_centroids(series, &cluster, k, m, &mut centroids);
427
428    let mut prev_inertia = f64::INFINITY;
429    let mut iter = 0usize;
430    let mut converged = false;
431    let mut inertia = f64::INFINITY;
432
433    while iter < max_iter {
434        iter += 1;
435
436        // --- Assignment: min-SBD centroid, storing the SBD shift per series. ---
437        let mut new_cluster = vec![0usize; n];
438        let mut dist_to_own = vec![0.0f64; n];
439        for i in 0..n {
440            let mut best_c = 0usize;
441            let mut best_d = f64::INFINITY;
442            for (c, cent) in centroids.iter().enumerate() {
443                let d = sbd(&series[i], cent).map(|r| r.distance).unwrap_or(1.0);
444                if d < best_d {
445                    best_d = d;
446                    best_c = c;
447                }
448            }
449            new_cluster[i] = best_c;
450            dist_to_own[i] = best_d;
451        }
452
453        // Empty-cluster recovery: reseed each empty cluster from the series
454        // currently farthest (max SBD) from its assigned centroid.
455        recover_empty_clusters(&mut new_cluster, &dist_to_own, n, k);
456
457        // --- Refinement: shape-extraction centroids. ---
458        refine_centroids(series, &new_cluster, k, m, &mut centroids);
459
460        // --- Inertia (after refinement, against the just-assigned labels). ---
461        inertia = 0.0;
462        for i in 0..n {
463            let d = sbd(&series[i], &centroids[new_cluster[i]])
464                .map(|r| r.distance)
465                .unwrap_or(1.0);
466            inertia += d;
467        }
468
469        let changed = new_cluster != cluster;
470        cluster = new_cluster;
471
472        if !changed || (prev_inertia - inertia).abs() < tol {
473            converged = true;
474            break;
475        }
476        prev_inertia = inertia;
477    }
478
479    RestartOutcome {
480        cluster,
481        centroids,
482        inertia,
483        iter,
484        converged,
485        restart_idx,
486    }
487}
488
489/// Recompute every centroid via shape extraction from its current members.
490///
491/// An empty cluster keeps its previous centroid (recovery happens in the
492/// assignment step); a non-empty cluster's centroid is overwritten in place.
493fn refine_centroids(
494    series: &[Vec<f64>],
495    cluster: &[usize],
496    k: usize,
497    m: usize,
498    centroids: &mut [Vec<f64>],
499) {
500    for c in 0..k {
501        let members: Vec<usize> = (0..series.len()).filter(|&i| cluster[i] == c).collect();
502        if members.is_empty() {
503            continue;
504        }
505        centroids[c] = shape_extraction(series, &members, &centroids[c], m);
506    }
507}
508
509/// Shape-extraction centroid for one cluster (decision 5 — the k-Shape crux).
510///
511/// Each member is aligned to `centroid` by its SBD optimal shift, stacked into
512/// `X` (`n_k × m`); then `S = XᵀX`, `Q = I_m − O_m/m` (centering over the TIME
513/// dimension, divisor `m` = series length), `M = QᵀSQ`. The centroid is the
514/// eigenvector of `M` at the **largest** eigenvalue (nalgebra returns ascending,
515/// so argmax), sign-fixed to minimize total SBD to the members, then
516/// z-normalized.
517fn shape_extraction(
518    series: &[Vec<f64>],
519    members: &[usize],
520    centroid: &[f64],
521    m: usize,
522) -> Vec<f64> {
523    let n_k = members.len();
524
525    // Align each member to the current centroid by its SBD shift, then
526    // re-z-normalize the shifted vector. If the centroid is all-zero (first
527    // refinement, before any assignment produced a shape), SBD's constant-series
528    // guard returns shift 0 — the members are used unshifted, which is correct
529    // for a random-partition seed.
530    let mut x_aligned: Vec<Vec<f64>> = Vec::with_capacity(n_k);
531    for &i in members {
532        let shift = sbd(centroid, &series[i]).map(|r| r.shift).unwrap_or(0);
533        let shifted = circular_shift(&series[i], shift);
534        x_aligned.push(z_normalize_window(&shifted));
535    }
536
537    // S = XᵀX  (m × m). S[a][b] = Σ_i X[i][a] · X[i][b].
538    let mut s = DMatrix::<f64>::zeros(m, m);
539    for row_vec in &x_aligned {
540        for a in 0..m {
541            let va = row_vec[a];
542            if va == 0.0 {
543                continue;
544            }
545            for b in 0..m {
546                s[(a, b)] += va * row_vec[b];
547            }
548        }
549    }
550
551    // M = Qᵀ S Q with Q = I_m − O_m/m (centering over time; divisor m).
552    // Q is symmetric, so M = Q S Q. Compute QS then (QS)Q.
553    // (Q A)[a][b] = A[a][b] − mean_over_a(A[:,b]); (B Q)[a][b] = B[a][b] − mean_over_b(B[a][:]).
554    let inv_m = 1.0 / m as f64;
555    // QS: subtract, from each column, that column's mean over rows.
556    let mut qs = s.clone();
557    for b in 0..m {
558        let mut col_mean = 0.0;
559        for a in 0..m {
560            col_mean += s[(a, b)];
561        }
562        col_mean *= inv_m;
563        for a in 0..m {
564            qs[(a, b)] -= col_mean;
565        }
566    }
567    // (QS)Q: subtract, from each row, that row's mean over columns.
568    let mut mmat = qs.clone();
569    for a in 0..m {
570        let mut row_mean = 0.0;
571        for b in 0..m {
572            row_mean += qs[(a, b)];
573        }
574        row_mean *= inv_m;
575        for b in 0..m {
576            mmat[(a, b)] -= row_mean;
577        }
578    }
579    // Symmetrize defensively (M is symmetric in exact arithmetic).
580    for a in 0..m {
581        for b in (a + 1)..m {
582            let avg = 0.5 * (mmat[(a, b)] + mmat[(b, a)]);
583            mmat[(a, b)] = avg;
584            mmat[(b, a)] = avg;
585        }
586    }
587
588    // Top eigenvector: nalgebra returns eigenvalues ASCENDING → take argmax.
589    let eig = SymmetricEigen::new(mmat);
590    let mut arg = 0usize;
591    let mut best_eval = f64::NEG_INFINITY;
592    for (i, &ev) in eig.eigenvalues.iter().enumerate() {
593        if ev > best_eval {
594            best_eval = ev;
595            arg = i;
596        }
597    }
598    let mut v: Vec<f64> = eig.eigenvectors.column(arg).iter().copied().collect();
599
600    // Sign fix: choose ±v minimizing Σ SBD(±v, member).
601    let neg: Vec<f64> = v.iter().map(|x| -x).collect();
602    let mut sum_pos = 0.0;
603    let mut sum_neg = 0.0;
604    for row_vec in &x_aligned {
605        sum_pos += sbd(&v, row_vec).map(|r| r.distance).unwrap_or(1.0);
606        sum_neg += sbd(&neg, row_vec).map(|r| r.distance).unwrap_or(1.0);
607    }
608    if sum_neg < sum_pos {
609        v = neg;
610    }
611
612    // z-normalize the centroid (mean 0, std 1) for the next iteration's SBD.
613    z_normalize_window(&v)
614}
615
616/// Circularly shift `x` by `shift` positions (positive = right / later).
617fn circular_shift(x: &[f64], shift: isize) -> Vec<f64> {
618    let n = x.len();
619    if n == 0 {
620        return Vec::new();
621    }
622    let n_i = n as isize;
623    let s = ((shift % n_i) + n_i) % n_i; // normalize to 0..n
624    let mut out = vec![0.0f64; n];
625    for (i, &v) in x.iter().enumerate() {
626        let j = ((i as isize + s) % n_i) as usize;
627        out[j] = v;
628    }
629    out
630}
631
632/// Ensure a random-partition init leaves no cluster empty by moving distinct
633/// series into empty clusters (deterministic given the RNG state).
634fn ensure_no_empty_random(cluster: &mut [usize], n: usize, k: usize, rng: &mut StdRng) {
635    loop {
636        let mut sizes = vec![0usize; k];
637        for &c in cluster.iter() {
638            sizes[c] += 1;
639        }
640        let empty: Vec<usize> = (0..k).filter(|&c| sizes[c] == 0).collect();
641        if empty.is_empty() {
642            return;
643        }
644        for c in empty {
645            let donors: Vec<usize> = (0..n).filter(|&i| sizes[cluster[i]] > 1).collect();
646            if donors.is_empty() {
647                return; // k == n edge; assignment loop tolerates this.
648            }
649            let pick = donors[rng.gen_range(0..donors.len())];
650            sizes[cluster[pick]] -= 1;
651            cluster[pick] = c;
652            sizes[c] += 1;
653        }
654    }
655}
656
657/// Recover empty clusters after a reassignment by moving the series currently
658/// farthest (max SBD to its centroid) into each empty cluster. Never panics.
659fn recover_empty_clusters(cluster: &mut [usize], dist_to_own: &[f64], n: usize, k: usize) {
660    loop {
661        let mut sizes = vec![0usize; k];
662        for &c in cluster.iter() {
663            sizes[c] += 1;
664        }
665        let Some(empty) = (0..k).find(|&c| sizes[c] == 0) else {
666            return;
667        };
668        let mut best_i = None;
669        let mut best_d = f64::NEG_INFINITY;
670        for i in 0..n {
671            if sizes[cluster[i]] <= 1 {
672                continue;
673            }
674            let d = dist_to_own[i];
675            if d > best_d {
676                best_d = d;
677                best_i = Some(i);
678            }
679        }
680        match best_i {
681            Some(i) => cluster[i] = empty,
682            None => return, // no movable series (k == n); leave as-is.
683        }
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::f64::consts::PI;
691
692    /// Build an FdMatrix from row-major curves (each inner Vec is one series/row).
693    fn matrix_from_rows(rows: &[Vec<f64>]) -> FdMatrix {
694        let n = rows.len();
695        let m = rows[0].len();
696        let mut data = vec![0.0; n * m];
697        for (i, r) in rows.iter().enumerate() {
698            for (j, &v) in r.iter().enumerate() {
699                data[i + j * n] = v; // column-major
700            }
701        }
702        FdMatrix::from_slice(&data, n, m).unwrap()
703    }
704
705    /// Permutation-invariant purity of `labels` against `truth`.
706    fn purity(labels: &[usize], truth: &[usize], k: usize) -> f64 {
707        let n = labels.len();
708        let n_truth = truth.iter().copied().max().unwrap_or(0) + 1;
709        let mut correct = 0usize;
710        for c in 0..k {
711            let mut counts = vec![0usize; n_truth];
712            for i in 0..n {
713                if labels[i] == c {
714                    counts[truth[i]] += 1;
715                }
716            }
717            correct += counts.iter().copied().max().unwrap_or(0);
718        }
719        correct as f64 / n as f64
720    }
721
722    /// Two shape groups with random per-series CIRCULAR shifts + light noise.
723    /// Group 0 is a single-period sine; group 1 is a single-period cosine-like
724    /// (double-frequency) bump — distinct base shapes.
725    fn shifted_groups(seed: u64) -> (FdMatrix, Vec<usize>) {
726        let m = 40usize;
727        let mut rng = StdRng::seed_from_u64(seed);
728        let mut rows = Vec::new();
729        let mut truth = Vec::new();
730        let base_a: Vec<f64> = (0..m)
731            .map(|j| (2.0 * PI * j as f64 / m as f64).sin())
732            .collect();
733        let base_b: Vec<f64> = (0..m)
734            .map(|j| (4.0 * PI * j as f64 / m as f64).sin())
735            .collect();
736        for (label, base) in [(0usize, &base_a), (1usize, &base_b)] {
737            for _ in 0..8 {
738                let shift = rng.gen_range(0..m) as isize;
739                let shifted = circular_shift(base, shift);
740                let noisy: Vec<f64> = shifted
741                    .iter()
742                    .map(|&v| v + (rng.gen::<f64>() - 0.5) * 0.05)
743                    .collect();
744                rows.push(noisy);
745                truth.push(label);
746            }
747        }
748        (matrix_from_rows(&rows), truth)
749    }
750
751    #[test]
752    fn test_kshape_recovers_shifted_groups() {
753        let (data, truth) = shifted_groups(7);
754        let cfg = KShapeConfig {
755            n_clusters: 2,
756            n_init: 10,
757            seed: 3,
758            ..Default::default()
759        };
760        let res = kshape_fd(&data, &cfg).unwrap();
761        assert_eq!(res.cluster.len(), 16);
762        let p = purity(&res.cluster, &truth, 2);
763        assert!((p - 1.0).abs() < 1e-12, "purity {p} != 1.0");
764        assert_eq!(res.n_clusters(), 2);
765        // Centroids are z-normalized (mean ~0).
766        for c in 0..2 {
767            let row = res.centroids.row(c);
768            let mean: f64 = row.iter().sum::<f64>() / row.len() as f64;
769            assert!(mean.abs() < 1e-8, "centroid {c} not zero-mean: {mean}");
770        }
771    }
772
773    #[test]
774    fn test_kshape_centroid_sign() {
775        // A clean single-shape cluster: 6 identical (up to tiny noise) sine curves,
776        // no shifts. The extracted centroid must correlate POSITIVELY with members.
777        let m = 32usize;
778        let base: Vec<f64> = (0..m)
779            .map(|j| (2.0 * PI * j as f64 / m as f64).sin())
780            .collect();
781        let mut rng = StdRng::seed_from_u64(1);
782        let rows: Vec<Vec<f64>> = (0..6)
783            .map(|_| {
784                base.iter()
785                    .map(|&v| v + (rng.gen::<f64>() - 0.5) * 0.01)
786                    .collect()
787            })
788            .collect();
789        let data = matrix_from_rows(&rows);
790        let cfg = KShapeConfig::new(1);
791        let res = kshape_fd(&data, &cfg).unwrap();
792        let cent = res.centroids.row(0);
793        let base_z = z_normalize_window(&base);
794        let cent_z = z_normalize_window(&cent);
795        // Pearson correlation (both zero-mean, unit-std) = mean of products.
796        let corr: f64 = cent_z
797            .iter()
798            .zip(base_z.iter())
799            .map(|(a, b)| a * b)
800            .sum::<f64>()
801            / m as f64;
802        assert!(
803            corr > 0.99,
804            "centroid must correlate positively, corr={corr}"
805        );
806    }
807
808    #[test]
809    fn test_kshape_empty_cluster_recovery() {
810        // k = 5 but only two natural groups. Must not panic; all clusters non-empty.
811        let (data, _) = shifted_groups(11);
812        let cfg = KShapeConfig {
813            n_clusters: 5,
814            n_init: 3,
815            seed: 2,
816            ..Default::default()
817        };
818        let res = kshape_fd(&data, &cfg).unwrap();
819        assert_eq!(res.cluster.len(), 16);
820        assert!(res.cluster.iter().all(|&c| c < 5));
821        let mut sizes = vec![0usize; 5];
822        for &c in &res.cluster {
823            sizes[c] += 1;
824        }
825        assert!(
826            sizes.iter().all(|&s| s >= 1),
827            "an empty cluster survived: {sizes:?}"
828        );
829    }
830
831    #[test]
832    fn test_kshape_deterministic() {
833        let (data, _) = shifted_groups(5);
834        let cfg = KShapeConfig {
835            n_clusters: 2,
836            n_init: 5,
837            seed: 42,
838            ..Default::default()
839        };
840        let a = kshape_fd(&data, &cfg).unwrap();
841        let b = kshape_fd(&data, &cfg).unwrap();
842        assert_eq!(a.cluster, b.cluster, "same seed must give identical labels");
843        assert_eq!(a.inertia.to_bits(), b.inertia.to_bits());
844        assert_eq!(a.n_init_best, b.n_init_best);
845        // Centroids byte-identical too (sequential==parallel: SBD is RNG-free).
846        let n = a.centroids.nrows();
847        let m = a.centroids.ncols();
848        for i in 0..n {
849            for j in 0..m {
850                assert_eq!(a.centroids[(i, j)].to_bits(), b.centroids[(i, j)].to_bits());
851            }
852        }
853    }
854
855    #[test]
856    fn test_kshape_best_of_n_init() {
857        // n_init > 1 must return inertia no worse than a single-init baseline on
858        // the same base seed.
859        let (data, _) = shifted_groups(9);
860        let multi = KShapeConfig {
861            n_clusters: 2,
862            n_init: 10,
863            seed: 4,
864            ..Default::default()
865        };
866        let single = KShapeConfig {
867            n_init: 1,
868            ..multi.clone()
869        };
870        let rm = kshape_fd(&data, &multi).unwrap();
871        let rs = kshape_fd(&data, &single).unwrap();
872        assert!(
873            rm.inertia <= rs.inertia + 1e-12,
874            "multi-init inertia {} worse than single-init {}",
875            rm.inertia,
876            rs.inertia
877        );
878    }
879
880    #[test]
881    fn test_kshape_predict() {
882        let (data, _) = shifted_groups(13);
883        let cfg = KShapeConfig {
884            n_clusters: 2,
885            n_init: 10,
886            seed: 6,
887            ..Default::default()
888        };
889        let res = kshape_fd(&data, &cfg).unwrap();
890
891        // predict on the training data reproduces the training labels exactly.
892        let preds = res.predict(&data).unwrap();
893        assert_eq!(preds, res.cluster, "predict(train) must reproduce cluster");
894
895        // A new series near group A (a shifted copy of series 0) routes to A.
896        let m = data.ncols();
897        let src = data.row(0);
898        let novel = circular_shift(&src, 7);
899        let test = matrix_from_rows(&[novel]);
900        let p = res.predict(&test).unwrap();
901        assert_eq!(p.len(), 1);
902        assert_eq!(
903            p[0], res.cluster[0],
904            "shifted copy of series 0 should route to its cluster"
905        );
906        let _ = m;
907    }
908
909    #[test]
910    fn test_kshape_validation() {
911        let (data, _) = shifted_groups(1);
912        // n_clusters = 0.
913        let cfg0 = KShapeConfig::new(0);
914        assert!(matches!(
915            kshape_fd(&data, &cfg0),
916            Err(FdarError::InvalidParameter { .. })
917        ));
918        // n_clusters > n.
919        let cfg_big = KShapeConfig::new(999);
920        assert!(matches!(
921            kshape_fd(&data, &cfg_big),
922            Err(FdarError::InvalidParameter { .. })
923        ));
924        // n_init = 0.
925        let cfg_ni = KShapeConfig {
926            n_init: 0,
927            ..KShapeConfig::new(2)
928        };
929        assert!(matches!(
930            kshape_fd(&data, &cfg_ni),
931            Err(FdarError::InvalidParameter { .. })
932        ));
933        // Empty data.
934        let empty = FdMatrix::zeros(0, 0);
935        assert!(matches!(
936            kshape_fd(&empty, &KShapeConfig::new(2)),
937            Err(FdarError::InvalidDimension { .. })
938        ));
939        // predict dimension mismatch.
940        let res = kshape_fd(&data, &KShapeConfig::new(2)).unwrap();
941        let wrong = matrix_from_rows(&[vec![1.0, 2.0, 3.0]]);
942        assert!(matches!(
943            res.predict(&wrong),
944            Err(FdarError::InvalidDimension { .. })
945        ));
946    }
947
948    #[test]
949    fn test_sbd_kmedoids_recovers_groups() {
950        // Two shifted-shape groups → k-medoids over SBD recovers them at high
951        // purity, proving it uses the shape-invariant SBD matrix (an L2/DTW
952        // backend would be fooled by the circular shifts).
953        let (data, truth) = shifted_groups(7);
954        let cfg = KMedoidsConfig {
955            k: 2,
956            max_iter: 100,
957            seed: 3,
958        };
959        let res = sbd_kmedoids(&data, &cfg).unwrap();
960        assert_eq!(res.labels.len(), 16);
961        assert_eq!(res.medoid_indices.len(), 2);
962        let p = purity(&res.labels, &truth, 2);
963        assert!(p >= 0.9, "SBD k-medoids purity {p} too low (< 0.9)");
964    }
965
966    #[test]
967    fn test_sbd_kmedoids_uses_sbd_matrix() {
968        // sbd_kmedoids == manual composition sbd_distance_matrix + kmedoids_from_distances
969        // (same seed → identical labels and medoids).
970        let (data, _) = shifted_groups(5);
971        let cfg = KMedoidsConfig {
972            k: 2,
973            max_iter: 100,
974            seed: 42,
975        };
976        let res = sbd_kmedoids(&data, &cfg).unwrap();
977        let dist = sbd_distance_matrix(&data).unwrap();
978        let manual = kmedoids_from_distances(&dist, &cfg).unwrap();
979        assert_eq!(
980            res.labels, manual.labels,
981            "labels must match manual composition"
982        );
983        assert_eq!(
984            res.medoid_indices, manual.medoid_indices,
985            "medoids must match manual composition"
986        );
987        assert_eq!(
988            res.total_within_distance.to_bits(),
989            manual.total_within_distance.to_bits()
990        );
991    }
992
993    #[test]
994    fn test_sbd_kmedoids_validation() {
995        let (data, _) = shifted_groups(1);
996        // k = 0 → error (propagated from kmedoids_from_distances).
997        let cfg0 = KMedoidsConfig {
998            k: 0,
999            ..Default::default()
1000        };
1001        assert!(matches!(
1002            sbd_kmedoids(&data, &cfg0),
1003            Err(FdarError::InvalidParameter { .. })
1004        ));
1005        // k > n → error.
1006        let cfg_big = KMedoidsConfig {
1007            k: 999,
1008            ..Default::default()
1009        };
1010        assert!(matches!(
1011            sbd_kmedoids(&data, &cfg_big),
1012            Err(FdarError::InvalidParameter { .. })
1013        ));
1014    }
1015
1016    /// Crate-root re-exports for the full v0.34.0 SBD + k-Shape surface resolve.
1017    ///
1018    /// Uses `crate::` paths (the same items published at the crate root); a full
1019    /// external `use fdars_core::{...}` resolution is additionally covered by the
1020    /// `sbd_kmedoids` doctest, which is compiled as an out-of-crate binary.
1021    #[test]
1022    fn test_kshape_reexports() {
1023        use crate::{
1024            kshape_fd, sbd, sbd_distance_matrix, sbd_kmedoids, KMedoidsConfig, KMedoidsResult,
1025            KShapeConfig, KShapeResult, SbdResult,
1026        };
1027        // Reference each item so an unresolved name fails to compile.
1028        let _f: fn(&FdMatrix, &KShapeConfig) -> Result<KShapeResult, FdarError> = kshape_fd;
1029        let _k: fn(&FdMatrix, &KMedoidsConfig) -> Result<KMedoidsResult, FdarError> = sbd_kmedoids;
1030        let _s: fn(&[f64], &[f64]) -> Result<SbdResult, FdarError> = sbd;
1031        let _m: fn(&FdMatrix) -> Result<FdMatrix, FdarError> = sbd_distance_matrix;
1032    }
1033}