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