Skip to main content

sc_neurocore_engine/analysis/
distance.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Spike train distance and similarity metrics
8
9use super::basic::isi;
10use super::rate::instantaneous_rate;
11
12/// Van Rossum 2001 — exponential-kernel spike train distance.
13pub fn van_rossum_distance(train_a: &[i32], train_b: &[i32], dt: f64, tau_ms: f64) -> f64 {
14    let tau = tau_ms / 1000.0;
15    let n = train_a.len().min(train_b.len());
16    if n == 0 || tau <= 0.0 {
17        return 0.0;
18    }
19
20    // Build exponential decay kernel
21    let decay: Vec<f64> = (0..n).map(|i| (-((i as f64) * dt) / tau).exp()).collect();
22
23    // Convolve (full, truncated to n)
24    let fa = convolve_full_truncated(train_a, &decay, n);
25    let fb = convolve_full_truncated(train_b, &decay, n);
26
27    let sum_sq: f64 = fa.iter().zip(fb.iter()).map(|(a, b)| (a - b).powi(2)).sum();
28    (sum_sq * dt / tau).sqrt()
29}
30
31fn convolve_full_truncated(signal: &[i32], kernel: &[f64], out_len: usize) -> Vec<f64> {
32    let n = signal.len().min(out_len);
33    let mut result = vec![0.0_f64; out_len];
34    for i in 0..n {
35        if signal[i] == 0 {
36            continue;
37        }
38        let s = signal[i] as f64;
39        for (j, &k) in kernel.iter().enumerate() {
40            let idx = i + j;
41            if idx >= out_len {
42                break;
43            }
44            result[idx] += s * k;
45        }
46    }
47    result
48}
49
50/// Victor-Purpura 1996 — edit distance between spike time arrays.
51/// cost_per_s: cost of shifting a spike by 1 second (q parameter).
52pub fn victor_purpura_distance(times_a: &[f64], times_b: &[f64], cost_per_s: f64) -> f64 {
53    let na = times_a.len();
54    let nb = times_b.len();
55    if na == 0 {
56        return nb as f64;
57    }
58    if nb == 0 {
59        return na as f64;
60    }
61
62    // DP table
63    let mut d = vec![vec![0.0_f64; nb + 1]; na + 1];
64    for i in 0..=na {
65        d[i][0] = i as f64;
66    }
67    for j in 0..=nb {
68        d[0][j] = j as f64;
69    }
70    for i in 1..=na {
71        for j in 1..=nb {
72            let shift_cost = cost_per_s * (times_a[i - 1] - times_b[j - 1]).abs();
73            d[i][j] = (d[i - 1][j] + 1.0)
74                .min(d[i][j - 1] + 1.0)
75                .min(d[i - 1][j - 1] + shift_cost);
76        }
77    }
78    d[na][nb]
79}
80
81/// ISI-distance (Kreuz et al. 2007) — ratio-based ISI comparison.
82pub fn isi_distance(train_a: &[i32], train_b: &[i32], dt: f64) -> f64 {
83    let isi_a = isi(train_a, dt);
84    let isi_b = isi(train_b, dt);
85    let n = isi_a.len().min(isi_b.len());
86    if n == 0 {
87        return f64::NAN;
88    }
89    let sum: f64 = (0..n)
90        .map(|i| {
91            let a = isi_a[i];
92            let b = isi_b[i];
93            if a == 0.0 && b == 0.0 {
94                0.0
95            } else if a <= b {
96                if b > 0.0 {
97                    (a / b - 1.0).abs()
98                } else {
99                    0.0
100                }
101            } else if a > 0.0 {
102                (b / a - 1.0).abs()
103            } else {
104                0.0
105            }
106        })
107        .sum();
108    sum / n as f64
109}
110
111/// SPIKE-distance (Kreuz et al. 2013).
112/// Time-resolved distance based on nearest-neighbour spike differences.
113pub fn spike_distance(times_a: &[f64], times_b: &[f64], t_start: f64, t_end: f64) -> f64 {
114    let mut ta: Vec<f64> = times_a
115        .iter()
116        .copied()
117        .filter(|&t| t >= t_start && t <= t_end)
118        .collect();
119    let mut tb: Vec<f64> = times_b
120        .iter()
121        .copied()
122        .filter(|&t| t >= t_start && t <= t_end)
123        .collect();
124    ta.sort_by(|a, b| a.partial_cmp(b).unwrap());
125    tb.sort_by(|a, b| a.partial_cmp(b).unwrap());
126
127    if ta.is_empty() && tb.is_empty() {
128        return 0.0;
129    }
130    if ta.is_empty() || tb.is_empty() {
131        return 1.0;
132    }
133
134    let n_eval = 100_usize;
135    let step = (t_end - t_start) / (n_eval - 1).max(1) as f64;
136    let mut sum = 0.0_f64;
137
138    for k in 0..n_eval {
139        let t = t_start + k as f64 * step;
140        let idx_a = ta.partition_point(|&x| x <= t);
141        let idx_b = tb.partition_point(|&x| x <= t);
142
143        let prev_a = if idx_a > 0 { ta[idx_a - 1] } else { t_start };
144        let next_a = if idx_a < ta.len() { ta[idx_a] } else { t_end };
145        let prev_b = if idx_b > 0 { tb[idx_b - 1] } else { t_start };
146        let next_b = if idx_b < tb.len() { tb[idx_b] } else { t_end };
147
148        let isi_a = (next_a - prev_a).max(1e-30);
149        let isi_b = (next_b - prev_b).max(1e-30);
150
151        let da = (t - prev_a).abs().min((t - next_a).abs());
152        let db = (t - prev_b).abs().min((t - next_b).abs());
153
154        sum += (da / isi_a - db / isi_b).abs();
155    }
156    sum / n_eval as f64
157}
158
159fn local_isi(times: &[f64], idx: usize) -> f64 {
160    if times.len() < 2 {
161        return 1.0;
162    }
163    if idx == 0 {
164        return times[1] - times[0];
165    }
166    if idx >= times.len() - 1 {
167        return times[times.len() - 1] - times[times.len() - 2];
168    }
169    (times[idx] - times[idx - 1]).min(times[idx + 1] - times[idx])
170}
171
172/// SPIKE-synchronization (Kreuz et al. 2015). Coincidence-based, normalised to [0, 1].
173pub fn spike_sync(times_a: &[f64], times_b: &[f64], t_start: f64, t_end: f64) -> f64 {
174    let mut ta: Vec<f64> = times_a
175        .iter()
176        .copied()
177        .filter(|&t| t >= t_start && t <= t_end)
178        .collect();
179    let mut tb: Vec<f64> = times_b
180        .iter()
181        .copied()
182        .filter(|&t| t >= t_start && t <= t_end)
183        .collect();
184    ta.sort_by(|a, b| a.partial_cmp(b).unwrap());
185    tb.sort_by(|a, b| a.partial_cmp(b).unwrap());
186
187    if ta.is_empty() || tb.is_empty() {
188        return 0.0;
189    }
190
191    let total_possible = ta.len() + tb.len();
192    let mut total_coincidences = 0_usize;
193
194    // Forward pass: for each spike in A, find nearest in B
195    for (i, &t) in ta.iter().enumerate() {
196        let j = nearest_idx(&tb, t);
197        let isi_a = local_isi(&ta, i);
198        let isi_b = local_isi(&tb, j);
199        let tau = isi_a.min(isi_b) / 2.0;
200        if tau > 0.0 && (tb[j] - t).abs() < tau {
201            total_coincidences += 1;
202        }
203    }
204
205    // Reverse pass: for each spike in B, find nearest in A
206    for (j, &t) in tb.iter().enumerate() {
207        let i = nearest_idx(&ta, t);
208        let isi_a = local_isi(&ta, i);
209        let isi_b = local_isi(&tb, j);
210        let tau = isi_a.min(isi_b) / 2.0;
211        if tau > 0.0 && (ta[i] - t).abs() < tau {
212            total_coincidences += 1;
213        }
214    }
215
216    if total_possible == 0 {
217        return 0.0;
218    }
219    total_coincidences as f64 / total_possible as f64
220}
221
222fn nearest_idx(sorted: &[f64], target: f64) -> usize {
223    if sorted.is_empty() {
224        return 0;
225    }
226    let pos = sorted.partition_point(|&x| x < target);
227    if pos == 0 {
228        return 0;
229    }
230    if pos >= sorted.len() {
231        return sorted.len() - 1;
232    }
233    if (sorted[pos] - target).abs() < (sorted[pos - 1] - target).abs() {
234        pos
235    } else {
236        pos - 1
237    }
238}
239
240/// Binned SPIKE-synchronization profile (Kreuz et al. 2015).
241pub fn spike_sync_profile(
242    times_a: &[f64],
243    times_b: &[f64],
244    n_bins: usize,
245    t_start: f64,
246    t_end: f64,
247) -> Vec<f64> {
248    let mut profile = vec![0.0_f64; n_bins];
249    let bin_width = (t_end - t_start) / n_bins as f64;
250    for k in 0..n_bins {
251        let lo = t_start + k as f64 * bin_width;
252        let hi = lo + bin_width;
253        let sub_a: Vec<f64> = times_a
254            .iter()
255            .copied()
256            .filter(|&t| t >= lo && t < hi)
257            .collect();
258        let sub_b: Vec<f64> = times_b
259            .iter()
260            .copied()
261            .filter(|&t| t >= lo && t < hi)
262            .collect();
263        if !sub_a.is_empty() || !sub_b.is_empty() {
264            profile[k] = spike_sync(&sub_a, &sub_b, lo, hi);
265        }
266    }
267    profile
268}
269
270/// Binned SPIKE-distance profile (Kreuz et al. 2013).
271pub fn spike_profile(
272    times_a: &[f64],
273    times_b: &[f64],
274    n_bins: usize,
275    t_start: f64,
276    t_end: f64,
277) -> Vec<f64> {
278    let mut profile = vec![0.0_f64; n_bins];
279    let bin_width = (t_end - t_start) / n_bins as f64;
280    for k in 0..n_bins {
281        let lo = t_start + k as f64 * bin_width;
282        let hi = lo + bin_width;
283        let sub_a: Vec<f64> = times_a
284            .iter()
285            .copied()
286            .filter(|&t| t >= lo && t < hi)
287            .collect();
288        let sub_b: Vec<f64> = times_b
289            .iter()
290            .copied()
291            .filter(|&t| t >= lo && t < hi)
292            .collect();
293        profile[k] = spike_distance(&sub_a, &sub_b, lo, hi);
294    }
295    profile
296}
297
298/// Binned ISI-distance profile (Kreuz et al. 2007).
299pub fn isi_profile(
300    binary_train_a: &[i32],
301    binary_train_b: &[i32],
302    dt: f64,
303    n_bins: usize,
304) -> Vec<f64> {
305    let n = binary_train_a.len().min(binary_train_b.len());
306    let bin_size = (n / n_bins).max(1);
307    let mut profile = vec![0.0_f64; n_bins];
308    for k in 0..n_bins {
309        let start = k * bin_size;
310        let end = (start + bin_size).min(n);
311        if start >= n {
312            break;
313        }
314        profile[k] = isi_distance(&binary_train_a[start..end], &binary_train_b[start..end], dt);
315    }
316    profile
317}
318
319/// Adaptive SPIKE-distance (Kreuz et al. 2013).
320/// cost=0: pure SPIKE-distance. cost=1: ISI-like weighting.
321pub fn adaptive_spike_distance(
322    times_a: &[f64],
323    times_b: &[f64],
324    t_start: f64,
325    t_end: f64,
326    cost: f64,
327) -> f64 {
328    let sd = spike_distance(times_a, times_b, t_start, t_end);
329    let ta: Vec<f64> = times_a
330        .iter()
331        .copied()
332        .filter(|&t| t >= t_start && t <= t_end)
333        .collect();
334    let tb: Vec<f64> = times_b
335        .iter()
336        .copied()
337        .filter(|&t| t >= t_start && t <= t_end)
338        .collect();
339
340    let mean_isi = |times: &[f64]| -> f64 {
341        if times.len() > 1 {
342            let mut sorted = times.to_vec();
343            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
344            let diffs: Vec<f64> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
345            diffs.iter().sum::<f64>() / diffs.len() as f64
346        } else {
347            t_end - t_start
348        }
349    };
350
351    let mean_a = mean_isi(&ta);
352    let mean_b = mean_isi(&tb);
353    let ratio = (mean_a - mean_b).abs() / (mean_a + mean_b).max(1e-30);
354    (1.0 - cost) * sd + cost * ratio
355}
356
357/// Schreiber et al. 2003 — spike train similarity via smoothed correlation.
358pub fn schreiber_similarity(train_a: &[i32], train_b: &[i32], dt: f64, sigma_ms: f64) -> f64 {
359    let fa: Vec<f64> = train_a.iter().map(|&v| v as f64).collect();
360    let fb: Vec<f64> = train_b.iter().map(|&v| v as f64).collect();
361    let ra = instantaneous_rate(&fa, dt, "gaussian", sigma_ms);
362    let rb = instantaneous_rate(&fb, dt, "gaussian", sigma_ms);
363    let n = ra.len().min(rb.len());
364    if n == 0 {
365        return 0.0;
366    }
367
368    let mean_a: f64 = ra[..n].iter().sum::<f64>() / n as f64;
369    let mean_b: f64 = rb[..n].iter().sum::<f64>() / n as f64;
370
371    let mut sum_ab = 0.0_f64;
372    let mut sum_aa = 0.0_f64;
373    let mut sum_bb = 0.0_f64;
374    for i in 0..n {
375        let a = ra[i] - mean_a;
376        let b = rb[i] - mean_b;
377        sum_ab += a * b;
378        sum_aa += a * a;
379        sum_bb += b * b;
380    }
381    let denom = (sum_aa * sum_bb).sqrt();
382    if denom == 0.0 {
383        return 0.0;
384    }
385    sum_ab / denom
386}
387
388/// Hunter-Milton 2003 similarity. Fraction of spikes with nearest-neighbour < dt_max.
389pub fn hunter_milton_similarity(times_a: &[f64], times_b: &[f64], dt_max: f64) -> f64 {
390    if times_a.is_empty() || times_b.is_empty() {
391        return 0.0;
392    }
393    let total = times_a.len() + times_b.len();
394    let mut count = 0_usize;
395    for &t in times_a {
396        if times_b.iter().any(|&tb| (tb - t).abs() < dt_max) {
397            count += 1;
398        }
399    }
400    for &t in times_b {
401        if times_a.iter().any(|&ta| (ta - t).abs() < dt_max) {
402            count += 1;
403        }
404    }
405    count as f64 / total as f64
406}
407
408/// Earth mover's distance between spike time distributions (Rubner et al. 1998).
409pub fn earth_movers_distance(
410    times_a: &[f64],
411    times_b: &[f64],
412    t_start: f64,
413    t_end: f64,
414    n_bins: usize,
415) -> f64 {
416    let bin_width = (t_end - t_start) / n_bins as f64;
417
418    let histogram = |times: &[f64]| -> Vec<f64> {
419        let mut h = vec![0.0_f64; n_bins];
420        for &t in times {
421            let idx = ((t - t_start) / bin_width) as usize;
422            if idx < n_bins {
423                h[idx] += 1.0;
424            }
425        }
426        let s: f64 = h.iter().sum();
427        if s > 0.0 {
428            for v in &mut h {
429                *v /= s;
430            }
431        }
432        h
433    };
434
435    let ha = histogram(times_a);
436    let hb = histogram(times_b);
437
438    // Cumulative sum difference
439    let mut cum_a = 0.0_f64;
440    let mut cum_b = 0.0_f64;
441    let mut emd = 0.0_f64;
442    for i in 0..n_bins {
443        cum_a += ha[i];
444        cum_b += hb[i];
445        emd += (cum_a - cum_b).abs();
446    }
447    emd * bin_width
448}
449
450/// All-pairs Victor-Purpura distance matrix for multiple neurons.
451pub fn multi_neuron_victor_purpura(spike_times_list: &[&[f64]], cost_per_s: f64) -> Vec<Vec<f64>> {
452    let n = spike_times_list.len();
453    let mut mat = vec![vec![0.0_f64; n]; n];
454    for i in 0..n {
455        for j in (i + 1)..n {
456            let d = victor_purpura_distance(spike_times_list[i], spike_times_list[j], cost_per_s);
457            mat[i][j] = d;
458            mat[j][i] = d;
459        }
460    }
461    mat
462}
463
464/// Generalized Victor-Purpura with arbitrary cost function.
465/// cost_func(dt) returns the cost of shifting a spike by dt seconds.
466pub fn generalized_victor_purpura(
467    times_a: &[f64],
468    times_b: &[f64],
469    cost_func: fn(f64) -> f64,
470) -> f64 {
471    let na = times_a.len();
472    let nb = times_b.len();
473    if na == 0 {
474        return nb as f64;
475    }
476    if nb == 0 {
477        return na as f64;
478    }
479    let mut d = vec![vec![0.0_f64; nb + 1]; na + 1];
480    for i in 0..=na {
481        d[i][0] = i as f64;
482    }
483    for j in 0..=nb {
484        d[0][j] = j as f64;
485    }
486    for i in 1..=na {
487        for j in 1..=nb {
488            let shift = cost_func(times_a[i - 1] - times_b[j - 1]);
489            d[i][j] = (d[i - 1][j] + 1.0)
490                .min(d[i][j - 1] + 1.0)
491                .min(d[i - 1][j - 1] + shift);
492        }
493    }
494    d[na][nb]
495}
496
497/// All-pairs spike train distance matrix.
498/// metric: "spike_distance", "spike_sync", "victor_purpura".
499pub fn spike_distance_matrix(
500    spike_times_list: &[&[f64]],
501    metric: &str,
502    t_start: f64,
503    t_end: f64,
504) -> Vec<Vec<f64>> {
505    let n = spike_times_list.len();
506    let mut mat = vec![vec![0.0_f64; n]; n];
507    for i in 0..n {
508        for j in (i + 1)..n {
509            let d = match metric {
510                "spike_sync" => {
511                    1.0 - spike_sync(spike_times_list[i], spike_times_list[j], t_start, t_end)
512                }
513                "victor_purpura" => {
514                    victor_purpura_distance(spike_times_list[i], spike_times_list[j], 1000.0)
515                }
516                _ => spike_distance(spike_times_list[i], spike_times_list[j], t_start, t_end),
517            };
518            mat[i][j] = d;
519            mat[j][i] = d;
520        }
521    }
522    mat
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn make_train(spikes: &[usize], len: usize) -> Vec<i32> {
530        let mut t = vec![0i32; len];
531        for &s in spikes {
532            t[s] = 1;
533        }
534        t
535    }
536
537    // ── van_rossum_distance ─────────────────────────────────────────
538
539    #[test]
540    fn test_van_rossum_identical() {
541        let train = make_train(&[10, 30, 50], 100);
542        let d = van_rossum_distance(&train, &train, 0.001, 10.0);
543        assert!(d.abs() < 1e-10, "identical trains → distance 0, got {d}");
544    }
545
546    #[test]
547    fn test_van_rossum_different() {
548        let a = make_train(&[10, 30, 50], 100);
549        let b = make_train(&[20, 40, 60], 100);
550        let d = van_rossum_distance(&a, &b, 0.001, 10.0);
551        assert!(d > 0.0, "different trains → positive distance");
552    }
553
554    #[test]
555    fn test_van_rossum_symmetry() {
556        let a = make_train(&[10, 30], 100);
557        let b = make_train(&[20, 40], 100);
558        let d1 = van_rossum_distance(&a, &b, 0.001, 10.0);
559        let d2 = van_rossum_distance(&b, &a, 0.001, 10.0);
560        assert!((d1 - d2).abs() < 1e-10, "distance should be symmetric");
561    }
562
563    #[test]
564    fn test_van_rossum_empty() {
565        let a = vec![0i32; 100];
566        let b = make_train(&[50], 100);
567        let d = van_rossum_distance(&a, &b, 0.001, 10.0);
568        assert!(d > 0.0, "empty vs non-empty → positive distance");
569    }
570
571    // ── victor_purpura_distance ─────────────────────────────────────
572
573    #[test]
574    fn test_vp_identical() {
575        let times = vec![0.1, 0.3, 0.5];
576        let d = victor_purpura_distance(&times, &times, 1000.0);
577        assert!(d < 1e-10, "identical spike times → 0");
578    }
579
580    #[test]
581    fn test_vp_empty_vs_spikes() {
582        let d = victor_purpura_distance(&[], &[0.1, 0.2, 0.3], 1000.0);
583        assert!((d - 3.0).abs() < 1e-10, "3 insertions → cost 3");
584    }
585
586    #[test]
587    fn test_vp_close_spikes() {
588        let a = vec![0.1];
589        let b = vec![0.1001];
590        let d = victor_purpura_distance(&a, &b, 1000.0);
591        // shift cost = 1000 * 0.0001 = 0.1, cheaper than delete+insert = 2
592        assert!(d < 0.2, "close spikes → small shift cost, got {d}");
593    }
594
595    #[test]
596    fn test_vp_symmetry() {
597        let a = vec![0.1, 0.5];
598        let b = vec![0.3, 0.7];
599        let d1 = victor_purpura_distance(&a, &b, 1000.0);
600        let d2 = victor_purpura_distance(&b, &a, 1000.0);
601        assert!((d1 - d2).abs() < 1e-10);
602    }
603
604    // ── isi_distance ────────────────────────────────────────────────
605
606    #[test]
607    fn test_isi_distance_identical() {
608        let train = make_train(&[10, 30, 50, 70], 100);
609        let d = isi_distance(&train, &train, 0.001);
610        assert!(d.abs() < 1e-10, "identical trains → ISI distance 0");
611    }
612
613    #[test]
614    fn test_isi_distance_empty() {
615        let a = make_train(&[50], 100);
616        let b = make_train(&[50], 100);
617        // Only 1 spike each → no ISI → NaN
618        let d = isi_distance(&a, &b, 0.001);
619        assert!(d.is_nan(), "single spike → NaN ISI distance");
620    }
621
622    #[test]
623    fn test_isi_distance_different() {
624        let a = make_train(&[10, 20, 30], 100); // uniform ISIs
625        let b = make_train(&[10, 15, 30], 100); // non-uniform
626        let d = isi_distance(&a, &b, 0.001);
627        assert!(d > 0.0, "different ISI patterns → positive distance");
628    }
629
630    // ── spike_distance ──────────────────────────────────────────────
631
632    #[test]
633    fn test_spike_distance_identical() {
634        let times = vec![0.1, 0.3, 0.5, 0.7, 0.9];
635        let d = spike_distance(&times, &times, 0.0, 1.0);
636        assert!(d < 1e-10, "identical → 0, got {d}");
637    }
638
639    #[test]
640    fn test_spike_distance_empty_both() {
641        let d = spike_distance(&[], &[], 0.0, 1.0);
642        assert_eq!(d, 0.0);
643    }
644
645    #[test]
646    fn test_spike_distance_one_empty() {
647        let d = spike_distance(&[0.5], &[], 0.0, 1.0);
648        assert_eq!(d, 1.0);
649    }
650
651    #[test]
652    fn test_spike_distance_symmetry() {
653        let a = vec![0.2, 0.6];
654        let b = vec![0.4, 0.8];
655        let d1 = spike_distance(&a, &b, 0.0, 1.0);
656        let d2 = spike_distance(&b, &a, 0.0, 1.0);
657        assert!((d1 - d2).abs() < 1e-10);
658    }
659
660    // ── spike_sync ──────────────────────────────────────────────────
661
662    #[test]
663    fn test_spike_sync_identical() {
664        let times = vec![0.1, 0.3, 0.5, 0.7, 0.9];
665        let s = spike_sync(&times, &times, 0.0, 1.0);
666        assert!(
667            (s - 1.0).abs() < 1e-10,
668            "identical trains → sync=1.0, got {s}"
669        );
670    }
671
672    #[test]
673    fn test_spike_sync_empty() {
674        let s = spike_sync(&[], &[0.5], 0.0, 1.0);
675        assert_eq!(s, 0.0);
676    }
677
678    #[test]
679    fn test_spike_sync_far_apart() {
680        let a = vec![0.1];
681        let b = vec![0.9];
682        let s = spike_sync(&a, &b, 0.0, 1.0);
683        assert!(s < 0.5, "far apart → low sync, got {s}");
684    }
685
686    // ── spike_sync_profile ──────────────────────────────────────────
687
688    #[test]
689    fn test_sync_profile_length() {
690        let a = vec![0.1, 0.3, 0.5];
691        let b = vec![0.1, 0.3, 0.5];
692        let p = spike_sync_profile(&a, &b, 10, 0.0, 1.0);
693        assert_eq!(p.len(), 10);
694    }
695
696    // ── spike_profile ───────────────────────────────────────────────
697
698    #[test]
699    fn test_spike_profile_length() {
700        let a = vec![0.2, 0.6];
701        let b = vec![0.4, 0.8];
702        let p = spike_profile(&a, &b, 5, 0.0, 1.0);
703        assert_eq!(p.len(), 5);
704    }
705
706    // ── isi_profile ─────────────────────────────────────────────────
707
708    #[test]
709    fn test_isi_profile_length() {
710        let a = make_train(&[5, 15, 25, 35, 45], 50);
711        let b = make_train(&[5, 15, 25, 35, 45], 50);
712        let p = isi_profile(&a, &b, 0.001, 5);
713        assert_eq!(p.len(), 5);
714    }
715
716    // ── adaptive_spike_distance ─────────────────────────────────────
717
718    #[test]
719    fn test_adaptive_cost_zero_equals_spike() {
720        let a = vec![0.2, 0.6];
721        let b = vec![0.4, 0.8];
722        let sd = spike_distance(&a, &b, 0.0, 1.0);
723        let ad = adaptive_spike_distance(&a, &b, 0.0, 1.0, 0.0);
724        assert!((sd - ad).abs() < 1e-10, "cost=0 → pure spike distance");
725    }
726
727    #[test]
728    fn test_adaptive_cost_one() {
729        let a = vec![0.2, 0.6];
730        let b = vec![0.4, 0.8];
731        let ad = adaptive_spike_distance(&a, &b, 0.0, 1.0, 1.0);
732        assert!(ad >= 0.0, "cost=1 → non-negative");
733    }
734
735    // ── schreiber_similarity ────────────────────────────────────────
736
737    #[test]
738    fn test_schreiber_identical() {
739        let train = make_train(&[10, 30, 50, 70, 90], 100);
740        let s = schreiber_similarity(&train, &train, 0.001, 5.0);
741        assert!(
742            (s - 1.0).abs() < 1e-6,
743            "identical trains → similarity 1.0, got {s}"
744        );
745    }
746
747    #[test]
748    fn test_schreiber_empty() {
749        let a = vec![0i32; 100];
750        let b = vec![0i32; 100];
751        let s = schreiber_similarity(&a, &b, 0.001, 5.0);
752        assert_eq!(s, 0.0, "zero trains → 0.0");
753    }
754
755    // ── hunter_milton_similarity ────────────────────────────────────
756
757    #[test]
758    fn test_hunter_identical() {
759        let times = vec![0.1, 0.3, 0.5];
760        let s = hunter_milton_similarity(&times, &times, 0.01);
761        assert!((s - 1.0).abs() < 1e-10, "identical → 1.0, got {s}");
762    }
763
764    #[test]
765    fn test_hunter_empty() {
766        let s = hunter_milton_similarity(&[], &[0.5], 0.01);
767        assert_eq!(s, 0.0);
768    }
769
770    #[test]
771    fn test_hunter_far_apart() {
772        let a = vec![0.1];
773        let b = vec![0.9];
774        let s = hunter_milton_similarity(&a, &b, 0.01);
775        assert_eq!(s, 0.0, "far apart → 0.0");
776    }
777
778    // ── earth_movers_distance ───────────────────────────────────────
779
780    #[test]
781    fn test_emd_identical() {
782        let times = vec![0.1, 0.3, 0.5, 0.7, 0.9];
783        let d = earth_movers_distance(&times, &times, 0.0, 1.0, 100);
784        assert!(d < 1e-10, "identical → 0, got {d}");
785    }
786
787    #[test]
788    fn test_emd_different() {
789        let a = vec![0.1, 0.2];
790        let b = vec![0.8, 0.9];
791        let d = earth_movers_distance(&a, &b, 0.0, 1.0, 100);
792        assert!(d > 0.0, "different distributions → positive EMD");
793    }
794
795    #[test]
796    fn test_emd_symmetry() {
797        let a = vec![0.1, 0.2];
798        let b = vec![0.8, 0.9];
799        let d1 = earth_movers_distance(&a, &b, 0.0, 1.0, 100);
800        let d2 = earth_movers_distance(&b, &a, 0.0, 1.0, 100);
801        assert!((d1 - d2).abs() < 1e-10);
802    }
803
804    // ── multi_neuron_victor_purpura ─────────────────────────────────
805
806    #[test]
807    fn test_multi_vp_diagonal_zero() {
808        let t1 = vec![0.1, 0.3];
809        let t2 = vec![0.2, 0.4];
810        let trains: Vec<&[f64]> = vec![&t1, &t2];
811        let mat = multi_neuron_victor_purpura(&trains, 1000.0);
812        assert_eq!(mat[0][0], 0.0);
813        assert_eq!(mat[1][1], 0.0);
814        assert!(mat[0][1] > 0.0);
815        assert!((mat[0][1] - mat[1][0]).abs() < 1e-10);
816    }
817
818    // ── generalized_victor_purpura ──────────────────────────────────
819
820    #[test]
821    fn test_gen_vp_linear_matches_standard() {
822        fn linear_cost(dt: f64) -> f64 {
823            1000.0 * dt.abs()
824        }
825        let a = vec![0.1, 0.5];
826        let b = vec![0.2, 0.6];
827        let d_gen = generalized_victor_purpura(&a, &b, linear_cost);
828        let d_std = victor_purpura_distance(&a, &b, 1000.0);
829        assert!((d_gen - d_std).abs() < 1e-10);
830    }
831
832    #[test]
833    fn test_gen_vp_quadratic_cost() {
834        fn quad_cost(dt: f64) -> f64 {
835            1000.0 * dt * dt
836        }
837        let a = vec![0.1];
838        let b = vec![0.2];
839        let d = generalized_victor_purpura(&a, &b, quad_cost);
840        // shift cost = 1000 * 0.01 = 10 → cheaper to delete+insert (cost=2)
841        assert!(
842            (d - 2.0).abs() < 1e-10,
843            "high shift cost → delete+insert, got {d}"
844        );
845    }
846
847    // ── spike_distance_matrix ───────────────────────────────────────
848
849    #[test]
850    fn test_distance_matrix_shape() {
851        let t1 = vec![0.1, 0.5];
852        let t2 = vec![0.2, 0.6];
853        let t3 = vec![0.3, 0.7];
854        let trains: Vec<&[f64]> = vec![&t1, &t2, &t3];
855        let mat = spike_distance_matrix(&trains, "spike_distance", 0.0, 1.0);
856        assert_eq!(mat.len(), 3);
857        assert_eq!(mat[0].len(), 3);
858        assert_eq!(mat[0][0], 0.0);
859        assert!((mat[0][1] - mat[1][0]).abs() < 1e-10);
860    }
861
862    #[test]
863    fn test_distance_matrix_vp_metric() {
864        let t1 = vec![0.1];
865        let t2 = vec![0.9];
866        let trains: Vec<&[f64]> = vec![&t1, &t2];
867        let mat = spike_distance_matrix(&trains, "victor_purpura", 0.0, 1.0);
868        assert!(mat[0][1] > 0.0);
869    }
870
871    // ── helpers ─────────────────────────────────────────────────────
872
873    #[test]
874    fn test_local_isi_boundaries() {
875        let times = vec![0.1, 0.3, 0.7];
876        assert!((local_isi(&times, 0) - 0.2).abs() < 1e-10);
877        assert!((local_isi(&times, 2) - 0.4).abs() < 1e-10);
878        // Middle: min(0.2, 0.4) = 0.2
879        assert!((local_isi(&times, 1) - 0.2).abs() < 1e-10);
880    }
881
882    #[test]
883    fn test_local_isi_single() {
884        assert_eq!(local_isi(&[0.5], 0), 1.0);
885    }
886
887    #[test]
888    fn test_nearest_idx() {
889        let sorted = vec![0.1, 0.3, 0.5, 0.7];
890        assert_eq!(nearest_idx(&sorted, 0.0), 0);
891        assert_eq!(nearest_idx(&sorted, 0.29), 1);
892        assert_eq!(nearest_idx(&sorted, 0.9), 3);
893    }
894}