1#[derive(Debug, Clone, PartialEq)]
8pub struct ClusterAssignment {
9 pub doc_id: u64,
11 pub cluster_id: usize,
13 pub distance: f32,
15}
16
17#[derive(Debug, Clone)]
19pub struct SemanticCluster {
20 pub cluster_id: usize,
22 pub centroid: Vec<f32>,
24 pub member_count: u64,
26 pub total_drift: f32,
28}
29
30impl SemanticCluster {
31 pub fn euclidean_distance(&self, embedding: &[f32]) -> f32 {
35 if self.centroid.is_empty() || embedding.is_empty() {
36 return 0.0;
37 }
38 if self.centroid.len() != embedding.len() {
39 return 0.0;
40 }
41 let sum_sq: f32 = self
42 .centroid
43 .iter()
44 .zip(embedding.iter())
45 .map(|(a, b)| (a - b) * (a - b))
46 .sum();
47 sum_sq.sqrt()
48 }
49
50 pub fn update_centroid(&mut self, new_embedding: &[f32], learning_rate: f32) {
59 if self.centroid.len() != new_embedding.len() {
60 return;
61 }
62 if self.centroid.is_empty() {
63 return;
64 }
65
66 let old_centroid = self.centroid.clone();
67
68 for (c, &e) in self.centroid.iter_mut().zip(new_embedding.iter()) {
69 *c = (1.0 - learning_rate) * (*c) + learning_rate * e;
70 }
71
72 let drift: f32 = old_centroid
74 .iter()
75 .zip(self.centroid.iter())
76 .map(|(a, b)| (a - b) * (a - b))
77 .sum::<f32>()
78 .sqrt();
79
80 self.total_drift += drift;
81 self.member_count += 1;
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct ClusterManagerConfig {
88 pub n_clusters: usize,
90 pub learning_rate: f32,
92 pub drift_threshold: f32,
94}
95
96impl Default for ClusterManagerConfig {
97 fn default() -> Self {
98 Self {
99 n_clusters: 8,
100 learning_rate: 0.1,
101 drift_threshold: 0.5,
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct ClusterManagerStats {
109 pub total_clusters: usize,
111 pub total_assignments: u64,
113 pub avg_cluster_size: f64,
115 pub most_drifted_cluster: Option<usize>,
117 pub unstable_clusters: usize,
119}
120
121pub struct SemanticClusterManager {
126 pub clusters: Vec<SemanticCluster>,
128 pub config: ClusterManagerConfig,
130 pub total_assignments: u64,
132}
133
134impl SemanticClusterManager {
135 pub fn new(config: ClusterManagerConfig) -> Self {
137 let n = config.n_clusters;
138 let clusters = (0..n)
139 .map(|i| SemanticCluster {
140 cluster_id: i,
141 centroid: Vec::new(),
142 member_count: 0,
143 total_drift: 0.0,
144 })
145 .collect();
146 Self {
147 clusters,
148 config,
149 total_assignments: 0,
150 }
151 }
152
153 pub fn initialize_centroids(&mut self, centroids: Vec<Vec<f32>>) {
157 let limit = centroids.len().min(self.config.n_clusters);
158 for (i, centroid) in centroids.into_iter().take(limit).enumerate() {
159 self.clusters[i].centroid = centroid;
160 }
161 }
162
163 pub fn assign(&mut self, doc_id: u64, embedding: &[f32]) -> Option<ClusterAssignment> {
170 let mut best_cluster_id: Option<usize> = None;
172 let mut best_distance = f32::MAX;
173
174 for cluster in &self.clusters {
175 if cluster.centroid.is_empty() || cluster.centroid.len() != embedding.len() {
176 continue;
177 }
178 let dist = cluster.euclidean_distance(embedding);
179 if dist < best_distance {
180 best_distance = dist;
181 best_cluster_id = Some(cluster.cluster_id);
182 }
183 }
184
185 let cluster_id = best_cluster_id?;
186
187 let lr = self.config.learning_rate;
189 self.clusters[cluster_id].update_centroid(embedding, lr);
190
191 self.total_assignments += 1;
192
193 Some(ClusterAssignment {
194 doc_id,
195 cluster_id,
196 distance: best_distance,
197 })
198 }
199
200 pub fn nearest_cluster(&self, embedding: &[f32]) -> Option<usize> {
203 let mut best_cluster_id: Option<usize> = None;
204 let mut best_distance = f32::MAX;
205
206 for cluster in &self.clusters {
207 if cluster.centroid.is_empty() || cluster.centroid.len() != embedding.len() {
208 continue;
209 }
210 let dist = cluster.euclidean_distance(embedding);
211 if dist < best_distance {
212 best_distance = dist;
213 best_cluster_id = Some(cluster.cluster_id);
214 }
215 }
216
217 best_cluster_id
218 }
219
220 pub fn cluster(&self, cluster_id: usize) -> Option<&SemanticCluster> {
222 self.clusters.get(cluster_id)
223 }
224
225 pub fn stats(&self) -> ClusterManagerStats {
227 let total_clusters = self.clusters.len();
228
229 let avg_cluster_size = if total_clusters == 0 {
230 0.0
231 } else {
232 let total_members: u64 = self.clusters.iter().map(|c| c.member_count).sum();
233 total_members as f64 / total_clusters as f64
234 };
235
236 let most_drifted_cluster = self
238 .clusters
239 .iter()
240 .max_by(|a, b| {
241 a.total_drift
242 .partial_cmp(&b.total_drift)
243 .unwrap_or(std::cmp::Ordering::Equal)
244 })
245 .and_then(|c| {
246 if c.total_drift > 0.0 {
247 Some(c.cluster_id)
248 } else {
249 None
250 }
251 });
252
253 let threshold = self.config.drift_threshold;
254 let unstable_clusters = self
255 .clusters
256 .iter()
257 .filter(|c| c.total_drift > threshold)
258 .count();
259
260 ClusterManagerStats {
261 total_clusters,
262 total_assignments: self.total_assignments,
263 avg_cluster_size,
264 most_drifted_cluster,
265 unstable_clusters,
266 }
267 }
268
269 pub fn reset_drift(&mut self) {
271 for cluster in &mut self.clusters {
272 cluster.total_drift = 0.0;
273 }
274 }
275}
276
277#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn make_config(n_clusters: usize) -> ClusterManagerConfig {
284 ClusterManagerConfig {
285 n_clusters,
286 learning_rate: 0.1,
287 drift_threshold: 0.5,
288 }
289 }
290
291 fn make_manager(n_clusters: usize) -> SemanticClusterManager {
292 SemanticClusterManager::new(make_config(n_clusters))
293 }
294
295 #[test]
298 fn test_euclidean_distance_basic() {
299 let cluster = SemanticCluster {
300 cluster_id: 0,
301 centroid: vec![0.0, 0.0, 0.0],
302 member_count: 0,
303 total_drift: 0.0,
304 };
305 let dist = cluster.euclidean_distance(&[3.0, 4.0, 0.0]);
306 assert!((dist - 5.0).abs() < 1e-5, "expected 5.0, got {dist}");
307 }
308
309 #[test]
310 fn test_euclidean_distance_zero_when_same() {
311 let cluster = SemanticCluster {
312 cluster_id: 0,
313 centroid: vec![1.0, 2.0, 3.0],
314 member_count: 0,
315 total_drift: 0.0,
316 };
317 let dist = cluster.euclidean_distance(&[1.0, 2.0, 3.0]);
318 assert!(dist.abs() < 1e-6, "expected 0.0, got {dist}");
319 }
320
321 #[test]
322 fn test_euclidean_distance_empty_centroid_returns_zero() {
323 let cluster = SemanticCluster {
324 cluster_id: 0,
325 centroid: vec![],
326 member_count: 0,
327 total_drift: 0.0,
328 };
329 let dist = cluster.euclidean_distance(&[1.0, 2.0]);
330 assert_eq!(dist, 0.0);
331 }
332
333 #[test]
334 fn test_euclidean_distance_empty_embedding_returns_zero() {
335 let cluster = SemanticCluster {
336 cluster_id: 0,
337 centroid: vec![1.0, 2.0],
338 member_count: 0,
339 total_drift: 0.0,
340 };
341 let dist = cluster.euclidean_distance(&[]);
342 assert_eq!(dist, 0.0);
343 }
344
345 #[test]
346 fn test_euclidean_distance_dimension_mismatch_returns_zero() {
347 let cluster = SemanticCluster {
348 cluster_id: 0,
349 centroid: vec![1.0, 2.0],
350 member_count: 0,
351 total_drift: 0.0,
352 };
353 let dist = cluster.euclidean_distance(&[1.0, 2.0, 3.0]);
354 assert_eq!(dist, 0.0);
355 }
356
357 #[test]
358 fn test_update_centroid_shifts_toward_embedding() {
359 let mut cluster = SemanticCluster {
360 cluster_id: 0,
361 centroid: vec![0.0, 0.0],
362 member_count: 0,
363 total_drift: 0.0,
364 };
365 cluster.update_centroid(&[10.0, 10.0], 1.0);
367 assert!((cluster.centroid[0] - 10.0).abs() < 1e-5);
368 assert!((cluster.centroid[1] - 10.0).abs() < 1e-5);
369 }
370
371 #[test]
372 fn test_update_centroid_ema_formula() {
373 let mut cluster = SemanticCluster {
374 cluster_id: 0,
375 centroid: vec![0.0],
376 member_count: 0,
377 total_drift: 0.0,
378 };
379 cluster.update_centroid(&[1.0], 0.1);
380 assert!((cluster.centroid[0] - 0.1).abs() < 1e-6);
382 }
383
384 #[test]
385 fn test_update_centroid_increments_member_count() {
386 let mut cluster = SemanticCluster {
387 cluster_id: 0,
388 centroid: vec![0.0],
389 member_count: 5,
390 total_drift: 0.0,
391 };
392 cluster.update_centroid(&[1.0], 0.1);
393 assert_eq!(cluster.member_count, 6);
394 }
395
396 #[test]
397 fn test_update_centroid_accumulates_drift() {
398 let mut cluster = SemanticCluster {
399 cluster_id: 0,
400 centroid: vec![0.0, 0.0],
401 member_count: 0,
402 total_drift: 0.0,
403 };
404 cluster.update_centroid(&[10.0, 0.0], 0.1);
405 assert!(
407 (cluster.total_drift - 1.0).abs() < 1e-5,
408 "drift={}",
409 cluster.total_drift
410 );
411
412 cluster.update_centroid(&[10.0, 0.0], 0.1);
413 assert!(cluster.total_drift > 1.0);
415 }
416
417 #[test]
418 fn test_update_centroid_noop_on_dimension_mismatch() {
419 let mut cluster = SemanticCluster {
420 cluster_id: 0,
421 centroid: vec![1.0, 2.0],
422 member_count: 3,
423 total_drift: 0.5,
424 };
425 cluster.update_centroid(&[9.0, 9.0, 9.0], 0.5);
426 assert_eq!(cluster.member_count, 3);
428 assert!((cluster.total_drift - 0.5).abs() < 1e-6);
429 assert!((cluster.centroid[0] - 1.0).abs() < 1e-6);
430 }
431
432 #[test]
435 fn test_new_creates_correct_number_of_clusters() {
436 let mgr = make_manager(5);
437 assert_eq!(mgr.clusters.len(), 5);
438 for (i, c) in mgr.clusters.iter().enumerate() {
439 assert_eq!(c.cluster_id, i);
440 assert!(c.centroid.is_empty());
441 assert_eq!(c.member_count, 0);
442 }
443 }
444
445 #[test]
446 fn test_initialize_centroids_sets_centroids() {
447 let mut mgr = make_manager(3);
448 let centroids = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
449 mgr.initialize_centroids(centroids.clone());
450 for (i, c) in centroids.iter().enumerate() {
451 assert_eq!(&mgr.clusters[i].centroid, c);
452 }
453 }
454
455 #[test]
456 fn test_initialize_centroids_more_than_n_clusters_is_clamped() {
457 let mut mgr = make_manager(2);
458 let centroids = vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]];
459 mgr.initialize_centroids(centroids);
460 assert_eq!(mgr.clusters[0].centroid, vec![1.0]);
462 assert_eq!(mgr.clusters[1].centroid, vec![2.0]);
463 }
464
465 #[test]
466 fn test_assign_returns_none_when_no_initialised_clusters() {
467 let mut mgr = make_manager(3);
468 let result = mgr.assign(42, &[0.5, 0.5]);
469 assert!(result.is_none());
470 }
471
472 #[test]
473 fn test_assign_returns_none_on_dimension_mismatch() {
474 let mut mgr = make_manager(2);
475 mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
476 let result = mgr.assign(1, &[0.5, 0.5, 0.5]);
478 assert!(result.is_none());
479 }
480
481 #[test]
482 fn test_assign_returns_nearest_cluster() {
483 let mut mgr = make_manager(2);
484 mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
486
487 let result = mgr.assign(1, &[0.9, 0.1]).expect("should assign");
489 assert_eq!(result.cluster_id, 0);
490
491 let result2 = mgr.assign(2, &[0.1, 0.9]).expect("should assign");
493 assert_eq!(result2.cluster_id, 1);
494 }
495
496 #[test]
497 fn test_assign_distance_is_correct() {
498 let mut mgr = make_manager(1);
499 mgr.initialize_centroids(vec![vec![0.0, 0.0, 0.0]]);
500 let result = mgr.assign(1, &[3.0, 4.0, 0.0]).expect("should assign");
501 assert!(
503 (result.distance - 5.0).abs() < 1e-5,
504 "dist={}",
505 result.distance
506 );
507 }
508
509 #[test]
510 fn test_assign_increments_total_assignments() {
511 let mut mgr = make_manager(1);
512 mgr.initialize_centroids(vec![vec![0.0]]);
513 assert_eq!(mgr.total_assignments, 0);
514 mgr.assign(1, &[1.0]);
515 assert_eq!(mgr.total_assignments, 1);
516 mgr.assign(2, &[1.0]);
517 assert_eq!(mgr.total_assignments, 2);
518 }
519
520 #[test]
521 fn test_assign_increments_cluster_member_count() {
522 let mut mgr = make_manager(1);
523 mgr.initialize_centroids(vec![vec![0.0]]);
524 mgr.assign(1, &[1.0]);
525 mgr.assign(2, &[2.0]);
526 assert_eq!(mgr.clusters[0].member_count, 2);
527 }
528
529 #[test]
530 fn test_assign_updates_centroid() {
531 let mut mgr = make_manager(1);
532 mgr.initialize_centroids(vec![vec![0.0]]);
533 mgr.assign(1, &[1.0]);
534 assert!((mgr.clusters[0].centroid[0] - 0.1).abs() < 1e-5);
536 }
537
538 #[test]
539 fn test_nearest_cluster_no_mutation() {
540 let mut mgr = make_manager(2);
541 mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
542 let before_count = mgr.clusters[0].member_count;
543 let id = mgr.nearest_cluster(&[0.9, 0.1]).expect("should find");
544 assert_eq!(id, 0);
545 assert_eq!(
546 mgr.clusters[0].member_count, before_count,
547 "nearest_cluster must not mutate"
548 );
549 assert_eq!(mgr.total_assignments, 0);
550 }
551
552 #[test]
553 fn test_nearest_cluster_returns_none_for_uninitialised() {
554 let mgr = make_manager(3);
555 let result = mgr.nearest_cluster(&[0.5]);
556 assert!(result.is_none());
557 }
558
559 #[test]
560 fn test_nearest_cluster_returns_correct_id() {
561 let mut mgr = make_manager(3);
562 mgr.initialize_centroids(vec![vec![10.0, 0.0], vec![0.0, 10.0], vec![5.0, 5.0]]);
563 let id = mgr.nearest_cluster(&[5.1, 5.1]).expect("should find");
565 assert_eq!(id, 2);
566 }
567
568 #[test]
569 fn test_cluster_getter() {
570 let mut mgr = make_manager(3);
571 mgr.initialize_centroids(vec![vec![7.0]]);
572 let c = mgr.cluster(0).expect("cluster 0 exists");
573 assert_eq!(c.centroid, vec![7.0]);
574 assert!(mgr.cluster(100).is_none());
575 }
576
577 #[test]
578 fn test_stats_total_clusters() {
579 let mgr = make_manager(4);
580 assert_eq!(mgr.stats().total_clusters, 4);
581 }
582
583 #[test]
584 fn test_stats_total_assignments() {
585 let mut mgr = make_manager(1);
586 mgr.initialize_centroids(vec![vec![0.0]]);
587 mgr.assign(1, &[1.0]);
588 mgr.assign(2, &[2.0]);
589 assert_eq!(mgr.stats().total_assignments, 2);
590 }
591
592 #[test]
593 fn test_stats_avg_cluster_size() {
594 let mut mgr = make_manager(2);
595 mgr.initialize_centroids(vec![vec![0.0], vec![10.0]]);
596 mgr.assign(1, &[0.1]);
598 mgr.assign(2, &[0.2]);
599 mgr.assign(3, &[0.3]);
600 mgr.assign(4, &[9.9]);
601 let stats = mgr.stats();
602 assert!(
604 (stats.avg_cluster_size - 2.0).abs() < 1e-6,
605 "avg={}",
606 stats.avg_cluster_size
607 );
608 }
609
610 #[test]
611 fn test_stats_most_drifted_cluster() {
612 let mut mgr = make_manager(2);
613 mgr.initialize_centroids(vec![vec![0.0], vec![100.0]]);
614 for _ in 0..10 {
616 mgr.assign(99, &[0.0]); }
618 mgr.clusters[1].total_drift = 99.0;
620 let stats = mgr.stats();
621 assert_eq!(stats.most_drifted_cluster, Some(1));
622 }
623
624 #[test]
625 fn test_stats_most_drifted_cluster_none_when_all_zero() {
626 let mgr = make_manager(3);
627 let stats = mgr.stats();
628 assert!(stats.most_drifted_cluster.is_none());
629 }
630
631 #[test]
632 fn test_stats_unstable_clusters() {
633 let mut mgr = make_manager(3);
634 mgr.initialize_centroids(vec![vec![0.0], vec![5.0], vec![10.0]]);
635 mgr.clusters[0].total_drift = 0.1;
636 mgr.clusters[1].total_drift = 0.6; mgr.clusters[2].total_drift = 1.5; let stats = mgr.stats();
639 assert_eq!(stats.unstable_clusters, 2);
640 }
641
642 #[test]
643 fn test_reset_drift_zeroes_all() {
644 let mut mgr = make_manager(3);
645 mgr.clusters[0].total_drift = 1.0;
646 mgr.clusters[1].total_drift = 2.0;
647 mgr.clusters[2].total_drift = 3.0;
648 mgr.reset_drift();
649 for c in &mgr.clusters {
650 assert_eq!(c.total_drift, 0.0);
651 }
652 }
653
654 #[test]
655 fn test_default_config() {
656 let cfg = ClusterManagerConfig::default();
657 assert_eq!(cfg.n_clusters, 8);
658 assert!((cfg.learning_rate - 0.1).abs() < 1e-6);
659 assert!((cfg.drift_threshold - 0.5).abs() < 1e-6);
660 }
661
662 #[test]
663 fn test_assign_doc_id_preserved() {
664 let mut mgr = make_manager(1);
665 mgr.initialize_centroids(vec![vec![0.0]]);
666 let result = mgr.assign(12345, &[0.5]).expect("should assign");
667 assert_eq!(result.doc_id, 12345);
668 }
669
670 #[test]
671 fn test_total_drift_accumulates_over_multiple_assigns() {
672 let mut mgr = make_manager(1);
673 mgr.initialize_centroids(vec![vec![0.0, 0.0]]);
674 mgr.assign(1, &[10.0, 10.0]);
675 mgr.assign(2, &[10.0, 10.0]);
676 mgr.assign(3, &[10.0, 10.0]);
677 assert!(mgr.clusters[0].total_drift > 0.0);
678 }
679}
680
681use std::collections::HashMap;
686
687#[derive(Debug, Clone)]
689pub struct BatchClusterConfig {
690 pub num_clusters: usize,
692 pub max_iterations: usize,
694 pub convergence_threshold: f64,
696 pub embedding_dim: usize,
698}
699
700impl Default for BatchClusterConfig {
701 fn default() -> Self {
702 Self {
703 num_clusters: 8,
704 max_iterations: 100,
705 convergence_threshold: 1e-6,
706 embedding_dim: 0,
707 }
708 }
709}
710
711#[derive(Debug, Clone)]
713pub struct BatchCluster {
714 pub id: usize,
716 pub centroid: Vec<f64>,
718 pub member_count: usize,
720 pub inertia: f64,
722}
723
724#[derive(Debug, Clone)]
726pub struct BatchClusterManagerStats {
727 pub num_clusters: usize,
729 pub total_members: usize,
731 pub iterations_run: usize,
733 pub converged: bool,
735 pub total_inertia: f64,
737}
738
739pub struct BatchSemanticClusterManager {
744 config: BatchClusterConfig,
745 clusters: Vec<BatchCluster>,
746 assignments: HashMap<String, usize>,
747 iterations_run: usize,
748 converged: bool,
749}
750
751pub fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
755 if a.len() != b.len() || a.is_empty() {
756 return 0.0;
757 }
758 a.iter()
759 .zip(b.iter())
760 .map(|(x, y)| (x - y) * (x - y))
761 .sum::<f64>()
762 .sqrt()
763}
764
765pub fn vec_mean(vectors: &[&[f64]]) -> Vec<f64> {
769 if vectors.is_empty() {
770 return Vec::new();
771 }
772 let dim = vectors[0].len();
773 let n = vectors.len() as f64;
774 let mut mean = vec![0.0; dim];
775 for v in vectors {
776 for (i, &val) in v.iter().enumerate() {
777 if i < dim {
778 mean[i] += val;
779 }
780 }
781 }
782 for m in &mut mean {
783 *m /= n;
784 }
785 mean
786}
787
788impl BatchSemanticClusterManager {
789 pub fn new(config: BatchClusterConfig) -> Self {
791 Self {
792 config,
793 clusters: Vec::new(),
794 assignments: HashMap::new(),
795 iterations_run: 0,
796 converged: false,
797 }
798 }
799
800 pub fn fit(&mut self, embeddings: &[(String, Vec<f64>)]) -> Result<(), String> {
808 let k = self.config.num_clusters;
809
810 if embeddings.is_empty() {
811 return Err("cannot fit on empty embeddings".to_string());
812 }
813 if embeddings.len() < k {
814 return Err(format!(
815 "need at least {} embeddings but got {}",
816 k,
817 embeddings.len()
818 ));
819 }
820
821 let dim = if self.config.embedding_dim > 0 {
823 self.config.embedding_dim
824 } else {
825 embeddings[0].1.len()
826 };
827 if dim == 0 {
828 return Err("embedding dimension is zero".to_string());
829 }
830 for (id, v) in embeddings {
831 if v.len() != dim {
832 return Err(format!(
833 "embedding '{}' has dimension {} but expected {}",
834 id,
835 v.len(),
836 dim
837 ));
838 }
839 }
840
841 self.clusters.clear();
843 self.assignments.clear();
844 self.iterations_run = 0;
845 self.converged = false;
846
847 let mut centroids: Vec<Vec<f64>> = Vec::with_capacity(k);
849 for (_id, v) in embeddings {
850 let is_dup = centroids.iter().any(|c| {
851 c.iter()
852 .zip(v.iter())
853 .all(|(a, b)| (a - b).abs() < f64::EPSILON)
854 });
855 if !is_dup {
856 centroids.push(v.clone());
857 }
858 if centroids.len() == k {
859 break;
860 }
861 }
862 if centroids.len() < k {
863 return Err(format!(
864 "need at least {} distinct embeddings but found only {}",
865 k,
866 centroids.len()
867 ));
868 }
869
870 let mut assignments: Vec<usize> = vec![0; embeddings.len()];
871
872 for iter in 0..self.config.max_iterations {
873 for (idx, (_id, v)) in embeddings.iter().enumerate() {
875 let mut best_cluster = 0;
876 let mut best_dist = f64::MAX;
877 for (ci, c) in centroids.iter().enumerate() {
878 let d = euclidean_distance(v, c);
879 if d < best_dist {
880 best_dist = d;
881 best_cluster = ci;
882 }
883 }
884 assignments[idx] = best_cluster;
885 }
886
887 let mut new_centroids = vec![vec![0.0; dim]; k];
889 let mut counts = vec![0usize; k];
890
891 for (idx, (_id, v)) in embeddings.iter().enumerate() {
892 let ci = assignments[idx];
893 counts[ci] += 1;
894 for (j, &val) in v.iter().enumerate() {
895 new_centroids[ci][j] += val;
896 }
897 }
898
899 for ci in 0..k {
900 if counts[ci] > 0 {
901 let n = counts[ci] as f64;
902 for val in new_centroids[ci].iter_mut() {
903 *val /= n;
904 }
905 } else {
906 new_centroids[ci] = centroids[ci].clone();
908 }
909 }
910
911 let max_movement = centroids
913 .iter()
914 .zip(new_centroids.iter())
915 .map(|(old, new)| euclidean_distance(old, new))
916 .fold(0.0_f64, f64::max);
917
918 centroids = new_centroids;
919 self.iterations_run = iter + 1;
920
921 if max_movement < self.config.convergence_threshold {
922 self.converged = true;
923 break;
924 }
925 }
926
927 for (idx, (_id, v)) in embeddings.iter().enumerate() {
930 let mut best_cluster = 0;
931 let mut best_dist = f64::MAX;
932 for (ci, c) in centroids.iter().enumerate() {
933 let d = euclidean_distance(v, c);
934 if d < best_dist {
935 best_dist = d;
936 best_cluster = ci;
937 }
938 }
939 assignments[idx] = best_cluster;
940 }
941
942 let mut inertias = vec![0.0_f64; k];
944 let mut member_counts = vec![0usize; k];
945 for (idx, (_id, v)) in embeddings.iter().enumerate() {
946 let ci = assignments[idx];
947 member_counts[ci] += 1;
948 let d = euclidean_distance(v, ¢roids[ci]);
949 inertias[ci] += d * d;
950 }
951
952 self.clusters = (0..k)
953 .map(|ci| BatchCluster {
954 id: ci,
955 centroid: centroids[ci].clone(),
956 member_count: member_counts[ci],
957 inertia: inertias[ci],
958 })
959 .collect();
960
961 for (idx, (id, _v)) in embeddings.iter().enumerate() {
962 self.assignments.insert(id.clone(), assignments[idx]);
963 }
964
965 Ok(())
966 }
967
968 pub fn predict(&self, embedding: &[f64]) -> Result<usize, String> {
972 if self.clusters.is_empty() {
973 return Err("not fitted yet".to_string());
974 }
975
976 let mut best_cluster = 0;
977 let mut best_dist = f64::MAX;
978 for cluster in &self.clusters {
979 let d = euclidean_distance(embedding, &cluster.centroid);
980 if d < best_dist {
981 best_dist = d;
982 best_cluster = cluster.id;
983 }
984 }
985 Ok(best_cluster)
986 }
987
988 pub fn get_cluster(&self, id: usize) -> Option<&BatchCluster> {
990 self.clusters.get(id)
991 }
992
993 pub fn get_assignment(&self, doc_id: &str) -> Option<usize> {
995 self.assignments.get(doc_id).copied()
996 }
997
998 pub fn cluster_members(&self, cluster_id: usize) -> Vec<String> {
1000 self.assignments
1001 .iter()
1002 .filter(|(_id, &cid)| cid == cluster_id)
1003 .map(|(id, _)| id.clone())
1004 .collect()
1005 }
1006
1007 pub fn total_inertia(&self) -> f64 {
1009 self.clusters.iter().map(|c| c.inertia).sum()
1010 }
1011
1012 pub fn silhouette_score_approx(
1020 &self,
1021 embeddings: &[(String, Vec<f64>)],
1022 ) -> Result<f64, String> {
1023 if self.clusters.is_empty() {
1024 return Err("not fitted yet".to_string());
1025 }
1026 if embeddings.is_empty() {
1027 return Err("no embeddings provided".to_string());
1028 }
1029 if self.clusters.len() < 2 {
1030 return Ok(0.0);
1032 }
1033
1034 let k = self.clusters.len();
1036 let mut cluster_vecs: Vec<Vec<&[f64]>> = vec![Vec::new(); k];
1037 for (id, v) in embeddings {
1038 if let Some(&ci) = self.assignments.get(id) {
1039 if ci < k {
1040 cluster_vecs[ci].push(v.as_slice());
1041 }
1042 }
1043 }
1044
1045 let mut total_sil = 0.0_f64;
1046 let mut count = 0usize;
1047
1048 for (id, v) in embeddings {
1049 let ci = match self.assignments.get(id) {
1050 Some(&c) => c,
1051 None => continue,
1052 };
1053
1054 let own_members = &cluster_vecs[ci];
1056 let a = if own_members.len() <= 1 {
1057 0.0
1058 } else {
1059 let sum: f64 = own_members.iter().map(|m| euclidean_distance(v, m)).sum();
1060 sum / (own_members.len() - 1) as f64
1062 };
1063
1064 let mut b = f64::MAX;
1066 for cluster in &self.clusters {
1067 if cluster.id == ci {
1068 continue;
1069 }
1070 let d = euclidean_distance(v, &cluster.centroid);
1071 if d < b {
1072 b = d;
1073 }
1074 }
1075 if b == f64::MAX {
1076 b = 0.0;
1077 }
1078
1079 let max_ab = a.max(b);
1080 let sil = if max_ab > 0.0 { (b - a) / max_ab } else { 0.0 };
1081
1082 total_sil += sil;
1083 count += 1;
1084 }
1085
1086 if count == 0 {
1087 return Ok(0.0);
1088 }
1089 Ok(total_sil / count as f64)
1090 }
1091
1092 pub fn stats(&self) -> BatchClusterManagerStats {
1094 BatchClusterManagerStats {
1095 num_clusters: self.clusters.len(),
1096 total_members: self.clusters.iter().map(|c| c.member_count).sum(),
1097 iterations_run: self.iterations_run,
1098 converged: self.converged,
1099 total_inertia: self.total_inertia(),
1100 }
1101 }
1102}
1103
1104#[cfg(test)]
1107mod batch_tests {
1108 use super::*;
1109
1110 fn default_config(k: usize, dim: usize) -> BatchClusterConfig {
1111 BatchClusterConfig {
1112 num_clusters: k,
1113 max_iterations: 100,
1114 convergence_threshold: 1e-6,
1115 embedding_dim: dim,
1116 }
1117 }
1118
1119 fn well_separated_2d(n_per_cluster: usize) -> Vec<(String, Vec<f64>)> {
1120 let mut data = Vec::new();
1122 for i in 0..n_per_cluster {
1123 data.push((
1124 format!("a{}", i),
1125 vec![0.0 + i as f64 * 0.01, 0.0 + i as f64 * 0.01],
1126 ));
1127 }
1128 for i in 0..n_per_cluster {
1129 data.push((
1130 format!("b{}", i),
1131 vec![100.0 + i as f64 * 0.01, 100.0 + i as f64 * 0.01],
1132 ));
1133 }
1134 data
1135 }
1136
1137 #[test]
1140 fn test_batch_fit_well_separated_converges() {
1141 let data = well_separated_2d(20);
1142 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1143 mgr.fit(&data).expect("fit should succeed");
1144 assert!(mgr.converged, "should converge on well-separated data");
1145 }
1146
1147 #[test]
1150 fn test_batch_predict_near_centroids() {
1151 let data = well_separated_2d(20);
1152 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1153 mgr.fit(&data).expect("fit should succeed");
1154
1155 let c0 = mgr.predict(&[0.05, 0.05]).expect("predict");
1156 let c1 = mgr.predict(&[100.05, 100.05]).expect("predict");
1157 assert_ne!(c0, c1, "should assign to different clusters");
1158 }
1159
1160 #[test]
1163 fn test_batch_fit_empty_returns_error() {
1164 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1165 let result = mgr.fit(&[]);
1166 assert!(result.is_err());
1167 assert!(result.expect_err("should be error").contains("empty"),);
1168 }
1169
1170 #[test]
1173 fn test_batch_fit_too_few_embeddings() {
1174 let data = vec![("a".to_string(), vec![1.0, 2.0])];
1175 let mut mgr = BatchSemanticClusterManager::new(default_config(3, 2));
1176 let result = mgr.fit(&data);
1177 assert!(result.is_err());
1178 }
1179
1180 #[test]
1183 fn test_batch_convergence_flag() {
1184 let data: Vec<(String, Vec<f64>)> = (0..5)
1186 .map(|i| (format!("d{}", i), vec![1.0, 1.0]))
1187 .collect();
1188 let mut mgr = BatchSemanticClusterManager::new(default_config(1, 2));
1189 mgr.fit(&data).expect("fit should succeed");
1190 assert!(mgr.converged);
1191 }
1192
1193 #[test]
1196 fn test_batch_max_iterations_respected() {
1197 let data = well_separated_2d(20);
1198 let mut cfg = default_config(2, 2);
1199 cfg.max_iterations = 3;
1200 cfg.convergence_threshold = 0.0; let mut mgr = BatchSemanticClusterManager::new(cfg);
1202 mgr.fit(&data).expect("fit should succeed");
1203 assert_eq!(mgr.iterations_run, 3);
1204 assert!(!mgr.converged);
1205 }
1206
1207 #[test]
1210 fn test_batch_cluster_members_match() {
1211 let data = well_separated_2d(10);
1212 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1213 mgr.fit(&data).expect("fit should succeed");
1214
1215 let mut total = 0;
1216 for ci in 0..2 {
1217 let members = mgr.cluster_members(ci);
1218 for mid in &members {
1219 assert_eq!(
1220 mgr.get_assignment(mid),
1221 Some(ci),
1222 "member {} should be in cluster {}",
1223 mid,
1224 ci
1225 );
1226 }
1227 total += members.len();
1228 }
1229 assert_eq!(total, data.len());
1230 }
1231
1232 #[test]
1235 fn test_batch_total_inertia_non_negative() {
1236 let data = well_separated_2d(10);
1237 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1238 mgr.fit(&data).expect("fit");
1239 assert!(mgr.total_inertia() >= 0.0);
1240 }
1241
1242 #[test]
1245 fn test_batch_inertia_decreases_with_more_clusters() {
1246 let data = well_separated_2d(20);
1247
1248 let mut mgr1 = BatchSemanticClusterManager::new(default_config(1, 2));
1249 mgr1.fit(&data).expect("fit k=1");
1250 let inertia1 = mgr1.total_inertia();
1251
1252 let mut mgr2 = BatchSemanticClusterManager::new(default_config(2, 2));
1253 mgr2.fit(&data).expect("fit k=2");
1254 let inertia2 = mgr2.total_inertia();
1255
1256 assert!(
1257 inertia2 <= inertia1,
1258 "k=2 inertia ({}) should be <= k=1 inertia ({})",
1259 inertia2,
1260 inertia1
1261 );
1262 }
1263
1264 #[test]
1267 fn test_batch_single_dimension() {
1268 let data: Vec<(String, Vec<f64>)> = vec![
1269 ("a".to_string(), vec![0.0]),
1270 ("b".to_string(), vec![1.0]),
1271 ("c".to_string(), vec![100.0]),
1272 ("d".to_string(), vec![101.0]),
1273 ];
1274 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 1));
1275 mgr.fit(&data).expect("fit");
1276 let ca = mgr.get_assignment("a").expect("a assigned");
1277 let cb = mgr.get_assignment("b").expect("b assigned");
1278 let cc = mgr.get_assignment("c").expect("c assigned");
1279 let cd = mgr.get_assignment("d").expect("d assigned");
1280 assert_eq!(ca, cb, "a and b should be in same cluster");
1281 assert_eq!(cc, cd, "c and d should be in same cluster");
1282 assert_ne!(ca, cc, "groups should be different");
1283 }
1284
1285 #[test]
1288 fn test_batch_multiple_fits_reset_state() {
1289 let data1 = well_separated_2d(10);
1290 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1291 mgr.fit(&data1).expect("fit 1");
1292 let inertia1 = mgr.total_inertia();
1293
1294 let data2: Vec<(String, Vec<f64>)> = (0..10)
1296 .map(|i| (format!("x{}", i), vec![i as f64, i as f64]))
1297 .collect();
1298 mgr.fit(&data2).expect("fit 2");
1299
1300 assert!(mgr.get_assignment("a0").is_none());
1302 assert!(mgr.get_assignment("x0").is_some());
1304 let _inertia2 = mgr.total_inertia();
1306 let _ = inertia1; }
1308
1309 #[test]
1312 fn test_batch_get_assignment_unknown() {
1313 let data = well_separated_2d(5);
1314 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1315 mgr.fit(&data).expect("fit");
1316 assert!(mgr.get_assignment("nonexistent").is_none());
1317 }
1318
1319 #[test]
1322 fn test_batch_stats_reflect_state() {
1323 let data = well_separated_2d(10);
1324 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1325 mgr.fit(&data).expect("fit");
1326
1327 let stats = mgr.stats();
1328 assert_eq!(stats.num_clusters, 2);
1329 assert_eq!(stats.total_members, 20);
1330 assert!(stats.iterations_run > 0);
1331 assert!(stats.total_inertia >= 0.0);
1332 }
1333
1334 #[test]
1337 fn test_batch_predict_not_fitted() {
1338 let mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1339 let result = mgr.predict(&[1.0, 2.0]);
1340 assert!(result.is_err());
1341 }
1342
1343 #[test]
1346 fn test_batch_get_cluster() {
1347 let data = well_separated_2d(10);
1348 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1349 mgr.fit(&data).expect("fit");
1350
1351 let c = mgr.get_cluster(0).expect("cluster 0 exists");
1352 assert_eq!(c.id, 0);
1353 assert!(!c.centroid.is_empty());
1354 assert!(mgr.get_cluster(999).is_none());
1355 }
1356
1357 #[test]
1360 fn test_batch_silhouette_score_range() {
1361 let data = well_separated_2d(20);
1362 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1363 mgr.fit(&data).expect("fit");
1364
1365 let sil = mgr.silhouette_score_approx(&data).expect("silhouette");
1366 assert!(
1367 (-1.0..=1.0).contains(&sil),
1368 "silhouette {} out of [-1,1]",
1369 sil
1370 );
1371 }
1372
1373 #[test]
1376 fn test_batch_silhouette_score_high_for_separated() {
1377 let data = well_separated_2d(20);
1378 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1379 mgr.fit(&data).expect("fit");
1380
1381 let sil = mgr.silhouette_score_approx(&data).expect("silhouette");
1382 assert!(
1383 sil > 0.5,
1384 "expected high silhouette for separated clusters, got {}",
1385 sil
1386 );
1387 }
1388
1389 #[test]
1392 fn test_batch_silhouette_not_fitted() {
1393 let mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1394 let result = mgr.silhouette_score_approx(&[]);
1395 assert!(result.is_err());
1396 }
1397
1398 #[test]
1401 fn test_batch_cluster_members_empty_cluster() {
1402 let data = well_separated_2d(10);
1404 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1405 mgr.fit(&data).expect("fit");
1406
1407 let members = mgr.cluster_members(999);
1408 assert!(members.is_empty());
1409 }
1410
1411 #[test]
1414 fn test_euclidean_distance_f64() {
1415 let d = euclidean_distance(&[0.0, 0.0], &[3.0, 4.0]);
1416 assert!((d - 5.0).abs() < 1e-10);
1417 }
1418
1419 #[test]
1420 fn test_euclidean_distance_same_point() {
1421 let d = euclidean_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]);
1422 assert!(d.abs() < 1e-15);
1423 }
1424
1425 #[test]
1426 fn test_euclidean_distance_mismatch() {
1427 let d = euclidean_distance(&[1.0], &[1.0, 2.0]);
1428 assert_eq!(d, 0.0);
1429 }
1430
1431 #[test]
1434 fn test_vec_mean_basic() {
1435 let v1 = vec![2.0, 4.0];
1436 let v2 = vec![4.0, 8.0];
1437 let mean = vec_mean(&[v1.as_slice(), v2.as_slice()]);
1438 assert!((mean[0] - 3.0).abs() < 1e-10);
1439 assert!((mean[1] - 6.0).abs() < 1e-10);
1440 }
1441
1442 #[test]
1443 fn test_vec_mean_empty() {
1444 let mean = vec_mean(&[]);
1445 assert!(mean.is_empty());
1446 }
1447
1448 #[test]
1451 fn test_batch_fit_dimension_mismatch() {
1452 let data = vec![
1453 ("a".to_string(), vec![1.0, 2.0]),
1454 ("b".to_string(), vec![3.0, 4.0, 5.0]),
1455 ];
1456 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1457 let result = mgr.fit(&data);
1458 assert!(result.is_err());
1459 }
1460
1461 #[test]
1464 fn test_batch_fit_zero_dim_vectors() {
1465 let data: Vec<(String, Vec<f64>)> =
1466 vec![("a".to_string(), vec![]), ("b".to_string(), vec![])];
1467 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 0));
1468 let result = mgr.fit(&data);
1469 assert!(result.is_err());
1470 }
1471
1472 #[test]
1475 fn test_batch_three_clusters() {
1476 let mut data: Vec<(String, Vec<f64>)> = Vec::new();
1477 for i in 0..10 {
1478 data.push((format!("g0_{}", i), vec![0.0 + i as f64 * 0.001, 0.0]));
1479 }
1480 for i in 0..10 {
1481 data.push((format!("g1_{}", i), vec![100.0 + i as f64 * 0.001, 0.0]));
1482 }
1483 for i in 0..10 {
1484 data.push((format!("g2_{}", i), vec![0.0, 100.0 + i as f64 * 0.001]));
1485 }
1486
1487 let mut mgr = BatchSemanticClusterManager::new(default_config(3, 2));
1488 mgr.fit(&data).expect("fit 3 clusters");
1489
1490 let c0 = mgr.get_assignment("g0_0").expect("g0_0");
1492 let c1 = mgr.get_assignment("g1_0").expect("g1_0");
1493 let c2 = mgr.get_assignment("g2_0").expect("g2_0");
1494 assert_ne!(c0, c1);
1495 assert_ne!(c0, c2);
1496 assert_ne!(c1, c2);
1497 }
1498
1499 #[test]
1502 fn test_batch_stats_unfitted() {
1503 let mgr = BatchSemanticClusterManager::new(default_config(4, 3));
1504 let stats = mgr.stats();
1505 assert_eq!(stats.num_clusters, 0);
1506 assert_eq!(stats.total_members, 0);
1507 assert_eq!(stats.iterations_run, 0);
1508 assert!(!stats.converged);
1509 assert_eq!(stats.total_inertia, 0.0);
1510 }
1511
1512 #[test]
1515 fn test_batch_predict_consistency() {
1516 let data = well_separated_2d(20);
1517 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1518 mgr.fit(&data).expect("fit");
1519
1520 for (id, v) in &data {
1522 let predicted = mgr.predict(v).expect("predict");
1523 let assigned = mgr.get_assignment(id).expect("assignment");
1524 assert_eq!(predicted, assigned, "predict({}) != assignment({})", id, id);
1525 }
1526 }
1527
1528 #[test]
1531 fn test_batch_identical_embeddings_fewer_distinct_than_k() {
1532 let data: Vec<(String, Vec<f64>)> = (0..10)
1534 .map(|i| (format!("d{}", i), vec![5.0, 5.0]))
1535 .collect();
1536 let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1537 let result = mgr.fit(&data);
1538 assert!(result.is_err());
1539 assert!(result.expect_err("err").contains("distinct"),);
1540 }
1541}