1#[derive(Debug, Clone)]
8pub struct ClusterPoint {
9 pub id: u64,
11 pub vector: Vec<f32>,
13 pub cluster_id: Option<usize>,
15}
16
17#[derive(Debug, Clone)]
19pub struct Cluster {
20 pub id: usize,
22 pub centroid: Vec<f32>,
24 pub member_ids: Vec<u64>,
26}
27
28impl Cluster {
29 #[inline]
31 pub fn size(&self) -> usize {
32 self.member_ids.len()
33 }
34
35 #[inline]
37 pub fn is_empty(&self) -> bool {
38 self.member_ids.is_empty()
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct ClusterStats {
45 pub k: usize,
47 pub total_points: usize,
49 pub inertia: f64,
51 pub largest_cluster: usize,
53 pub smallest_cluster: usize,
55}
56
57impl ClusterStats {
58 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#[derive(Debug, Clone)]
82pub struct AnalyzerConfig {
83 pub max_iterations: usize,
85 pub convergence_threshold: f64,
87 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#[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#[inline]
120fn euclidean_distance(a: &[f32], b: &[f32]) -> f64 {
121 squared_distance(a, b).sqrt()
122}
123
124pub struct SemanticClusterAnalyzer {
133 pub points: Vec<ClusterPoint>,
135 pub clusters: Vec<Cluster>,
137 pub config: AnalyzerConfig,
139}
140
141impl SemanticClusterAnalyzer {
142 pub fn new(config: AnalyzerConfig) -> Self {
144 Self {
145 points: Vec::new(),
146 clusters: Vec::new(),
147 config,
148 }
149 }
150
151 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 pub fn run_kmeans(&mut self, k: usize) -> ClusterStats {
173 if k == 0 || self.points.is_empty() || k > self.points.len() {
174 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 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 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 for _iter in 0..self.config.max_iterations {
206 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 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 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(¢roids[ci]);
240 }
241 }
242
243 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 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 self.compute_stats_internal(k)
276 }
277
278 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 pub fn outliers(&self, factor: f64) -> Vec<u64> {
309 if self.clusters.is_empty() {
310 return Vec::new();
311 }
312
313 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 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 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#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn analyzer() -> SemanticClusterAnalyzer {
373 SemanticClusterAnalyzer::new(AnalyzerConfig::default())
374 }
375
376 fn fill_two_clusters(a: &mut SemanticClusterAnalyzer) {
377 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 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 #[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 #[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 #[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 #[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 assert!(!a.clusters[0].is_empty());
435 assert!(!a.clusters[1].is_empty());
436 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 #[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 let stats = a.run_kmeans(2);
465 assert_eq!(stats.k, 2);
466 assert!(stats.inertia >= 0.0);
467 }
468
469 #[test]
471 fn test_outliers_detected() {
472 let mut a = analyzer();
473 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 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 #[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 assert!(
497 out.len() < a.points.len(),
498 "not all points should be outliers"
499 );
500 }
501
502 #[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 #[test]
512 fn test_nearest_cluster() {
513 let mut a = analyzer();
514 fill_two_clusters(&mut a);
515 a.run_kmeans(2);
516 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 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 #[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 #[test]
543 fn test_balance_ratio_perfect() {
544 let mut a = analyzer();
545 fill_two_clusters(&mut a); 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 #[test]
556 fn test_balance_ratio_unbalanced() {
557 let mut a = analyzer();
558 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 #[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 #[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 #[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 #[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 #[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); assert_eq!(stats.k, 0);
632 assert_eq!(stats.total_points, 0);
633 assert!(a.clusters.is_empty());
634 }
635
636 #[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 #[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 #[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}