Skip to main content

ipfrs_semantic/
semantic_clusterer.rs

1//! `SemanticClusterer` — Multi-algorithm semantic vector clustering engine.
2//!
3//! Supports KMeans (with KMeans++ seeding), MiniBatchKMeans, DBSCAN, and
4//! Agglomerative clustering over f64 embedding vectors. Uses xorshift64 for
5//! all pseudo-random operations (no `rand` crate).
6
7use std::fmt;
8use thiserror::Error;
9
10// ---------------------------------------------------------------------------
11// PRNG — xorshift64 (seed=42 default)
12// ---------------------------------------------------------------------------
13
14#[inline]
15fn xorshift64(state: &mut u64) -> u64 {
16    *state ^= *state << 13;
17    *state ^= *state >> 7;
18    *state ^= *state << 17;
19    *state
20}
21
22// ---------------------------------------------------------------------------
23// Public error type
24// ---------------------------------------------------------------------------
25
26/// Errors that can occur during clustering operations.
27#[derive(Debug, Error)]
28pub enum ClusterError {
29    /// Not enough points to form the requested number of clusters.
30    #[error("insufficient points: need at least {min}, got {got}")]
31    InsufficientPoints { min: usize, got: usize },
32
33    /// A point's embedding dimensionality does not match the clusterer's `dims`.
34    #[error("dimension mismatch: expected {expected}, got {got}")]
35    DimensionMismatch { expected: usize, got: usize },
36
37    /// All clusters ended up empty after an iteration.
38    #[error("all clusters became empty")]
39    EmptyClusters,
40
41    /// A configuration parameter is invalid.
42    #[error("invalid parameter: {0}")]
43    InvalidParameter(String),
44}
45
46// ---------------------------------------------------------------------------
47// Linkage criterion for agglomerative clustering
48// ---------------------------------------------------------------------------
49
50/// Linkage criterion used by agglomerative clustering.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Linkage {
53    /// Minimise variance (simplified: behaves like `Average` in this implementation).
54    Ward,
55    /// Distance between the two farthest members of each cluster.
56    Complete,
57    /// Mean of all pairwise distances between cluster members.
58    Average,
59    /// Distance between the two nearest members of each cluster.
60    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// ---------------------------------------------------------------------------
75// Algorithm selector
76// ---------------------------------------------------------------------------
77
78/// Which clustering algorithm the `SemanticClusterer` should use.
79#[derive(Debug, Clone)]
80pub enum ClusterAlgorithm {
81    /// Standard KMeans with KMeans++ seeding.
82    KMeans {
83        /// Number of clusters to form.
84        k: usize,
85        /// Maximum number of EM iterations.
86        max_iter: u32,
87        /// Centroid-shift tolerance for early stopping.
88        tolerance: f64,
89    },
90    /// Mini-batch variant of KMeans for large datasets.
91    MiniBatchKMeans {
92        /// Number of clusters.
93        k: usize,
94        /// Points sampled per iteration.
95        batch_size: usize,
96        /// Maximum iterations.
97        max_iter: u32,
98    },
99    /// Density-Based Spatial Clustering of Applications with Noise.
100    DBSCAN {
101        /// Neighbourhood radius.
102        eps: f64,
103        /// Minimum neighbours (including self) to be a core point.
104        min_samples: usize,
105    },
106    /// Hierarchical agglomerative clustering.
107    Agglomerative {
108        /// Target number of clusters.
109        k: usize,
110        /// Linkage criterion.
111        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// ---------------------------------------------------------------------------
133// Core data structures
134// ---------------------------------------------------------------------------
135
136/// A single embedding vector with an associated string identifier.
137#[derive(Debug, Clone)]
138pub struct ScClusterPoint {
139    /// Unique identifier (e.g. CID or file path).
140    pub id: String,
141    /// The embedding vector.
142    pub embedding: Vec<f64>,
143    /// Assigned cluster index, or `None` for noise / unclustered points.
144    pub cluster_id: Option<usize>,
145}
146
147impl ScClusterPoint {
148    /// Construct a new unassigned cluster point.
149    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/// A cluster produced by the clusterer.
159#[derive(Debug, Clone)]
160pub struct ScCluster {
161    /// Zero-based cluster index.
162    pub id: usize,
163    /// Centroid vector (mean of all member embeddings).
164    pub centroid: Vec<f64>,
165    /// IDs of all member points.
166    pub member_ids: Vec<String>,
167    /// Sum of squared Euclidean distances from each member to the centroid.
168    pub inertia: f64,
169}
170
171impl ScCluster {
172    /// Number of members in this cluster.
173    pub fn size(&self) -> usize {
174        self.member_ids.len()
175    }
176
177    /// `true` when the cluster contains no members.
178    pub fn is_empty(&self) -> bool {
179        self.member_ids.is_empty()
180    }
181}
182
183/// The full result of a clustering run.
184#[derive(Debug, Clone)]
185pub struct ScClusteringResult {
186    /// Non-noise clusters produced.
187    pub clusters: Vec<ScCluster>,
188    /// Point IDs that were classified as noise (DBSCAN) or left unclustered.
189    pub noise_ids: Vec<String>,
190    /// Human-readable label for the algorithm used.
191    pub algorithm: String,
192    /// Mean silhouette coefficient in `[-1, 1]`.
193    pub silhouette_score: f64,
194    /// Total inertia (sum of all per-cluster inertias).
195    pub inertia: f64,
196    /// Number of EM / optimisation iterations performed.
197    pub iterations: u32,
198}
199
200/// Summary statistics for a clustering result.
201#[derive(Debug, Clone)]
202pub struct ScClustererStats {
203    /// Total points assigned to a cluster (excluding noise).
204    pub total_clustered: usize,
205    /// Number of noise points.
206    pub noise_count: usize,
207    /// Mean cluster size (excluding noise).
208    pub avg_cluster_size: f64,
209    /// Size of the largest cluster.
210    pub largest_cluster: usize,
211    /// Size of the smallest cluster (0 when there are no clusters).
212    pub smallest_cluster: usize,
213}
214
215// ---------------------------------------------------------------------------
216// Main engine
217// ---------------------------------------------------------------------------
218
219/// Multi-algorithm semantic vector clustering engine.
220///
221/// ```rust
222/// use ipfrs_semantic::semantic_clusterer::{
223///     SemanticClusterer, ClusterAlgorithm, ScClusterPoint,
224/// };
225///
226/// let points: Vec<ScClusterPoint> = (0..20)
227///     .map(|i| ScClusterPoint::new(format!("p{i}"), vec![(i % 4) as f64, (i / 4) as f64]))
228///     .collect();
229///
230/// let clusterer = SemanticClusterer::new(
231///     ClusterAlgorithm::KMeans { k: 4, max_iter: 100, tolerance: 1e-6 },
232///     2,
233/// );
234/// let result = clusterer.fit(&points).expect("clustering failed");
235/// assert_eq!(result.clusters.len(), 4);
236/// ```
237#[derive(Debug, Clone)]
238pub struct SemanticClusterer {
239    /// Algorithm configuration.
240    pub algorithm: ClusterAlgorithm,
241    /// Expected embedding dimensionality.
242    pub dims: usize,
243}
244
245impl SemanticClusterer {
246    /// Create a new clusterer with the given algorithm and embedding dimension.
247    pub fn new(algorithm: ClusterAlgorithm, dims: usize) -> Self {
248        Self { algorithm, dims }
249    }
250
251    // -----------------------------------------------------------------------
252    // Public interface
253    // -----------------------------------------------------------------------
254
255    /// Run clustering on the provided points.
256    ///
257    /// Returns a `ScClusteringResult` on success.
258    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        // Build final result with silhouette score
281        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    /// Assign a new point to the nearest cluster centroid in `result`.
289    ///
290    /// Returns `None` when `result` has no clusters.
291    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    /// Cosine distance: `1 - cosine_similarity(a, b)`.
308    ///
309    /// Returns `1.0` for zero vectors.
310    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    /// Euclidean distance between two vectors of equal length.
321    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    /// Mean silhouette coefficient for the clustering.
330    ///
331    /// Uses a random subset of up to 100 points when the dataset is large.
332    pub fn silhouette_score(points: &[ScClusterPoint], result: &ScClusteringResult) -> f64 {
333        if result.clusters.len() < 2 {
334            return 0.0;
335        }
336        // Build (point, cluster_id) pairs — noise excluded
337        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        // Subsample deterministically when large
347        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            // Fisher-Yates partial shuffle
351            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    /// Compute the centroid (element-wise mean) of a set of embeddings.
372    ///
373    /// Returns a zero vector when `embeddings` is empty or their slices are empty.
374    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    /// Summary statistics for a completed clustering result.
394    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    // -----------------------------------------------------------------------
424    // Validation helpers
425    // -----------------------------------------------------------------------
426
427    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    // -----------------------------------------------------------------------
440    // KMeans
441    // -----------------------------------------------------------------------
442
443    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            // Assignment step
468            for (i, p) in points.iter().enumerate() {
469                assignments[i] = nearest_centroid(&p.embedding, &centroids);
470            }
471
472            // Update step
473            let new_centroids = recompute_centroids(points, &assignments, k, self.dims);
474
475            // Convergence check
476            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    // -----------------------------------------------------------------------
492    // MiniBatchKMeans
493    // -----------------------------------------------------------------------
494
495    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        // Per-centroid update counts for incremental averaging
520        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            // Sample batch (with replacement via xorshift64)
528            let batch_indices: Vec<usize> = (0..effective_batch)
529                .map(|_| xorshift64(&mut state) as usize % points.len())
530                .collect();
531
532            // Assign batch points to nearest centroid
533            let batch_assignments: Vec<usize> = batch_indices
534                .iter()
535                .map(|&i| nearest_centroid(&points[i].embedding, &centroids))
536                .collect();
537
538            // Incremental centroid update
539            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        // Final assignment of all points
550        let assignments: Vec<usize> = points
551            .iter()
552            .map(|p| nearest_centroid(&p.embedding, &centroids))
553            .collect();
554
555        build_result_from_centroids(points, &assignments, centroids, iterations)
556    }
557
558    // -----------------------------------------------------------------------
559    // DBSCAN
560    // -----------------------------------------------------------------------
561
562    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        // -1 = unvisited, usize::MAX = noise, otherwise cluster index
581        let mut label: Vec<Option<usize>> = vec![None; n];
582        let mut cluster_id = 0usize;
583
584        // Pre-compute eps-neighbourhoods
585        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                // Mark as noise temporarily (will be revised if reachable)
601                label[i] = Some(usize::MAX);
602                continue;
603            }
604            // Expand cluster
605            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                    // Border point — assign to cluster
613                    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        // Collect results
631        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        // Build ScCluster for each group
643        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, &centroid).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(), // filled by caller
672            silhouette_score: 0.0,    // filled by caller
673            inertia: total_inertia,
674            iterations: 1,
675        })
676    }
677
678    // -----------------------------------------------------------------------
679    // Agglomerative
680    // -----------------------------------------------------------------------
681
682    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        // Each point starts in its own cluster (represented as a set of point indices)
700        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            // Find the pair with minimum linkage distance
707            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            // Merge b into a
724            let b_members = clusters.remove(merge_b);
725            clusters[merge_a].extend(b_members);
726        }
727
728        // Build ScCluster objects
729        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, &centroid).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
767// ---------------------------------------------------------------------------
768// Module-level helpers (not part of public API surface but accessible)
769// ---------------------------------------------------------------------------
770
771/// KMeans++ centroid initialisation using xorshift64.
772fn 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    // Pick first centroid uniformly at random
777    let first = xorshift64(&mut state) as usize % points.len();
778    centroids.push(points[first].embedding.clone());
779
780    for _ in 1..k {
781        // Compute squared distances to the nearest existing centroid
782        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            // All points are coincident; pick randomly
795            let idx = xorshift64(&mut state) as usize % points.len();
796            centroids.push(points[idx].embedding.clone());
797            continue;
798        }
799
800        // Weighted random draw via xorshift64
801        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
817/// Return the index of the nearest centroid to `embedding`.
818fn 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
831/// Recompute centroids as the mean of all assigned points.
832/// Empty clusters retain their previous position.
833fn 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
859/// Build a `ScClusteringResult` from a flat assignment array and centroid list.
860fn 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, &centroid).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
907/// Copy cluster assignments back into a fresh `ScClusterPoint` slice.
908fn 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
923/// Silhouette coefficient for a single point.
924fn silhouette_one(
925    point: &ScClusterPoint,
926    cid: usize,
927    all: &[(&ScClusterPoint, usize)],
928) -> Option<f64> {
929    // Mean distance to points in the same cluster (a)
930    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    // Nearest-other-cluster mean distance (b)
943    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
977/// Linkage distance between two clusters identified by their point-index sets.
978fn 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// ---------------------------------------------------------------------------
999// Tests
1000// ---------------------------------------------------------------------------
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::{
1005        kmeans_plus_plus_init, tag_points, xorshift64, ClusterAlgorithm, ClusterError, Linkage,
1006        ScClusterPoint, SemanticClusterer,
1007    };
1008
1009    // -----------------------------------------------------------------------
1010    // Helpers
1011    // -----------------------------------------------------------------------
1012
1013    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        // Three well-separated groups
1023        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    // -----------------------------------------------------------------------
1046    // 1. xorshift64 produces non-zero output from seed 42
1047    // -----------------------------------------------------------------------
1048    #[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    // -----------------------------------------------------------------------
1056    // 2. xorshift64 produces distinct successive values
1057    // -----------------------------------------------------------------------
1058    #[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    // -----------------------------------------------------------------------
1067    // 3. euclidean_distance: identical vectors → 0
1068    // -----------------------------------------------------------------------
1069    #[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    // -----------------------------------------------------------------------
1076    // 4. euclidean_distance: known value
1077    // -----------------------------------------------------------------------
1078    #[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    // -----------------------------------------------------------------------
1087    // 5. cosine_distance: identical non-zero vectors → 0
1088    // -----------------------------------------------------------------------
1089    #[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    // -----------------------------------------------------------------------
1097    // 6. cosine_distance: orthogonal vectors → 1
1098    // -----------------------------------------------------------------------
1099    #[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    // -----------------------------------------------------------------------
1108    // 7. cosine_distance: zero vector → 1
1109    // -----------------------------------------------------------------------
1110    #[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    // -----------------------------------------------------------------------
1118    // 8. compute_centroid: empty slice → empty vec
1119    // -----------------------------------------------------------------------
1120    #[test]
1121    fn test_compute_centroid_empty() {
1122        let c = SemanticClusterer::compute_centroid(&[]);
1123        assert!(c.is_empty());
1124    }
1125
1126    // -----------------------------------------------------------------------
1127    // 9. compute_centroid: known value
1128    // -----------------------------------------------------------------------
1129    #[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    // -----------------------------------------------------------------------
1139    // 10. KMeans: produces exactly k clusters
1140    // -----------------------------------------------------------------------
1141    #[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    // -----------------------------------------------------------------------
1157    // 11. KMeans: all points assigned (no noise)
1158    // -----------------------------------------------------------------------
1159    #[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    // -----------------------------------------------------------------------
1175    // 12. KMeans: total members == total points
1176    // -----------------------------------------------------------------------
1177    #[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    // -----------------------------------------------------------------------
1194    // 13. KMeans: insufficient points error
1195    // -----------------------------------------------------------------------
1196    #[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    // -----------------------------------------------------------------------
1212    // 14. KMeans: dimension mismatch error
1213    // -----------------------------------------------------------------------
1214    #[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    // -----------------------------------------------------------------------
1230    // 15. KMeans k=0 → InvalidParameter
1231    // -----------------------------------------------------------------------
1232    #[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    // -----------------------------------------------------------------------
1248    // 16. KMeans: inertia is non-negative
1249    // -----------------------------------------------------------------------
1250    #[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    // -----------------------------------------------------------------------
1266    // 17. KMeans: silhouette_score in [-1, 1]
1267    // -----------------------------------------------------------------------
1268    #[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    // -----------------------------------------------------------------------
1288    // 18. KMeans: k=1 special case — all points in one cluster
1289    // -----------------------------------------------------------------------
1290    #[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    // -----------------------------------------------------------------------
1307    // 19. MiniBatchKMeans: produces k clusters
1308    // -----------------------------------------------------------------------
1309    #[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    // -----------------------------------------------------------------------
1325    // 20. MiniBatchKMeans: all points assigned
1326    // -----------------------------------------------------------------------
1327    #[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    // -----------------------------------------------------------------------
1344    // 21. DBSCAN: noise detection
1345    // -----------------------------------------------------------------------
1346    #[test]
1347    fn test_dbscan_noise_detection() {
1348        let mut points = well_separated_2d();
1349        // Add an isolated outlier
1350        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    // -----------------------------------------------------------------------
1363    // 22. DBSCAN: well-separated clusters found
1364    // -----------------------------------------------------------------------
1365    #[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    // -----------------------------------------------------------------------
1384    // 23. DBSCAN: eps <= 0 → InvalidParameter
1385    // -----------------------------------------------------------------------
1386    #[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    // -----------------------------------------------------------------------
1403    // 24. DBSCAN: min_samples=0 → InvalidParameter
1404    // -----------------------------------------------------------------------
1405    #[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    // -----------------------------------------------------------------------
1422    // 25. Agglomerative (Ward): produces k clusters
1423    // -----------------------------------------------------------------------
1424    #[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    // -----------------------------------------------------------------------
1439    // 26. Agglomerative (Single): all points assigned
1440    // -----------------------------------------------------------------------
1441    #[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    // -----------------------------------------------------------------------
1457    // 27. Agglomerative (Complete): correct cluster count
1458    // -----------------------------------------------------------------------
1459    #[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    // -----------------------------------------------------------------------
1474    // 28. Agglomerative (Average): correct cluster count
1475    // -----------------------------------------------------------------------
1476    #[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    // -----------------------------------------------------------------------
1498    // 29. predict: assigns to the cluster with the nearest centroid
1499    // -----------------------------------------------------------------------
1500    #[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        // Point very close to first cluster's centroid (near [0.05, 0.05])
1514        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        // The predicted cluster should have members from the 'a' group
1519        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    // -----------------------------------------------------------------------
1532    // 30. predict: empty result → None
1533    // -----------------------------------------------------------------------
1534    #[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    // -----------------------------------------------------------------------
1558    // 31. stats: totals are consistent
1559    // -----------------------------------------------------------------------
1560    #[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    // -----------------------------------------------------------------------
1577    // 32. stats: avg_cluster_size is correct
1578    // -----------------------------------------------------------------------
1579    #[test]
1580    fn test_stats_avg_cluster_size() {
1581        let points = well_separated_2d(); // 30 points, k=3 → 10 each
1582        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    // -----------------------------------------------------------------------
1600    // 33. stats: largest >= smallest
1601    // -----------------------------------------------------------------------
1602    #[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    // -----------------------------------------------------------------------
1619    // 34. kmeans_plus_plus_init: produces exactly k centroids
1620    // -----------------------------------------------------------------------
1621    #[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    // -----------------------------------------------------------------------
1629    // 35. kmeans_plus_plus_init: centroids have correct dimensionality
1630    // -----------------------------------------------------------------------
1631    #[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 &centroids {
1636            assert_eq!(c.len(), 2);
1637        }
1638    }
1639
1640    // -----------------------------------------------------------------------
1641    // 36. ScClusterPoint::new: sets cluster_id to None
1642    // -----------------------------------------------------------------------
1643    #[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    // -----------------------------------------------------------------------
1650    // 37. ScCluster::is_empty / size
1651    // -----------------------------------------------------------------------
1652    #[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    // -----------------------------------------------------------------------
1675    // 38. DBSCAN: single-point cluster (min_samples=1)
1676    // -----------------------------------------------------------------------
1677    #[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    // -----------------------------------------------------------------------
1693    // 39. tag_points assigns cluster_id correctly
1694    // -----------------------------------------------------------------------
1695    #[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    // -----------------------------------------------------------------------
1718    // 40. KMeans: algorithm label contains "kmeans"
1719    // -----------------------------------------------------------------------
1720    #[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    // -----------------------------------------------------------------------
1740    // 41. DBSCAN: algorithm label contains "dbscan"
1741    // -----------------------------------------------------------------------
1742    #[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    // -----------------------------------------------------------------------
1761    // 42. Agglomerative k=n: each point in its own cluster
1762    // -----------------------------------------------------------------------
1763    #[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}