1use std::fmt;
8use thiserror::Error;
9
10#[inline]
15fn xorshift64(state: &mut u64) -> u64 {
16 *state ^= *state << 13;
17 *state ^= *state >> 7;
18 *state ^= *state << 17;
19 *state
20}
21
22#[derive(Debug, Error)]
28pub enum ClusterError {
29 #[error("insufficient points: need at least {min}, got {got}")]
31 InsufficientPoints { min: usize, got: usize },
32
33 #[error("dimension mismatch: expected {expected}, got {got}")]
35 DimensionMismatch { expected: usize, got: usize },
36
37 #[error("all clusters became empty")]
39 EmptyClusters,
40
41 #[error("invalid parameter: {0}")]
43 InvalidParameter(String),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Linkage {
53 Ward,
55 Complete,
57 Average,
59 Single,
61}
62
63impl fmt::Display for Linkage {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 match self {
66 Linkage::Ward => write!(f, "ward"),
67 Linkage::Complete => write!(f, "complete"),
68 Linkage::Average => write!(f, "average"),
69 Linkage::Single => write!(f, "single"),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
80pub enum ClusterAlgorithm {
81 KMeans {
83 k: usize,
85 max_iter: u32,
87 tolerance: f64,
89 },
90 MiniBatchKMeans {
92 k: usize,
94 batch_size: usize,
96 max_iter: u32,
98 },
99 DBSCAN {
101 eps: f64,
103 min_samples: usize,
105 },
106 Agglomerative {
108 k: usize,
110 linkage: Linkage,
112 },
113}
114
115impl fmt::Display for ClusterAlgorithm {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 match self {
118 ClusterAlgorithm::KMeans { k, .. } => write!(f, "kmeans(k={k})"),
119 ClusterAlgorithm::MiniBatchKMeans { k, .. } => {
120 write!(f, "mini_batch_kmeans(k={k})")
121 }
122 ClusterAlgorithm::DBSCAN { eps, min_samples } => {
123 write!(f, "dbscan(eps={eps},min_samples={min_samples})")
124 }
125 ClusterAlgorithm::Agglomerative { k, linkage } => {
126 write!(f, "agglomerative(k={k},linkage={linkage})")
127 }
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
138pub struct ScClusterPoint {
139 pub id: String,
141 pub embedding: Vec<f64>,
143 pub cluster_id: Option<usize>,
145}
146
147impl ScClusterPoint {
148 pub fn new(id: impl Into<String>, embedding: Vec<f64>) -> Self {
150 Self {
151 id: id.into(),
152 embedding,
153 cluster_id: None,
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct ScCluster {
161 pub id: usize,
163 pub centroid: Vec<f64>,
165 pub member_ids: Vec<String>,
167 pub inertia: f64,
169}
170
171impl ScCluster {
172 pub fn size(&self) -> usize {
174 self.member_ids.len()
175 }
176
177 pub fn is_empty(&self) -> bool {
179 self.member_ids.is_empty()
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct ScClusteringResult {
186 pub clusters: Vec<ScCluster>,
188 pub noise_ids: Vec<String>,
190 pub algorithm: String,
192 pub silhouette_score: f64,
194 pub inertia: f64,
196 pub iterations: u32,
198}
199
200#[derive(Debug, Clone)]
202pub struct ScClustererStats {
203 pub total_clustered: usize,
205 pub noise_count: usize,
207 pub avg_cluster_size: f64,
209 pub largest_cluster: usize,
211 pub smallest_cluster: usize,
213}
214
215#[derive(Debug, Clone)]
238pub struct SemanticClusterer {
239 pub algorithm: ClusterAlgorithm,
241 pub dims: usize,
243}
244
245impl SemanticClusterer {
246 pub fn new(algorithm: ClusterAlgorithm, dims: usize) -> Self {
248 Self { algorithm, dims }
249 }
250
251 pub fn fit(&self, points: &[ScClusterPoint]) -> Result<ScClusteringResult, ClusterError> {
259 self.validate_points(points)?;
260 let algorithm_label = self.algorithm.to_string();
261 let result = match &self.algorithm {
262 ClusterAlgorithm::KMeans {
263 k,
264 max_iter,
265 tolerance,
266 } => self.fit_kmeans(points, *k, *max_iter, *tolerance),
267 ClusterAlgorithm::MiniBatchKMeans {
268 k,
269 batch_size,
270 max_iter,
271 } => self.fit_mini_batch_kmeans(points, *k, *batch_size, *max_iter),
272 ClusterAlgorithm::DBSCAN { eps, min_samples } => {
273 self.fit_dbscan(points, *eps, *min_samples)
274 }
275 ClusterAlgorithm::Agglomerative { k, linkage } => {
276 self.fit_agglomerative(points, *k, *linkage)
277 }
278 }?;
279
280 let mut final_result = result;
282 final_result.algorithm = algorithm_label;
283 let tagged = tag_points(points, &final_result);
284 final_result.silhouette_score = Self::silhouette_score(&tagged, &final_result);
285 Ok(final_result)
286 }
287
288 pub fn predict(&self, point: &ScClusterPoint, result: &ScClusteringResult) -> Option<usize> {
292 if result.clusters.is_empty() {
293 return None;
294 }
295 let mut best_id = 0usize;
296 let mut best_dist = f64::MAX;
297 for cluster in &result.clusters {
298 let d = Self::euclidean_distance(&point.embedding, &cluster.centroid);
299 if d < best_dist {
300 best_dist = d;
301 best_id = cluster.id;
302 }
303 }
304 Some(best_id)
305 }
306
307 pub fn cosine_distance(a: &[f64], b: &[f64]) -> f64 {
311 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
312 let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
313 let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
314 if na == 0.0 || nb == 0.0 {
315 return 1.0;
316 }
317 1.0 - (dot / (na * nb)).clamp(-1.0, 1.0)
318 }
319
320 pub fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
322 a.iter()
323 .zip(b.iter())
324 .map(|(x, y)| (x - y).powi(2))
325 .sum::<f64>()
326 .sqrt()
327 }
328
329 pub fn silhouette_score(points: &[ScClusterPoint], result: &ScClusteringResult) -> f64 {
333 if result.clusters.len() < 2 {
334 return 0.0;
335 }
336 let assigned: Vec<(&ScClusterPoint, usize)> = points
338 .iter()
339 .filter_map(|p| p.cluster_id.map(|c| (p, c)))
340 .collect();
341
342 if assigned.len() < 2 {
343 return 0.0;
344 }
345
346 let sample: Vec<&(&ScClusterPoint, usize)> = if assigned.len() > 100 {
348 let mut state: u64 = 42;
349 let mut indices: Vec<usize> = (0..assigned.len()).collect();
350 for i in 0..100 {
352 let j = i + (xorshift64(&mut state) as usize % (assigned.len() - i));
353 indices.swap(i, j);
354 }
355 indices[..100].iter().map(|&i| &assigned[i]).collect()
356 } else {
357 assigned.iter().collect()
358 };
359
360 let scores: Vec<f64> = sample
361 .iter()
362 .filter_map(|(p, cid)| silhouette_one(p, *cid, &assigned))
363 .collect();
364
365 if scores.is_empty() {
366 return 0.0;
367 }
368 scores.iter().sum::<f64>() / scores.len() as f64
369 }
370
371 pub fn compute_centroid(embeddings: &[&[f64]]) -> Vec<f64> {
375 if embeddings.is_empty() {
376 return Vec::new();
377 }
378 let dims = embeddings[0].len();
379 if dims == 0 {
380 return Vec::new();
381 }
382 let mut centroid = vec![0.0f64; dims];
383 for emb in embeddings {
384 for (c, v) in centroid.iter_mut().zip(emb.iter()) {
385 *c += v;
386 }
387 }
388 let n = embeddings.len() as f64;
389 centroid.iter_mut().for_each(|c| *c /= n);
390 centroid
391 }
392
393 pub fn stats(result: &ScClusteringResult) -> ScClustererStats {
395 let total_clustered: usize = result.clusters.iter().map(|c| c.member_ids.len()).sum();
396 let noise_count = result.noise_ids.len();
397 let avg_cluster_size = if result.clusters.is_empty() {
398 0.0
399 } else {
400 total_clustered as f64 / result.clusters.len() as f64
401 };
402 let largest_cluster = result
403 .clusters
404 .iter()
405 .map(|c| c.member_ids.len())
406 .max()
407 .unwrap_or(0);
408 let smallest_cluster = result
409 .clusters
410 .iter()
411 .map(|c| c.member_ids.len())
412 .min()
413 .unwrap_or(0);
414 ScClustererStats {
415 total_clustered,
416 noise_count,
417 avg_cluster_size,
418 largest_cluster,
419 smallest_cluster,
420 }
421 }
422
423 fn validate_points(&self, points: &[ScClusterPoint]) -> Result<(), ClusterError> {
428 for p in points {
429 if p.embedding.len() != self.dims {
430 return Err(ClusterError::DimensionMismatch {
431 expected: self.dims,
432 got: p.embedding.len(),
433 });
434 }
435 }
436 Ok(())
437 }
438
439 fn fit_kmeans(
444 &self,
445 points: &[ScClusterPoint],
446 k: usize,
447 max_iter: u32,
448 tolerance: f64,
449 ) -> Result<ScClusteringResult, ClusterError> {
450 if k == 0 {
451 return Err(ClusterError::InvalidParameter("k must be > 0".into()));
452 }
453 if points.len() < k {
454 return Err(ClusterError::InsufficientPoints {
455 min: k,
456 got: points.len(),
457 });
458 }
459
460 let mut centroids = kmeans_plus_plus_init(points, k, 42);
461 let mut assignments = vec![0usize; points.len()];
462 let mut iterations = 0u32;
463
464 for _ in 0..max_iter {
465 iterations += 1;
466
467 for (i, p) in points.iter().enumerate() {
469 assignments[i] = nearest_centroid(&p.embedding, ¢roids);
470 }
471
472 let new_centroids = recompute_centroids(points, &assignments, k, self.dims);
474
475 let max_shift = centroids
477 .iter()
478 .zip(new_centroids.iter())
479 .map(|(old, new)| Self::euclidean_distance(old, new))
480 .fold(0.0f64, f64::max);
481
482 centroids = new_centroids;
483 if max_shift < tolerance {
484 break;
485 }
486 }
487
488 build_result_from_centroids(points, &assignments, centroids, iterations)
489 }
490
491 fn fit_mini_batch_kmeans(
496 &self,
497 points: &[ScClusterPoint],
498 k: usize,
499 batch_size: usize,
500 max_iter: u32,
501 ) -> Result<ScClusteringResult, ClusterError> {
502 if k == 0 {
503 return Err(ClusterError::InvalidParameter("k must be > 0".into()));
504 }
505 if points.len() < k {
506 return Err(ClusterError::InsufficientPoints {
507 min: k,
508 got: points.len(),
509 });
510 }
511 let effective_batch = batch_size.min(points.len());
512 if effective_batch == 0 {
513 return Err(ClusterError::InvalidParameter(
514 "batch_size must be > 0".into(),
515 ));
516 }
517
518 let mut centroids = kmeans_plus_plus_init(points, k, 42);
519 let mut counts = vec![1u64; k];
521 let mut state: u64 = 0x_dead_beef_cafe_babe_u64;
522 let mut iterations = 0u32;
523
524 for _ in 0..max_iter {
525 iterations += 1;
526
527 let batch_indices: Vec<usize> = (0..effective_batch)
529 .map(|_| xorshift64(&mut state) as usize % points.len())
530 .collect();
531
532 let batch_assignments: Vec<usize> = batch_indices
534 .iter()
535 .map(|&i| nearest_centroid(&points[i].embedding, ¢roids))
536 .collect();
537
538 for (&point_idx, &cid) in batch_indices.iter().zip(batch_assignments.iter()) {
540 counts[cid] += 1;
541 let lr = 1.0 / counts[cid] as f64;
542 let emb = &points[point_idx].embedding;
543 for (c, v) in centroids[cid].iter_mut().zip(emb.iter()) {
544 *c += lr * (v - *c);
545 }
546 }
547 }
548
549 let assignments: Vec<usize> = points
551 .iter()
552 .map(|p| nearest_centroid(&p.embedding, ¢roids))
553 .collect();
554
555 build_result_from_centroids(points, &assignments, centroids, iterations)
556 }
557
558 fn fit_dbscan(
563 &self,
564 points: &[ScClusterPoint],
565 eps: f64,
566 min_samples: usize,
567 ) -> Result<ScClusteringResult, ClusterError> {
568 if eps <= 0.0 {
569 return Err(ClusterError::InvalidParameter(
570 "eps must be positive".into(),
571 ));
572 }
573 if min_samples == 0 {
574 return Err(ClusterError::InvalidParameter(
575 "min_samples must be > 0".into(),
576 ));
577 }
578
579 let n = points.len();
580 let mut label: Vec<Option<usize>> = vec![None; n];
582 let mut cluster_id = 0usize;
583
584 let neighbours: Vec<Vec<usize>> = (0..n)
586 .map(|i| {
587 (0..n)
588 .filter(|&j| {
589 Self::euclidean_distance(&points[i].embedding, &points[j].embedding) <= eps
590 })
591 .collect()
592 })
593 .collect();
594
595 for i in 0..n {
596 if label[i].is_some() {
597 continue;
598 }
599 if neighbours[i].len() < min_samples {
600 label[i] = Some(usize::MAX);
602 continue;
603 }
604 label[i] = Some(cluster_id);
606 let mut queue: Vec<usize> = neighbours[i].clone();
607 let mut head = 0;
608 while head < queue.len() {
609 let q = queue[head];
610 head += 1;
611 if label[q] == Some(usize::MAX) {
612 label[q] = Some(cluster_id);
614 }
615 if label[q].is_some() && label[q] != Some(usize::MAX) {
616 continue;
617 }
618 label[q] = Some(cluster_id);
619 if neighbours[q].len() >= min_samples {
620 for &nb in &neighbours[q] {
621 if label[nb].is_none() || label[nb] == Some(usize::MAX) {
622 queue.push(nb);
623 }
624 }
625 }
626 }
627 cluster_id += 1;
628 }
629
630 let k = cluster_id;
632 let mut cluster_members: Vec<Vec<String>> = vec![Vec::new(); k];
633 let mut noise_ids: Vec<String> = Vec::new();
634
635 for (i, lbl) in label.iter().enumerate() {
636 match lbl {
637 None | Some(usize::MAX) => noise_ids.push(points[i].id.clone()),
638 &Some(cid) => cluster_members[cid].push(points[i].id.clone()),
639 }
640 }
641
642 let clusters: Vec<ScCluster> = cluster_members
644 .into_iter()
645 .enumerate()
646 .map(|(cid, members)| {
647 let embeddings: Vec<&[f64]> = members
648 .iter()
649 .filter_map(|id| points.iter().find(|p| &p.id == id))
650 .map(|p| p.embedding.as_slice())
651 .collect();
652 let centroid = Self::compute_centroid(&embeddings);
653 let inertia = embeddings
654 .iter()
655 .map(|e| Self::euclidean_distance(e, ¢roid).powi(2))
656 .sum();
657 ScCluster {
658 id: cid,
659 centroid,
660 member_ids: members,
661 inertia,
662 }
663 })
664 .collect();
665
666 let total_inertia: f64 = clusters.iter().map(|c| c.inertia).sum();
667
668 Ok(ScClusteringResult {
669 clusters,
670 noise_ids,
671 algorithm: String::new(), silhouette_score: 0.0, inertia: total_inertia,
674 iterations: 1,
675 })
676 }
677
678 fn fit_agglomerative(
683 &self,
684 points: &[ScClusterPoint],
685 k: usize,
686 linkage: Linkage,
687 ) -> Result<ScClusteringResult, ClusterError> {
688 if k == 0 {
689 return Err(ClusterError::InvalidParameter("k must be > 0".into()));
690 }
691 if points.len() < k {
692 return Err(ClusterError::InsufficientPoints {
693 min: k,
694 got: points.len(),
695 });
696 }
697
698 let n = points.len();
699 let mut clusters: Vec<Vec<usize>> = (0..n).map(|i| vec![i]).collect();
701
702 let mut iterations = 0u32;
703
704 while clusters.len() > k {
705 iterations += 1;
706 let nc = clusters.len();
708 let mut min_dist = f64::MAX;
709 let mut merge_a = 0usize;
710 let mut merge_b = 1usize;
711
712 for a in 0..nc {
713 for b in (a + 1)..nc {
714 let d = linkage_distance(&clusters[a], &clusters[b], points, linkage);
715 if d < min_dist {
716 min_dist = d;
717 merge_a = a;
718 merge_b = b;
719 }
720 }
721 }
722
723 let b_members = clusters.remove(merge_b);
725 clusters[merge_a].extend(b_members);
726 }
727
728 let result_clusters: Vec<ScCluster> = clusters
730 .into_iter()
731 .enumerate()
732 .map(|(cid, member_indices)| {
733 let embeddings: Vec<&[f64]> = member_indices
734 .iter()
735 .map(|&i| points[i].embedding.as_slice())
736 .collect();
737 let centroid = Self::compute_centroid(&embeddings);
738 let inertia = embeddings
739 .iter()
740 .map(|e| Self::euclidean_distance(e, ¢roid).powi(2))
741 .sum();
742 ScCluster {
743 id: cid,
744 centroid,
745 member_ids: member_indices
746 .iter()
747 .map(|&i| points[i].id.clone())
748 .collect(),
749 inertia,
750 }
751 })
752 .collect();
753
754 let total_inertia: f64 = result_clusters.iter().map(|c| c.inertia).sum();
755
756 Ok(ScClusteringResult {
757 clusters: result_clusters,
758 noise_ids: Vec::new(),
759 algorithm: String::new(),
760 silhouette_score: 0.0,
761 inertia: total_inertia,
762 iterations,
763 })
764 }
765}
766
767fn kmeans_plus_plus_init(points: &[ScClusterPoint], k: usize, seed: u64) -> Vec<Vec<f64>> {
773 let mut state = if seed == 0 { 1 } else { seed };
774 let mut centroids: Vec<Vec<f64>> = Vec::with_capacity(k);
775
776 let first = xorshift64(&mut state) as usize % points.len();
778 centroids.push(points[first].embedding.clone());
779
780 for _ in 1..k {
781 let dists: Vec<f64> = points
783 .iter()
784 .map(|p| {
785 centroids
786 .iter()
787 .map(|c| SemanticClusterer::euclidean_distance(&p.embedding, c).powi(2))
788 .fold(f64::MAX, f64::min)
789 })
790 .collect();
791
792 let total: f64 = dists.iter().sum();
793 if total == 0.0 {
794 let idx = xorshift64(&mut state) as usize % points.len();
796 centroids.push(points[idx].embedding.clone());
797 continue;
798 }
799
800 let threshold = (xorshift64(&mut state) as f64 / u64::MAX as f64) * total;
802 let mut cumulative = 0.0;
803 let mut chosen = points.len() - 1;
804 for (i, &d) in dists.iter().enumerate() {
805 cumulative += d;
806 if cumulative >= threshold {
807 chosen = i;
808 break;
809 }
810 }
811 centroids.push(points[chosen].embedding.clone());
812 }
813
814 centroids
815}
816
817fn nearest_centroid(embedding: &[f64], centroids: &[Vec<f64>]) -> usize {
819 let mut best = 0usize;
820 let mut best_dist = f64::MAX;
821 for (i, c) in centroids.iter().enumerate() {
822 let d = SemanticClusterer::euclidean_distance(embedding, c);
823 if d < best_dist {
824 best_dist = d;
825 best = i;
826 }
827 }
828 best
829}
830
831fn recompute_centroids(
834 points: &[ScClusterPoint],
835 assignments: &[usize],
836 k: usize,
837 dims: usize,
838) -> Vec<Vec<f64>> {
839 let mut sums = vec![vec![0.0f64; dims]; k];
840 let mut counts = vec![0usize; k];
841 for (p, &cid) in points.iter().zip(assignments.iter()) {
842 for (s, v) in sums[cid].iter_mut().zip(p.embedding.iter()) {
843 *s += v;
844 }
845 counts[cid] += 1;
846 }
847 sums.iter_mut()
848 .zip(counts.iter())
849 .map(|(sum, &cnt)| {
850 if cnt > 0 {
851 sum.iter().map(|&s| s / cnt as f64).collect()
852 } else {
853 sum.clone()
854 }
855 })
856 .collect()
857}
858
859fn build_result_from_centroids(
861 points: &[ScClusterPoint],
862 assignments: &[usize],
863 centroids: Vec<Vec<f64>>,
864 iterations: u32,
865) -> Result<ScClusteringResult, ClusterError> {
866 let k = centroids.len();
867 let mut member_sets: Vec<Vec<String>> = vec![Vec::new(); k];
868 for (p, &cid) in points.iter().zip(assignments.iter()) {
869 member_sets[cid].push(p.id.clone());
870 }
871
872 let clusters: Vec<ScCluster> = centroids
873 .into_iter()
874 .enumerate()
875 .map(|(cid, centroid)| {
876 let members = &member_sets[cid];
877 let inertia: f64 = members
878 .iter()
879 .filter_map(|id| points.iter().find(|p| &p.id == id))
880 .map(|p| SemanticClusterer::euclidean_distance(&p.embedding, ¢roid).powi(2))
881 .sum();
882 ScCluster {
883 id: cid,
884 centroid,
885 member_ids: members.clone(),
886 inertia,
887 }
888 })
889 .collect();
890
891 if clusters.iter().all(|c| c.is_empty()) {
892 return Err(ClusterError::EmptyClusters);
893 }
894
895 let total_inertia: f64 = clusters.iter().map(|c| c.inertia).sum();
896
897 Ok(ScClusteringResult {
898 clusters,
899 noise_ids: Vec::new(),
900 algorithm: String::new(),
901 silhouette_score: 0.0,
902 inertia: total_inertia,
903 iterations,
904 })
905}
906
907fn tag_points(points: &[ScClusterPoint], result: &ScClusteringResult) -> Vec<ScClusterPoint> {
909 let mut tagged: Vec<ScClusterPoint> = points.to_vec();
910 for p in &mut tagged {
911 p.cluster_id = None;
912 }
913 for cluster in &result.clusters {
914 for id in &cluster.member_ids {
915 if let Some(tp) = tagged.iter_mut().find(|p| &p.id == id) {
916 tp.cluster_id = Some(cluster.id);
917 }
918 }
919 }
920 tagged
921}
922
923fn silhouette_one(
925 point: &ScClusterPoint,
926 cid: usize,
927 all: &[(&ScClusterPoint, usize)],
928) -> Option<f64> {
929 let same: Vec<f64> = all
931 .iter()
932 .filter(|(p, c)| *c == cid && p.id != point.id)
933 .map(|(p, _)| SemanticClusterer::euclidean_distance(&point.embedding, &p.embedding))
934 .collect();
935
936 let a = if same.is_empty() {
937 return None;
938 } else {
939 same.iter().sum::<f64>() / same.len() as f64
940 };
941
942 let other_clusters: std::collections::HashSet<usize> = all
944 .iter()
945 .filter(|(_, c)| *c != cid)
946 .map(|(_, c)| *c)
947 .collect();
948
949 let b = other_clusters
950 .iter()
951 .map(|&oc| {
952 let dists: Vec<f64> = all
953 .iter()
954 .filter(|(_, c)| *c == oc)
955 .map(|(p, _)| SemanticClusterer::euclidean_distance(&point.embedding, &p.embedding))
956 .collect();
957 if dists.is_empty() {
958 f64::MAX
959 } else {
960 dists.iter().sum::<f64>() / dists.len() as f64
961 }
962 })
963 .fold(f64::MAX, f64::min);
964
965 if b == f64::MAX {
966 return None;
967 }
968
969 let denom = a.max(b);
970 if denom == 0.0 {
971 Some(0.0)
972 } else {
973 Some((b - a) / denom)
974 }
975}
976
977fn linkage_distance(a: &[usize], b: &[usize], points: &[ScClusterPoint], linkage: Linkage) -> f64 {
979 let mut dists: Vec<f64> = Vec::with_capacity(a.len() * b.len());
980 for &ai in a {
981 for &bi in b {
982 dists.push(SemanticClusterer::euclidean_distance(
983 &points[ai].embedding,
984 &points[bi].embedding,
985 ));
986 }
987 }
988 if dists.is_empty() {
989 return 0.0;
990 }
991 match linkage {
992 Linkage::Single => dists.iter().cloned().fold(f64::MAX, f64::min),
993 Linkage::Complete => dists.iter().cloned().fold(f64::MIN, f64::max),
994 Linkage::Average | Linkage::Ward => dists.iter().sum::<f64>() / dists.len() as f64,
995 }
996}
997
998#[cfg(test)]
1003mod tests {
1004 use super::{
1005 kmeans_plus_plus_init, tag_points, xorshift64, ClusterAlgorithm, ClusterError, Linkage,
1006 ScClusterPoint, SemanticClusterer,
1007 };
1008
1009 fn pts(coords: &[(f64, f64)]) -> Vec<ScClusterPoint> {
1014 coords
1015 .iter()
1016 .enumerate()
1017 .map(|(i, &(x, y))| ScClusterPoint::new(format!("p{i}"), vec![x, y]))
1018 .collect()
1019 }
1020
1021 fn well_separated_2d() -> Vec<ScClusterPoint> {
1022 let mut v = Vec::new();
1024 for i in 0..10 {
1025 v.push(ScClusterPoint::new(
1026 format!("a{i}"),
1027 vec![i as f64 * 0.01, i as f64 * 0.01],
1028 ));
1029 }
1030 for i in 0..10 {
1031 v.push(ScClusterPoint::new(
1032 format!("b{i}"),
1033 vec![10.0 + i as f64 * 0.01, 10.0 + i as f64 * 0.01],
1034 ));
1035 }
1036 for i in 0..10 {
1037 v.push(ScClusterPoint::new(
1038 format!("c{i}"),
1039 vec![20.0 + i as f64 * 0.01, 20.0 + i as f64 * 0.01],
1040 ));
1041 }
1042 v
1043 }
1044
1045 #[test]
1049 fn test_xorshift64_nonzero() {
1050 let mut s: u64 = 42;
1051 let v = xorshift64(&mut s);
1052 assert_ne!(v, 0);
1053 }
1054
1055 #[test]
1059 fn test_xorshift64_distinct() {
1060 let mut s: u64 = 42;
1061 let a = xorshift64(&mut s);
1062 let b = xorshift64(&mut s);
1063 assert_ne!(a, b);
1064 }
1065
1066 #[test]
1070 fn test_euclidean_self_distance_zero() {
1071 let v = vec![1.0, 2.0, 3.0];
1072 assert_eq!(SemanticClusterer::euclidean_distance(&v, &v), 0.0);
1073 }
1074
1075 #[test]
1079 fn test_euclidean_known() {
1080 let a = vec![0.0, 0.0];
1081 let b = vec![3.0, 4.0];
1082 let d = SemanticClusterer::euclidean_distance(&a, &b);
1083 assert!((d - 5.0).abs() < 1e-10, "expected 5, got {d}");
1084 }
1085
1086 #[test]
1090 fn test_cosine_distance_identical() {
1091 let v = vec![1.0, 2.0, 3.0];
1092 let d = SemanticClusterer::cosine_distance(&v, &v);
1093 assert!(d.abs() < 1e-10, "expected 0, got {d}");
1094 }
1095
1096 #[test]
1100 fn test_cosine_distance_orthogonal() {
1101 let a = vec![1.0, 0.0];
1102 let b = vec![0.0, 1.0];
1103 let d = SemanticClusterer::cosine_distance(&a, &b);
1104 assert!((d - 1.0).abs() < 1e-10, "expected 1, got {d}");
1105 }
1106
1107 #[test]
1111 fn test_cosine_distance_zero_vector() {
1112 let a = vec![0.0, 0.0];
1113 let b = vec![1.0, 0.0];
1114 assert_eq!(SemanticClusterer::cosine_distance(&a, &b), 1.0);
1115 }
1116
1117 #[test]
1121 fn test_compute_centroid_empty() {
1122 let c = SemanticClusterer::compute_centroid(&[]);
1123 assert!(c.is_empty());
1124 }
1125
1126 #[test]
1130 fn test_compute_centroid_known() {
1131 let a = [0.0f64, 2.0];
1132 let b = [2.0f64, 0.0];
1133 let c = SemanticClusterer::compute_centroid(&[&a, &b]);
1134 assert!((c[0] - 1.0).abs() < 1e-10);
1135 assert!((c[1] - 1.0).abs() < 1e-10);
1136 }
1137
1138 #[test]
1142 fn test_kmeans_cluster_count() {
1143 let points = well_separated_2d();
1144 let clusterer = SemanticClusterer::new(
1145 ClusterAlgorithm::KMeans {
1146 k: 3,
1147 max_iter: 100,
1148 tolerance: 1e-6,
1149 },
1150 2,
1151 );
1152 let result = clusterer.fit(&points).expect("fit failed");
1153 assert_eq!(result.clusters.len(), 3);
1154 }
1155
1156 #[test]
1160 fn test_kmeans_no_noise() {
1161 let points = well_separated_2d();
1162 let clusterer = SemanticClusterer::new(
1163 ClusterAlgorithm::KMeans {
1164 k: 3,
1165 max_iter: 100,
1166 tolerance: 1e-6,
1167 },
1168 2,
1169 );
1170 let result = clusterer.fit(&points).expect("fit failed");
1171 assert!(result.noise_ids.is_empty());
1172 }
1173
1174 #[test]
1178 fn test_kmeans_all_points_assigned() {
1179 let points = well_separated_2d();
1180 let clusterer = SemanticClusterer::new(
1181 ClusterAlgorithm::KMeans {
1182 k: 3,
1183 max_iter: 100,
1184 tolerance: 1e-6,
1185 },
1186 2,
1187 );
1188 let result = clusterer.fit(&points).expect("fit failed");
1189 let total: usize = result.clusters.iter().map(|c| c.member_ids.len()).sum();
1190 assert_eq!(total, points.len());
1191 }
1192
1193 #[test]
1197 fn test_kmeans_insufficient_points() {
1198 let points = pts(&[(0.0, 0.0), (1.0, 1.0)]);
1199 let clusterer = SemanticClusterer::new(
1200 ClusterAlgorithm::KMeans {
1201 k: 5,
1202 max_iter: 10,
1203 tolerance: 1e-4,
1204 },
1205 2,
1206 );
1207 let err = clusterer.fit(&points).expect_err("should fail");
1208 assert!(matches!(err, ClusterError::InsufficientPoints { .. }));
1209 }
1210
1211 #[test]
1215 fn test_kmeans_dimension_mismatch() {
1216 let points = vec![ScClusterPoint::new("x", vec![1.0, 2.0, 3.0])];
1217 let clusterer = SemanticClusterer::new(
1218 ClusterAlgorithm::KMeans {
1219 k: 1,
1220 max_iter: 10,
1221 tolerance: 1e-4,
1222 },
1223 2,
1224 );
1225 let err = clusterer.fit(&points).expect_err("should fail");
1226 assert!(matches!(err, ClusterError::DimensionMismatch { .. }));
1227 }
1228
1229 #[test]
1233 fn test_kmeans_k_zero() {
1234 let points = pts(&[(0.0, 0.0)]);
1235 let clusterer = SemanticClusterer::new(
1236 ClusterAlgorithm::KMeans {
1237 k: 0,
1238 max_iter: 10,
1239 tolerance: 1e-4,
1240 },
1241 2,
1242 );
1243 let err = clusterer.fit(&points).expect_err("should fail");
1244 assert!(matches!(err, ClusterError::InvalidParameter(_)));
1245 }
1246
1247 #[test]
1251 fn test_kmeans_inertia_nonneg() {
1252 let points = well_separated_2d();
1253 let clusterer = SemanticClusterer::new(
1254 ClusterAlgorithm::KMeans {
1255 k: 3,
1256 max_iter: 100,
1257 tolerance: 1e-6,
1258 },
1259 2,
1260 );
1261 let result = clusterer.fit(&points).expect("fit failed");
1262 assert!(result.inertia >= 0.0);
1263 }
1264
1265 #[test]
1269 fn test_kmeans_silhouette_range() {
1270 let points = well_separated_2d();
1271 let clusterer = SemanticClusterer::new(
1272 ClusterAlgorithm::KMeans {
1273 k: 3,
1274 max_iter: 100,
1275 tolerance: 1e-6,
1276 },
1277 2,
1278 );
1279 let result = clusterer.fit(&points).expect("fit failed");
1280 assert!(
1281 (-1.0..=1.0).contains(&result.silhouette_score),
1282 "score={}",
1283 result.silhouette_score
1284 );
1285 }
1286
1287 #[test]
1291 fn test_kmeans_k1() {
1292 let points = pts(&[(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)]);
1293 let clusterer = SemanticClusterer::new(
1294 ClusterAlgorithm::KMeans {
1295 k: 1,
1296 max_iter: 10,
1297 tolerance: 1e-4,
1298 },
1299 2,
1300 );
1301 let result = clusterer.fit(&points).expect("fit failed");
1302 assert_eq!(result.clusters.len(), 1);
1303 assert_eq!(result.clusters[0].member_ids.len(), 3);
1304 }
1305
1306 #[test]
1310 fn test_mini_batch_kmeans_cluster_count() {
1311 let points = well_separated_2d();
1312 let clusterer = SemanticClusterer::new(
1313 ClusterAlgorithm::MiniBatchKMeans {
1314 k: 3,
1315 batch_size: 10,
1316 max_iter: 200,
1317 },
1318 2,
1319 );
1320 let result = clusterer.fit(&points).expect("fit failed");
1321 assert_eq!(result.clusters.len(), 3);
1322 }
1323
1324 #[test]
1328 fn test_mini_batch_all_assigned() {
1329 let points = well_separated_2d();
1330 let clusterer = SemanticClusterer::new(
1331 ClusterAlgorithm::MiniBatchKMeans {
1332 k: 3,
1333 batch_size: 10,
1334 max_iter: 200,
1335 },
1336 2,
1337 );
1338 let result = clusterer.fit(&points).expect("fit failed");
1339 let total: usize = result.clusters.iter().map(|c| c.member_ids.len()).sum();
1340 assert_eq!(total + result.noise_ids.len(), points.len());
1341 }
1342
1343 #[test]
1347 fn test_dbscan_noise_detection() {
1348 let mut points = well_separated_2d();
1349 points.push(ScClusterPoint::new("outlier", vec![100.0, 100.0]));
1351 let clusterer = SemanticClusterer::new(
1352 ClusterAlgorithm::DBSCAN {
1353 eps: 1.0,
1354 min_samples: 2,
1355 },
1356 2,
1357 );
1358 let result = clusterer.fit(&points).expect("fit failed");
1359 assert!(result.noise_ids.contains(&"outlier".to_string()));
1360 }
1361
1362 #[test]
1366 fn test_dbscan_finds_clusters() {
1367 let points = well_separated_2d();
1368 let clusterer = SemanticClusterer::new(
1369 ClusterAlgorithm::DBSCAN {
1370 eps: 1.0,
1371 min_samples: 2,
1372 },
1373 2,
1374 );
1375 let result = clusterer.fit(&points).expect("fit failed");
1376 assert!(
1377 result.clusters.len() >= 3,
1378 "found {} clusters",
1379 result.clusters.len()
1380 );
1381 }
1382
1383 #[test]
1387 fn test_dbscan_invalid_eps() {
1388 let points = pts(&[(0.0, 0.0)]);
1389 let clusterer = SemanticClusterer::new(
1390 ClusterAlgorithm::DBSCAN {
1391 eps: -0.1,
1392 min_samples: 2,
1393 },
1394 2,
1395 );
1396 assert!(matches!(
1397 clusterer.fit(&points).expect_err("should fail"),
1398 ClusterError::InvalidParameter(_)
1399 ));
1400 }
1401
1402 #[test]
1406 fn test_dbscan_invalid_min_samples() {
1407 let points = pts(&[(0.0, 0.0)]);
1408 let clusterer = SemanticClusterer::new(
1409 ClusterAlgorithm::DBSCAN {
1410 eps: 1.0,
1411 min_samples: 0,
1412 },
1413 2,
1414 );
1415 assert!(matches!(
1416 clusterer.fit(&points).expect_err("should fail"),
1417 ClusterError::InvalidParameter(_)
1418 ));
1419 }
1420
1421 #[test]
1425 fn test_agglomerative_ward_k_clusters() {
1426 let points = well_separated_2d();
1427 let clusterer = SemanticClusterer::new(
1428 ClusterAlgorithm::Agglomerative {
1429 k: 3,
1430 linkage: Linkage::Ward,
1431 },
1432 2,
1433 );
1434 let result = clusterer.fit(&points).expect("fit failed");
1435 assert_eq!(result.clusters.len(), 3);
1436 }
1437
1438 #[test]
1442 fn test_agglomerative_single_all_assigned() {
1443 let points = well_separated_2d();
1444 let clusterer = SemanticClusterer::new(
1445 ClusterAlgorithm::Agglomerative {
1446 k: 3,
1447 linkage: Linkage::Single,
1448 },
1449 2,
1450 );
1451 let result = clusterer.fit(&points).expect("fit failed");
1452 let total: usize = result.clusters.iter().map(|c| c.member_ids.len()).sum();
1453 assert_eq!(total, points.len());
1454 }
1455
1456 #[test]
1460 fn test_agglomerative_complete_count() {
1461 let points = well_separated_2d();
1462 let clusterer = SemanticClusterer::new(
1463 ClusterAlgorithm::Agglomerative {
1464 k: 2,
1465 linkage: Linkage::Complete,
1466 },
1467 2,
1468 );
1469 let result = clusterer.fit(&points).expect("fit failed");
1470 assert_eq!(result.clusters.len(), 2);
1471 }
1472
1473 #[test]
1477 fn test_agglomerative_average_count() {
1478 let points = pts(&[
1479 (0.0, 0.0),
1480 (0.1, 0.0),
1481 (0.0, 0.1),
1482 (10.0, 0.0),
1483 (10.1, 0.0),
1484 (10.0, 0.1),
1485 ]);
1486 let clusterer = SemanticClusterer::new(
1487 ClusterAlgorithm::Agglomerative {
1488 k: 2,
1489 linkage: Linkage::Average,
1490 },
1491 2,
1492 );
1493 let result = clusterer.fit(&points).expect("fit failed");
1494 assert_eq!(result.clusters.len(), 2);
1495 }
1496
1497 #[test]
1501 fn test_predict_nearest_cluster() {
1502 let points = well_separated_2d();
1503 let clusterer = SemanticClusterer::new(
1504 ClusterAlgorithm::KMeans {
1505 k: 3,
1506 max_iter: 100,
1507 tolerance: 1e-6,
1508 },
1509 2,
1510 );
1511 let result = clusterer.fit(&points).expect("fit failed");
1512
1513 let new_point = ScClusterPoint::new("new", vec![0.05, 0.05]);
1515 let predicted = clusterer.predict(&new_point, &result);
1516 assert!(predicted.is_some());
1517 let pid = predicted.expect("predict returned None");
1518 let cluster = result
1520 .clusters
1521 .iter()
1522 .find(|c| c.id == pid)
1523 .expect("cluster not found");
1524 assert!(
1525 cluster.member_ids.iter().any(|id| id.starts_with('a')),
1526 "predicted cluster should contain 'a' points, got {:?}",
1527 cluster.member_ids
1528 );
1529 }
1530
1531 #[test]
1535 fn test_predict_empty_result() {
1536 use super::ScClusteringResult;
1537 let clusterer = SemanticClusterer::new(
1538 ClusterAlgorithm::KMeans {
1539 k: 1,
1540 max_iter: 1,
1541 tolerance: 1e-4,
1542 },
1543 2,
1544 );
1545 let empty_result = ScClusteringResult {
1546 clusters: vec![],
1547 noise_ids: vec![],
1548 algorithm: "test".into(),
1549 silhouette_score: 0.0,
1550 inertia: 0.0,
1551 iterations: 0,
1552 };
1553 let point = ScClusterPoint::new("x", vec![0.0, 0.0]);
1554 assert!(clusterer.predict(&point, &empty_result).is_none());
1555 }
1556
1557 #[test]
1561 fn test_stats_consistency() {
1562 let points = well_separated_2d();
1563 let clusterer = SemanticClusterer::new(
1564 ClusterAlgorithm::KMeans {
1565 k: 3,
1566 max_iter: 100,
1567 tolerance: 1e-6,
1568 },
1569 2,
1570 );
1571 let result = clusterer.fit(&points).expect("fit failed");
1572 let stats = SemanticClusterer::stats(&result);
1573 assert_eq!(stats.total_clustered + stats.noise_count, points.len());
1574 }
1575
1576 #[test]
1580 fn test_stats_avg_cluster_size() {
1581 let points = well_separated_2d(); let clusterer = SemanticClusterer::new(
1583 ClusterAlgorithm::KMeans {
1584 k: 3,
1585 max_iter: 100,
1586 tolerance: 1e-6,
1587 },
1588 2,
1589 );
1590 let result = clusterer.fit(&points).expect("fit failed");
1591 let stats = SemanticClusterer::stats(&result);
1592 assert!(
1593 (stats.avg_cluster_size - 10.0).abs() < 1.0,
1594 "avg={}",
1595 stats.avg_cluster_size
1596 );
1597 }
1598
1599 #[test]
1603 fn test_stats_largest_ge_smallest() {
1604 let points = well_separated_2d();
1605 let clusterer = SemanticClusterer::new(
1606 ClusterAlgorithm::KMeans {
1607 k: 3,
1608 max_iter: 100,
1609 tolerance: 1e-6,
1610 },
1611 2,
1612 );
1613 let result = clusterer.fit(&points).expect("fit failed");
1614 let stats = SemanticClusterer::stats(&result);
1615 assert!(stats.largest_cluster >= stats.smallest_cluster);
1616 }
1617
1618 #[test]
1622 fn test_kmeans_pp_init_count() {
1623 let points = well_separated_2d();
1624 let centroids = kmeans_plus_plus_init(&points, 3, 42);
1625 assert_eq!(centroids.len(), 3);
1626 }
1627
1628 #[test]
1632 fn test_kmeans_pp_init_dims() {
1633 let points = well_separated_2d();
1634 let centroids = kmeans_plus_plus_init(&points, 3, 42);
1635 for c in ¢roids {
1636 assert_eq!(c.len(), 2);
1637 }
1638 }
1639
1640 #[test]
1644 fn test_cluster_point_new_unclustered() {
1645 let p = ScClusterPoint::new("id", vec![1.0, 2.0]);
1646 assert!(p.cluster_id.is_none());
1647 }
1648
1649 #[test]
1653 fn test_sc_cluster_size_and_empty() {
1654 use super::ScCluster;
1655 let empty = ScCluster {
1656 id: 0,
1657 centroid: vec![0.0],
1658 member_ids: vec![],
1659 inertia: 0.0,
1660 };
1661 assert!(empty.is_empty());
1662 assert_eq!(empty.size(), 0);
1663
1664 let non_empty = ScCluster {
1665 id: 1,
1666 centroid: vec![1.0],
1667 member_ids: vec!["a".into(), "b".into()],
1668 inertia: 0.5,
1669 };
1670 assert!(!non_empty.is_empty());
1671 assert_eq!(non_empty.size(), 2);
1672 }
1673
1674 #[test]
1678 fn test_dbscan_single_point_cluster() {
1679 let points = vec![ScClusterPoint::new("only", vec![0.0, 0.0])];
1680 let clusterer = SemanticClusterer::new(
1681 ClusterAlgorithm::DBSCAN {
1682 eps: 1.0,
1683 min_samples: 1,
1684 },
1685 2,
1686 );
1687 let result = clusterer.fit(&points).expect("fit failed");
1688 assert_eq!(result.clusters.len(), 1);
1689 assert!(result.noise_ids.is_empty());
1690 }
1691
1692 #[test]
1696 fn test_tag_points() {
1697 let points = pts(&[(0.0, 0.0), (1.0, 1.0)]);
1698 let clusterer = SemanticClusterer::new(
1699 ClusterAlgorithm::KMeans {
1700 k: 2,
1701 max_iter: 10,
1702 tolerance: 1e-4,
1703 },
1704 2,
1705 );
1706 let result = clusterer.fit(&points).expect("fit failed");
1707 let tagged = tag_points(&points, &result);
1708 for tp in &tagged {
1709 assert!(
1710 tp.cluster_id.is_some(),
1711 "point {} should be assigned",
1712 tp.id
1713 );
1714 }
1715 }
1716
1717 #[test]
1721 fn test_kmeans_algorithm_label() {
1722 let points = well_separated_2d();
1723 let clusterer = SemanticClusterer::new(
1724 ClusterAlgorithm::KMeans {
1725 k: 3,
1726 max_iter: 50,
1727 tolerance: 1e-6,
1728 },
1729 2,
1730 );
1731 let result = clusterer.fit(&points).expect("fit failed");
1732 assert!(
1733 result.algorithm.contains("kmeans"),
1734 "label={}",
1735 result.algorithm
1736 );
1737 }
1738
1739 #[test]
1743 fn test_dbscan_algorithm_label() {
1744 let points = well_separated_2d();
1745 let clusterer = SemanticClusterer::new(
1746 ClusterAlgorithm::DBSCAN {
1747 eps: 1.0,
1748 min_samples: 2,
1749 },
1750 2,
1751 );
1752 let result = clusterer.fit(&points).expect("fit failed");
1753 assert!(
1754 result.algorithm.contains("dbscan"),
1755 "label={}",
1756 result.algorithm
1757 );
1758 }
1759
1760 #[test]
1764 fn test_agglomerative_k_equals_n() {
1765 let points = pts(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]);
1766 let clusterer = SemanticClusterer::new(
1767 ClusterAlgorithm::Agglomerative {
1768 k: 3,
1769 linkage: Linkage::Single,
1770 },
1771 2,
1772 );
1773 let result = clusterer.fit(&points).expect("fit failed");
1774 assert_eq!(result.clusters.len(), 3);
1775 for c in &result.clusters {
1776 assert_eq!(c.member_ids.len(), 1);
1777 }
1778 }
1779}