Skip to main content

sklears_clustering/validation/
validation_types.rs

1//! Core types and validation metrics for clustering evaluation
2//!
3//! This module provides fundamental data structures and enumerations used
4//! throughout the clustering validation framework, including distance metrics,
5//! result structures, and configuration types.
6
7use std::collections::HashMap;
8
9/// Distance metrics available for clustering validation
10///
11/// Different distance metrics can significantly impact validation results,
12/// particularly for high-dimensional data or data with varying scales.
13#[derive(Debug, Clone, Copy, PartialEq, Default)]
14pub enum ValidationMetric {
15    /// Euclidean distance (L2 norm)
16    ///
17    /// Best for: Dense, continuous features with similar scales
18    /// Formula: sqrt(Σ(xi - yi)²)
19    #[default]
20    Euclidean,
21
22    /// Manhattan distance (L1 norm)
23    ///
24    /// Best for: High-dimensional sparse data, categorical data
25    /// Formula: Σ|xi - yi|
26    Manhattan,
27
28    /// Cosine distance
29    ///
30    /// Best for: Text data, high-dimensional data where magnitude is less important
31    /// Formula: 1 - (a·b)/(||a||·||b||)
32    Cosine,
33
34    /// Chebyshev distance (L∞ norm)
35    ///
36    /// Best for: When the maximum difference in any dimension is critical
37    /// Formula: max|xi - yi|
38    Chebyshev,
39
40    /// Minkowski distance with custom p parameter
41    ///
42    /// Generalizes Euclidean (p=2) and Manhattan (p=1) distances
43    /// Formula: (Σ|xi - yi|^p)^(1/p)
44    Minkowski(f64),
45}
46
47impl ValidationMetric {
48    /// Compute distance between two points using the selected metric
49    pub fn compute_distance(&self, a: &[f64], b: &[f64]) -> f64 {
50        if a.len() != b.len() {
51            return f64::NAN;
52        }
53
54        match self {
55            ValidationMetric::Euclidean => a
56                .iter()
57                .zip(b.iter())
58                .map(|(x, y)| (x - y).powi(2))
59                .sum::<f64>()
60                .sqrt(),
61
62            ValidationMetric::Manhattan => a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).sum(),
63
64            ValidationMetric::Cosine => {
65                let dot_product: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
66                let norm_a: f64 = a.iter().map(|x| x.powi(2)).sum::<f64>().sqrt();
67                let norm_b: f64 = b.iter().map(|x| x.powi(2)).sum::<f64>().sqrt();
68
69                if norm_a == 0.0 || norm_b == 0.0 {
70                    1.0
71                } else {
72                    1.0 - (dot_product / (norm_a * norm_b))
73                }
74            }
75
76            ValidationMetric::Chebyshev => a
77                .iter()
78                .zip(b.iter())
79                .map(|(x, y)| (x - y).abs())
80                .fold(0.0, f64::max),
81
82            ValidationMetric::Minkowski(p) => {
83                if *p <= 0.0 {
84                    return f64::NAN;
85                }
86                a.iter()
87                    .zip(b.iter())
88                    .map(|(x, y)| (x - y).abs().powf(*p))
89                    .sum::<f64>()
90                    .powf(1.0 / p)
91            }
92        }
93    }
94
95    /// Get the name of the metric for display purposes
96    pub fn name(&self) -> &str {
97        match self {
98            ValidationMetric::Euclidean => "Euclidean",
99            ValidationMetric::Manhattan => "Manhattan",
100            ValidationMetric::Cosine => "Cosine",
101            ValidationMetric::Chebyshev => "Chebyshev",
102            ValidationMetric::Minkowski(p) => {
103                if *p == 1.0 {
104                    "Minkowski (L1)"
105                } else if *p == 2.0 {
106                    "Minkowski (L2)"
107                } else {
108                    "Minkowski"
109                }
110            }
111        }
112    }
113
114    /// Check if the metric is suitable for high-dimensional data
115    pub fn is_high_dimensional_suitable(&self) -> bool {
116        matches!(self, ValidationMetric::Manhattan | ValidationMetric::Cosine)
117    }
118
119    /// Check if the metric requires feature scaling
120    pub fn requires_scaling(&self) -> bool {
121        matches!(
122            self,
123            ValidationMetric::Euclidean
124                | ValidationMetric::Chebyshev
125                | ValidationMetric::Minkowski(_)
126        )
127    }
128}
129
130/// Result of silhouette analysis
131///
132/// The silhouette analysis provides a measure of how well each sample
133/// fits within its assigned cluster compared to other clusters.
134#[derive(Debug, Clone)]
135pub struct SilhouetteResult {
136    /// Individual silhouette coefficients for each sample
137    ///
138    /// Values range from -1 to 1, where:
139    /// - 1: sample is far from neighboring clusters
140    /// - 0: sample is on or very close to decision boundary
141    /// - -1: sample might have been assigned to wrong cluster
142    pub sample_silhouettes: Vec<f64>,
143
144    /// Mean silhouette coefficient across all samples
145    ///
146    /// General interpretation:
147    /// - 0.7-1.0: Strong cluster structure
148    /// - 0.5-0.7: Reasonable cluster structure
149    /// - 0.25-0.5: Weak cluster structure
150    /// - <0.25: No substantial cluster structure
151    pub mean_silhouette: f64,
152
153    /// Average silhouette coefficient per cluster
154    ///
155    /// Helps identify which clusters are well-formed vs problematic
156    pub cluster_silhouettes: HashMap<i32, f64>,
157
158    /// Number of samples in each cluster
159    pub cluster_sizes: HashMap<i32, usize>,
160
161    /// Confidence intervals for mean silhouette (if computed)
162    pub confidence_interval: Option<(f64, f64)>,
163}
164
165impl SilhouetteResult {
166    /// Get the best performing cluster by silhouette score
167    pub fn best_cluster(&self) -> Option<(i32, f64)> {
168        self.cluster_silhouettes
169            .iter()
170            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
171            .map(|(&id, &score)| (id, score))
172    }
173
174    /// Get the worst performing cluster by silhouette score
175    pub fn worst_cluster(&self) -> Option<(i32, f64)> {
176        self.cluster_silhouettes
177            .iter()
178            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
179            .map(|(&id, &score)| (id, score))
180    }
181
182    /// Get problematic samples (silhouette < threshold)
183    pub fn problematic_samples(&self, threshold: f64) -> Vec<usize> {
184        self.sample_silhouettes
185            .iter()
186            .enumerate()
187            .filter(|(_, &score)| score < threshold)
188            .map(|(idx, _)| idx)
189            .collect()
190    }
191
192    /// Calculate quality assessment based on silhouette scores
193    pub fn quality_assessment(&self) -> SilhouetteQuality {
194        if self.mean_silhouette >= 0.7 {
195            SilhouetteQuality::Excellent
196        } else if self.mean_silhouette >= 0.5 {
197            SilhouetteQuality::Good
198        } else if self.mean_silhouette >= 0.25 {
199            SilhouetteQuality::Fair
200        } else {
201            SilhouetteQuality::Poor
202        }
203    }
204}
205
206/// Quality assessment based on silhouette scores
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum SilhouetteQuality {
209    /// Excellent cluster structure (silhouette >= 0.7)
210    Excellent,
211    /// Good cluster structure (silhouette >= 0.5)
212    Good,
213    /// Fair cluster structure (silhouette >= 0.25)
214    Fair,
215    /// Poor cluster structure (silhouette < 0.25)
216    Poor,
217}
218
219impl std::fmt::Display for SilhouetteQuality {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        match self {
222            SilhouetteQuality::Excellent => write!(f, "Excellent"),
223            SilhouetteQuality::Good => write!(f, "Good"),
224            SilhouetteQuality::Fair => write!(f, "Fair"),
225            SilhouetteQuality::Poor => write!(f, "Poor"),
226        }
227    }
228}
229
230/// Gap statistic result for optimal cluster number selection
231///
232/// The gap statistic compares the within-cluster sum of squares
233/// for different numbers of clusters against a null reference distribution.
234#[derive(Debug, Clone)]
235pub struct GapStatisticResult {
236    /// Gap values for each tested k
237    ///
238    /// Higher gap values indicate better clustering
239    pub gap_values: Vec<f64>,
240
241    /// Standard errors for gap values
242    ///
243    /// Used to determine statistical significance of differences
244    pub gap_std_errors: Vec<f64>,
245
246    /// Optimal number of clusters according to gap statistic
247    ///
248    /// Determined using the "one standard error" rule:
249    /// Choose the smallest k such that Gap(k) >= Gap(k+1) - se(k+1)
250    pub optimal_k: usize,
251
252    /// K values that were tested
253    pub k_values: Vec<usize>,
254
255    /// Within-cluster sum of squares for each k
256    pub within_cluster_ss: Vec<f64>,
257
258    /// Reference distribution statistics
259    pub reference_statistics: Vec<ReferenceStatistics>,
260
261    /// Number of reference datasets used per k
262    pub n_references: usize,
263}
264
265impl GapStatisticResult {
266    /// Get the gap value for a specific k
267    pub fn gap_for_k(&self, k: usize) -> Option<f64> {
268        self.k_values
269            .iter()
270            .position(|&x| x == k)
271            .map(|idx| self.gap_values[idx])
272    }
273
274    /// Get the recommended k values (top candidates)
275    pub fn recommended_k_values(&self, top_n: usize) -> Vec<(usize, f64)> {
276        let mut k_gap_pairs: Vec<_> = self
277            .k_values
278            .iter()
279            .zip(self.gap_values.iter())
280            .map(|(&k, &gap)| (k, gap))
281            .collect();
282
283        k_gap_pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
284        k_gap_pairs.truncate(top_n);
285        k_gap_pairs
286    }
287
288    /// Check if the optimal k is statistically significant
289    pub fn is_optimal_significant(&self) -> bool {
290        if let Some(optimal_idx) = self.k_values.iter().position(|&k| k == self.optimal_k) {
291            if optimal_idx + 1 < self.gap_values.len() {
292                let gap_k = self.gap_values[optimal_idx];
293                let gap_k_plus_1 = self.gap_values[optimal_idx + 1];
294                let se_k_plus_1 = self.gap_std_errors[optimal_idx + 1];
295
296                return gap_k >= gap_k_plus_1 - se_k_plus_1;
297            }
298        }
299        false
300    }
301}
302
303/// Reference distribution statistics for gap statistic
304#[derive(Debug, Clone)]
305pub struct ReferenceStatistics {
306    /// K value
307    pub k: usize,
308    /// Mean log(W_k) across reference datasets
309    pub mean_log_w: f64,
310    /// Standard deviation of log(W_k) across reference datasets
311    pub std_log_w: f64,
312    /// Individual log(W_k) values from reference datasets
313    pub reference_log_w_values: Vec<f64>,
314}
315
316/// Comprehensive clustering validation metrics
317///
318/// Combines multiple internal validation measures to provide
319/// a holistic view of clustering quality.
320#[derive(Debug, Clone)]
321pub struct ValidationMetrics {
322    /// Silhouette analysis results
323    pub silhouette: SilhouetteResult,
324
325    /// Calinski-Harabasz Index (Variance Ratio Criterion)
326    ///
327    /// Higher values indicate better defined clusters
328    /// Formula: (SSB/(k-1)) / (SSW/(n-k))
329    /// where SSB = between-cluster sum of squares, SSW = within-cluster sum of squares
330    pub calinski_harabasz: f64,
331
332    /// Davies-Bouldin Index
333    ///
334    /// Lower values indicate better clustering
335    /// Formula: (1/k) * Σ max((σi + σj) / d(ci, cj))
336    /// where σi = avg distance to centroid, d(ci, cj) = centroid distance
337    pub davies_bouldin: f64,
338
339    /// Inertia (within-cluster sum of squared distances to centroids)
340    ///
341    /// Lower values indicate tighter clusters
342    pub inertia: f64,
343
344    /// Dunn Index (ratio of minimum inter-cluster distance to maximum intra-cluster distance)
345    ///
346    /// Higher values indicate better separation and compactness
347    pub dunn_index: Option<f64>,
348
349    /// Silhouette width variance
350    ///
351    /// Lower variance indicates more consistent cluster quality
352    pub silhouette_variance: f64,
353
354    /// Number of clusters
355    pub n_clusters: usize,
356
357    /// Number of samples
358    pub n_samples: usize,
359
360    /// Distance metric used
361    pub metric_used: ValidationMetric,
362}
363
364impl ValidationMetrics {
365    /// Create a summary score combining multiple metrics
366    ///
367    /// Returns a score between 0 and 1, where higher is better
368    pub fn composite_score(&self) -> f64 {
369        // Normalize individual metrics to [0, 1] scale
370        let silhouette_norm = (self.silhouette.mean_silhouette + 1.0) / 2.0; // [-1, 1] -> [0, 1]
371        let ch_norm = (self.calinski_harabasz / (1.0 + self.calinski_harabasz)).min(1.0);
372        let db_norm = 1.0 / (1.0 + self.davies_bouldin); // Lower is better, so invert
373        let dunn_norm = self.dunn_index.unwrap_or(0.0).min(1.0);
374
375        // Weighted combination
376        let weights = (0.4, 0.3, 0.2, 0.1); // (silhouette, CH, DB, Dunn)
377        weights.0 * silhouette_norm
378            + weights.1 * ch_norm
379            + weights.2 * db_norm
380            + weights.3 * dunn_norm
381    }
382
383    /// Get overall quality assessment
384    pub fn overall_quality(&self) -> ClusterQuality {
385        let score = self.composite_score();
386        if score >= 0.8 {
387            ClusterQuality::Excellent
388        } else if score >= 0.6 {
389            ClusterQuality::Good
390        } else if score >= 0.4 {
391            ClusterQuality::Fair
392        } else {
393            ClusterQuality::Poor
394        }
395    }
396
397    /// Generate quality summary text
398    pub fn quality_summary(&self) -> String {
399        format!(
400            "Clustering Quality Summary:\n\
401             - {} clusters, {} samples\n\
402             - Silhouette score: {:.3} ({})\n\
403             - Calinski-Harabasz: {:.2}\n\
404             - Davies-Bouldin: {:.3}\n\
405             - Composite score: {:.3}\n\
406             - Overall quality: {:?}",
407            self.n_clusters,
408            self.n_samples,
409            self.silhouette.mean_silhouette,
410            self.silhouette.quality_assessment(),
411            self.calinski_harabasz,
412            self.davies_bouldin,
413            self.composite_score(),
414            self.overall_quality()
415        )
416    }
417}
418
419/// External validation metrics when ground truth labels are available
420///
421/// These metrics compare clustering results against known true cluster assignments.
422#[derive(Debug, Clone, Default)]
423pub struct ExternalValidationMetrics {
424    /// Adjusted Rand Index (ARI)
425    ///
426    /// Measures similarity between two clusterings, adjusted for chance
427    /// Range: [-1, 1], where 1 = perfect agreement, 0 = random agreement
428    pub adjusted_rand_index: f64,
429
430    /// Normalized Mutual Information (NMI)
431    ///
432    /// Measures mutual dependence between cluster assignments
433    /// Range: [0, 1], where 1 = perfect agreement, 0 = independent
434    pub normalized_mutual_info: f64,
435
436    /// V-measure (harmonic mean of homogeneity and completeness)
437    ///
438    /// Balanced measure ensuring both criteria are satisfied
439    /// Range: [0, 1], where 1 = perfect clustering
440    pub v_measure: f64,
441
442    /// Homogeneity score
443    ///
444    /// Whether each cluster contains only members of a single class
445    /// Range: [0, 1], where 1 = perfectly homogeneous
446    pub homogeneity: f64,
447
448    /// Completeness score
449    ///
450    /// Whether all members of a class are assigned to the same cluster
451    /// Range: [0, 1], where 1 = perfectly complete
452    pub completeness: f64,
453
454    /// Fowlkes-Mallows Index (FM)
455    ///
456    /// Geometric mean of precision and recall
457    /// Range: [0, 1], where 1 = perfect clustering
458    pub fowlkes_mallows: f64,
459
460    /// Jaccard Index
461    ///
462    /// Measures similarity as intersection over union of pairs
463    /// Range: [0, 1], where 1 = identical clusterings
464    pub jaccard_index: Option<f64>,
465
466    /// Purity score
467    ///
468    /// Fraction of samples correctly clustered
469    /// Range: [0, 1], where 1 = perfect clustering
470    pub purity: Option<f64>,
471
472    /// Inverse purity (coverage)
473    ///
474    /// Measures how well each true class is represented by clusters
475    pub inverse_purity: Option<f64>,
476}
477
478impl ExternalValidationMetrics {
479    /// Create a consensus score from multiple external metrics
480    pub fn consensus_score(&self) -> f64 {
481        let scores = [
482            self.adjusted_rand_index.max(0.0), // Ensure non-negative for averaging
483            self.normalized_mutual_info,
484            self.v_measure,
485            self.fowlkes_mallows,
486        ];
487
488        scores.iter().sum::<f64>() / scores.len() as f64
489    }
490
491    /// Check if clustering significantly matches ground truth
492    pub fn is_significant_match(&self, threshold: f64) -> bool {
493        self.consensus_score() >= threshold
494    }
495
496    /// Get the best performing metric
497    pub fn best_metric(&self) -> (&str, f64) {
498        let metrics = vec![
499            ("ARI", self.adjusted_rand_index.max(0.0)),
500            ("NMI", self.normalized_mutual_info),
501            ("V-measure", self.v_measure),
502            ("Fowlkes-Mallows", self.fowlkes_mallows),
503        ];
504
505        metrics
506            .into_iter()
507            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
508            .unwrap_or(("None", 0.0))
509    }
510}
511
512/// Overall clustering quality assessment
513#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum ClusterQuality {
515    /// Excellent clustering quality (composite score >= 0.8)
516    Excellent,
517    /// Good clustering quality (composite score >= 0.6)
518    Good,
519    /// Fair clustering quality (composite score >= 0.4)
520    Fair,
521    /// Poor clustering quality (composite score < 0.4)
522    Poor,
523}
524
525impl ClusterQuality {
526    /// Get a human-readable description
527    pub fn description(&self) -> &str {
528        match self {
529            ClusterQuality::Excellent => "Excellent: Strong, well-separated clusters",
530            ClusterQuality::Good => "Good: Clear cluster structure with minor issues",
531            ClusterQuality::Fair => "Fair: Some cluster structure, but improvements needed",
532            ClusterQuality::Poor => "Poor: Weak or no discernible cluster structure",
533        }
534    }
535
536    /// Get a numeric score representation
537    pub fn score(&self) -> f64 {
538        match self {
539            ClusterQuality::Excellent => 0.9,
540            ClusterQuality::Good => 0.7,
541            ClusterQuality::Fair => 0.5,
542            ClusterQuality::Poor => 0.2,
543        }
544    }
545}
546
547/// Configuration for validation computations
548#[derive(Debug, Clone)]
549pub struct ValidationConfig {
550    /// Distance metric to use
551    pub metric: ValidationMetric,
552
553    /// Whether to compute confidence intervals
554    pub compute_confidence_intervals: bool,
555
556    /// Confidence level for intervals (e.g., 0.95 for 95%)
557    pub confidence_level: f64,
558
559    /// Whether to compute optional expensive metrics (e.g., Dunn index)
560    pub compute_expensive_metrics: bool,
561
562    /// Random seed for reproducible results
563    pub random_seed: Option<u64>,
564
565    /// Number of bootstrap samples for confidence intervals
566    pub n_bootstrap_samples: usize,
567
568    /// Whether to use parallel computation where possible
569    pub use_parallel: bool,
570
571    /// Threshold for considering samples as problematic
572    pub problematic_threshold: f64,
573}
574
575impl Default for ValidationConfig {
576    fn default() -> Self {
577        Self {
578            metric: ValidationMetric::Euclidean,
579            compute_confidence_intervals: false,
580            confidence_level: 0.95,
581            compute_expensive_metrics: false,
582            random_seed: None,
583            n_bootstrap_samples: 1000,
584            use_parallel: true,
585            problematic_threshold: 0.0,
586        }
587    }
588}
589
590impl ValidationConfig {
591    /// Create a fast configuration for quick validation
592    pub fn fast() -> Self {
593        Self {
594            compute_confidence_intervals: false,
595            compute_expensive_metrics: false,
596            n_bootstrap_samples: 100,
597            ..Default::default()
598        }
599    }
600
601    /// Create a comprehensive configuration for thorough analysis
602    pub fn comprehensive() -> Self {
603        Self {
604            compute_confidence_intervals: true,
605            compute_expensive_metrics: true,
606            n_bootstrap_samples: 2000,
607            ..Default::default()
608        }
609    }
610
611    /// Create a configuration optimized for high-dimensional data
612    pub fn high_dimensional() -> Self {
613        Self {
614            metric: ValidationMetric::Cosine,
615            compute_expensive_metrics: false, // Dunn index is expensive in high dimensions
616            ..Default::default()
617        }
618    }
619}
620
621#[allow(non_snake_case)]
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    #[test]
627    fn test_validation_metric_distances() {
628        let a = vec![1.0, 2.0, 3.0];
629        let b = vec![4.0, 5.0, 6.0];
630
631        // Test Euclidean distance
632        let euclidean = ValidationMetric::Euclidean;
633        let dist = euclidean.compute_distance(&a, &b);
634        assert!((dist - 5.196152422706632).abs() < 1e-10);
635
636        // Test Manhattan distance
637        let manhattan = ValidationMetric::Manhattan;
638        let dist = manhattan.compute_distance(&a, &b);
639        assert!((dist - 9.0).abs() < 1e-10);
640
641        // Test Cosine distance
642        let cosine = ValidationMetric::Cosine;
643        let dist = cosine.compute_distance(&a, &b);
644        assert!((0.0..=2.0).contains(&dist));
645    }
646
647    #[test]
648    fn test_validation_metric_properties() {
649        let euclidean = ValidationMetric::Euclidean;
650        assert_eq!(euclidean.name(), "Euclidean");
651        assert!(!euclidean.is_high_dimensional_suitable());
652        assert!(euclidean.requires_scaling());
653
654        let cosine = ValidationMetric::Cosine;
655        assert_eq!(cosine.name(), "Cosine");
656        assert!(cosine.is_high_dimensional_suitable());
657        assert!(!cosine.requires_scaling());
658    }
659
660    #[test]
661    fn test_silhouette_result_methods() {
662        let sample_silhouettes = vec![0.8, 0.7, 0.9, 0.3, 0.6];
663        let mut cluster_silhouettes = HashMap::new();
664        cluster_silhouettes.insert(0, 0.8);
665        cluster_silhouettes.insert(1, 0.5);
666
667        let result = SilhouetteResult {
668            sample_silhouettes: sample_silhouettes.clone(),
669            mean_silhouette: 0.66,
670            cluster_silhouettes,
671            cluster_sizes: HashMap::new(),
672            confidence_interval: None,
673        };
674
675        assert_eq!(result.quality_assessment(), SilhouetteQuality::Good);
676        assert_eq!(result.best_cluster(), Some((0, 0.8)));
677        assert_eq!(result.worst_cluster(), Some((1, 0.5)));
678
679        let problematic = result.problematic_samples(0.4);
680        assert_eq!(problematic, vec![3]);
681    }
682
683    #[test]
684    fn test_cluster_quality_methods() {
685        let excellent = ClusterQuality::Excellent;
686        assert_eq!(excellent.score(), 0.9);
687        assert!(excellent.description().contains("Excellent"));
688
689        let poor = ClusterQuality::Poor;
690        assert_eq!(poor.score(), 0.2);
691        assert!(poor.description().contains("Poor"));
692    }
693
694    #[test]
695    fn test_validation_config_presets() {
696        let fast = ValidationConfig::fast();
697        assert!(!fast.compute_confidence_intervals);
698        assert_eq!(fast.n_bootstrap_samples, 100);
699
700        let comprehensive = ValidationConfig::comprehensive();
701        assert!(comprehensive.compute_confidence_intervals);
702        assert_eq!(comprehensive.n_bootstrap_samples, 2000);
703
704        let high_dim = ValidationConfig::high_dimensional();
705        assert_eq!(high_dim.metric, ValidationMetric::Cosine);
706        assert!(!high_dim.compute_expensive_metrics);
707    }
708
709    #[test]
710    fn test_minkowski_distance() {
711        let a = vec![1.0, 2.0];
712        let b = vec![3.0, 4.0];
713
714        // Minkowski with p=1 should equal Manhattan
715        let minkowski_1 = ValidationMetric::Minkowski(1.0);
716        let manhattan = ValidationMetric::Manhattan;
717
718        let dist_m1 = minkowski_1.compute_distance(&a, &b);
719        let dist_man = manhattan.compute_distance(&a, &b);
720        assert!((dist_m1 - dist_man).abs() < 1e-10);
721
722        // Minkowski with p=2 should equal Euclidean
723        let minkowski_2 = ValidationMetric::Minkowski(2.0);
724        let euclidean = ValidationMetric::Euclidean;
725
726        let dist_m2 = minkowski_2.compute_distance(&a, &b);
727        let dist_euc = euclidean.compute_distance(&a, &b);
728        assert!((dist_m2 - dist_euc).abs() < 1e-10);
729    }
730
731    #[test]
732    fn test_gap_statistic_result_methods() {
733        let result = GapStatisticResult {
734            gap_values: vec![0.5, 0.8, 0.6, 0.4],
735            gap_std_errors: vec![0.1, 0.15, 0.12, 0.08],
736            optimal_k: 2,
737            k_values: vec![1, 2, 3, 4],
738            within_cluster_ss: vec![10.0, 5.0, 7.0, 8.0],
739            reference_statistics: Vec::new(),
740            n_references: 10,
741        };
742
743        assert_eq!(result.gap_for_k(2), Some(0.8));
744        assert_eq!(result.gap_for_k(5), None);
745
746        let recommended = result.recommended_k_values(2);
747        assert_eq!(recommended.len(), 2);
748        assert_eq!(recommended[0], (2, 0.8)); // Highest gap
749    }
750
751    #[test]
752    fn test_external_validation_metrics() {
753        let metrics = ExternalValidationMetrics {
754            adjusted_rand_index: 0.8,
755            normalized_mutual_info: 0.75,
756            v_measure: 0.78,
757            homogeneity: 0.8,
758            completeness: 0.76,
759            fowlkes_mallows: 0.82,
760            jaccard_index: Some(0.7),
761            purity: Some(0.85),
762            inverse_purity: Some(0.8),
763        };
764
765        let consensus = metrics.consensus_score();
766        assert!(consensus > 0.7 && consensus < 0.9);
767
768        assert!(metrics.is_significant_match(0.7));
769        assert!(!metrics.is_significant_match(0.9));
770
771        let (best_name, _) = metrics.best_metric();
772        assert_eq!(best_name, "Fowlkes-Mallows");
773    }
774}