Skip to main content

quantrs2_device/ml_optimization/
fallback_scirs2.rs

1//! Fallback implementations for SciRS2 functionality when the feature is not available
2//!
3//! This module provides basic implementations of SciRS2 functions that are used
4//! in the ML optimization module when the scirs2 feature is not enabled.
5
6use scirs2_core::ndarray::{Array1, Array2};
7use std::collections::HashMap;
8
9/// Fallback error type for optimization
10#[derive(Debug, Clone)]
11pub struct OptimizeError {
12    pub message: String,
13}
14
15impl std::fmt::Display for OptimizeError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        write!(f, "Optimization error: {}", self.message)
18    }
19}
20
21impl std::error::Error for OptimizeError {}
22
23/// Fallback result type for optimization
24pub type OptimizeResult<T> = Result<T, OptimizeError>;
25
26/// Basic statistics functions
27pub fn mean(data: &[f64]) -> f64 {
28    if data.is_empty() {
29        return 0.0;
30    }
31    data.iter().sum::<f64>() / data.len() as f64
32}
33
34pub fn std(data: &[f64]) -> f64 {
35    if data.len() < 2 {
36        return 0.0;
37    }
38    let m = mean(data);
39    let variance = data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (data.len() - 1) as f64;
40    variance.sqrt()
41}
42
43pub fn var(data: &[f64]) -> f64 {
44    if data.len() < 2 {
45        return 0.0;
46    }
47    let m = mean(data);
48    data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / (data.len() - 1) as f64
49}
50
51pub fn corrcoef(x: &[f64], y: &[f64]) -> f64 {
52    pearsonr(x, y)
53}
54
55pub fn pearsonr(x: &[f64], y: &[f64]) -> f64 {
56    if x.len() != y.len() || x.len() < 2 {
57        return 0.0;
58    }
59
60    let mean_x = mean(x);
61    let mean_y = mean(y);
62
63    let numerator: f64 = x
64        .iter()
65        .zip(y.iter())
66        .map(|(xi, yi)| (xi - mean_x) * (yi - mean_y))
67        .sum();
68
69    let sum_sq_x: f64 = x.iter().map(|xi| (xi - mean_x).powi(2)).sum();
70    let sum_sq_y: f64 = y.iter().map(|yi| (yi - mean_y).powi(2)).sum();
71
72    let denominator = (sum_sq_x * sum_sq_y).sqrt();
73
74    if denominator == 0.0 {
75        0.0
76    } else {
77        numerator / denominator
78    }
79}
80
81pub fn spearmanr(x: &[f64], y: &[f64]) -> f64 {
82    // Simplified Spearman correlation - just return Pearson for fallback
83    pearsonr(x, y)
84}
85
86/// Fallback optimization function
87pub fn minimize<F>(
88    _objective: F,
89    _initial_guess: &[f64],
90    _bounds: Option<&[(f64, f64)]>,
91) -> OptimizeResult<MinimizeResult>
92where
93    F: Fn(&[f64]) -> f64,
94{
95    // Basic fallback - return the initial guess as "optimal"
96    Ok(MinimizeResult {
97        x: _initial_guess.to_vec(),
98        fun: 0.0,
99        success: true,
100        message: "Fallback optimization".to_string(),
101        nit: 0,
102        nfev: 0,
103    })
104}
105
106/// Result type for minimize function
107#[derive(Debug, Clone)]
108pub struct MinimizeResult {
109    pub x: Vec<f64>,
110    pub fun: f64,
111    pub success: bool,
112    pub message: String,
113    pub nit: usize,
114    pub nfev: usize,
115}
116
117/// Real symmetric eigensolver using the cyclic Jacobi eigenvalue algorithm.
118///
119/// This is a pure-Rust fallback used when the `scirs2` feature is disabled.
120/// It assumes the input matrix is **symmetric** (e.g. covariance or Fisher
121/// information matrices). For robustness the input is symmetrized as
122/// `(A + Aᵀ) / 2` before the iteration begins.
123///
124/// Returns `(eigenvalues, eigenvectors)` where the columns of the
125/// eigenvector matrix are orthonormal. A non-square input is an honest error.
126pub fn eig(matrix: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), String> {
127    let (rows, cols) = matrix.dim();
128    if rows != cols {
129        return Err(format!("eig requires a square matrix, got {rows}x{cols}"));
130    }
131    let (eigenvalues, eigenvectors) = jacobi_symmetric_eig(matrix)?;
132    Ok((eigenvalues, eigenvectors))
133}
134
135/// Real singular value decomposition via the eigendecomposition of `AᵀA`.
136///
137/// Pure-Rust fallback used when the `scirs2` feature is disabled. Computes the
138/// right singular vectors `V` and singular values `σ = sqrt(eig(AᵀA))`
139/// (clamped to be non-negative, sorted descending), then recovers the left
140/// singular vectors via `U[:, i] = A V[:, i] / σ_i`. Columns whose singular
141/// value is numerically zero are filled with an orthonormal completion.
142///
143/// Returns `(U, S, Vt)` with `Vt = Vᵀ`.
144pub fn svd(matrix: &Array2<f64>) -> Result<(Array2<f64>, Array1<f64>, Array2<f64>), String> {
145    let (m, n) = matrix.dim();
146    if m == 0 || n == 0 {
147        return Err("svd requires a non-empty matrix".to_string());
148    }
149
150    // Form A^T A (n x n, symmetric positive semi-definite).
151    let mut ata = Array2::<f64>::zeros((n, n));
152    for i in 0..n {
153        for j in i..n {
154            let mut acc = 0.0;
155            for k in 0..m {
156                acc += matrix[(k, i)] * matrix[(k, j)];
157            }
158            ata[(i, j)] = acc;
159            ata[(j, i)] = acc;
160        }
161    }
162
163    // Eigendecomposition (ascending eigenvalues). Reverse to descending so the
164    // largest singular values come first.
165    let (eigenvalues, eigenvectors) = jacobi_symmetric_eig(&ata)?;
166
167    // Singular values are sqrt of (clamped) eigenvalues, sorted descending.
168    let mut order: Vec<usize> = (0..n).collect();
169    order.sort_by(|&a, &b| eigenvalues[b].total_cmp(&eigenvalues[a]));
170
171    let mut singular_values = Array1::<f64>::zeros(n);
172    let mut v = Array2::<f64>::zeros((n, n));
173    for (new_idx, &old_idx) in order.iter().enumerate() {
174        singular_values[new_idx] = eigenvalues[old_idx].max(0.0).sqrt();
175        for row in 0..n {
176            v[(row, new_idx)] = eigenvectors[(row, old_idx)];
177        }
178    }
179
180    // Left singular vectors: U[:, i] = A V[:, i] / sigma_i.
181    let mut u = Array2::<f64>::zeros((m, m));
182    let k = m.min(n);
183    // Threshold for treating a singular value as zero, relative to the largest.
184    let max_sigma = singular_values.iter().cloned().fold(0.0_f64, f64::max);
185    let tol = max_sigma * (m.max(n) as f64) * f64::EPSILON;
186
187    let mut filled = vec![false; m];
188    for i in 0..k {
189        let sigma = singular_values[i];
190        if sigma > tol {
191            for r in 0..m {
192                let mut acc = 0.0;
193                for c in 0..n {
194                    acc += matrix[(r, c)] * v[(c, i)];
195                }
196                u[(r, i)] = acc / sigma;
197            }
198            filled[i] = true;
199        }
200    }
201
202    // Complete U to an orthonormal basis (for zero / missing columns) using
203    // modified Gram-Schmidt against the already-filled columns.
204    complete_orthonormal_basis(&mut u, &filled);
205
206    let vt = v.t().to_owned();
207    Ok((u, singular_values, vt))
208}
209
210pub fn matrix_norm(matrix: &Array2<f64>) -> f64 {
211    // Frobenius norm
212    matrix.iter().map(|x| x * x).sum::<f64>().sqrt()
213}
214
215/// Cyclic Jacobi eigenvalue algorithm for real symmetric matrices.
216///
217/// Returns `(eigenvalues, eigenvectors)` with eigenvalues sorted in ascending
218/// order and the corresponding orthonormal eigenvectors stored as columns.
219/// The input is defensively symmetrized as `(A + Aᵀ) / 2`.
220fn jacobi_symmetric_eig(matrix: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), String> {
221    let n = matrix.nrows();
222    if n != matrix.ncols() {
223        return Err("jacobi_symmetric_eig requires a square matrix".to_string());
224    }
225    if n == 0 {
226        return Ok((Array1::zeros(0), Array2::zeros((0, 0))));
227    }
228
229    // Defensive symmetrization: a = (A + A^T) / 2.
230    let mut a = Array2::<f64>::zeros((n, n));
231    for i in 0..n {
232        for j in 0..n {
233            a[(i, j)] = 0.5 * (matrix[(i, j)] + matrix[(j, i)]);
234        }
235    }
236
237    let mut eigenvectors = Array2::<f64>::eye(n);
238
239    if n == 1 {
240        return Ok((Array1::from_vec(vec![a[(0, 0)]]), eigenvectors));
241    }
242
243    let max_sweeps = 100;
244    for _ in 0..max_sweeps {
245        // Sum of squares of off-diagonal elements.
246        let mut off = 0.0;
247        for p in 0..n {
248            for q in (p + 1)..n {
249                off += a[(p, q)] * a[(p, q)];
250            }
251        }
252        if off <= f64::EPSILON * f64::EPSILON {
253            break;
254        }
255
256        for p in 0..n {
257            for q in (p + 1)..n {
258                let apq = a[(p, q)];
259                if apq.abs() <= f64::MIN_POSITIVE {
260                    continue;
261                }
262                let app = a[(p, p)];
263                let aqq = a[(q, q)];
264
265                // Compute the Jacobi rotation (cos theta, sin theta).
266                let tau = (aqq - app) / (2.0 * apq);
267                let t = if tau >= 0.0 {
268                    1.0 / (tau + (1.0 + tau * tau).sqrt())
269                } else {
270                    -1.0 / (-tau + (1.0 + tau * tau).sqrt())
271                };
272                let c = 1.0 / (1.0 + t * t).sqrt();
273                let s = t * c;
274
275                // Apply rotation to rows/columns p and q of A.
276                for k in 0..n {
277                    let akp = a[(k, p)];
278                    let akq = a[(k, q)];
279                    a[(k, p)] = c * akp - s * akq;
280                    a[(k, q)] = s * akp + c * akq;
281                }
282                for k in 0..n {
283                    let apk = a[(p, k)];
284                    let aqk = a[(q, k)];
285                    a[(p, k)] = c * apk - s * aqk;
286                    a[(q, k)] = s * apk + c * aqk;
287                }
288
289                // Accumulate the rotation into the eigenvector matrix.
290                for k in 0..n {
291                    let vkp = eigenvectors[(k, p)];
292                    let vkq = eigenvectors[(k, q)];
293                    eigenvectors[(k, p)] = c * vkp - s * vkq;
294                    eigenvectors[(k, q)] = s * vkp + c * vkq;
295                }
296            }
297        }
298    }
299
300    let mut eigenvalues = Array1::<f64>::zeros(n);
301    for i in 0..n {
302        eigenvalues[i] = a[(i, i)];
303    }
304
305    // Sort eigenvalues ascending and reorder eigenvectors accordingly.
306    let mut order: Vec<usize> = (0..n).collect();
307    order.sort_by(|&a_idx, &b_idx| eigenvalues[a_idx].total_cmp(&eigenvalues[b_idx]));
308
309    let mut sorted_values = Array1::<f64>::zeros(n);
310    let mut sorted_vectors = Array2::<f64>::zeros((n, n));
311    for (new_idx, &old_idx) in order.iter().enumerate() {
312        sorted_values[new_idx] = eigenvalues[old_idx];
313        for row in 0..n {
314            sorted_vectors[(row, new_idx)] = eigenvectors[(row, old_idx)];
315        }
316    }
317
318    Ok((sorted_values, sorted_vectors))
319}
320
321/// Fill the unfilled columns of `u` with an orthonormal completion of the
322/// already-filled columns, using modified Gram-Schmidt against the canonical
323/// basis. `filled[i]` indicates that column `i` already holds a unit vector.
324fn complete_orthonormal_basis(u: &mut Array2<f64>, filled: &[bool]) {
325    let m = u.nrows();
326    let ncols = u.ncols();
327
328    // Collect indices of columns that still need to be generated.
329    let mut next_canonical = 0usize;
330    for col in 0..ncols {
331        if col < filled.len() && filled[col] {
332            continue;
333        }
334        // Find a canonical basis vector not yet (numerically) in the span.
335        loop {
336            if next_canonical >= m {
337                // No more canonical directions; leave as zero column.
338                break;
339            }
340            let mut candidate = Array1::<f64>::zeros(m);
341            candidate[next_canonical] = 1.0;
342            next_canonical += 1;
343
344            // Orthogonalize against all previously established columns.
345            for prev in 0..col {
346                let mut dot = 0.0;
347                for r in 0..m {
348                    dot += candidate[r] * u[(r, prev)];
349                }
350                for r in 0..m {
351                    candidate[r] -= dot * u[(r, prev)];
352                }
353            }
354
355            let norm = candidate.iter().map(|x| x * x).sum::<f64>().sqrt();
356            if norm > 1e-12 {
357                for r in 0..m {
358                    u[(r, col)] = candidate[r] / norm;
359                }
360                break;
361            }
362        }
363    }
364}
365
366/// Statistical test results
367#[derive(Debug, Clone)]
368pub struct TTestResult {
369    pub statistic: f64,
370    pub pvalue: f64,
371}
372
373#[derive(Debug, Clone, Copy)]
374pub enum Alternative {
375    TwoSided,
376    Less,
377    Greater,
378}
379
380pub const fn ttest_1samp(data: &[f64], _popmean: f64) -> TTestResult {
381    TTestResult {
382        statistic: 0.0,
383        pvalue: 0.5,
384    }
385}
386
387pub const fn ttest_ind(data1: &[f64], data2: &[f64]) -> TTestResult {
388    TTestResult {
389        statistic: 0.0,
390        pvalue: 0.5,
391    }
392}
393
394pub const fn ks_2samp(data1: &[f64], data2: &[f64]) -> TTestResult {
395    TTestResult {
396        statistic: 0.0,
397        pvalue: 0.5,
398    }
399}
400
401pub const fn shapiro_wilk(data: &[f64]) -> TTestResult {
402    TTestResult {
403        statistic: 0.0,
404        pvalue: 0.5,
405    }
406}
407
408/// Distribution modules
409pub mod distributions {
410    use super::*;
411
412    pub struct Normal {
413        pub mean: f64,
414        pub std: f64,
415    }
416
417    impl Normal {
418        pub const fn new(mean: f64, std: f64) -> Self {
419            Self { mean, std }
420        }
421
422        pub fn pdf(&self, x: f64) -> f64 {
423            let z = (x - self.mean) / self.std;
424            (-0.5 * z * z).exp() / (self.std * (2.0 * std::f64::consts::PI).sqrt())
425        }
426
427        pub fn cdf(&self, x: f64) -> f64 {
428            // Simplified CDF approximation
429            0.5 * (1.0 + ((x - self.mean) / (self.std * 2.0_f64.sqrt())).tanh())
430        }
431    }
432
433    pub const fn norm(mean: f64, std: f64) -> Normal {
434        Normal::new(mean, std)
435    }
436
437    pub const fn gamma(_shape: f64, _scale: f64) -> Normal {
438        Normal::new(1.0, 1.0) // Fallback to normal
439    }
440
441    pub const fn chi2(_df: f64) -> Normal {
442        Normal::new(1.0, 1.0) // Fallback to normal
443    }
444
445    pub const fn beta(_a: f64, _b: f64) -> Normal {
446        Normal::new(0.5, 0.1) // Fallback to normal
447    }
448
449    pub const fn uniform(_low: f64, _high: f64) -> Normal {
450        Normal::new(0.0, 1.0) // Fallback to standard normal
451    }
452}
453
454/// Graph-related fallback functions
455#[derive(Debug, Clone)]
456pub struct Graph<N, E> {
457    nodes: Vec<N>,
458    edges: Vec<(usize, usize, E)>,
459}
460
461impl<N, E> Default for Graph<N, E> {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467impl<N, E> Graph<N, E> {
468    pub const fn new() -> Self {
469        Self {
470            nodes: Vec::new(),
471            edges: Vec::new(),
472        }
473    }
474
475    pub fn add_node(&mut self, node: N) -> usize {
476        self.nodes.push(node);
477        self.nodes.len() - 1
478    }
479
480    pub fn add_edge(&mut self, a: usize, b: usize, edge: E) {
481        self.edges.push((a, b, edge));
482    }
483
484    pub fn nodes(&self) -> impl Iterator<Item = &N> {
485        self.nodes.iter()
486    }
487
488    pub fn node_count(&self) -> usize {
489        self.nodes.len()
490    }
491
492    pub fn edge_count(&self) -> usize {
493        self.edges.len()
494    }
495}
496
497pub const fn shortest_path<N, E>(
498    _graph: &Graph<N, E>,
499    _start: usize,
500    _end: usize,
501) -> Option<Vec<usize>> {
502    None // Fallback - no path found
503}
504
505pub fn betweenness_centrality<N, E>(
506    _graph: &Graph<N, E>,
507    _normalized: bool,
508) -> HashMap<usize, f64> {
509    HashMap::new() // Fallback - empty centrality
510}
511
512pub fn closeness_centrality<N, E>(_graph: &Graph<N, E>, _normalized: bool) -> HashMap<usize, f64> {
513    HashMap::new() // Fallback - empty centrality
514}
515
516pub const fn minimum_spanning_tree<N, E>(_graph: &Graph<N, E>) -> Vec<(usize, usize)> {
517    Vec::new() // Fallback - empty MST
518}
519
520pub const fn strongly_connected_components<N, E>(_graph: &Graph<N, E>) -> Vec<Vec<usize>> {
521    Vec::new() // Fallback - no components
522}
523
524/// Clustering fit result
525#[derive(Debug, Clone)]
526pub struct KMeansResult {
527    pub labels: Vec<usize>,
528    pub centers: Array2<f64>,
529    pub silhouette_score: f64,
530    pub inertia: f64,
531}
532
533/// Basic KMeans clustering fallback implementation (real Lloyd's algorithm).
534#[derive(Debug, Clone)]
535pub struct KMeans {
536    pub n_clusters: usize,
537    /// Centroids learned by the most recent `fit` call (column = feature).
538    fitted_centers: Option<Array2<f64>>,
539}
540
541impl KMeans {
542    pub const fn new(n_clusters: usize) -> Self {
543        Self {
544            n_clusters,
545            fitted_centers: None,
546        }
547    }
548
549    pub fn fit(&mut self, data: &Array2<f64>) -> Result<KMeansResult, String> {
550        let n_points = data.nrows();
551        let n_features = data.ncols();
552
553        if self.n_clusters == 0 {
554            return Err("KMeans requires n_clusters >= 1".to_string());
555        }
556        if n_points == 0 {
557            return Err("KMeans requires a non-empty dataset".to_string());
558        }
559        if n_points < self.n_clusters {
560            return Err(format!(
561                "KMeans requires at least n_clusters ({}) data points, got {}",
562                self.n_clusters, n_points
563            ));
564        }
565
566        // Deterministic k-means++ style initialization seeded from the data.
567        let mut centers = kmeans_plus_plus_init(data, self.n_clusters);
568
569        let mut labels = vec![0usize; n_points];
570        let max_iters = 100;
571
572        for _ in 0..max_iters {
573            // Assignment step: nearest centroid by squared Euclidean distance.
574            let mut changed = false;
575            for p in 0..n_points {
576                let mut best = 0usize;
577                let mut best_dist = f64::INFINITY;
578                for c in 0..self.n_clusters {
579                    let mut dist = 0.0;
580                    for f in 0..n_features {
581                        let diff = data[(p, f)] - centers[(c, f)];
582                        dist += diff * diff;
583                    }
584                    if dist < best_dist {
585                        best_dist = dist;
586                        best = c;
587                    }
588                }
589                if labels[p] != best {
590                    labels[p] = best;
591                    changed = true;
592                }
593            }
594
595            // Update step: recompute centroids as the mean of assigned points.
596            let mut sums = Array2::<f64>::zeros((self.n_clusters, n_features));
597            let mut counts = vec![0usize; self.n_clusters];
598            for p in 0..n_points {
599                let c = labels[p];
600                counts[c] += 1;
601                for f in 0..n_features {
602                    sums[(c, f)] += data[(p, f)];
603                }
604            }
605            for c in 0..self.n_clusters {
606                if counts[c] > 0 {
607                    let inv = 1.0 / counts[c] as f64;
608                    for f in 0..n_features {
609                        centers[(c, f)] = sums[(c, f)] * inv;
610                    }
611                } else {
612                    // Re-seed an empty cluster onto the point farthest from its
613                    // assigned centroid to avoid a degenerate (collapsed) cluster.
614                    if let Some(far) = farthest_point(data, &centers, &labels) {
615                        for f in 0..n_features {
616                            centers[(c, f)] = data[(far, f)];
617                        }
618                        changed = true;
619                    }
620                }
621            }
622
623            if !changed {
624                break;
625            }
626        }
627
628        // Inertia: sum of squared distances of points to their centroid.
629        let mut inertia = 0.0;
630        for p in 0..n_points {
631            let c = labels[p];
632            for f in 0..n_features {
633                let diff = data[(p, f)] - centers[(c, f)];
634                inertia += diff * diff;
635            }
636        }
637
638        let silhouette_score = silhouette(data, &labels, self.n_clusters);
639
640        self.fitted_centers = Some(centers.clone());
641
642        Ok(KMeansResult {
643            labels,
644            centers,
645            silhouette_score,
646            inertia,
647        })
648    }
649
650    pub fn predict(&self, data: &Array2<f64>) -> Result<Array1<usize>, String> {
651        let centers = self.fitted_centers.as_ref().ok_or_else(|| {
652            "KMeans::predict called before fit; no centroids available".to_string()
653        })?;
654        let n_features = centers.ncols();
655        if data.ncols() != n_features {
656            return Err(format!(
657                "KMeans::predict feature mismatch: model has {} features, data has {}",
658                n_features,
659                data.ncols()
660            ));
661        }
662        let n_points = data.nrows();
663        let mut labels = Array1::<usize>::zeros(n_points);
664        for p in 0..n_points {
665            let mut best = 0usize;
666            let mut best_dist = f64::INFINITY;
667            for c in 0..centers.nrows() {
668                let mut dist = 0.0;
669                for f in 0..n_features {
670                    let diff = data[(p, f)] - centers[(c, f)];
671                    dist += diff * diff;
672                }
673                if dist < best_dist {
674                    best_dist = dist;
675                    best = c;
676                }
677            }
678            labels[p] = best;
679        }
680        Ok(labels)
681    }
682
683    pub fn fit_predict(&mut self, data: &Array2<f64>) -> Result<Array1<usize>, String> {
684        let result = self.fit(data)?;
685        Ok(Array1::from_vec(result.labels))
686    }
687}
688
689/// Deterministic k-means++ initialization seeded from the data itself.
690///
691/// The first centroid is the data point closest to the global mean (a
692/// reproducible, data-driven choice). Each subsequent centroid is the point
693/// that maximizes the minimum squared distance to the already-chosen centroids
694/// (the deterministic "farthest-point" variant of k-means++ — no RNG required).
695fn kmeans_plus_plus_init(data: &Array2<f64>, k: usize) -> Array2<f64> {
696    let n_points = data.nrows();
697    let n_features = data.ncols();
698    let mut centers = Array2::<f64>::zeros((k, n_features));
699
700    if n_points == 0 || k == 0 {
701        return centers;
702    }
703
704    // First centroid: the point nearest the global mean.
705    let mut mean = Array1::<f64>::zeros(n_features);
706    for p in 0..n_points {
707        for f in 0..n_features {
708            mean[f] += data[(p, f)];
709        }
710    }
711    for f in 0..n_features {
712        mean[f] /= n_points as f64;
713    }
714
715    let mut first = 0usize;
716    let mut first_dist = f64::INFINITY;
717    for p in 0..n_points {
718        let mut dist = 0.0;
719        for f in 0..n_features {
720            let diff = data[(p, f)] - mean[f];
721            dist += diff * diff;
722        }
723        if dist < first_dist {
724            first_dist = dist;
725            first = p;
726        }
727    }
728    for f in 0..n_features {
729        centers[(0, f)] = data[(first, f)];
730    }
731    let mut chosen = vec![first];
732
733    // Remaining centroids via deterministic farthest-point selection.
734    for c in 1..k {
735        let mut best_point = 0usize;
736        let mut best_min_dist = -1.0;
737        for p in 0..n_points {
738            if chosen.contains(&p) {
739                continue;
740            }
741            let mut min_dist = f64::INFINITY;
742            for &cc in &chosen {
743                let mut dist = 0.0;
744                for f in 0..n_features {
745                    let diff = data[(p, f)] - data[(cc, f)];
746                    dist += diff * diff;
747                }
748                if dist < min_dist {
749                    min_dist = dist;
750                }
751            }
752            if min_dist > best_min_dist {
753                best_min_dist = min_dist;
754                best_point = p;
755            }
756        }
757        for f in 0..n_features {
758            centers[(c, f)] = data[(best_point, f)];
759        }
760        chosen.push(best_point);
761    }
762
763    centers
764}
765
766/// Index of the point with the largest squared distance to its assigned
767/// centroid. Used to re-seed empty clusters during Lloyd iteration.
768fn farthest_point(data: &Array2<f64>, centers: &Array2<f64>, labels: &[usize]) -> Option<usize> {
769    let n_points = data.nrows();
770    let n_features = data.ncols();
771    let mut best = None;
772    let mut best_dist = -1.0;
773    for p in 0..n_points {
774        let c = labels[p];
775        let mut dist = 0.0;
776        for f in 0..n_features {
777            let diff = data[(p, f)] - centers[(c, f)];
778            dist += diff * diff;
779        }
780        if dist > best_dist {
781            best_dist = dist;
782            best = Some(p);
783        }
784    }
785    best
786}
787
788/// Mean silhouette coefficient over all samples (Euclidean distance).
789///
790/// Returns 0.0 when the score is undefined (fewer than two clusters or
791/// singleton clusters), matching the conventional silhouette definition.
792fn silhouette(data: &Array2<f64>, labels: &[usize], n_clusters: usize) -> f64 {
793    let n_points = data.nrows();
794    if n_clusters < 2 || n_points < 2 {
795        return 0.0;
796    }
797    let n_features = data.ncols();
798
799    let dist = |a: usize, b: usize| -> f64 {
800        let mut acc = 0.0;
801        for f in 0..n_features {
802            let diff = data[(a, f)] - data[(b, f)];
803            acc += diff * diff;
804        }
805        acc.sqrt()
806    };
807
808    let mut total = 0.0;
809    for i in 0..n_points {
810        let ci = labels[i];
811
812        // a(i): mean intra-cluster distance.
813        let mut a_sum = 0.0;
814        let mut a_count = 0usize;
815        for j in 0..n_points {
816            if j != i && labels[j] == ci {
817                a_sum += dist(i, j);
818                a_count += 1;
819            }
820        }
821        // Singleton cluster contributes silhouette 0 by convention.
822        if a_count == 0 {
823            continue;
824        }
825        let a_i = a_sum / a_count as f64;
826
827        // b(i): minimum mean distance to any other cluster.
828        let mut b_i = f64::INFINITY;
829        for other in 0..n_clusters {
830            if other == ci {
831                continue;
832            }
833            let mut b_sum = 0.0;
834            let mut b_count = 0usize;
835            for j in 0..n_points {
836                if labels[j] == other {
837                    b_sum += dist(i, j);
838                    b_count += 1;
839                }
840            }
841            if b_count > 0 {
842                let mean_other = b_sum / b_count as f64;
843                if mean_other < b_i {
844                    b_i = mean_other;
845                }
846            }
847        }
848        if b_i.is_finite() {
849            let denom = a_i.max(b_i);
850            if denom > 0.0 {
851                total += (b_i - a_i) / denom;
852            }
853        }
854    }
855
856    total / n_points as f64
857}
858
859/// Other ML algorithm fallbacks
860#[derive(Debug, Clone)]
861pub struct DBSCAN;
862
863impl Default for DBSCAN {
864    fn default() -> Self {
865        Self::new()
866    }
867}
868
869impl DBSCAN {
870    pub const fn new() -> Self {
871        Self
872    }
873    pub fn fit_predict(&mut self, _data: &Array2<f64>) -> Result<Array1<i32>, String> {
874        let n_points = _data.nrows();
875        Ok(Array1::zeros(n_points)) // All points in cluster 0
876    }
877}
878
879#[derive(Debug, Clone)]
880pub struct IsolationForest;
881
882impl Default for IsolationForest {
883    fn default() -> Self {
884        Self::new()
885    }
886}
887
888impl IsolationForest {
889    pub const fn new() -> Self {
890        Self
891    }
892    pub const fn fit(&mut self, _data: &Array2<f64>) -> Result<(), String> {
893        Ok(())
894    }
895    pub fn predict(&self, _data: &Array2<f64>) -> Result<Array1<i32>, String> {
896        let n_points = _data.nrows();
897        Ok(Array1::ones(n_points)) // All points are inliers (1)
898    }
899    pub fn decision_function(&self, _data: &Array2<f64>) -> Result<Array1<f64>, String> {
900        let n_points = _data.nrows();
901        Ok(Array1::ones(n_points) * 0.5) // Neutral anomaly scores
902    }
903}
904
905pub fn train_test_split<T: Clone>(
906    data: &Array2<T>,
907    targets: &Array1<T>,
908    test_size: f64,
909) -> (Array2<T>, Array2<T>, Array1<T>, Array1<T>) {
910    let n = data.nrows();
911    let test_n = (n as f64 * test_size) as usize;
912    let train_n = n - test_n;
913
914    // Simple split without shuffling for fallback
915    let x_train = data
916        .slice(scirs2_core::ndarray::s![0..train_n, ..])
917        .to_owned();
918    let x_test = data
919        .slice(scirs2_core::ndarray::s![train_n.., ..])
920        .to_owned();
921    let y_train = targets
922        .slice(scirs2_core::ndarray::s![0..train_n])
923        .to_owned();
924    let y_test = targets
925        .slice(scirs2_core::ndarray::s![train_n..])
926        .to_owned();
927
928    (x_train, x_test, y_train, y_test)
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use scirs2_core::ndarray::array;
935
936    fn approx(a: f64, b: f64, tol: f64) -> bool {
937        (a - b).abs() <= tol
938    }
939
940    #[test]
941    fn test_eig_diagonal() {
942        // eig of diag([3, 1]) must return eigenvalues {1, 3} (ascending).
943        let m = array![[3.0, 0.0], [0.0, 1.0]];
944        let (vals, vecs) = eig(&m).expect("eig should succeed");
945        assert!(approx(vals[0], 1.0, 1e-9), "got {}", vals[0]);
946        assert!(approx(vals[1], 3.0, 1e-9), "got {}", vals[1]);
947        // Eigenvectors orthonormal: V^T V == I.
948        for i in 0..2 {
949            for j in 0..2 {
950                let mut dot = 0.0;
951                for k in 0..2 {
952                    dot += vecs[(k, i)] * vecs[(k, j)];
953                }
954                let expected = if i == j { 1.0 } else { 0.0 };
955                assert!(approx(dot, expected, 1e-9));
956            }
957        }
958    }
959
960    #[test]
961    fn test_eig_symmetric_reconstruction() {
962        // For symmetric A: A == V diag(lambda) V^T.
963        let a = array![[2.0, 1.0], [1.0, 2.0]];
964        let (vals, vecs) = eig(&a).expect("eig should succeed");
965        // Eigenvalues of [[2,1],[1,2]] are 1 and 3.
966        assert!(approx(vals[0], 1.0, 1e-9));
967        assert!(approx(vals[1], 3.0, 1e-9));
968        for r in 0..2 {
969            for c in 0..2 {
970                let mut recon = 0.0;
971                for k in 0..2 {
972                    recon += vecs[(r, k)] * vals[k] * vecs[(c, k)];
973                }
974                assert!(approx(recon, a[(r, c)], 1e-9));
975            }
976        }
977    }
978
979    #[test]
980    fn test_eig_non_square_errors() {
981        let m = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
982        assert!(eig(&m).is_err());
983    }
984
985    #[test]
986    fn test_svd_reconstruction() {
987        // U diag(S) Vt should reconstruct A.
988        let a = array![[3.0, 1.0], [1.0, 3.0], [0.0, 2.0]];
989        let (u, s, vt) = svd(&a).expect("svd should succeed");
990        let (m, n) = a.dim();
991        for r in 0..m {
992            for c in 0..n {
993                let mut recon = 0.0;
994                for k in 0..n.min(m) {
995                    recon += u[(r, k)] * s[k] * vt[(k, c)];
996                }
997                assert!(
998                    approx(recon, a[(r, c)], 1e-6),
999                    "recon[{r},{c}]={recon} expected {}",
1000                    a[(r, c)]
1001                );
1002            }
1003        }
1004        // Singular values must be non-negative and sorted descending.
1005        for k in 1..s.len() {
1006            assert!(s[k] <= s[k - 1] + 1e-12);
1007            assert!(s[k] >= -1e-12);
1008        }
1009    }
1010
1011    #[test]
1012    fn test_svd_singular_values_known() {
1013        // Diagonal matrix => singular values are |diagonal| sorted descending.
1014        let a = array![[2.0, 0.0], [0.0, 5.0]];
1015        let (_, s, _) = svd(&a).expect("svd should succeed");
1016        assert!(approx(s[0], 5.0, 1e-9));
1017        assert!(approx(s[1], 2.0, 1e-9));
1018    }
1019
1020    #[test]
1021    fn test_kmeans_two_clusters() {
1022        // Two well-separated clusters around (0,0) and (10,10).
1023        let data = array![
1024            [0.0, 0.0],
1025            [0.1, -0.1],
1026            [-0.1, 0.2],
1027            [10.0, 10.0],
1028            [10.1, 9.9],
1029            [9.8, 10.2],
1030        ];
1031        let mut km = KMeans::new(2);
1032        let result = km.fit(&data).expect("kmeans fit should succeed");
1033
1034        // The first three points share a label distinct from the last three.
1035        let l0 = result.labels[0];
1036        assert_eq!(result.labels[1], l0);
1037        assert_eq!(result.labels[2], l0);
1038        let l1 = result.labels[3];
1039        assert_eq!(result.labels[4], l1);
1040        assert_eq!(result.labels[5], l1);
1041        assert_ne!(l0, l1, "clusters must be separated");
1042
1043        // Centers near the true cluster means (0,0) and (10,10).
1044        let mut near_origin = false;
1045        let mut near_ten = false;
1046        for c in 0..2 {
1047            let cx = result.centers[(c, 0)];
1048            let cy = result.centers[(c, 1)];
1049            if approx(cx, 0.0, 0.5) && approx(cy, 0.0, 0.5) {
1050                near_origin = true;
1051            }
1052            if approx(cx, 10.0, 0.5) && approx(cy, 10.0, 0.5) {
1053                near_ten = true;
1054            }
1055        }
1056        assert!(near_origin && near_ten, "centers must match true means");
1057
1058        // Inertia must be small and finite for tight, well-separated clusters.
1059        assert!(result.inertia.is_finite());
1060        assert!(result.inertia < 1.0, "inertia {} too large", result.inertia);
1061
1062        // Well-separated clusters => silhouette close to 1.
1063        assert!(
1064            result.silhouette_score > 0.8,
1065            "silhouette {} too low",
1066            result.silhouette_score
1067        );
1068    }
1069
1070    #[test]
1071    fn test_kmeans_predict_matches_fit() {
1072        let data = array![[0.0, 0.0], [0.2, 0.1], [9.0, 9.0], [9.1, 8.9],];
1073        let mut km = KMeans::new(2);
1074        let fitted = km.fit(&data).expect("fit should succeed");
1075        let predicted = km.predict(&data).expect("predict should succeed");
1076        for i in 0..data.nrows() {
1077            assert_eq!(predicted[i], fitted.labels[i]);
1078        }
1079    }
1080
1081    #[test]
1082    fn test_kmeans_predict_before_fit_errors() {
1083        let km = KMeans::new(2);
1084        let data = array![[0.0, 0.0]];
1085        assert!(km.predict(&data).is_err());
1086    }
1087
1088    #[test]
1089    fn test_kmeans_too_few_points_errors() {
1090        let data = array![[0.0, 0.0]];
1091        let mut km = KMeans::new(3);
1092        assert!(km.fit(&data).is_err());
1093    }
1094}