Skip to main content

ipfrs_semantic/
embedding_cluster_analyzer.rs

1//! Embedding Cluster Analyzer — comprehensive cluster analysis for embedding spaces.
2//!
3//! Provides cluster quality metrics (silhouette, Davies-Bouldin, Calinski-Harabász),
4//! outlier detection with multiple strategies, local density estimation, and
5//! cluster evolution tracking between analysis snapshots.
6
7// ─── Types ────────────────────────────────────────────────────────────────────
8
9/// Newtype wrapper around a cluster index.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub struct ClusterId(pub usize);
12
13/// A point in the embedding space with optional cluster assignment.
14#[derive(Debug, Clone)]
15pub struct EcaClusterPoint {
16    /// Unique string identifier for this point.
17    pub id: String,
18    /// The embedding vector.
19    pub embedding: Vec<f64>,
20    /// Which cluster this point belongs to, if known.
21    pub cluster: Option<ClusterId>,
22    /// Euclidean distance from this point to its assigned cluster centroid.
23    pub distance_to_centroid: f64,
24}
25
26/// Description of a single cluster including geometric properties.
27#[derive(Debug, Clone)]
28pub struct ClusterDescriptor {
29    /// Cluster index.
30    pub id: ClusterId,
31    /// The centroid of this cluster.
32    pub centroid: Vec<f64>,
33    /// Radius: maximum distance from centroid to any member point.
34    pub radius: f64,
35    /// Density: number of members per unit volume (simplified to member count).
36    pub density: f64,
37    /// Number of member points.
38    pub point_count: usize,
39    /// Optional human-readable label for this cluster.
40    pub label: Option<String>,
41}
42
43/// Score indicating how much of an outlier a point is.
44#[derive(Debug, Clone)]
45pub struct OutlierScore {
46    /// ID of the outlying point.
47    pub point_id: String,
48    /// Outlier score (higher = more anomalous).
49    pub score: f64,
50    /// The reason this point was flagged.
51    pub reason: OutlierReason,
52}
53
54/// Reason a point was identified as an outlier.
55#[derive(Debug, Clone)]
56pub enum OutlierReason {
57    /// Point is too far from its cluster centroid (z-score exceeded threshold).
58    FarFromCentroid {
59        /// Actual distance to centroid.
60        distance: f64,
61        /// The threshold distance that was exceeded.
62        threshold: f64,
63    },
64    /// Point resides in a low-density region.
65    LowDensityRegion {
66        /// Estimated local density at this point.
67        local_density: f64,
68    },
69    /// Point's cluster has too few members to be reliable.
70    IsolatedPoint,
71}
72
73/// Configuration for the `EmbeddingClusterAnalyzer`.
74#[derive(Debug, Clone)]
75pub struct EcaAnalyzerConfig {
76    /// Number of standard deviations beyond the mean for a point to be an outlier.
77    pub outlier_threshold_sigma: f64,
78    /// Minimum number of points in a cluster; smaller clusters produce `IsolatedPoint` outliers.
79    pub min_cluster_size: usize,
80    /// Radius used for local density estimation.
81    pub density_radius: f64,
82    /// Maximum fraction of total points that may be reported as outliers (0..1).
83    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/// Cluster quality metrics computed over all points and clusters.
98#[derive(Debug, Clone)]
99pub struct ClusterQuality {
100    /// Mean silhouette coefficient over all points (range −1 to 1; higher is better).
101    pub silhouette_score: f64,
102    /// Davies-Bouldin index (lower is better; 0 if only one cluster).
103    pub davies_bouldin_index: f64,
104    /// Calinski-Harabász score (higher is better; 0 if degenerate).
105    pub calinski_harabasz_score: f64,
106    /// Mean squared distance from each point to its centroid.
107    pub intra_cluster_variance: f64,
108}
109
110/// Summary statistics for the analyzer.
111#[derive(Debug, Clone)]
112pub struct EcaAnalyzerStats {
113    /// Total number of points held by the analyzer.
114    pub point_count: usize,
115    /// Number of clusters currently registered.
116    pub cluster_count: usize,
117    /// Cumulative number of quality analyses performed.
118    pub total_analyses: u64,
119    /// Average number of points per cluster (0.0 if no clusters).
120    pub avg_cluster_size: f64,
121    /// Number of outliers detected in the last `detect_outliers` call.
122    pub outlier_count: usize,
123}
124
125// ─── Analyzer ─────────────────────────────────────────────────────────────────
126
127/// Comprehensive cluster analysis system for embedding spaces.
128///
129/// Tracks points and their cluster assignments, computes quality metrics,
130/// detects outliers, estimates local density, and identifies cluster drift
131/// relative to a previous snapshot.
132pub struct EmbeddingClusterAnalyzer {
133    /// Analyzer configuration.
134    pub config: EcaAnalyzerConfig,
135    /// All points currently held by the analyzer.
136    pub points: Vec<EcaClusterPoint>,
137    /// Registered cluster descriptors.
138    pub clusters: Vec<ClusterDescriptor>,
139    /// Cumulative number of `compute_cluster_quality` calls.
140    pub total_analyses: u64,
141    /// Cached outlier count from the last `detect_outliers` call.
142    last_outlier_count: usize,
143}
144
145impl EmbeddingClusterAnalyzer {
146    // ── Construction ──────────────────────────────────────────────────────────
147
148    /// Create a new analyzer with the given configuration.
149    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    // ── Point management ──────────────────────────────────────────────────────
160
161    /// Add an embedding point to the analyzer.
162    ///
163    /// `distance_to_centroid` starts at `0.0`; call `set_clusters` or
164    /// `recompute_distances` to update it.
165    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    /// Replace the cluster descriptors and re-assign / recompute distances.
175    ///
176    /// For each point whose `cluster` field is `None`, the nearest cluster
177    /// (by cosine distance to centroid) is assigned. Then `distance_to_centroid`
178    /// is recomputed for every point using L2 distance to its centroid.
179    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    /// Assign points with `cluster == None` to the nearest cluster centroid
186    /// (cosine distance).
187    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    /// Recompute `distance_to_centroid` for every assigned point.
212    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    // ── Distance metrics ──────────────────────────────────────────────────────
227
228    /// Euclidean (L2) distance between two vectors.
229    ///
230    /// Returns `0.0` if either slice is empty.
231    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    /// Cosine distance (1 − cosine similarity) between two vectors.
246    ///
247    /// Returns `1.0` if either vector has zero norm.
248    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        // Clamp to [-1, 1] to guard against floating-point drift.
266        1.0 - similarity.clamp(-1.0, 1.0)
267    }
268
269    // ── Cluster quality ───────────────────────────────────────────────────────
270
271    /// Compute comprehensive cluster quality metrics.
272    ///
273    /// Increments `total_analyses` on each call.
274    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        // ── Intra-cluster variance ─────────────────────────────────────────
281        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        // ── Silhouette score ───────────────────────────────────────────────
292        let silhouette_score = self.compute_silhouette();
293
294        // ── Davies-Bouldin index ───────────────────────────────────────────
295        let davies_bouldin_index = self.compute_davies_bouldin();
296
297        // ── Calinski-Harabász score ────────────────────────────────────────
298        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    /// Mean silhouette coefficient (simplified centroid-based variant).
309    ///
310    /// For each point:
311    ///   - `a` = distance to its own cluster centroid
312    ///   - `b` = minimum average distance to any other cluster centroid
313    ///   - silhouette = (b − a) / max(a, b)
314    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                // b = minimum L2 distance to any other cluster centroid
333                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    /// Davies-Bouldin index.
362    ///
363    /// Returns `0.0` when there is only one cluster or no clusters.
364    fn compute_davies_bouldin(&self) -> f64 {
365        let k = self.clusters.len();
366        if k < 2 {
367            return 0.0;
368        }
369
370        // σ_i = mean distance to centroid for cluster i
371        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    /// Calinski-Harabász score.
420    ///
421    /// Returns `0.0` for degenerate cases (< 2 clusters, < 2 points, etc.).
422    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        // Global centroid
428        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        // BGSS = sum over clusters of n_k * ||centroid_k - global_centroid||^2
445        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        // WGSS = sum of all distance_to_centroid^2
460        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    // ── Outlier detection ─────────────────────────────────────────────────────
480
481    /// Detect outlier points using three strategies:
482    ///
483    /// 1. **FarFromCentroid** — per-cluster z-score of `distance_to_centroid` > `threshold_sigma`
484    /// 2. **IsolatedPoint** — member of a cluster with fewer than `min_cluster_size` points
485    ///
486    /// Results are capped at `max_outlier_fraction × total_points`, ordered by
487    /// descending outlier score.
488    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            // IsolatedPoint check
509            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            // FarFromCentroid check
521            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        // Sort descending by score
551        scores.sort_by(|a, b| {
552            b.score
553                .partial_cmp(&a.score)
554                .unwrap_or(std::cmp::Ordering::Equal)
555        });
556
557        // Cap at max_outlier_fraction
558        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    // ── Local density ─────────────────────────────────────────────────────────
566
567    /// Estimate the local density around a point (by index).
568    ///
569    /// Counts how many other points lie within `density_radius` (L2).
570    /// Returns `0.0` for invalid indices.
571    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    // ── Cluster evolution ─────────────────────────────────────────────────────
589
590    /// Compare cluster centroids between `self` (current) and `prev` (snapshot).
591    ///
592    /// For each cluster in `self`, finds the closest cluster in `prev` by L2
593    /// centroid distance. If the distance exceeds `0.1`, a message is appended:
594    /// `"cluster {id} shifted by {dist:.3}"`.
595    pub fn cluster_evolution(&self, prev: &EmbeddingClusterAnalyzer) -> Vec<String> {
596        let mut events = Vec::new();
597
598        for curr_cluster in &self.clusters {
599            // Find closest cluster in prev by centroid distance
600            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    // ── Queries ───────────────────────────────────────────────────────────────
621
622    /// Return the `k` points in `cluster` that are closest to the centroid.
623    ///
624    /// Points are ordered by ascending `distance_to_centroid`.
625    /// Returns an empty `Vec` if the cluster does not exist.
626    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    // ── Statistics ────────────────────────────────────────────────────────────
644
645    /// Return a summary of the current analyzer state.
646    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// ─── Tests ────────────────────────────────────────────────────────────────────
666
667#[cfg(test)]
668mod tests {
669    use crate::embedding_cluster_analyzer::{
670        ClusterDescriptor, ClusterId, EcaAnalyzerConfig, EcaClusterPoint, EmbeddingClusterAnalyzer,
671        OutlierReason,
672    };
673
674    // ── Helper constructors ──────────────────────────────────────────────────
675
676    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    // ── 1: Default config values ─────────────────────────────────────────────
696
697    #[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    // ── 2: New analyzer is empty ─────────────────────────────────────────────
707
708    #[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    // ── 3: Add point increments point count ──────────────────────────────────
717
718    #[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    // ── 4: Add point stores correct id ───────────────────────────────────────
727
728    #[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    // ── 5: Add point stores correct embedding ────────────────────────────────
736
737    #[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    // ── 6: Add point initial distance is zero ────────────────────────────────
745
746    #[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    // ── 7: L2 distance — zero vector ─────────────────────────────────────────
754
755    #[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    // ── 8: L2 distance — 3-4-5 triangle ─────────────────────────────────────
762
763    #[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    // ── 9: L2 distance — symmetric ───────────────────────────────────────────
770
771    #[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    // ── 10: Cosine distance — identical vectors ───────────────────────────────
781
782    #[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    // ── 11: Cosine distance — orthogonal vectors ──────────────────────────────
790
791    #[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    // ── 12: Cosine distance — zero vector returns 1.0 ─────────────────────────
800
801    #[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    // ── 13: set_clusters registers descriptors ────────────────────────────────
808
809    #[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    // ── 14: set_clusters assigns unassigned points ────────────────────────────
817
818    #[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    // ── 15: set_clusters recomputes distance ──────────────────────────────────
827
828    #[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    // ── 16: set_clusters preserves explicit assignment ────────────────────────
837
838    #[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        // Explicit cluster should NOT be overwritten
847        assert_eq!(a.points[0].cluster, Some(ClusterId(1)));
848    }
849
850    // ── 17: intra_cluster_variance is mean of squared distances ──────────────
851
852    #[test]
853    fn test_intra_cluster_variance() {
854        let mut a = make_analyzer();
855        // Two points, both at distance 3 from centroid [0,0] along x-axis
856        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        // Each distance = 3, so variance = (9 + 9) / 2 = 9
861        assert!((q.intra_cluster_variance - 9.0).abs() < 1e-9);
862    }
863
864    // ── 18: silhouette is 0 with single cluster ───────────────────────────────
865
866    #[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    // ── 19: silhouette positive for well-separated clusters ───────────────────
876
877    #[test]
878    fn test_silhouette_well_separated() {
879        let mut a = make_analyzer();
880        // Cluster 0 near origin, cluster 1 far away
881        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    // ── 20: davies_bouldin 0 with single cluster ─────────────────────────────
908
909    #[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    // ── 21: calinski_harabasz 0 with single cluster ───────────────────────────
919
920    #[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    // ── 22: total_analyses increments ────────────────────────────────────────
931
932    #[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    // ── 23: detect_outliers returns empty for empty analyzer ─────────────────
944
945    #[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    // ── 24: detect_outliers flags FarFromCentroid ─────────────────────────────
953
954    #[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        // 5 tight points + 1 very far point
963        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    // ── 25: detect_outliers flags IsolatedPoint ────────────────────────────────
984
985    #[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        // Only 2 points in cluster (< min_cluster_size=3)
993        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    // ── 26: detect_outliers caps at max_outlier_fraction ──────────────────────
1005
1006    #[test]
1007    fn test_detect_outliers_cap() {
1008        let mut a = EmbeddingClusterAnalyzer::new(EcaAnalyzerConfig {
1009            outlier_threshold_sigma: 0.001, // very sensitive — many outliers
1010            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    // ── 27: local_density counts neighbors within radius ─────────────────────
1030
1031    #[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        // origin should have 2 neighbors (near1 and near2)
1043        let density = a.local_density(0);
1044        assert!((density - 2.0).abs() < 1e-10);
1045    }
1046
1047    // ── 28: local_density returns 0.0 for invalid index ──────────────────────
1048
1049    #[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    // ── 29: cluster_evolution detects centroid shift ──────────────────────────
1056
1057    #[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        // shift centroid by 5.0 >> 0.1 threshold
1064        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    // ── 30: cluster_evolution no event for tiny shift ────────────────────────
1072
1073    #[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    // ── 31: cluster_evolution empty when no prev clusters ────────────────────
1086
1087    #[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        // prev has no clusters, so no events can be generated
1095        assert!(events.is_empty());
1096    }
1097
1098    // ── 32: top_k_by_cluster returns correct count ───────────────────────────
1099
1100    #[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    // ── 33: top_k_by_cluster ordered by distance ─────────────────────────────
1112
1113    #[test]
1114    fn test_top_k_by_cluster_order() {
1115        let mut a = make_analyzer();
1116        // distances from centroid [0.0] will be 5,3,1 — sorted → 1,3,5
1117        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    // ── 34: top_k_by_cluster returns empty for unknown cluster ────────────────
1129
1130    #[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    // ── 35: analyzer_stats correct point count ────────────────────────────────
1140
1141    #[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    // ── 36: analyzer_stats correct cluster count ──────────────────────────────
1151
1152    #[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    // ── 37: analyzer_stats avg_cluster_size ──────────────────────────────────
1164
1165    #[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    // ── 38: analyzer_stats outlier_count after detect_outliers ───────────────
1180
1181    #[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    // ── 39: multiple set_clusters replaces previous ───────────────────────────
1199
1200    #[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    // ── 40: nearest cluster assignment uses cosine metric ─────────────────────
1212
1213    #[test]
1214    fn test_assignment_uses_cosine() {
1215        let mut a = make_analyzer();
1216        // [1.0, 0.0] is closer (cosine) to [2.0, 0.0] than to [0.0, 1.0]
1217        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    // ── 41: calinski_harabász positive for well-separated clusters ────────────
1226
1227    #[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    // ── 42: davies_bouldin low for well-separated clusters ────────────────────
1253
1254    #[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    // ── 43: outlier score ordering (highest first) ────────────────────────────
1280
1281    #[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        // 5 tight cluster members + 2 outliers at different distances
1290        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    // ── 44: ClusterDescriptor label stored correctly ──────────────────────────
1309
1310    #[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    // ── 45: EcaClusterPoint fields accessible ────────────────────────────────
1318
1319    #[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}