Skip to main content

fdars_core/shapelet/
discovery.rs

1//! Shapelet discovery & ranking: candidate generation, discriminative quality
2//! scoring (information gain / F-statistic), and top-K selection with
3//! self-similarity pruning.
4//!
5//! Builds on the Phase 57 distance core ([`shapelet_distance`], [`Shapelet`]):
6//! given a labeled training curve set, enumerate candidate subsequences
7//! (exhaustively or via deterministic seeded random sampling bounded by
8//! `max_candidates`), score each by how well its distance orderline separates
9//! the class labels, and greedily select a non-redundant [`ShapeletSet`].
10//!
11//! # Determinism
12//!
13//! The candidate SET is fixed by `config.seed` *before* scoring, scoring is
14//! pure, and the final ranking uses [`f64::total_cmp`] on quality with a
15//! `(series_idx, start, length)` tie-break. Two fits with the same config are
16//! therefore byte-identical, and the sequential (`parallel` off) result matches
17//! the parallel one exactly.
18
19use crate::error::FdarError;
20use crate::helpers::seed_for_thread;
21use crate::iter_maybe_parallel;
22use crate::matrix::FdMatrix;
23use crate::shapelet::distance::{shapelet_distance, Shapelet};
24use rand::Rng;
25#[cfg(feature = "parallel")]
26use rayon::iter::ParallelIterator;
27
28/// Discriminative quality measure used to score a candidate shapelet's distance
29/// orderline against the class labels.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32#[non_exhaustive]
33pub enum QualityMeasure {
34    /// Information gain on the optimal distance-split threshold (Ye & Keogh /
35    /// Hills–Lines default). Assumes roughly balanced classes.
36    #[default]
37    InfoGain,
38    /// One-way ANOVA F-statistic of the distance vector grouped by label. Less
39    /// sensitive to class imbalance than information gain.
40    FStatistic,
41}
42
43/// Configuration for [`discover_shapelets`].
44///
45/// `max_length` and `max_shapelets` accept the sentinel value `0`, which is
46/// resolved at fit time: `max_length = 0` clamps to the series length
47/// (`ncols`), and `max_shapelets = 0` resolves to `min(10 * n_train, 1000)`
48/// (the sktime-style default).
49#[derive(Debug, Clone, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct ShapeletDiscoveryConfig {
52    /// Minimum candidate subsequence length (`>= 1`). Default `3`.
53    pub min_length: usize,
54    /// Maximum candidate subsequence length. `0` = clamp to series length.
55    pub max_length: usize,
56    /// Cap on the number of candidates evaluated. `Some(m)` random-samples `m`
57    /// (seeded, reproducible) when the exhaustive count exceeds `m`; `None` =
58    /// exhaustive. Default `Some(10_000)`.
59    pub max_candidates: Option<usize>,
60    /// Number of shapelets to keep after selection (`>= 1`). `0` resolves to
61    /// `min(10 * n_train, 1000)` at fit time.
62    pub max_shapelets: usize,
63    /// Discriminative quality measure. Default [`QualityMeasure::InfoGain`].
64    pub quality: QualityMeasure,
65    /// Seed for deterministic candidate sampling. Default `0`.
66    pub seed: u64,
67}
68
69impl Default for ShapeletDiscoveryConfig {
70    fn default() -> Self {
71        Self {
72            min_length: 3,
73            max_length: 0,
74            max_candidates: Some(10_000),
75            max_shapelets: 0,
76            quality: QualityMeasure::InfoGain,
77            seed: 0,
78        }
79    }
80}
81
82/// A discovered, ranked, non-redundant set of shapelets.
83///
84/// Each contained [`Shapelet`] has its `quality` field populated with the score
85/// under [`ShapeletSet::quality`]. The set is ordered by quality descending.
86#[derive(Debug, Clone, PartialEq)]
87#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
88#[non_exhaustive]
89pub struct ShapeletSet {
90    /// Selected shapelets, ordered by quality descending.
91    pub shapelets: Vec<Shapelet>,
92    /// The quality measure the shapelets were scored under.
93    pub quality: QualityMeasure,
94}
95
96impl ShapeletSet {
97    /// The selected shapelets (ordered by quality descending).
98    #[must_use]
99    pub fn shapelets(&self) -> &[Shapelet] {
100        &self.shapelets
101    }
102
103    /// Number of selected shapelets.
104    #[must_use]
105    pub fn len(&self) -> usize {
106        self.shapelets.len()
107    }
108
109    /// Whether the set is empty.
110    #[must_use]
111    pub fn is_empty(&self) -> bool {
112        self.shapelets.is_empty()
113    }
114
115    /// The quality measure the shapelets were scored under.
116    #[must_use]
117    pub fn quality(&self) -> QualityMeasure {
118        self.quality
119    }
120}
121
122/// Shannon entropy (base 2) of a label multiset given per-class counts.
123fn entropy_from_counts(counts: &[usize], total: usize) -> f64 {
124    if total == 0 {
125        return 0.0;
126    }
127    let n = total as f64;
128    let mut h = 0.0;
129    for &c in counts {
130        if c > 0 {
131            let p = c as f64 / n;
132            h -= p * p.log2();
133        }
134    }
135    h
136}
137
138/// Information gain of the *optimal* distance-split threshold.
139///
140/// `orderline` holds `(distance, label)` pairs (labels pre-remapped to
141/// `0..n_classes`). Sorts by distance (`total_cmp`), then scans candidate
142/// thresholds at midpoints between consecutive *distinct* distances, returning
143/// `max_θ IG(θ)` with `IG(θ) = H(all) − (|L|/n·H(L) + |R|/n·H(R))`.
144fn information_gain(orderline: &mut [(f64, usize)], n_classes: usize) -> f64 {
145    let n = orderline.len();
146    if n < 2 || n_classes < 2 {
147        return 0.0;
148    }
149    orderline.sort_by(|a, b| a.0.total_cmp(&b.0));
150
151    // Total per-class counts (parent entropy).
152    let mut total_counts = vec![0usize; n_classes];
153    for &(_, y) in orderline.iter() {
154        total_counts[y] += 1;
155    }
156    let parent_h = entropy_from_counts(&total_counts, n);
157
158    // Incrementally move points from the right side to the left as the split
159    // sweeps upward. left_counts starts empty; after including index t, the left
160    // side is orderline[0..=t] and the right side is orderline[t+1..].
161    let mut left_counts = vec![0usize; n_classes];
162    let mut right_counts = total_counts.clone();
163    let mut best_ig = 0.0f64;
164
165    for t in 0..(n - 1) {
166        let (d, y) = orderline[t];
167        left_counts[y] += 1;
168        right_counts[y] -= 1;
169
170        // Only a genuine split boundary: the next distance must be strictly
171        // larger (a midpoint between distinct distances exists).
172        let d_next = orderline[t + 1].0;
173        if d_next <= d {
174            continue;
175        }
176        let n_left = t + 1;
177        let n_right = n - n_left;
178        let h_left = entropy_from_counts(&left_counts, n_left);
179        let h_right = entropy_from_counts(&right_counts, n_right);
180        let weighted = (n_left as f64 / n as f64) * h_left + (n_right as f64 / n as f64) * h_right;
181        let ig = parent_h - weighted;
182        if ig > best_ig {
183            best_ig = ig;
184        }
185    }
186    best_ig
187}
188
189/// One-way ANOVA F-statistic of a 1-D distance vector grouped by class label.
190///
191/// This is the **scalar / 1-D analogue** of
192/// [`crate::function_on_scalar::integrated_f_statistic`], which computes the
193/// pointwise F over an `FdMatrix` and integrates it across the grid. Here the
194/// "grid" is a single point (the sdist value), so no integration is needed:
195///
196/// ```text
197/// F = MS_between / MS_within
198///   = [SS_between / (k − 1)] / [SS_within / (n − k)]
199/// ```
200///
201/// where `k` is the number of classes. Returns `0.0` when the within-group mean
202/// square is numerically zero (matching the `integrated_f_statistic` guard).
203///
204/// `labels` are pre-remapped to `0..n_classes`.
205fn f_statistic_1d(distances: &[f64], labels: &[usize], n_classes: usize) -> f64 {
206    let n = distances.len();
207    if n == 0 || n_classes < 2 || n <= n_classes {
208        return 0.0;
209    }
210    let mut group_sum = vec![0.0f64; n_classes];
211    let mut group_cnt = vec![0usize; n_classes];
212    let mut grand_sum = 0.0f64;
213    for (&d, &y) in distances.iter().zip(labels.iter()) {
214        group_sum[y] += d;
215        group_cnt[y] += 1;
216        grand_sum += d;
217    }
218    let grand_mean = grand_sum / n as f64;
219    let mut group_mean = vec![0.0f64; n_classes];
220    for g in 0..n_classes {
221        if group_cnt[g] > 0 {
222            group_mean[g] = group_sum[g] / group_cnt[g] as f64;
223        }
224    }
225    let mut ss_between = 0.0f64;
226    for g in 0..n_classes {
227        let diff = group_mean[g] - grand_mean;
228        ss_between += group_cnt[g] as f64 * diff * diff;
229    }
230    let mut ss_within = 0.0f64;
231    for (&d, &y) in distances.iter().zip(labels.iter()) {
232        let diff = d - group_mean[y];
233        ss_within += diff * diff;
234    }
235    let ms_between = ss_between / (n_classes as f64 - 1.0).max(1.0);
236    let ms_within = ss_within / (n as f64 - n_classes as f64).max(1.0);
237    if ms_within > 1e-15 {
238        ms_between / ms_within
239    } else {
240        0.0
241    }
242}
243
244/// A candidate subsequence location in the training set.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246struct Candidate {
247    series_idx: usize,
248    start: usize,
249    length: usize,
250}
251
252/// Enumerate every `(series_idx, start, length)` triple, or a deterministic
253/// seeded random sample of `max_candidates` of them.
254///
255/// The returned list is sorted by `(series_idx, start, length)` so the candidate
256/// order (and hence the whole fit) is fixed by the seed before scoring.
257fn generate_candidates(
258    n_series: usize,
259    ncols: usize,
260    min_length: usize,
261    max_length: usize,
262    max_candidates: Option<usize>,
263    seed: u64,
264) -> Vec<Candidate> {
265    // Exhaustive count = n_series * Σ_{L=min..=max} (ncols - L + 1).
266    let per_series: usize = (min_length..=max_length).map(|l| ncols - l + 1).sum();
267    let total = n_series.saturating_mul(per_series);
268
269    let exhaustive = match max_candidates {
270        Some(m) => total <= m,
271        None => true,
272    };
273
274    if exhaustive {
275        let mut out = Vec::with_capacity(total);
276        for series_idx in 0..n_series {
277            for length in min_length..=max_length {
278                for start in 0..=(ncols - length) {
279                    out.push(Candidate {
280                        series_idx,
281                        start,
282                        length,
283                    });
284                }
285            }
286        }
287        return out;
288    }
289
290    // Contracted: sample `m` distinct triples deterministically. We sample a
291    // linear index into the flattened enumeration and reject duplicates, which
292    // is reproducible from the seed and independent of enumeration order.
293    let m = max_candidates.unwrap(); // exhaustive == false ⇒ Some(_)
294    let mut rng = seed_for_thread(seed, 0);
295    use std::collections::HashSet;
296    let mut chosen: HashSet<usize> = HashSet::with_capacity(m);
297    // Guard against pathological rejection loops (should not trigger since
298    // m < total, but keeps termination provable).
299    let max_draws = m.saturating_mul(64).max(total);
300    let mut draws = 0usize;
301    while chosen.len() < m && draws < max_draws {
302        let idx = rng.gen_range(0..total);
303        chosen.insert(idx);
304        draws += 1;
305    }
306
307    let mut out: Vec<Candidate> = chosen
308        .into_iter()
309        .map(|lin| decode_candidate(lin, n_series, ncols, min_length, max_length))
310        .collect();
311    // Deterministic candidate order regardless of HashSet iteration order.
312    out.sort_by_key(|c| (c.series_idx, c.start, c.length));
313    out
314}
315
316/// Decode a flat enumeration index back into a `(series_idx, start, length)`
317/// triple, matching the exhaustive nesting order (series → length → start).
318fn decode_candidate(
319    lin: usize,
320    _n_series: usize,
321    ncols: usize,
322    min_length: usize,
323    max_length: usize,
324) -> Candidate {
325    let per_series: usize = (min_length..=max_length).map(|l| ncols - l + 1).sum();
326    let series_idx = lin / per_series;
327    let mut rem = lin % per_series;
328    let mut length = min_length;
329    loop {
330        let starts = ncols - length + 1;
331        if rem < starts {
332            return Candidate {
333                series_idx,
334                start: rem,
335                length,
336            };
337        }
338        rem -= starts;
339        length += 1;
340    }
341}
342
343/// Discover a non-redundant [`ShapeletSet`] from a labeled training curve set.
344///
345/// Enumerates candidate subsequences over `[config.min_length, config.max_length]`
346/// (exhaustively, or a deterministic seeded random sample of
347/// `config.max_candidates`), scores each candidate by how well its distance
348/// orderline separates the class labels (information gain or F-statistic per
349/// `config.quality`), then greedily selects the top `config.max_shapelets` with
350/// self-similarity pruning: once a shapelet from series `i` spanning
351/// `[start, start+length)` is selected, any not-yet-selected candidate from the
352/// same series whose range overlaps it is discarded.
353///
354/// `data` is a column-major [`FdMatrix`] with rows = curves and columns =
355/// evaluation points; `labels[i]` is the integer class of curve `i`.
356///
357/// # Determinism
358///
359/// The result is byte-identical across runs with the same `config` and identical
360/// whether or not the `parallel` feature is enabled.
361///
362/// # Errors
363///
364/// - [`FdarError::InvalidDimension`] if `labels.len()` != number of curves.
365/// - [`FdarError::InvalidParameter`] if fewer than 2 distinct classes are
366///   present, if `min_length < 1`, `min_length > max_length`,
367///   `max_length > ncols`, or the resolved `max_shapelets < 1`.
368///
369/// # Examples
370///
371/// ```
372/// use fdars_core::matrix::FdMatrix;
373/// use fdars_core::shapelet::{discover_shapelets, ShapeletDiscoveryConfig};
374///
375/// // Two classes of length-8 curves; class 1 carries a rising ramp in the
376/// // middle that class 0 lacks.
377/// let n = 8usize;
378/// let m = 8usize;
379/// let mut data = vec![0.0f64; n * m];
380/// let mut labels = vec![0usize; n];
381/// for i in 0..n {
382///     let class1 = i % 2 == 1;
383///     labels[i] = usize::from(class1);
384///     for j in 0..m {
385///         // column-major: element (i, j) at i + j*n
386///         let base = 0.1 * (i as f64) + 0.05 * (j as f64);
387///         let motif = if class1 && (3..6).contains(&j) { (j as f64) * 2.0 } else { 0.0 };
388///         data[i + j * n] = base + motif;
389///     }
390/// }
391/// let data = FdMatrix::from_column_major(data, n, m).unwrap();
392///
393/// let cfg = ShapeletDiscoveryConfig { max_shapelets: 3, ..Default::default() };
394/// let set = discover_shapelets(&data, &labels, &cfg).unwrap();
395/// assert!(!set.is_empty());
396/// assert!(set.len() <= 3);
397/// ```
398#[must_use = "the discovered shapelet set should not be discarded"]
399pub fn discover_shapelets(
400    data: &FdMatrix,
401    labels: &[usize],
402    config: &ShapeletDiscoveryConfig,
403) -> Result<ShapeletSet, FdarError> {
404    let (n_series, ncols) = data.shape();
405
406    // --- validation ---
407    if labels.len() != n_series {
408        return Err(FdarError::InvalidDimension {
409            parameter: "labels",
410            expected: format!("{n_series} labels (one per curve)"),
411            actual: format!("{} labels", labels.len()),
412        });
413    }
414    if n_series == 0 || ncols == 0 {
415        return Err(FdarError::InvalidDimension {
416            parameter: "data",
417            expected: "at least one curve with at least one point".to_string(),
418            actual: format!("{n_series}x{ncols}"),
419        });
420    }
421
422    // Distinct classes + a dense 0..n_classes remap.
423    let mut distinct: Vec<usize> = labels.to_vec();
424    distinct.sort_unstable();
425    distinct.dedup();
426    let n_classes = distinct.len();
427    if n_classes < 2 {
428        return Err(FdarError::InvalidParameter {
429            parameter: "labels",
430            message: format!("at least 2 distinct classes required, found {n_classes}"),
431        });
432    }
433    let remap = |y: usize| distinct.iter().position(|&d| d == y).unwrap();
434    let labels_dense: Vec<usize> = labels.iter().map(|&y| remap(y)).collect();
435
436    if config.min_length < 1 {
437        return Err(FdarError::InvalidParameter {
438            parameter: "min_length",
439            message: "min_length must be >= 1".to_string(),
440        });
441    }
442    // Resolve max_length sentinel.
443    let max_length = if config.max_length == 0 {
444        ncols
445    } else {
446        config.max_length
447    };
448    if config.min_length > max_length {
449        return Err(FdarError::InvalidParameter {
450            parameter: "min_length",
451            message: format!(
452                "min_length ({}) > max_length ({max_length})",
453                config.min_length
454            ),
455        });
456    }
457    if max_length > ncols {
458        return Err(FdarError::InvalidParameter {
459            parameter: "max_length",
460            message: format!("max_length ({max_length}) > series length ({ncols})"),
461        });
462    }
463    // Resolve max_shapelets sentinel.
464    let max_shapelets = if config.max_shapelets == 0 {
465        (10 * n_series).min(1000)
466    } else {
467        config.max_shapelets
468    };
469    if max_shapelets < 1 {
470        return Err(FdarError::InvalidParameter {
471            parameter: "max_shapelets",
472            message: "max_shapelets must be >= 1".to_string(),
473        });
474    }
475
476    // --- candidate generation (seed-fixed, order-deterministic) ---
477    let candidates = generate_candidates(
478        n_series,
479        ncols,
480        config.min_length,
481        max_length,
482        config.max_candidates,
483        config.seed,
484    );
485
486    // Pre-extract each curve row contiguously (rows are non-contiguous in the
487    // column-major layout).
488    let series_rows: Vec<Vec<f64>> = {
489        let mut rows = Vec::with_capacity(n_series);
490        let mut buf = vec![0.0f64; ncols];
491        for i in 0..n_series {
492            data.row_to_buf(i, &mut buf);
493            rows.push(buf.clone());
494        }
495        rows
496    };
497
498    // --- score candidates (parallel over the fixed candidate set) ---
499    let quality = config.quality;
500    let scored: Vec<(f64, Candidate)> = iter_maybe_parallel!(0..candidates.len())
501        .map(|ci| {
502            let cand = candidates[ci];
503            let src = &series_rows[cand.series_idx];
504            // Shapelet is z-normalized at construction.
505            let shp = Shapelet::from_source(src, cand.series_idx, cand.start, cand.length)
506                .expect("candidate window is in-range by construction");
507            // Distance orderline: one sdist per training series.
508            let mut orderline: Vec<(f64, usize)> = Vec::with_capacity(n_series);
509            for (i, row) in series_rows.iter().enumerate() {
510                let (d, _off) = shapelet_distance(&shp.values, row, f64::INFINITY)
511                    .expect("series length >= shapelet length by construction");
512                orderline.push((d, labels_dense[i]));
513            }
514            let score = match quality {
515                QualityMeasure::InfoGain => information_gain(&mut orderline, n_classes),
516                QualityMeasure::FStatistic => {
517                    let dists: Vec<f64> = orderline.iter().map(|&(d, _)| d).collect();
518                    let labs: Vec<usize> = orderline.iter().map(|&(_, y)| y).collect();
519                    f_statistic_1d(&dists, &labs, n_classes)
520                }
521            };
522            (score, cand)
523        })
524        .collect();
525
526    // --- rank: quality desc, tie-break (series_idx, start, length) ---
527    let mut ranked = scored;
528    ranked.sort_by(|a, b| {
529        b.0.total_cmp(&a.0).then_with(|| {
530            (a.1.series_idx, a.1.start, a.1.length).cmp(&(b.1.series_idx, b.1.start, b.1.length))
531        })
532    });
533
534    // --- greedy selection + self-similarity pruning ---
535    // Track, per series, the accepted [start, end) intervals so we can reject
536    // overlapping same-series candidates.
537    let mut accepted_ranges: std::collections::HashMap<usize, Vec<(usize, usize)>> =
538        std::collections::HashMap::new();
539    let mut selected: Vec<Shapelet> = Vec::with_capacity(max_shapelets);
540
541    for (score, cand) in ranked {
542        if selected.len() >= max_shapelets {
543            break;
544        }
545        let start = cand.start;
546        let end = cand.start + cand.length;
547        let overlaps = accepted_ranges
548            .get(&cand.series_idx)
549            .is_some_and(|ranges| ranges.iter().any(|&(s, e)| !(end <= s || e <= start)));
550        if overlaps {
551            continue;
552        }
553        let src = &series_rows[cand.series_idx];
554        let mut shp = Shapelet::from_source(src, cand.series_idx, cand.start, cand.length)
555            .expect("candidate window is in-range by construction");
556        shp.quality = score;
557        accepted_ranges
558            .entry(cand.series_idx)
559            .or_default()
560            .push((start, end));
561        selected.push(shp);
562    }
563
564    Ok(ShapeletSet {
565        shapelets: selected,
566        quality: config.quality,
567    })
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    /// Build a 2-class dataset: class 0 = smooth noise-free baseline, class 1 =
575    /// baseline with a distinctive triangular motif planted at a fixed offset.
576    /// Returns (data, labels, motif_start, motif_len).
577    fn planted_motif_dataset() -> (FdMatrix, Vec<usize>, usize, usize) {
578        let n = 20usize;
579        let m = 40usize;
580        let motif_start = 15usize;
581        let motif_len = 8usize;
582        let mut flat = vec![0.0f64; n * m];
583        let mut labels = vec![0usize; n];
584        for i in 0..n {
585            let class1 = i % 2 == 1;
586            labels[i] = usize::from(class1);
587            let offset = 0.01 * (i as f64); // tiny per-curve baseline shift
588            for j in 0..m {
589                let mut v = offset + (j as f64) * 0.001;
590                // Small deterministic shape jitter (survives per-window
591                // z-normalization) → nonzero within-class variance in the
592                // distance orderline, so the F-statistic is finite/well-defined
593                // rather than tripped by a zero within-group mean square.
594                let hash = (i.wrapping_mul(2654435761) ^ j.wrapping_mul(40503)) % 211;
595                v += 0.05 * (hash as f64 / 211.0 - 0.5);
596                if class1 && j >= motif_start && j < motif_start + motif_len {
597                    // Triangular spike unique to class 1.
598                    let k = j - motif_start;
599                    let half = motif_len / 2;
600                    let tri = if k <= half {
601                        k as f64
602                    } else {
603                        (motif_len - k) as f64
604                    };
605                    v += tri;
606                }
607                flat[i + j * n] = v;
608            }
609        }
610        (
611            FdMatrix::from_column_major(flat, n, m).unwrap(),
612            labels,
613            motif_start,
614            motif_len,
615        )
616    }
617
618    #[test]
619    fn test_discover_known_motif() {
620        let (data, labels, motif_start, motif_len) = planted_motif_dataset();
621        let cfg = ShapeletDiscoveryConfig {
622            min_length: motif_len,
623            max_length: motif_len,
624            max_candidates: None, // exhaustive on this small dataset
625            max_shapelets: 3,
626            quality: QualityMeasure::InfoGain,
627            seed: 0,
628        };
629        let set = discover_shapelets(&data, &labels, &cfg).unwrap();
630        assert!(!set.is_empty(), "no shapelets discovered");
631        // The top shapelet should be a class-1 curve and align with the motif.
632        let top = &set.shapelets()[0];
633        assert!(top.quality > 0.0, "top shapelet has non-positive quality");
634        // Overlap with the planted motif region.
635        let s = top.start;
636        let e = top.start + top.length;
637        assert!(
638            !(e <= motif_start || motif_start + motif_len <= s),
639            "top shapelet [{s},{e}) does not overlap planted motif [{motif_start},{})",
640            motif_start + motif_len
641        );
642        // A perfectly separating shapelet reaches max entropy for a balanced
643        // 2-class split = 1.0 bit.
644        assert!(
645            top.quality > 0.9,
646            "top shapelet IG {} not near max entropy 1.0",
647            top.quality
648        );
649    }
650
651    #[test]
652    fn test_discover_tractable_contracted() {
653        // n=100 series of length 200, contracted to a modest candidate budget.
654        let n = 100usize;
655        let m = 200usize;
656        let mut flat = vec![0.0f64; n * m];
657        let mut labels = vec![0usize; n];
658        for i in 0..n {
659            let class1 = i % 2 == 1;
660            labels[i] = usize::from(class1);
661            for j in 0..m {
662                let mut v = (j as f64) * 0.01 + (i as f64) * 0.001;
663                if class1 && (80..90).contains(&j) {
664                    v += 5.0;
665                }
666                flat[i + j * n] = v;
667            }
668        }
669        let data = FdMatrix::from_column_major(flat, n, m).unwrap();
670        let cfg = ShapeletDiscoveryConfig {
671            min_length: 10,
672            max_length: 20,
673            max_candidates: Some(800),
674            max_shapelets: 5,
675            quality: QualityMeasure::InfoGain,
676            seed: 7,
677        };
678        let start = std::time::Instant::now();
679        let set = discover_shapelets(&data, &labels, &cfg).unwrap();
680        let elapsed = start.elapsed();
681        assert!(
682            elapsed.as_secs() < 10,
683            "contracted discovery too slow: {elapsed:?}"
684        );
685        assert!(set.len() <= 5, "returned more than max_shapelets");
686        assert!(!set.is_empty());
687    }
688
689    #[test]
690    fn test_infogain_optimal_split() {
691        // Clean separation: class 0 has small distances, class 1 has large.
692        let mut orderline = vec![
693            (0.1, 0usize),
694            (0.2, 0),
695            (0.15, 0),
696            (5.0, 1),
697            (5.5, 1),
698            (6.0, 1),
699        ];
700        let ig = information_gain(&mut orderline, 2);
701        // Perfect balanced 2-class split → IG == parent entropy == 1.0 bit.
702        assert!((ig - 1.0).abs() < 1e-12, "IG for clean split not 1.0: {ig}");
703
704        // A non-separating orderline (interleaved labels, all equal distance)
705        // yields zero gain.
706        let mut flat = vec![(1.0, 0usize), (1.0, 1), (1.0, 0), (1.0, 1)];
707        let ig0 = information_gain(&mut flat, 2);
708        assert!(ig0.abs() < 1e-12, "IG for degenerate split not 0: {ig0}");
709    }
710
711    #[test]
712    fn test_fstatistic_measure() {
713        // Discriminative: distances well separated by class.
714        let disc_d = [0.1, 0.12, 0.09, 5.0, 5.1, 4.9];
715        let labs = [0usize, 0, 0, 1, 1, 1];
716        let f_disc = f_statistic_1d(&disc_d, &labs, 2);
717        // Noise: distances uncorrelated with class.
718        let noise_d = [1.0, 5.0, 1.0, 5.0, 1.0, 5.0];
719        let f_noise = f_statistic_1d(&noise_d, &labs, 2);
720        assert!(
721            f_disc > f_noise,
722            "F-stat did not rank discriminative above noise: {f_disc} vs {f_noise}"
723        );
724        assert!(
725            f_disc > 10.0,
726            "discriminative F-stat unexpectedly low: {f_disc}"
727        );
728
729        // End-to-end: FStatistic quality path runs and returns a set.
730        let (data, labels, _, motif_len) = planted_motif_dataset();
731        let cfg = ShapeletDiscoveryConfig {
732            min_length: motif_len,
733            max_length: motif_len,
734            max_candidates: None,
735            max_shapelets: 3,
736            quality: QualityMeasure::FStatistic,
737            seed: 0,
738        };
739        let set = discover_shapelets(&data, &labels, &cfg).unwrap();
740        assert!(!set.is_empty());
741        assert_eq!(set.quality(), QualityMeasure::FStatistic);
742        assert!(set.shapelets()[0].quality > 0.0);
743    }
744
745    #[test]
746    fn test_self_similarity_pruning() {
747        let (data, labels, _, _) = planted_motif_dataset();
748        // Small lengths + many shapelets → without pruning, adjacent overlapping
749        // windows from the best series would dominate.
750        let cfg = ShapeletDiscoveryConfig {
751            min_length: 6,
752            max_length: 6,
753            max_candidates: None,
754            max_shapelets: 8,
755            quality: QualityMeasure::InfoGain,
756            seed: 0,
757        };
758        let set = discover_shapelets(&data, &labels, &cfg).unwrap();
759        // No two selected shapelets from the SAME series may overlap.
760        let shp = set.shapelets();
761        for a in 0..shp.len() {
762            for b in (a + 1)..shp.len() {
763                if shp[a].series_idx == shp[b].series_idx {
764                    let (sa, ea) = (shp[a].start, shp[a].start + shp[a].length);
765                    let (sb, eb) = (shp[b].start, shp[b].start + shp[b].length);
766                    assert!(
767                        ea <= sb || eb <= sa,
768                        "same-series shapelets overlap: [{sa},{ea}) & [{sb},{eb})"
769                    );
770                }
771            }
772        }
773    }
774
775    #[test]
776    fn test_discover_deterministic() {
777        // Larger-than-budget candidate space forces random sampling; same seed
778        // must reproduce byte-identical results.
779        let n = 30usize;
780        let m = 60usize;
781        let mut flat = vec![0.0f64; n * m];
782        let mut labels = vec![0usize; n];
783        for i in 0..n {
784            let class1 = i % 2 == 1;
785            labels[i] = usize::from(class1);
786            for j in 0..m {
787                let mut v = (j as f64) * 0.02 + (i as f64) * 0.003;
788                if class1 && (20..30).contains(&j) {
789                    v += 3.0;
790                }
791                flat[i + j * n] = v;
792            }
793        }
794        let data = FdMatrix::from_column_major(flat, n, m).unwrap();
795        let cfg = ShapeletDiscoveryConfig {
796            min_length: 8,
797            max_length: 12,
798            max_candidates: Some(500),
799            max_shapelets: 6,
800            quality: QualityMeasure::InfoGain,
801            seed: 123,
802        };
803        let a = discover_shapelets(&data, &labels, &cfg).unwrap();
804        let b = discover_shapelets(&data, &labels, &cfg).unwrap();
805        assert_eq!(a, b, "same-seed fits not byte-identical");
806    }
807
808    #[test]
809    fn test_discover_validation() {
810        let (data, labels, _, _) = planted_motif_dataset();
811        let (_n, ncols) = data.shape();
812
813        // <2 classes.
814        let one_class = vec![0usize; labels.len()];
815        let cfg = ShapeletDiscoveryConfig::default();
816        assert!(matches!(
817            discover_shapelets(&data, &one_class, &cfg),
818            Err(FdarError::InvalidParameter { .. })
819        ));
820
821        // label/row mismatch.
822        let short_labels = vec![0usize, 1];
823        assert!(matches!(
824            discover_shapelets(&data, &short_labels, &cfg),
825            Err(FdarError::InvalidDimension { .. })
826        ));
827
828        // min > max.
829        let cfg_bad = ShapeletDiscoveryConfig {
830            min_length: 10,
831            max_length: 5,
832            ..Default::default()
833        };
834        assert!(matches!(
835            discover_shapelets(&data, &labels, &cfg_bad),
836            Err(FdarError::InvalidParameter { .. })
837        ));
838
839        // max_length > ncols.
840        let cfg_big = ShapeletDiscoveryConfig {
841            min_length: 3,
842            max_length: ncols + 5,
843            ..Default::default()
844        };
845        assert!(matches!(
846            discover_shapelets(&data, &labels, &cfg_big),
847            Err(FdarError::InvalidParameter { .. })
848        ));
849    }
850}