Skip to main content

ipfrs_semantic/
cluster_analyzer.rs

1//! Semantic Cluster Analyzer — k-means++ style cluster analysis over embedding vectors.
2//!
3//! Provides cluster statistics, inertia computation, and outlier detection
4//! using a deterministic k-means++ initialization strategy (no random selection).
5
6/// A single point in the embedding space.
7#[derive(Debug, Clone)]
8pub struct ClusterPoint {
9    /// Unique identifier for this point.
10    pub id: u64,
11    /// The embedding vector.
12    pub vector: Vec<f32>,
13    /// The cluster this point is assigned to, or `None` if unassigned.
14    pub cluster_id: Option<usize>,
15}
16
17/// A cluster of embedding vectors with a computed centroid.
18#[derive(Debug, Clone)]
19pub struct Cluster {
20    /// Cluster index (0-based).
21    pub id: usize,
22    /// The centroid vector (mean of all member vectors).
23    pub centroid: Vec<f32>,
24    /// IDs of all member points.
25    pub member_ids: Vec<u64>,
26}
27
28impl Cluster {
29    /// Returns the number of members in this cluster.
30    #[inline]
31    pub fn size(&self) -> usize {
32        self.member_ids.len()
33    }
34
35    /// Returns `true` when the cluster has no members.
36    #[inline]
37    pub fn is_empty(&self) -> bool {
38        self.member_ids.is_empty()
39    }
40}
41
42/// Aggregate statistics over a completed k-means run.
43#[derive(Debug, Clone)]
44pub struct ClusterStats {
45    /// Number of clusters requested (`k`).
46    pub k: usize,
47    /// Total number of points clustered.
48    pub total_points: usize,
49    /// Sum of squared Euclidean distances from each point to its assigned centroid.
50    pub inertia: f64,
51    /// Size of the largest cluster.
52    pub largest_cluster: usize,
53    /// Size of the smallest non-empty cluster.
54    pub smallest_cluster: usize,
55}
56
57impl ClusterStats {
58    /// Returns `smallest / largest` (1.0 = perfectly balanced).
59    /// Returns 0.0 when `largest_cluster == 0`.
60    pub fn balance_ratio(&self) -> f64 {
61        if self.largest_cluster == 0 {
62            return 0.0;
63        }
64        self.smallest_cluster as f64 / self.largest_cluster as f64
65    }
66}
67
68impl Default for ClusterStats {
69    fn default() -> Self {
70        Self {
71            k: 0,
72            total_points: 0,
73            inertia: 0.0,
74            largest_cluster: 0,
75            smallest_cluster: 0,
76        }
77    }
78}
79
80/// Configuration for the [`SemanticClusterAnalyzer`].
81#[derive(Debug, Clone)]
82pub struct AnalyzerConfig {
83    /// Maximum number of k-means iterations (default 50).
84    pub max_iterations: usize,
85    /// Stop early when all centroid movements fall below this threshold (default 1e-4).
86    pub convergence_threshold: f64,
87    /// Point is considered an outlier when its distance to its centroid exceeds
88    /// `outlier_distance_factor * avg_intra_distance` (default 3.0).
89    pub outlier_distance_factor: f64,
90}
91
92impl Default for AnalyzerConfig {
93    fn default() -> Self {
94        Self {
95            max_iterations: 50,
96            convergence_threshold: 1e-4,
97            outlier_distance_factor: 3.0,
98        }
99    }
100}
101
102// ──────────────────────────────────────────────────────────────────────────────
103// Internal helpers
104// ──────────────────────────────────────────────────────────────────────────────
105
106/// Squared Euclidean distance between two equal-length slices.
107#[inline]
108fn squared_distance(a: &[f32], b: &[f32]) -> f64 {
109    a.iter()
110        .zip(b.iter())
111        .map(|(&x, &y)| {
112            let d = (x - y) as f64;
113            d * d
114        })
115        .sum()
116}
117
118/// Euclidean distance between two equal-length slices.
119#[inline]
120fn euclidean_distance(a: &[f32], b: &[f32]) -> f64 {
121    squared_distance(a, b).sqrt()
122}
123
124// ──────────────────────────────────────────────────────────────────────────────
125// SemanticClusterAnalyzer
126// ──────────────────────────────────────────────────────────────────────────────
127
128/// Performs k-means++ style cluster analysis over a set of embedding vectors.
129///
130/// Initialization is **deterministic** (k-means++ greedy farthest-point selection,
131/// no randomness), which means results are reproducible given the same input order.
132pub struct SemanticClusterAnalyzer {
133    /// All managed points.
134    pub points: Vec<ClusterPoint>,
135    /// Clusters produced by the most recent [`run_kmeans`](Self::run_kmeans) call.
136    pub clusters: Vec<Cluster>,
137    /// Analyzer configuration.
138    pub config: AnalyzerConfig,
139}
140
141impl SemanticClusterAnalyzer {
142    /// Create a new analyzer with the given configuration.
143    pub fn new(config: AnalyzerConfig) -> Self {
144        Self {
145            points: Vec::new(),
146            clusters: Vec::new(),
147            config,
148        }
149    }
150
151    /// Add a point to the analyzer.
152    pub fn add_point(&mut self, id: u64, vector: Vec<f32>) {
153        self.points.push(ClusterPoint {
154            id,
155            vector,
156            cluster_id: None,
157        });
158    }
159
160    /// Run k-means clustering with `k` clusters.
161    ///
162    /// ### Initialization (deterministic k-means++ style)
163    /// 1. First centroid = `points[0].vector`.
164    /// 2. Each subsequent centroid = the point whose *minimum* distance to any
165    ///    already-chosen centroid is the largest (greedy farthest-point).
166    ///
167    /// ### Iteration
168    /// Assign → recompute → check convergence (or `max_iterations` reached).
169    ///
170    /// Returns a default [`ClusterStats`] when `k == 0`, `points` is empty,
171    /// or `k > points.len()`.
172    pub fn run_kmeans(&mut self, k: usize) -> ClusterStats {
173        if k == 0 || self.points.is_empty() || k > self.points.len() {
174            // Reset cluster assignments
175            for p in &mut self.points {
176                p.cluster_id = None;
177            }
178            self.clusters.clear();
179            return ClusterStats::default();
180        }
181
182        let dim = self.points[0].vector.len();
183
184        // ── Step 1: Deterministic k-means++ centroid initialisation ──────────
185        let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
186        centroids.push(self.points[0].vector.clone());
187
188        for _ in 1..k {
189            // For each point, compute its minimum distance to any existing centroid.
190            let farthest_idx = (0..self.points.len())
191                .map(|i| {
192                    let min_dist = centroids
193                        .iter()
194                        .map(|c| squared_distance(&self.points[i].vector, c))
195                        .fold(f64::INFINITY, f64::min);
196                    (i, min_dist)
197                })
198                .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
199                .map(|(idx, _)| idx)
200                .unwrap_or(0);
201            centroids.push(self.points[farthest_idx].vector.clone());
202        }
203
204        // ── Step 2: Iterative assignment & centroid update ───────────────────
205        for _iter in 0..self.config.max_iterations {
206            // Assignment step
207            for p in &mut self.points {
208                let nearest = centroids
209                    .iter()
210                    .enumerate()
211                    .map(|(ci, c)| (ci, squared_distance(&p.vector, c)))
212                    .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
213                    .map(|(ci, _)| ci)
214                    .unwrap_or(0);
215                p.cluster_id = Some(nearest);
216            }
217
218            // Recompute centroids
219            let mut new_centroids: Vec<Vec<f32>> = vec![vec![0.0f32; dim]; k];
220            let mut counts: Vec<usize> = vec![0; k];
221
222            for p in &self.points {
223                if let Some(ci) = p.cluster_id {
224                    counts[ci] += 1;
225                    for (d, &v) in new_centroids[ci].iter_mut().zip(p.vector.iter()) {
226                        *d += v;
227                    }
228                }
229            }
230
231            // Average; keep old centroid for empty clusters
232            for ci in 0..k {
233                if counts[ci] > 0 {
234                    let n = counts[ci] as f32;
235                    for d in &mut new_centroids[ci] {
236                        *d /= n;
237                    }
238                } else {
239                    new_centroids[ci].clone_from(&centroids[ci]);
240                }
241            }
242
243            // Convergence check: all movements < threshold
244            let converged = centroids
245                .iter()
246                .zip(new_centroids.iter())
247                .all(|(old, new)| euclidean_distance(old, new) < self.config.convergence_threshold);
248
249            centroids = new_centroids;
250
251            if converged {
252                break;
253            }
254        }
255
256        // ── Step 3: Build Cluster structs ────────────────────────────────────
257        let mut cluster_members: Vec<Vec<u64>> = vec![Vec::new(); k];
258        for p in &self.points {
259            if let Some(ci) = p.cluster_id {
260                cluster_members[ci].push(p.id);
261            }
262        }
263
264        self.clusters = centroids
265            .iter()
266            .enumerate()
267            .map(|(ci, c)| Cluster {
268                id: ci,
269                centroid: c.clone(),
270                member_ids: cluster_members[ci].clone(),
271            })
272            .collect();
273
274        // ── Step 4: Compute stats ────────────────────────────────────────────
275        self.compute_stats_internal(k)
276    }
277
278    // ─── Internal helpers ─────────────────────────────────────────────────────
279
280    /// Build [`ClusterStats`] from the current cluster and point state.
281    fn compute_stats_internal(&self, k: usize) -> ClusterStats {
282        let mut inertia = 0.0f64;
283        for p in &self.points {
284            if let Some(ci) = p.cluster_id {
285                if let Some(cluster) = self.clusters.get(ci) {
286                    inertia += squared_distance(&p.vector, &cluster.centroid);
287                }
288            }
289        }
290
291        let sizes: Vec<usize> = self.clusters.iter().map(|c| c.size()).collect();
292        let largest = sizes.iter().copied().max().unwrap_or(0);
293        let smallest = sizes.iter().copied().filter(|&s| s > 0).min().unwrap_or(0);
294
295        ClusterStats {
296            k,
297            total_points: self.points.len(),
298            inertia,
299            largest_cluster: largest,
300            smallest_cluster: smallest,
301        }
302    }
303
304    /// Return IDs of points whose distance to their centroid exceeds
305    /// `factor * avg_intra_distance`.
306    ///
307    /// If there are no assigned points the list is empty.
308    pub fn outliers(&self, factor: f64) -> Vec<u64> {
309        if self.clusters.is_empty() {
310            return Vec::new();
311        }
312
313        // Gather (point_id, distance_to_centroid) for every assigned point.
314        let distances: Vec<(u64, f64)> = self
315            .points
316            .iter()
317            .filter_map(|p| {
318                p.cluster_id.and_then(|ci| {
319                    self.clusters.get(ci).map(|c| {
320                        let dist = euclidean_distance(&p.vector, &c.centroid);
321                        (p.id, dist)
322                    })
323                })
324            })
325            .collect();
326
327        if distances.is_empty() {
328            return Vec::new();
329        }
330
331        let avg: f64 = distances.iter().map(|(_, d)| d).sum::<f64>() / distances.len() as f64;
332        let threshold = factor * avg;
333
334        distances
335            .into_iter()
336            .filter(|(_, d)| *d > threshold)
337            .map(|(id, _)| id)
338            .collect()
339    }
340
341    /// Return the cluster id of the nearest centroid to `query`,
342    /// or `None` when no clusters exist.
343    pub fn nearest_cluster(&self, query: &[f32]) -> Option<usize> {
344        self.clusters
345            .iter()
346            .map(|c| (c.id, squared_distance(query, &c.centroid)))
347            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
348            .map(|(id, _)| id)
349    }
350
351    /// Return cluster statistics for the current state.
352    ///
353    /// Returns a default [`ClusterStats`] when no clusters have been computed.
354    pub fn stats(&self) -> ClusterStats {
355        if self.clusters.is_empty() {
356            return ClusterStats::default();
357        }
358        self.compute_stats_internal(self.clusters.len())
359    }
360}
361
362// ──────────────────────────────────────────────────────────────────────────────
363// Tests
364// ──────────────────────────────────────────────────────────────────────────────
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    // ── helpers ───────────────────────────────────────────────────────────────
371
372    fn analyzer() -> SemanticClusterAnalyzer {
373        SemanticClusterAnalyzer::new(AnalyzerConfig::default())
374    }
375
376    fn fill_two_clusters(a: &mut SemanticClusterAnalyzer) {
377        // Group A — around (0, 0)
378        a.add_point(1, vec![0.0, 0.0]);
379        a.add_point(2, vec![0.1, 0.0]);
380        a.add_point(3, vec![0.0, 0.1]);
381        a.add_point(4, vec![0.05, 0.05]);
382        // Group B — around (10, 10)
383        a.add_point(5, vec![10.0, 10.0]);
384        a.add_point(6, vec![10.1, 10.0]);
385        a.add_point(7, vec![10.0, 10.1]);
386        a.add_point(8, vec![9.95, 9.95]);
387    }
388
389    // ── 1. add_point stores the correct id and vector ─────────────────────────
390    #[test]
391    fn test_add_point_stores_correctly() {
392        let mut a = analyzer();
393        a.add_point(42, vec![1.0, 2.0, 3.0]);
394        assert_eq!(a.points.len(), 1);
395        assert_eq!(a.points[0].id, 42);
396        assert_eq!(a.points[0].vector, vec![1.0, 2.0, 3.0]);
397        assert!(a.points[0].cluster_id.is_none());
398    }
399
400    // ── 2. add_point: multiple points accumulate ──────────────────────────────
401    #[test]
402    fn test_add_point_multiple() {
403        let mut a = analyzer();
404        for i in 0..10u64 {
405            a.add_point(i, vec![i as f32; 4]);
406        }
407        assert_eq!(a.points.len(), 10);
408    }
409
410    // ── 3. run_kmeans k=1 assigns all points to cluster 0 ────────────────────
411    #[test]
412    fn test_kmeans_k1_assigns_all() {
413        let mut a = analyzer();
414        fill_two_clusters(&mut a);
415        let stats = a.run_kmeans(1);
416        assert_eq!(stats.k, 1);
417        assert_eq!(stats.total_points, 8);
418        for p in &a.points {
419            assert_eq!(p.cluster_id, Some(0));
420        }
421        assert_eq!(a.clusters.len(), 1);
422        assert_eq!(a.clusters[0].member_ids.len(), 8);
423    }
424
425    // ── 4. run_kmeans k=2 separates the two well-separated groups ─────────────
426    #[test]
427    fn test_kmeans_k2_separates_clusters() {
428        let mut a = analyzer();
429        fill_two_clusters(&mut a);
430        let stats = a.run_kmeans(2);
431        assert_eq!(stats.k, 2);
432        assert_eq!(stats.total_points, 8);
433        // Both clusters should be non-empty
434        assert!(!a.clusters[0].is_empty());
435        assert!(!a.clusters[1].is_empty());
436        // All points from group A should be in one cluster and group B in another
437        // ids 1-4 should share a cluster; 5-8 should share the other
438        let c_of = |id: u64| {
439            a.points
440                .iter()
441                .find(|p| p.id == id)
442                .and_then(|p| p.cluster_id)
443        };
444        assert_eq!(c_of(1), c_of(2));
445        assert_eq!(c_of(2), c_of(3));
446        assert_eq!(c_of(3), c_of(4));
447        assert_eq!(c_of(5), c_of(6));
448        assert_eq!(c_of(6), c_of(7));
449        assert_eq!(c_of(7), c_of(8));
450        assert_ne!(c_of(1), c_of(5));
451    }
452
453    // ── 5. convergence stops before max_iterations ────────────────────────────
454    #[test]
455    fn test_convergence_stops_early() {
456        let config = AnalyzerConfig {
457            max_iterations: 1000,
458            convergence_threshold: 1e-4,
459            outlier_distance_factor: 3.0,
460        };
461        let mut a = SemanticClusterAnalyzer::new(config);
462        fill_two_clusters(&mut a);
463        // Should still produce correct result (convergence kicks in before 1000)
464        let stats = a.run_kmeans(2);
465        assert_eq!(stats.k, 2);
466        assert!(stats.inertia >= 0.0);
467    }
468
469    // ── 6. outliers: single outlier is detected ───────────────────────────────
470    #[test]
471    fn test_outliers_detected() {
472        let mut a = analyzer();
473        // Large tight cluster around origin so the centroid stays very close to (0,0)
474        // and the avg intra-cluster distance stays tiny.
475        for i in 0..50u64 {
476            let v = i as f32 * 0.001;
477            a.add_point(i + 1, vec![v, 0.0]);
478        }
479        // Far outlier — well beyond 3x avg intra-distance
480        a.add_point(999, vec![500.0, 500.0]);
481        a.run_kmeans(1);
482        let out = a.outliers(a.config.outlier_distance_factor);
483        assert!(out.contains(&999), "outlier id 999 should be detected");
484    }
485
486    // ── 7. outliers: no outliers in perfectly uniform data ─────────────────────
487    #[test]
488    fn test_no_outliers_uniform() {
489        let mut a = analyzer();
490        for i in 0..8u64 {
491            a.add_point(i, vec![i as f32 * 0.001, 0.0]);
492        }
493        a.run_kmeans(1);
494        let out = a.outliers(3.0);
495        // With uniform spacing, no point should be a dramatic outlier at factor 3
496        assert!(
497            out.len() < a.points.len(),
498            "not all points should be outliers"
499        );
500    }
501
502    // ── 8. outliers: empty clusters returns empty vec ─────────────────────────
503    #[test]
504    fn test_outliers_no_clusters() {
505        let a = analyzer();
506        let out = a.outliers(3.0);
507        assert!(out.is_empty());
508    }
509
510    // ── 9. nearest_cluster: returns correct cluster id ────────────────────────
511    #[test]
512    fn test_nearest_cluster() {
513        let mut a = analyzer();
514        fill_two_clusters(&mut a);
515        a.run_kmeans(2);
516        // A query near (0,0) should map to the cluster containing point 1
517        let nc_near_origin = a.nearest_cluster(&[0.0, 0.0]);
518        let cluster_of_pt1 = a
519            .points
520            .iter()
521            .find(|p| p.id == 1)
522            .and_then(|p| p.cluster_id);
523        assert_eq!(nc_near_origin, cluster_of_pt1);
524        // A query near (10,10) should map to the cluster containing point 5
525        let nc_near_10 = a.nearest_cluster(&[10.0, 10.0]);
526        let cluster_of_pt5 = a
527            .points
528            .iter()
529            .find(|p| p.id == 5)
530            .and_then(|p| p.cluster_id);
531        assert_eq!(nc_near_10, cluster_of_pt5);
532    }
533
534    // ── 10. nearest_cluster: none when no clusters ───────────────────────────
535    #[test]
536    fn test_nearest_cluster_empty() {
537        let a = analyzer();
538        assert!(a.nearest_cluster(&[1.0, 2.0]).is_none());
539    }
540
541    // ── 11. balance_ratio: 1.0 for perfectly balanced clusters ───────────────
542    #[test]
543    fn test_balance_ratio_perfect() {
544        let mut a = analyzer();
545        fill_two_clusters(&mut a); // 4 + 4 points
546        let stats = a.run_kmeans(2);
547        assert!(
548            (stats.balance_ratio() - 1.0).abs() < 1e-9,
549            "expected ratio 1.0, got {}",
550            stats.balance_ratio()
551        );
552    }
553
554    // ── 12. balance_ratio: < 1.0 for unbalanced clusters ─────────────────────
555    #[test]
556    fn test_balance_ratio_unbalanced() {
557        let mut a = analyzer();
558        // 1 vs 9 points (well-separated so k=2 gives those sizes)
559        a.add_point(1, vec![0.0, 0.0]);
560        for i in 2..=10u64 {
561            a.add_point(i, vec![100.0 + i as f32 * 0.01, 0.0]);
562        }
563        let stats = a.run_kmeans(2);
564        assert!(
565            stats.balance_ratio() < 1.0,
566            "ratio should be < 1.0, got {}",
567            stats.balance_ratio()
568        );
569    }
570
571    // ── 13. balance_ratio: 0.0 when largest == 0 ─────────────────────────────
572    #[test]
573    fn test_balance_ratio_zero() {
574        let stats = ClusterStats {
575            k: 0,
576            total_points: 0,
577            inertia: 0.0,
578            largest_cluster: 0,
579            smallest_cluster: 0,
580        };
581        assert_eq!(stats.balance_ratio(), 0.0);
582    }
583
584    // ── 14. inertia > 0 for non-trivial data ─────────────────────────────────
585    #[test]
586    fn test_inertia_positive() {
587        let mut a = analyzer();
588        fill_two_clusters(&mut a);
589        let stats = a.run_kmeans(2);
590        assert!(stats.inertia > 0.0, "inertia should be positive");
591    }
592
593    // ── 15. inertia decreases from k=1 to k=2 ────────────────────────────────
594    #[test]
595    fn test_inertia_decreases_with_more_clusters() {
596        let mut a1 = analyzer();
597        fill_two_clusters(&mut a1);
598        let stats1 = a1.run_kmeans(1);
599
600        let mut a2 = analyzer();
601        fill_two_clusters(&mut a2);
602        let stats2 = a2.run_kmeans(2);
603
604        assert!(
605            stats2.inertia < stats1.inertia,
606            "inertia with k=2 ({}) should be less than k=1 ({})",
607            stats2.inertia,
608            stats1.inertia
609        );
610    }
611
612    // ── 16. stats() returns current cluster state ─────────────────────────────
613    #[test]
614    fn test_stats_reflects_clusters() {
615        let mut a = analyzer();
616        fill_two_clusters(&mut a);
617        a.run_kmeans(2);
618        let s = a.stats();
619        assert_eq!(s.k, 2);
620        assert_eq!(s.total_points, 8);
621        assert!(s.inertia >= 0.0);
622    }
623
624    // ── 17. empty guard: k > n returns default stats ──────────────────────────
625    #[test]
626    fn test_guard_k_greater_than_n() {
627        let mut a = analyzer();
628        a.add_point(1, vec![0.0, 0.0]);
629        a.add_point(2, vec![1.0, 1.0]);
630        let stats = a.run_kmeans(5); // k=5 > n=2
631        assert_eq!(stats.k, 0);
632        assert_eq!(stats.total_points, 0);
633        assert!(a.clusters.is_empty());
634    }
635
636    // ── 18. empty guard: no points returns default stats ──────────────────────
637    #[test]
638    fn test_guard_empty_points() {
639        let mut a = analyzer();
640        let stats = a.run_kmeans(3);
641        assert_eq!(stats.k, 0);
642    }
643
644    // ── 19. Cluster::size and is_empty ────────────────────────────────────────
645    #[test]
646    fn test_cluster_size_and_is_empty() {
647        let c_empty = Cluster {
648            id: 0,
649            centroid: vec![0.0],
650            member_ids: vec![],
651        };
652        let c_full = Cluster {
653            id: 1,
654            centroid: vec![1.0],
655            member_ids: vec![1, 2, 3],
656        };
657        assert!(c_empty.is_empty());
658        assert_eq!(c_empty.size(), 0);
659        assert!(!c_full.is_empty());
660        assert_eq!(c_full.size(), 3);
661    }
662
663    // ── 20. nearest_cluster after k=1 always returns Some(0) ─────────────────
664    #[test]
665    fn test_nearest_cluster_k1() {
666        let mut a = analyzer();
667        fill_two_clusters(&mut a);
668        a.run_kmeans(1);
669        assert_eq!(a.nearest_cluster(&[999.0, 999.0]), Some(0));
670        assert_eq!(a.nearest_cluster(&[-999.0, -999.0]), Some(0));
671    }
672}