1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct ClusterId(pub usize);
12
13#[derive(Debug, Clone)]
15pub struct EcaClusterPoint {
16 pub id: String,
18 pub embedding: Vec<f64>,
20 pub cluster: Option<ClusterId>,
22 pub distance_to_centroid: f64,
24}
25
26#[derive(Debug, Clone)]
28pub struct ClusterDescriptor {
29 pub id: ClusterId,
31 pub centroid: Vec<f64>,
33 pub radius: f64,
35 pub density: f64,
37 pub point_count: usize,
39 pub label: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct OutlierScore {
46 pub point_id: String,
48 pub score: f64,
50 pub reason: OutlierReason,
52}
53
54#[derive(Debug, Clone)]
56pub enum OutlierReason {
57 FarFromCentroid {
59 distance: f64,
61 threshold: f64,
63 },
64 LowDensityRegion {
66 local_density: f64,
68 },
69 IsolatedPoint,
71}
72
73#[derive(Debug, Clone)]
75pub struct EcaAnalyzerConfig {
76 pub outlier_threshold_sigma: f64,
78 pub min_cluster_size: usize,
80 pub density_radius: f64,
82 pub max_outlier_fraction: f64,
84}
85
86impl Default for EcaAnalyzerConfig {
87 fn default() -> Self {
88 Self {
89 outlier_threshold_sigma: 2.5,
90 min_cluster_size: 3,
91 density_radius: 0.1,
92 max_outlier_fraction: 0.1,
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
99pub struct ClusterQuality {
100 pub silhouette_score: f64,
102 pub davies_bouldin_index: f64,
104 pub calinski_harabasz_score: f64,
106 pub intra_cluster_variance: f64,
108}
109
110#[derive(Debug, Clone)]
112pub struct EcaAnalyzerStats {
113 pub point_count: usize,
115 pub cluster_count: usize,
117 pub total_analyses: u64,
119 pub avg_cluster_size: f64,
121 pub outlier_count: usize,
123}
124
125pub struct EmbeddingClusterAnalyzer {
133 pub config: EcaAnalyzerConfig,
135 pub points: Vec<EcaClusterPoint>,
137 pub clusters: Vec<ClusterDescriptor>,
139 pub total_analyses: u64,
141 last_outlier_count: usize,
143}
144
145impl EmbeddingClusterAnalyzer {
146 pub fn new(config: EcaAnalyzerConfig) -> Self {
150 Self {
151 config,
152 points: Vec::new(),
153 clusters: Vec::new(),
154 total_analyses: 0,
155 last_outlier_count: 0,
156 }
157 }
158
159 pub fn add_point(&mut self, id: String, embedding: Vec<f64>, cluster: Option<ClusterId>) {
166 self.points.push(EcaClusterPoint {
167 id,
168 embedding,
169 cluster,
170 distance_to_centroid: 0.0,
171 });
172 }
173
174 pub fn set_clusters(&mut self, descriptors: Vec<ClusterDescriptor>) {
180 self.clusters = descriptors;
181 self.assign_unassigned_points();
182 self.recompute_distances();
183 }
184
185 fn assign_unassigned_points(&mut self) {
188 if self.clusters.is_empty() {
189 return;
190 }
191 for point in &mut self.points {
192 if point.cluster.is_some() {
193 continue;
194 }
195 let best = self
196 .clusters
197 .iter()
198 .enumerate()
199 .min_by(|(_, ca), (_, cb)| {
200 let da = Self::cosine_distance_static(&point.embedding, &ca.centroid);
201 let db = Self::cosine_distance_static(&point.embedding, &cb.centroid);
202 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
203 })
204 .map(|(i, _)| i);
205 if let Some(idx) = best {
206 point.cluster = Some(ClusterId(idx));
207 }
208 }
209 }
210
211 fn recompute_distances(&mut self) {
213 for point in &mut self.points {
214 let dist = match point.cluster {
215 None => 0.0,
216 Some(cid) => self
217 .clusters
218 .get(cid.0)
219 .map(|c| Self::l2_distance_static(&point.embedding, &c.centroid))
220 .unwrap_or(0.0),
221 };
222 point.distance_to_centroid = dist;
223 }
224 }
225
226 pub fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
232 Self::l2_distance_static(a, b)
233 }
234
235 fn l2_distance_static(a: &[f64], b: &[f64]) -> f64 {
236 let len = a.len().min(b.len());
237 a[..len]
238 .iter()
239 .zip(b[..len].iter())
240 .map(|(x, y)| (x - y) * (x - y))
241 .sum::<f64>()
242 .sqrt()
243 }
244
245 pub fn cosine_distance(a: &[f64], b: &[f64]) -> f64 {
249 Self::cosine_distance_static(a, b)
250 }
251
252 fn cosine_distance_static(a: &[f64], b: &[f64]) -> f64 {
253 let len = a.len().min(b.len());
254 let dot: f64 = a[..len]
255 .iter()
256 .zip(b[..len].iter())
257 .map(|(x, y)| x * y)
258 .sum();
259 let norm_a: f64 = a[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
260 let norm_b: f64 = b[..len].iter().map(|x| x * x).sum::<f64>().sqrt();
261 if norm_a == 0.0 || norm_b == 0.0 {
262 return 1.0;
263 }
264 let similarity = dot / (norm_a * norm_b);
265 1.0 - similarity.clamp(-1.0, 1.0)
267 }
268
269 pub fn compute_cluster_quality(&mut self) -> ClusterQuality {
275 self.total_analyses += 1;
276
277 let n = self.points.len();
278 let k = self.clusters.len();
279
280 let intra_cluster_variance = if n == 0 {
282 0.0
283 } else {
284 self.points
285 .iter()
286 .map(|p| p.distance_to_centroid * p.distance_to_centroid)
287 .sum::<f64>()
288 / n as f64
289 };
290
291 let silhouette_score = self.compute_silhouette();
293
294 let davies_bouldin_index = self.compute_davies_bouldin();
296
297 let calinski_harabasz_score = self.compute_calinski_harabasz(n, k);
299
300 ClusterQuality {
301 silhouette_score,
302 davies_bouldin_index,
303 calinski_harabasz_score,
304 intra_cluster_variance,
305 }
306 }
307
308 fn compute_silhouette(&self) -> f64 {
315 let n = self.points.len();
316 let k = self.clusters.len();
317 if n == 0 || k < 2 {
318 return 0.0;
319 }
320
321 let scores: Vec<f64> = self
322 .points
323 .iter()
324 .map(|point| {
325 let own_cluster_idx = match point.cluster {
326 Some(cid) => cid.0,
327 None => return 0.0,
328 };
329
330 let a = point.distance_to_centroid;
331
332 let b = self
334 .clusters
335 .iter()
336 .enumerate()
337 .filter(|(i, _)| *i != own_cluster_idx)
338 .map(|(_, c)| Self::l2_distance_static(&point.embedding, &c.centroid))
339 .fold(f64::MAX, f64::min);
340
341 if b == f64::MAX {
342 return 0.0;
343 }
344
345 let denom = a.max(b);
346 if denom == 0.0 {
347 0.0
348 } else {
349 (b - a) / denom
350 }
351 })
352 .collect();
353
354 if scores.is_empty() {
355 0.0
356 } else {
357 scores.iter().sum::<f64>() / scores.len() as f64
358 }
359 }
360
361 fn compute_davies_bouldin(&self) -> f64 {
365 let k = self.clusters.len();
366 if k < 2 {
367 return 0.0;
368 }
369
370 let sigma: Vec<f64> = self
372 .clusters
373 .iter()
374 .map(|c| {
375 let members: Vec<f64> = self
376 .points
377 .iter()
378 .filter(|p| p.cluster == Some(c.id))
379 .map(|p| p.distance_to_centroid)
380 .collect();
381 if members.is_empty() {
382 0.0
383 } else {
384 members.iter().sum::<f64>() / members.len() as f64
385 }
386 })
387 .collect();
388
389 let db: f64 = self
390 .clusters
391 .iter()
392 .enumerate()
393 .map(|(i, ci)| {
394 let max_ratio = self
395 .clusters
396 .iter()
397 .enumerate()
398 .filter(|(j, _)| *j != i)
399 .map(|(j, cj)| {
400 let dist = Self::l2_distance_static(&ci.centroid, &cj.centroid);
401 if dist == 0.0 {
402 0.0
403 } else {
404 (sigma[i] + sigma[j]) / dist
405 }
406 })
407 .fold(f64::NEG_INFINITY, f64::max);
408 if max_ratio == f64::NEG_INFINITY {
409 0.0
410 } else {
411 max_ratio
412 }
413 })
414 .sum::<f64>();
415
416 db / k as f64
417 }
418
419 fn compute_calinski_harabasz(&self, n: usize, k: usize) -> f64 {
423 if n < 2 || k < 2 || n <= k {
424 return 0.0;
425 }
426
427 let dim = self.points.first().map(|p| p.embedding.len()).unwrap_or(0);
429 if dim == 0 {
430 return 0.0;
431 }
432
433 let mut global_centroid = vec![0.0_f64; dim];
434 for point in &self.points {
435 for (g, v) in global_centroid.iter_mut().zip(point.embedding.iter()) {
436 *g += v;
437 }
438 }
439 let n_f = n as f64;
440 for g in &mut global_centroid {
441 *g /= n_f;
442 }
443
444 let bgss: f64 = self
446 .clusters
447 .iter()
448 .map(|c| {
449 let n_k = self
450 .points
451 .iter()
452 .filter(|p| p.cluster == Some(c.id))
453 .count() as f64;
454 let dist_sq = Self::l2_distance_static(&c.centroid, &global_centroid).powi(2);
455 n_k * dist_sq
456 })
457 .sum();
458
459 let wgss: f64 = self
461 .points
462 .iter()
463 .map(|p| p.distance_to_centroid * p.distance_to_centroid)
464 .sum();
465
466 if wgss == 0.0 {
467 return 0.0;
468 }
469
470 let numerator = bgss / (k as f64 - 1.0);
471 let denominator = wgss / (n as f64 - k as f64);
472 if denominator == 0.0 {
473 0.0
474 } else {
475 numerator / denominator
476 }
477 }
478
479 pub fn detect_outliers(&mut self) -> Vec<OutlierScore> {
489 let total = self.points.len();
490 if total == 0 {
491 self.last_outlier_count = 0;
492 return Vec::new();
493 }
494
495 let mut scores: Vec<OutlierScore> = Vec::new();
496
497 for cluster in &self.clusters {
498 let members: Vec<(usize, f64)> = self
499 .points
500 .iter()
501 .enumerate()
502 .filter(|(_, p)| p.cluster == Some(cluster.id))
503 .map(|(i, p)| (i, p.distance_to_centroid))
504 .collect();
505
506 let count = members.len();
507
508 if count < self.config.min_cluster_size {
510 for (idx, dist) in &members {
511 scores.push(OutlierScore {
512 point_id: self.points[*idx].id.clone(),
513 score: 1.0 + dist,
514 reason: OutlierReason::IsolatedPoint,
515 });
516 }
517 continue;
518 }
519
520 let mean = members.iter().map(|(_, d)| *d).sum::<f64>() / count as f64;
522 let variance = members
523 .iter()
524 .map(|(_, d)| (d - mean) * (d - mean))
525 .sum::<f64>()
526 / count as f64;
527 let std_dev = variance.sqrt();
528
529 let threshold = mean + self.config.outlier_threshold_sigma * std_dev;
530
531 for (idx, dist) in &members {
532 if *dist > threshold {
533 let score = if std_dev > 0.0 {
534 (dist - mean) / std_dev
535 } else {
536 0.0
537 };
538 scores.push(OutlierScore {
539 point_id: self.points[*idx].id.clone(),
540 score,
541 reason: OutlierReason::FarFromCentroid {
542 distance: *dist,
543 threshold,
544 },
545 });
546 }
547 }
548 }
549
550 scores.sort_by(|a, b| {
552 b.score
553 .partial_cmp(&a.score)
554 .unwrap_or(std::cmp::Ordering::Equal)
555 });
556
557 let max_count = ((total as f64) * self.config.max_outlier_fraction).ceil() as usize;
559 scores.truncate(max_count);
560
561 self.last_outlier_count = scores.len();
562 scores
563 }
564
565 pub fn local_density(&self, point_idx: usize) -> f64 {
572 let Some(target) = self.points.get(point_idx) else {
573 return 0.0;
574 };
575 let radius = self.config.density_radius;
576 let count = self
577 .points
578 .iter()
579 .enumerate()
580 .filter(|(i, other)| {
581 *i != point_idx
582 && Self::l2_distance_static(&target.embedding, &other.embedding) <= radius
583 })
584 .count();
585 count as f64
586 }
587
588 pub fn cluster_evolution(&self, prev: &EmbeddingClusterAnalyzer) -> Vec<String> {
596 let mut events = Vec::new();
597
598 for curr_cluster in &self.clusters {
599 let closest = prev.clusters.iter().min_by(|a, b| {
601 let da = Self::l2_distance_static(&curr_cluster.centroid, &a.centroid);
602 let db = Self::l2_distance_static(&curr_cluster.centroid, &b.centroid);
603 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
604 });
605
606 if let Some(prev_cluster) = closest {
607 let dist = Self::l2_distance_static(&curr_cluster.centroid, &prev_cluster.centroid);
608 if dist > 0.1 {
609 events.push(format!(
610 "cluster {} shifted by {:.3}",
611 curr_cluster.id.0, dist
612 ));
613 }
614 }
615 }
616
617 events
618 }
619
620 pub fn top_k_by_cluster(&self, cluster: ClusterId, k: usize) -> Vec<&EcaClusterPoint> {
627 let mut members: Vec<&EcaClusterPoint> = self
628 .points
629 .iter()
630 .filter(|p| p.cluster == Some(cluster))
631 .collect();
632
633 members.sort_by(|a, b| {
634 a.distance_to_centroid
635 .partial_cmp(&b.distance_to_centroid)
636 .unwrap_or(std::cmp::Ordering::Equal)
637 });
638
639 members.truncate(k);
640 members
641 }
642
643 pub fn analyzer_stats(&self) -> EcaAnalyzerStats {
647 let point_count = self.points.len();
648 let cluster_count = self.clusters.len();
649 let avg_cluster_size = if cluster_count == 0 {
650 0.0
651 } else {
652 point_count as f64 / cluster_count as f64
653 };
654
655 EcaAnalyzerStats {
656 point_count,
657 cluster_count,
658 total_analyses: self.total_analyses,
659 avg_cluster_size,
660 outlier_count: self.last_outlier_count,
661 }
662 }
663}
664
665#[cfg(test)]
668mod tests {
669 use crate::embedding_cluster_analyzer::{
670 ClusterDescriptor, ClusterId, EcaAnalyzerConfig, EcaClusterPoint, EmbeddingClusterAnalyzer,
671 OutlierReason,
672 };
673
674 fn default_config() -> EcaAnalyzerConfig {
677 EcaAnalyzerConfig::default()
678 }
679
680 fn make_analyzer() -> EmbeddingClusterAnalyzer {
681 EmbeddingClusterAnalyzer::new(default_config())
682 }
683
684 fn make_descriptor(id: usize, centroid: Vec<f64>) -> ClusterDescriptor {
685 ClusterDescriptor {
686 id: ClusterId(id),
687 centroid,
688 radius: 1.0,
689 density: 1.0,
690 point_count: 0,
691 label: None,
692 }
693 }
694
695 #[test]
698 fn test_default_config() {
699 let cfg = EcaAnalyzerConfig::default();
700 assert!((cfg.outlier_threshold_sigma - 2.5).abs() < 1e-10);
701 assert_eq!(cfg.min_cluster_size, 3);
702 assert!((cfg.density_radius - 0.1).abs() < 1e-10);
703 assert!((cfg.max_outlier_fraction - 0.1).abs() < 1e-10);
704 }
705
706 #[test]
709 fn test_new_analyzer_empty() {
710 let a = make_analyzer();
711 assert_eq!(a.points.len(), 0);
712 assert_eq!(a.clusters.len(), 0);
713 assert_eq!(a.total_analyses, 0);
714 }
715
716 #[test]
719 fn test_add_point_count() {
720 let mut a = make_analyzer();
721 a.add_point("p1".into(), vec![1.0, 0.0], None);
722 a.add_point("p2".into(), vec![0.0, 1.0], None);
723 assert_eq!(a.points.len(), 2);
724 }
725
726 #[test]
729 fn test_add_point_id() {
730 let mut a = make_analyzer();
731 a.add_point("my-point".into(), vec![1.0], None);
732 assert_eq!(a.points[0].id, "my-point");
733 }
734
735 #[test]
738 fn test_add_point_embedding() {
739 let mut a = make_analyzer();
740 a.add_point("p".into(), vec![3.0, 4.0], None);
741 assert_eq!(a.points[0].embedding, vec![3.0, 4.0]);
742 }
743
744 #[test]
747 fn test_add_point_initial_distance_zero() {
748 let mut a = make_analyzer();
749 a.add_point("p".into(), vec![1.0], None);
750 assert_eq!(a.points[0].distance_to_centroid, 0.0);
751 }
752
753 #[test]
756 fn test_l2_distance_zero() {
757 let d = EmbeddingClusterAnalyzer::l2_distance(&[0.0, 0.0], &[0.0, 0.0]);
758 assert!(d.abs() < 1e-10);
759 }
760
761 #[test]
764 fn test_l2_distance_345() {
765 let d = EmbeddingClusterAnalyzer::l2_distance(&[0.0, 0.0], &[3.0, 4.0]);
766 assert!((d - 5.0).abs() < 1e-10);
767 }
768
769 #[test]
772 fn test_l2_distance_symmetric() {
773 let a = &[1.0, 2.0, 3.0];
774 let b = &[4.0, 5.0, 6.0];
775 let d1 = EmbeddingClusterAnalyzer::l2_distance(a, b);
776 let d2 = EmbeddingClusterAnalyzer::l2_distance(b, a);
777 assert!((d1 - d2).abs() < 1e-10);
778 }
779
780 #[test]
783 fn test_cosine_distance_identical() {
784 let v = &[1.0, 2.0, 3.0];
785 let d = EmbeddingClusterAnalyzer::cosine_distance(v, v);
786 assert!(d.abs() < 1e-10);
787 }
788
789 #[test]
792 fn test_cosine_distance_orthogonal() {
793 let a = &[1.0, 0.0];
794 let b = &[0.0, 1.0];
795 let d = EmbeddingClusterAnalyzer::cosine_distance(a, b);
796 assert!((d - 1.0).abs() < 1e-10);
797 }
798
799 #[test]
802 fn test_cosine_distance_zero_vector() {
803 let d = EmbeddingClusterAnalyzer::cosine_distance(&[0.0, 0.0], &[1.0, 0.0]);
804 assert!((d - 1.0).abs() < 1e-10);
805 }
806
807 #[test]
810 fn test_set_clusters_registers() {
811 let mut a = make_analyzer();
812 a.set_clusters(vec![make_descriptor(0, vec![1.0, 0.0])]);
813 assert_eq!(a.clusters.len(), 1);
814 }
815
816 #[test]
819 fn test_set_clusters_assigns_unassigned() {
820 let mut a = make_analyzer();
821 a.add_point("p".into(), vec![1.0, 0.0], None);
822 a.set_clusters(vec![make_descriptor(0, vec![1.0, 0.0])]);
823 assert_eq!(a.points[0].cluster, Some(ClusterId(0)));
824 }
825
826 #[test]
829 fn test_set_clusters_recomputes_distance() {
830 let mut a = make_analyzer();
831 a.add_point("p".into(), vec![4.0, 0.0], None);
832 a.set_clusters(vec![make_descriptor(0, vec![0.0, 0.0])]);
833 assert!((a.points[0].distance_to_centroid - 4.0).abs() < 1e-10);
834 }
835
836 #[test]
839 fn test_set_clusters_preserves_explicit() {
840 let mut a = make_analyzer();
841 a.add_point("p".into(), vec![0.0, 1.0], Some(ClusterId(1)));
842 a.set_clusters(vec![
843 make_descriptor(0, vec![0.0, 1.0]),
844 make_descriptor(1, vec![1.0, 0.0]),
845 ]);
846 assert_eq!(a.points[0].cluster, Some(ClusterId(1)));
848 }
849
850 #[test]
853 fn test_intra_cluster_variance() {
854 let mut a = make_analyzer();
855 a.add_point("p1".into(), vec![3.0, 0.0], Some(ClusterId(0)));
857 a.add_point("p2".into(), vec![-3.0, 0.0], Some(ClusterId(0)));
858 a.set_clusters(vec![make_descriptor(0, vec![0.0, 0.0])]);
859 let q = a.compute_cluster_quality();
860 assert!((q.intra_cluster_variance - 9.0).abs() < 1e-9);
862 }
863
864 #[test]
867 fn test_silhouette_single_cluster() {
868 let mut a = make_analyzer();
869 a.add_point("p1".into(), vec![1.0, 0.0], Some(ClusterId(0)));
870 a.set_clusters(vec![make_descriptor(0, vec![0.0, 0.0])]);
871 let q = a.compute_cluster_quality();
872 assert_eq!(q.silhouette_score, 0.0);
873 }
874
875 #[test]
878 fn test_silhouette_well_separated() {
879 let mut a = make_analyzer();
880 for i in 0..5_u32 {
882 a.add_point(
883 format!("a{i}"),
884 vec![i as f64 * 0.01, 0.0],
885 Some(ClusterId(0)),
886 );
887 }
888 for i in 0..5_u32 {
889 a.add_point(
890 format!("b{i}"),
891 vec![100.0 + i as f64 * 0.01, 0.0],
892 Some(ClusterId(1)),
893 );
894 }
895 a.set_clusters(vec![
896 make_descriptor(0, vec![0.02, 0.0]),
897 make_descriptor(1, vec![100.02, 0.0]),
898 ]);
899 let q = a.compute_cluster_quality();
900 assert!(
901 q.silhouette_score > 0.5,
902 "Expected high silhouette, got {}",
903 q.silhouette_score
904 );
905 }
906
907 #[test]
910 fn test_davies_bouldin_single_cluster() {
911 let mut a = make_analyzer();
912 a.add_point("p".into(), vec![1.0], Some(ClusterId(0)));
913 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
914 let q = a.compute_cluster_quality();
915 assert_eq!(q.davies_bouldin_index, 0.0);
916 }
917
918 #[test]
921 fn test_calinski_harabasz_single_cluster() {
922 let mut a = make_analyzer();
923 a.add_point("p1".into(), vec![1.0], Some(ClusterId(0)));
924 a.add_point("p2".into(), vec![2.0], Some(ClusterId(0)));
925 a.set_clusters(vec![make_descriptor(0, vec![1.5])]);
926 let q = a.compute_cluster_quality();
927 assert_eq!(q.calinski_harabasz_score, 0.0);
928 }
929
930 #[test]
933 fn test_total_analyses_increments() {
934 let mut a = make_analyzer();
935 a.add_point("p".into(), vec![1.0], Some(ClusterId(0)));
936 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
937 a.compute_cluster_quality();
938 a.compute_cluster_quality();
939 a.compute_cluster_quality();
940 assert_eq!(a.total_analyses, 3);
941 }
942
943 #[test]
946 fn test_detect_outliers_empty() {
947 let mut a = make_analyzer();
948 let outliers = a.detect_outliers();
949 assert!(outliers.is_empty());
950 }
951
952 #[test]
955 fn test_detect_outliers_far_from_centroid() {
956 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
957 outlier_threshold_sigma: 1.0,
958 min_cluster_size: 2,
959 max_outlier_fraction: 1.0,
960 ..Default::default()
961 });
962 for i in 0..5_u32 {
964 a.add_point(format!("n{i}"), vec![i as f64 * 0.01], Some(ClusterId(0)));
965 }
966 a.add_point("far".into(), vec![1000.0], Some(ClusterId(0)));
967 a.set_clusters(vec![make_descriptor(0, vec![0.02])]);
968
969 let outliers = a.detect_outliers();
970 assert!(!outliers.is_empty(), "Expected at least one outlier");
971 let far = outliers.iter().find(|o| o.point_id == "far");
972 assert!(far.is_some(), "Expected 'far' to be detected as outlier");
973 assert!(
974 matches!(
975 far.expect("test: 'far' outlier should be present in results")
976 .reason,
977 OutlierReason::FarFromCentroid { .. }
978 ),
979 "Expected FarFromCentroid reason"
980 );
981 }
982
983 #[test]
986 fn test_detect_outliers_isolated_point() {
987 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
988 min_cluster_size: 3,
989 max_outlier_fraction: 1.0,
990 ..Default::default()
991 });
992 a.add_point("p1".into(), vec![0.0], Some(ClusterId(0)));
994 a.add_point("p2".into(), vec![0.1], Some(ClusterId(0)));
995 a.set_clusters(vec![make_descriptor(0, vec![0.05])]);
996
997 let outliers = a.detect_outliers();
998 assert_eq!(outliers.len(), 2);
999 assert!(outliers
1000 .iter()
1001 .all(|o| matches!(o.reason, OutlierReason::IsolatedPoint)));
1002 }
1003
1004 #[test]
1007 fn test_detect_outliers_cap() {
1008 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
1009 outlier_threshold_sigma: 0.001, min_cluster_size: 3,
1011 max_outlier_fraction: 0.2,
1012 ..Default::default()
1013 });
1014 for i in 0..20_u32 {
1015 a.add_point(format!("p{i}"), vec![i as f64], Some(ClusterId(0)));
1016 }
1017 a.set_clusters(vec![make_descriptor(0, vec![10.0])]);
1018
1019 let outliers = a.detect_outliers();
1020 let cap = ((20_f64) * 0.2).ceil() as usize;
1021 assert!(
1022 outliers.len() <= cap,
1023 "outliers {} > cap {}",
1024 outliers.len(),
1025 cap
1026 );
1027 }
1028
1029 #[test]
1032 fn test_local_density_basic() {
1033 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
1034 density_radius: 1.5,
1035 ..Default::default()
1036 });
1037 a.add_point("origin".into(), vec![0.0], None);
1038 a.add_point("near1".into(), vec![1.0], None);
1039 a.add_point("near2".into(), vec![-1.0], None);
1040 a.add_point("far".into(), vec![10.0], None);
1041
1042 let density = a.local_density(0);
1044 assert!((density - 2.0).abs() < 1e-10);
1045 }
1046
1047 #[test]
1050 fn test_local_density_invalid_index() {
1051 let a = make_analyzer();
1052 assert_eq!(a.local_density(999), 0.0);
1053 }
1054
1055 #[test]
1058 fn test_cluster_evolution_shift() {
1059 let mut prev = make_analyzer();
1060 prev.set_clusters(vec![make_descriptor(0, vec![0.0, 0.0])]);
1061
1062 let mut curr = make_analyzer();
1063 curr.set_clusters(vec![make_descriptor(0, vec![5.0, 0.0])]);
1065
1066 let events = curr.cluster_evolution(&prev);
1067 assert!(!events.is_empty(), "Expected shift event");
1068 assert!(events[0].contains("shifted"), "Event: {}", events[0]);
1069 }
1070
1071 #[test]
1074 fn test_cluster_evolution_no_shift() {
1075 let mut prev = make_analyzer();
1076 prev.set_clusters(vec![make_descriptor(0, vec![0.0, 0.0])]);
1077
1078 let mut curr = make_analyzer();
1079 curr.set_clusters(vec![make_descriptor(0, vec![0.05, 0.0])]);
1080
1081 let events = curr.cluster_evolution(&prev);
1082 assert!(events.is_empty(), "Expected no shift events");
1083 }
1084
1085 #[test]
1088 fn test_cluster_evolution_empty_prev() {
1089 let prev = make_analyzer();
1090 let mut curr = make_analyzer();
1091 curr.set_clusters(vec![make_descriptor(0, vec![1.0])]);
1092
1093 let events = curr.cluster_evolution(&prev);
1094 assert!(events.is_empty());
1096 }
1097
1098 #[test]
1101 fn test_top_k_by_cluster_count() {
1102 let mut a = make_analyzer();
1103 for i in 0..10_u32 {
1104 a.add_point(format!("p{i}"), vec![i as f64], Some(ClusterId(0)));
1105 }
1106 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
1107 let top = a.top_k_by_cluster(ClusterId(0), 3);
1108 assert_eq!(top.len(), 3);
1109 }
1110
1111 #[test]
1114 fn test_top_k_by_cluster_order() {
1115 let mut a = make_analyzer();
1116 a.add_point("far".into(), vec![5.0], Some(ClusterId(0)));
1118 a.add_point("mid".into(), vec![3.0], Some(ClusterId(0)));
1119 a.add_point("close".into(), vec![1.0], Some(ClusterId(0)));
1120 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
1121
1122 let top = a.top_k_by_cluster(ClusterId(0), 3);
1123 assert_eq!(top[0].id, "close");
1124 assert_eq!(top[1].id, "mid");
1125 assert_eq!(top[2].id, "far");
1126 }
1127
1128 #[test]
1131 fn test_top_k_by_cluster_unknown() {
1132 let mut a = make_analyzer();
1133 a.add_point("p".into(), vec![1.0], Some(ClusterId(0)));
1134 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
1135 let top = a.top_k_by_cluster(ClusterId(99), 5);
1136 assert!(top.is_empty());
1137 }
1138
1139 #[test]
1142 fn test_analyzer_stats_point_count() {
1143 let mut a = make_analyzer();
1144 a.add_point("a".into(), vec![1.0], None);
1145 a.add_point("b".into(), vec![2.0], None);
1146 let stats = a.analyzer_stats();
1147 assert_eq!(stats.point_count, 2);
1148 }
1149
1150 #[test]
1153 fn test_analyzer_stats_cluster_count() {
1154 let mut a = make_analyzer();
1155 a.set_clusters(vec![
1156 make_descriptor(0, vec![0.0]),
1157 make_descriptor(1, vec![1.0]),
1158 ]);
1159 let stats = a.analyzer_stats();
1160 assert_eq!(stats.cluster_count, 2);
1161 }
1162
1163 #[test]
1166 fn test_analyzer_stats_avg_cluster_size() {
1167 let mut a = make_analyzer();
1168 for _ in 0..6 {
1169 a.add_point("p".into(), vec![0.0], None);
1170 }
1171 a.set_clusters(vec![
1172 make_descriptor(0, vec![0.0]),
1173 make_descriptor(1, vec![1.0]),
1174 ]);
1175 let stats = a.analyzer_stats();
1176 assert!((stats.avg_cluster_size - 3.0).abs() < 1e-10);
1177 }
1178
1179 #[test]
1182 fn test_analyzer_stats_outlier_count() {
1183 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
1184 min_cluster_size: 3,
1185 max_outlier_fraction: 1.0,
1186 ..Default::default()
1187 });
1188 a.add_point("p1".into(), vec![0.0], Some(ClusterId(0)));
1189 a.add_point("p2".into(), vec![0.1], Some(ClusterId(0)));
1190 a.set_clusters(vec![make_descriptor(0, vec![0.05])]);
1191
1192 let outliers = a.detect_outliers();
1193 let expected = outliers.len();
1194 let stats = a.analyzer_stats();
1195 assert_eq!(stats.outlier_count, expected);
1196 }
1197
1198 #[test]
1201 fn test_set_clusters_replaces_previous() {
1202 let mut a = make_analyzer();
1203 a.set_clusters(vec![make_descriptor(0, vec![0.0])]);
1204 a.set_clusters(vec![
1205 make_descriptor(0, vec![0.0]),
1206 make_descriptor(1, vec![1.0]),
1207 ]);
1208 assert_eq!(a.clusters.len(), 2);
1209 }
1210
1211 #[test]
1214 fn test_assignment_uses_cosine() {
1215 let mut a = make_analyzer();
1216 a.add_point("p".into(), vec![1.0, 0.0], None);
1218 a.set_clusters(vec![
1219 make_descriptor(0, vec![0.0, 1.0]),
1220 make_descriptor(1, vec![2.0, 0.0]),
1221 ]);
1222 assert_eq!(a.points[0].cluster, Some(ClusterId(1)));
1223 }
1224
1225 #[test]
1228 fn test_calinski_harabasz_positive() {
1229 let mut a = make_analyzer();
1230 for i in 0..5_u32 {
1231 a.add_point(
1232 format!("a{i}"),
1233 vec![i as f64 * 0.01, 0.0],
1234 Some(ClusterId(0)),
1235 );
1236 }
1237 for i in 0..5_u32 {
1238 a.add_point(
1239 format!("b{i}"),
1240 vec![100.0 + i as f64 * 0.01, 0.0],
1241 Some(ClusterId(1)),
1242 );
1243 }
1244 a.set_clusters(vec![
1245 make_descriptor(0, vec![0.02, 0.0]),
1246 make_descriptor(1, vec![100.02, 0.0]),
1247 ]);
1248 let q = a.compute_cluster_quality();
1249 assert!(q.calinski_harabasz_score > 0.0);
1250 }
1251
1252 #[test]
1255 fn test_davies_bouldin_well_separated() {
1256 let mut a = make_analyzer();
1257 for i in 0..5_u32 {
1258 a.add_point(format!("a{i}"), vec![i as f64 * 0.01], Some(ClusterId(0)));
1259 }
1260 for i in 0..5_u32 {
1261 a.add_point(
1262 format!("b{i}"),
1263 vec![1000.0 + i as f64 * 0.01],
1264 Some(ClusterId(1)),
1265 );
1266 }
1267 a.set_clusters(vec![
1268 make_descriptor(0, vec![0.02]),
1269 make_descriptor(1, vec![1000.02]),
1270 ]);
1271 let q = a.compute_cluster_quality();
1272 assert!(
1273 q.davies_bouldin_index < 0.1,
1274 "DB index: {}",
1275 q.davies_bouldin_index
1276 );
1277 }
1278
1279 #[test]
1282 fn test_outlier_score_ordering() {
1283 let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
1284 outlier_threshold_sigma: 0.5,
1285 min_cluster_size: 2,
1286 max_outlier_fraction: 1.0,
1287 ..Default::default()
1288 });
1289 for i in 0..5_u32 {
1291 a.add_point(format!("n{i}"), vec![i as f64 * 0.001], Some(ClusterId(0)));
1292 }
1293 a.add_point("out1".into(), vec![100.0], Some(ClusterId(0)));
1294 a.add_point("out2".into(), vec![200.0], Some(ClusterId(0)));
1295 a.set_clusters(vec![make_descriptor(0, vec![0.002])]);
1296
1297 let outliers = a.detect_outliers();
1298 for window in outliers.windows(2) {
1299 assert!(
1300 window[0].score >= window[1].score,
1301 "Not sorted: {} < {}",
1302 window[0].score,
1303 window[1].score
1304 );
1305 }
1306 }
1307
1308 #[test]
1311 fn test_cluster_descriptor_label() {
1312 let mut d = make_descriptor(0, vec![1.0]);
1313 d.label = Some("science".to_string());
1314 assert_eq!(d.label.as_deref(), Some("science"));
1315 }
1316
1317 #[test]
1320 fn test_cluster_point_fields() {
1321 let p = EcaClusterPoint {
1322 id: "x".into(),
1323 embedding: vec![1.0, 2.0],
1324 cluster: Some(ClusterId(3)),
1325 distance_to_centroid: 0.5,
1326 };
1327 assert_eq!(p.id, "x");
1328 assert_eq!(p.cluster, Some(ClusterId(3)));
1329 assert!((p.distance_to_centroid - 0.5).abs() < 1e-10);
1330 }
1331}