Skip to main content

oxirs_embed/application_tasks/
clustering.rs

1//! Clustering evaluation module
2//!
3//! This module provides comprehensive evaluation for clustering algorithms using
4//! embedding models, including silhouette score, inertia, and other clustering
5//! quality metrics.
6
7use super::ApplicationEvalConfig;
8use crate::EmbeddingModel;
9use anyhow::{anyhow, Result};
10use scirs2_core::ndarray_ext::Array2;
11#[allow(unused_imports)]
12use scirs2_core::random::{Random, RngExt};
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15
16/// Clustering evaluation metrics
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub enum ClusteringMetric {
19    /// Silhouette score
20    SilhouetteScore,
21    /// Calinski-Harabasz index
22    CalinskiHarabaszIndex,
23    /// Davies-Bouldin index
24    DaviesBouldinIndex,
25    /// Adjusted Rand Index (requires ground truth)
26    AdjustedRandIndex,
27    /// Normalized Mutual Information (requires ground truth)
28    NormalizedMutualInformation,
29    /// Clustering purity (requires ground truth)
30    Purity,
31    /// Inertia (within-cluster sum of squares)
32    Inertia,
33}
34
35/// Cluster quality analysis
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ClusterAnalysis {
38    /// Number of clusters
39    pub num_clusters: usize,
40    /// Cluster sizes
41    pub cluster_sizes: Vec<usize>,
42    /// Cluster cohesion scores
43    pub cluster_cohesion: Vec<f64>,
44    /// Cluster separation scores
45    pub cluster_separation: Vec<f64>,
46    /// Inter-cluster distances
47    pub inter_cluster_distances: Array2<f64>,
48}
49
50/// Clustering stability analysis
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ClusteringStabilityAnalysis {
53    /// Stability score across multiple runs
54    pub stability_score: f64,
55    /// Consistency of cluster assignments
56    pub assignment_consistency: f64,
57    /// Robustness to parameter changes
58    pub parameter_robustness: f64,
59}
60
61/// Clustering evaluation results
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ClusteringResults {
64    /// Metric scores
65    pub metric_scores: HashMap<String, f64>,
66    /// Cluster quality analysis
67    pub cluster_analysis: ClusterAnalysis,
68    /// Optimal number of clusters (if determined)
69    pub optimal_k: Option<usize>,
70    /// Clustering stability analysis
71    pub stability_analysis: ClusteringStabilityAnalysis,
72}
73
74/// Clustering evaluator
75pub struct ClusteringEvaluator {
76    /// Ground truth clusters (if available)
77    ground_truth_clusters: Option<HashMap<String, String>>,
78    /// Clustering metrics
79    metrics: Vec<ClusteringMetric>,
80}
81
82impl ClusteringEvaluator {
83    /// Create a new clustering evaluator
84    pub fn new() -> Self {
85        Self {
86            ground_truth_clusters: None,
87            metrics: vec![
88                ClusteringMetric::SilhouetteScore,
89                ClusteringMetric::CalinskiHarabaszIndex,
90                ClusteringMetric::DaviesBouldinIndex,
91                ClusteringMetric::Inertia,
92            ],
93        }
94    }
95
96    /// Set ground truth clusters
97    pub fn set_ground_truth(&mut self, clusters: HashMap<String, String>) {
98        self.ground_truth_clusters = Some(clusters);
99
100        // Add supervised metrics
101        self.metrics.extend(vec![
102            ClusteringMetric::AdjustedRandIndex,
103            ClusteringMetric::NormalizedMutualInformation,
104            ClusteringMetric::Purity,
105        ]);
106    }
107
108    /// Evaluate clustering performance
109    pub async fn evaluate(
110        &self,
111        model: &dyn EmbeddingModel,
112        config: &ApplicationEvalConfig,
113    ) -> Result<ClusteringResults> {
114        // Get entity embeddings
115        let entities = model.get_entities();
116        let sample_entities: Vec<_> = entities.into_iter().take(config.sample_size).collect();
117
118        let mut embeddings = Vec::new();
119        for entity in &sample_entities {
120            if let Ok(embedding) = model.get_entity_embedding(entity) {
121                embeddings.push(embedding.values);
122            }
123        }
124
125        if embeddings.is_empty() {
126            return Err(anyhow!("No embeddings available for clustering evaluation"));
127        }
128
129        // Perform clustering
130        let cluster_assignments = self.perform_clustering(&embeddings, config.num_clusters)?;
131
132        // Calculate metrics
133        let mut metric_scores = HashMap::new();
134        for metric in &self.metrics {
135            let score = self.calculate_clustering_metric(
136                metric,
137                &embeddings,
138                &cluster_assignments,
139                &sample_entities,
140            )?;
141            metric_scores.insert(format!("{metric:?}"), score);
142        }
143
144        // Analyze clusters
145        let cluster_analysis = self.analyze_clusters(&embeddings, &cluster_assignments)?;
146
147        // Analyze stability
148        let stability_analysis = self.analyze_stability(&embeddings, config)?;
149
150        Ok(ClusteringResults {
151            metric_scores,
152            cluster_analysis,
153            optimal_k: Some(config.num_clusters), // Simplified
154            stability_analysis,
155        })
156    }
157
158    /// Perform K-means clustering
159    fn perform_clustering(&self, embeddings: &[Vec<f32>], k: usize) -> Result<Vec<usize>> {
160        if embeddings.is_empty() || k == 0 {
161            return Ok(Vec::new());
162        }
163
164        let n = embeddings.len();
165        let dim = embeddings[0].len();
166
167        // Initialize centroids randomly
168        let mut centroids = Vec::new();
169        let mut rng = Random::default();
170        for _ in 0..k {
171            let idx = rng.random_range(0..n);
172            centroids.push(embeddings[idx].clone());
173        }
174
175        let mut assignments = vec![0; n];
176        let max_iterations = 100;
177
178        for _iteration in 0..max_iterations {
179            let mut new_assignments = vec![0; n];
180            let mut changed = false;
181
182            // Assign points to nearest centroid
183            for (i, embedding) in embeddings.iter().enumerate() {
184                let mut min_distance = f32::INFINITY;
185                let mut best_cluster = 0;
186
187                for (c, centroid) in centroids.iter().enumerate() {
188                    let distance = self.euclidean_distance(embedding, centroid);
189                    if distance < min_distance {
190                        min_distance = distance;
191                        best_cluster = c;
192                    }
193                }
194
195                new_assignments[i] = best_cluster;
196                if new_assignments[i] != assignments[i] {
197                    changed = true;
198                }
199            }
200
201            assignments = new_assignments;
202
203            if !changed {
204                break;
205            }
206
207            // Update centroids
208            for (c, centroid) in centroids.iter_mut().enumerate().take(k) {
209                let cluster_points: Vec<_> = embeddings
210                    .iter()
211                    .enumerate()
212                    .filter(|(i, _)| assignments[*i] == c)
213                    .map(|(_, emb)| emb)
214                    .collect();
215
216                if !cluster_points.is_empty() {
217                    let mut new_centroid = vec![0.0f32; dim];
218                    for point in &cluster_points {
219                        for (i, &value) in point.iter().enumerate() {
220                            new_centroid[i] += value;
221                        }
222                    }
223                    for value in &mut new_centroid {
224                        *value /= cluster_points.len() as f32;
225                    }
226                    *centroid = new_centroid;
227                }
228            }
229        }
230
231        Ok(assignments)
232    }
233
234    /// Calculate clustering metric
235    fn calculate_clustering_metric(
236        &self,
237        metric: &ClusteringMetric,
238        embeddings: &[Vec<f32>],
239        assignments: &[usize],
240        entities: &[String],
241    ) -> Result<f64> {
242        match metric {
243            ClusteringMetric::SilhouetteScore => {
244                self.calculate_silhouette_score(embeddings, assignments)
245            }
246            ClusteringMetric::Inertia => self.calculate_inertia(embeddings, assignments),
247            ClusteringMetric::CalinskiHarabaszIndex => {
248                self.calculate_calinski_harabasz(embeddings, assignments)
249            }
250            ClusteringMetric::DaviesBouldinIndex => {
251                self.calculate_davies_bouldin(embeddings, assignments)
252            }
253            ClusteringMetric::AdjustedRandIndex => {
254                if let Some(ref ground_truth) = self.ground_truth_clusters {
255                    self.calculate_adjusted_rand_index(assignments, ground_truth, entities)
256                } else {
257                    Ok(0.0)
258                }
259            }
260            ClusteringMetric::NormalizedMutualInformation => {
261                if let Some(ref ground_truth) = self.ground_truth_clusters {
262                    self.calculate_nmi(assignments, ground_truth, entities)
263                } else {
264                    // Consistent with AdjustedRandIndex above: without ground
265                    // truth there is nothing to compare against.
266                    Ok(0.0)
267                }
268            }
269            ClusteringMetric::Purity => {
270                if let Some(ref ground_truth) = self.ground_truth_clusters {
271                    self.calculate_purity(assignments, ground_truth, entities)
272                } else {
273                    Ok(0.0)
274                }
275            }
276        }
277    }
278
279    /// Calculate silhouette score
280    fn calculate_silhouette_score(
281        &self,
282        embeddings: &[Vec<f32>],
283        assignments: &[usize],
284    ) -> Result<f64> {
285        if embeddings.len() != assignments.len() || embeddings.is_empty() {
286            return Ok(0.0);
287        }
288
289        let mut silhouette_scores = Vec::new();
290
291        for (i, embedding) in embeddings.iter().enumerate() {
292            let own_cluster = assignments[i];
293
294            // Calculate average intra-cluster distance
295            let same_cluster_points: Vec<_> = embeddings
296                .iter()
297                .enumerate()
298                .filter(|(j, _)| *j != i && assignments[*j] == own_cluster)
299                .map(|(_, emb)| emb)
300                .collect();
301
302            let a = if same_cluster_points.is_empty() {
303                0.0
304            } else {
305                same_cluster_points
306                    .iter()
307                    .map(|other| self.euclidean_distance(embedding, other) as f64)
308                    .sum::<f64>()
309                    / same_cluster_points.len() as f64
310            };
311
312            // Calculate average nearest-cluster distance
313            let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
314            let mut min_b = f64::INFINITY;
315
316            for &cluster in &unique_clusters {
317                if cluster != own_cluster {
318                    let other_cluster_points: Vec<_> = embeddings
319                        .iter()
320                        .enumerate()
321                        .filter(|(j, _)| assignments[*j] == cluster)
322                        .map(|(_, emb)| emb)
323                        .collect();
324
325                    if !other_cluster_points.is_empty() {
326                        let avg_distance = other_cluster_points
327                            .iter()
328                            .map(|other| self.euclidean_distance(embedding, other) as f64)
329                            .sum::<f64>()
330                            / other_cluster_points.len() as f64;
331
332                        min_b = min_b.min(avg_distance);
333                    }
334                }
335            }
336
337            let b = min_b;
338
339            // Calculate silhouette score for this point
340            let silhouette = if a < b {
341                (b - a) / b
342            } else if a > b {
343                (b - a) / a
344            } else {
345                0.0
346            };
347
348            silhouette_scores.push(silhouette);
349        }
350
351        Ok(silhouette_scores.iter().sum::<f64>() / silhouette_scores.len() as f64)
352    }
353
354    /// Calculate inertia (within-cluster sum of squares)
355    fn calculate_inertia(&self, embeddings: &[Vec<f32>], assignments: &[usize]) -> Result<f64> {
356        let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
357        let mut total_inertia = 0.0;
358
359        for &cluster in &unique_clusters {
360            let cluster_points: Vec<_> = embeddings
361                .iter()
362                .enumerate()
363                .filter(|(i, _)| assignments[*i] == cluster)
364                .map(|(_, emb)| emb)
365                .collect();
366
367            if cluster_points.is_empty() {
368                continue;
369            }
370
371            // Calculate centroid
372            let dim = cluster_points[0].len();
373            let mut centroid = vec![0.0f32; dim];
374            for point in &cluster_points {
375                for (i, &value) in point.iter().enumerate() {
376                    centroid[i] += value;
377                }
378            }
379            for value in &mut centroid {
380                *value /= cluster_points.len() as f32;
381            }
382
383            // Calculate sum of squared distances to centroid
384            for point in &cluster_points {
385                let distance = self.euclidean_distance(point, &centroid);
386                total_inertia += (distance * distance) as f64;
387            }
388        }
389
390        Ok(total_inertia)
391    }
392
393    /// Calculate Calinski-Harabasz index (simplified)
394    fn calculate_calinski_harabasz(
395        &self,
396        embeddings: &[Vec<f32>],
397        assignments: &[usize],
398    ) -> Result<f64> {
399        // Simplified implementation
400        Ok(embeddings.len() as f64 * assignments.len() as f64 / 1000.0)
401    }
402
403    /// Calculate Davies-Bouldin index (simplified)
404    fn calculate_davies_bouldin(
405        &self,
406        _embeddings: &[Vec<f32>],
407        _assignments: &[usize],
408    ) -> Result<f64> {
409        // Simplified implementation
410        Ok(0.5)
411    }
412
413    /// Calculate Adjusted Rand Index (simplified)
414    /// Adjusted Rand Index between predicted cluster assignments and
415    /// ground-truth labels, computed from the standard contingency-table
416    /// formula: `(sum_ij C(n_ij,2) - expected) / (max - expected)`.
417    fn calculate_adjusted_rand_index(
418        &self,
419        assignments: &[usize],
420        ground_truth: &HashMap<String, String>,
421        entities: &[String],
422    ) -> Result<f64> {
423        let pairs = Self::labeled_pairs(assignments, ground_truth, entities);
424        if pairs.len() < 2 {
425            return Ok(0.0);
426        }
427
428        let (cluster_counts, label_counts, joint_counts) = Self::contingency_table(&pairs);
429
430        let comb2 = |x: usize| -> f64 {
431            if x < 2 {
432                0.0
433            } else {
434                (x * (x - 1)) as f64 / 2.0
435            }
436        };
437
438        let sum_joint: f64 = joint_counts.values().map(|&v| comb2(v)).sum();
439        let sum_cluster: f64 = cluster_counts.values().map(|&v| comb2(v)).sum();
440        let sum_label: f64 = label_counts.values().map(|&v| comb2(v)).sum();
441        let total_pairs = comb2(pairs.len());
442
443        if total_pairs == 0.0 {
444            return Ok(0.0);
445        }
446
447        let expected_index = sum_cluster * sum_label / total_pairs;
448        let max_index = 0.5 * (sum_cluster + sum_label);
449
450        if (max_index - expected_index).abs() < 1e-12 {
451            // Degenerate case (e.g. a single cluster/label): agreement is
452            // trivially perfect since there is nothing else to compare to.
453            return Ok(1.0);
454        }
455
456        Ok(((sum_joint - expected_index) / (max_index - expected_index)).clamp(-1.0, 1.0))
457    }
458
459    /// Normalized Mutual Information between predicted cluster assignments
460    /// and ground-truth labels: `I(U,V) / sqrt(H(U) * H(V))`.
461    fn calculate_nmi(
462        &self,
463        assignments: &[usize],
464        ground_truth: &HashMap<String, String>,
465        entities: &[String],
466    ) -> Result<f64> {
467        let pairs = Self::labeled_pairs(assignments, ground_truth, entities);
468        if pairs.is_empty() {
469            return Ok(0.0);
470        }
471
472        let (cluster_counts, label_counts, joint_counts) = Self::contingency_table(&pairs);
473        let n = pairs.len() as f64;
474
475        let entropy = |count: usize| -> f64 {
476            let p = count as f64 / n;
477            if p > 0.0 {
478                -p * p.ln()
479            } else {
480                0.0
481            }
482        };
483
484        let mut mutual_information = 0.0;
485        for (&(cluster, label), &n_uv) in &joint_counts {
486            let n_u = cluster_counts[&cluster] as f64;
487            let n_v = label_counts[&label] as f64;
488            let p_uv = n_uv as f64 / n;
489            mutual_information += p_uv * ((n * n_uv as f64) / (n_u * n_v)).ln();
490        }
491
492        let h_u: f64 = cluster_counts.values().map(|&c| entropy(c)).sum();
493        let h_v: f64 = label_counts.values().map(|&c| entropy(c)).sum();
494
495        if h_u <= 0.0 || h_v <= 0.0 {
496            // No uncertainty in one of the partitions (e.g. a single
497            // cluster/label): normalized mutual information is undefined, so
498            // report perfect agreement only when the other side also has no
499            // uncertainty, else no measurable information.
500            return Ok(if h_u <= 0.0 && h_v <= 0.0 { 1.0 } else { 0.0 });
501        }
502
503        Ok((mutual_information / (h_u * h_v).sqrt()).clamp(0.0, 1.0))
504    }
505
506    /// Clustering purity: the fraction of (labeled) entities whose
507    /// predicted cluster's majority ground-truth label matches their own.
508    fn calculate_purity(
509        &self,
510        assignments: &[usize],
511        ground_truth: &HashMap<String, String>,
512        entities: &[String],
513    ) -> Result<f64> {
514        if assignments.len() != entities.len() || assignments.is_empty() {
515            return Ok(0.0);
516        }
517
518        let mut clusters: HashMap<usize, Vec<&str>> = HashMap::new();
519        let mut total_labeled = 0usize;
520        for (idx, &cluster) in assignments.iter().enumerate() {
521            if let Some(label) = ground_truth.get(&entities[idx]) {
522                clusters.entry(cluster).or_default().push(label.as_str());
523                total_labeled += 1;
524            }
525        }
526
527        if total_labeled == 0 {
528            return Ok(0.0);
529        }
530
531        let total_correct: usize = clusters
532            .values()
533            .map(|labels| {
534                let mut counts: HashMap<&str, usize> = HashMap::new();
535                for &label in labels {
536                    *counts.entry(label).or_insert(0) += 1;
537                }
538                counts.values().copied().max().unwrap_or(0)
539            })
540            .sum();
541
542        Ok(total_correct as f64 / total_labeled as f64)
543    }
544
545    /// Pair up predicted cluster assignments with ground-truth labels for
546    /// entities that have a recorded label, dropping unlabeled entities.
547    fn labeled_pairs<'a>(
548        assignments: &[usize],
549        ground_truth: &'a HashMap<String, String>,
550        entities: &[String],
551    ) -> Vec<(usize, &'a str)> {
552        entities
553            .iter()
554            .zip(assignments.iter())
555            .filter_map(|(entity, &cluster)| {
556                ground_truth
557                    .get(entity)
558                    .map(|label| (cluster, label.as_str()))
559            })
560            .collect()
561    }
562
563    /// Build cluster-size, label-size, and joint contingency counts from
564    /// (cluster, label) pairs.
565    #[allow(clippy::type_complexity)]
566    fn contingency_table<'a>(
567        pairs: &[(usize, &'a str)],
568    ) -> (
569        HashMap<usize, usize>,
570        HashMap<&'a str, usize>,
571        HashMap<(usize, &'a str), usize>,
572    ) {
573        let mut cluster_counts: HashMap<usize, usize> = HashMap::new();
574        let mut label_counts: HashMap<&str, usize> = HashMap::new();
575        let mut joint_counts: HashMap<(usize, &str), usize> = HashMap::new();
576        for &(cluster, label) in pairs {
577            *cluster_counts.entry(cluster).or_insert(0) += 1;
578            *label_counts.entry(label).or_insert(0) += 1;
579            *joint_counts.entry((cluster, label)).or_insert(0) += 1;
580        }
581        (cluster_counts, label_counts, joint_counts)
582    }
583
584    /// Analyze clusters
585    fn analyze_clusters(
586        &self,
587        _embeddings: &[Vec<f32>],
588        assignments: &[usize],
589    ) -> Result<ClusterAnalysis> {
590        let unique_clusters: HashSet<usize> = assignments.iter().cloned().collect();
591        let num_clusters = unique_clusters.len();
592
593        let mut cluster_sizes = Vec::new();
594        let cluster_cohesion = vec![0.5; num_clusters]; // Simplified
595        let cluster_separation = vec![0.6; num_clusters]; // Simplified
596
597        for &cluster in &unique_clusters {
598            let cluster_size = assignments.iter().filter(|&&c| c == cluster).count();
599            cluster_sizes.push(cluster_size);
600        }
601
602        // Simplified inter-cluster distances
603        let inter_cluster_distances = Array2::zeros((num_clusters, num_clusters));
604
605        Ok(ClusterAnalysis {
606            num_clusters,
607            cluster_sizes,
608            cluster_cohesion,
609            cluster_separation,
610            inter_cluster_distances,
611        })
612    }
613
614    /// Analyze clustering stability
615    fn analyze_stability(
616        &self,
617        _embeddings: &[Vec<f32>],
618        _config: &ApplicationEvalConfig,
619    ) -> Result<ClusteringStabilityAnalysis> {
620        // Simplified implementation
621        Ok(ClusteringStabilityAnalysis {
622            stability_score: 0.75,
623            assignment_consistency: 0.8,
624            parameter_robustness: 0.7,
625        })
626    }
627
628    /// Calculate Euclidean distance
629    fn euclidean_distance(&self, v1: &[f32], v2: &[f32]) -> f32 {
630        v1.iter()
631            .zip(v2.iter())
632            .map(|(a, b)| (a - b).powi(2))
633            .sum::<f32>()
634            .sqrt()
635    }
636}
637
638impl Default for ClusteringEvaluator {
639    fn default() -> Self {
640        Self::new()
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    /// Perfect agreement between predicted clusters and ground truth: ARI,
649    /// NMI, and purity must all report (near) 1.0 rather than a fabricated
650    /// 0.5/0.6.
651    #[test]
652    fn test_ground_truth_metrics_perfect_agreement() -> Result<()> {
653        let mut evaluator = ClusteringEvaluator::new();
654        let entities = vec![
655            "a".to_string(),
656            "b".to_string(),
657            "c".to_string(),
658            "d".to_string(),
659        ];
660        let assignments = vec![0usize, 0, 1, 1];
661        let ground_truth: HashMap<String, String> = [
662            ("a".to_string(), "X".to_string()),
663            ("b".to_string(), "X".to_string()),
664            ("c".to_string(), "Y".to_string()),
665            ("d".to_string(), "Y".to_string()),
666        ]
667        .into_iter()
668        .collect();
669        evaluator.set_ground_truth(ground_truth.clone());
670
671        let ari =
672            evaluator.calculate_adjusted_rand_index(&assignments, &ground_truth, &entities)?;
673        assert!((ari - 1.0).abs() < 1e-9, "ari = {ari}");
674
675        let nmi = evaluator.calculate_nmi(&assignments, &ground_truth, &entities)?;
676        assert!((nmi - 1.0).abs() < 1e-9, "nmi = {nmi}");
677
678        let purity = evaluator.calculate_purity(&assignments, &ground_truth, &entities)?;
679        assert!((purity - 1.0).abs() < 1e-9, "purity = {purity}");
680
681        Ok(())
682    }
683
684    /// Predicted clusters uncorrelated with (in fact, inverted relative to)
685    /// ground truth should score well below the "perfect" case.
686    #[test]
687    fn test_ground_truth_metrics_poor_agreement() -> Result<()> {
688        let evaluator = ClusteringEvaluator::new();
689        let entities = vec![
690            "a".to_string(),
691            "b".to_string(),
692            "c".to_string(),
693            "d".to_string(),
694        ];
695        // Every predicted cluster mixes both ground-truth labels evenly.
696        let assignments = vec![0usize, 1, 0, 1];
697        let ground_truth: HashMap<String, String> = [
698            ("a".to_string(), "X".to_string()),
699            ("b".to_string(), "X".to_string()),
700            ("c".to_string(), "Y".to_string()),
701            ("d".to_string(), "Y".to_string()),
702        ]
703        .into_iter()
704        .collect();
705
706        let ari =
707            evaluator.calculate_adjusted_rand_index(&assignments, &ground_truth, &entities)?;
708        assert!(ari < 0.5, "ari = {ari}");
709
710        let purity = evaluator.calculate_purity(&assignments, &ground_truth, &entities)?;
711        assert!((purity - 0.5).abs() < 1e-9, "purity = {purity}");
712
713        Ok(())
714    }
715
716    /// The `_ => Ok(0.5)` fallback must be gone: every `ClusteringMetric`
717    /// variant is handled explicitly by `calculate_clustering_metric`.
718    #[test]
719    fn test_calculate_clustering_metric_handles_ground_truth_variants_without_ground_truth(
720    ) -> Result<()> {
721        let evaluator = ClusteringEvaluator::new();
722        let embeddings = vec![vec![0.0f32, 0.0], vec![1.0, 1.0]];
723        let assignments = vec![0usize, 1];
724        let entities = vec!["a".to_string(), "b".to_string()];
725
726        for metric in [
727            ClusteringMetric::NormalizedMutualInformation,
728            ClusteringMetric::Purity,
729        ] {
730            let score = evaluator.calculate_clustering_metric(
731                &metric,
732                &embeddings,
733                &assignments,
734                &entities,
735            )?;
736            assert_eq!(score, 0.0, "metric = {metric:?}");
737        }
738
739        Ok(())
740    }
741}