Skip to main content

ipfrs_semantic/
cluster_manager.rs

1//! Semantic Cluster Manager — online k-means-style document clustering over embeddings.
2//!
3//! Manages semantic embedding clusters, assigns documents to clusters, updates centroids
4//! incrementally via exponential moving average, and detects cluster drift over time.
5
6/// Assignment of a document to a cluster.
7#[derive(Debug, Clone, PartialEq)]
8pub struct ClusterAssignment {
9    /// Unique identifier of the document.
10    pub doc_id: u64,
11    /// The cluster this document was assigned to.
12    pub cluster_id: usize,
13    /// Euclidean distance from the document embedding to the cluster centroid.
14    pub distance: f32,
15}
16
17/// A single semantic cluster with a centroid maintained via incremental updates.
18#[derive(Debug, Clone)]
19pub struct SemanticCluster {
20    /// Cluster index (0-based).
21    pub cluster_id: usize,
22    /// Current centroid vector (mean embedding direction).
23    pub centroid: Vec<f32>,
24    /// Number of documents that have been assigned to and updated this cluster.
25    pub member_count: u64,
26    /// Cumulative Euclidean shift of the centroid across all updates.
27    pub total_drift: f32,
28}
29
30impl SemanticCluster {
31    /// Compute Euclidean distance between the centroid and `embedding`.
32    ///
33    /// Returns `0.0` if either vector is empty or if the dimensions differ.
34    pub fn euclidean_distance(&self, embedding: &[f32]) -> f32 {
35        if self.centroid.is_empty() || embedding.is_empty() {
36            return 0.0;
37        }
38        if self.centroid.len() != embedding.len() {
39            return 0.0;
40        }
41        let sum_sq: f32 = self
42            .centroid
43            .iter()
44            .zip(embedding.iter())
45            .map(|(a, b)| (a - b) * (a - b))
46            .sum();
47        sum_sq.sqrt()
48    }
49
50    /// Update the centroid toward `new_embedding` using an exponential moving average.
51    ///
52    /// Formula: `centroid[i] = (1 - learning_rate) * centroid[i] + learning_rate * new_embedding[i]`
53    ///
54    /// Computes the drift (Euclidean shift of the centroid) and adds it to `total_drift`.
55    /// Increments `member_count` by one.
56    ///
57    /// This is a no-op if the dimensions of `new_embedding` do not match the centroid.
58    pub fn update_centroid(&mut self, new_embedding: &[f32], learning_rate: f32) {
59        if self.centroid.len() != new_embedding.len() {
60            return;
61        }
62        if self.centroid.is_empty() {
63            return;
64        }
65
66        let old_centroid = self.centroid.clone();
67
68        for (c, &e) in self.centroid.iter_mut().zip(new_embedding.iter()) {
69            *c = (1.0 - learning_rate) * (*c) + learning_rate * e;
70        }
71
72        // Compute drift = Euclidean distance between old and new centroid.
73        let drift: f32 = old_centroid
74            .iter()
75            .zip(self.centroid.iter())
76            .map(|(a, b)| (a - b) * (a - b))
77            .sum::<f32>()
78            .sqrt();
79
80        self.total_drift += drift;
81        self.member_count += 1;
82    }
83}
84
85/// Configuration for [`SemanticClusterManager`].
86#[derive(Debug, Clone)]
87pub struct ClusterManagerConfig {
88    /// Number of clusters to maintain.
89    pub n_clusters: usize,
90    /// Centroid update learning rate (EMA weight for the new embedding).
91    pub learning_rate: f32,
92    /// Total drift threshold above which a cluster is considered unstable.
93    pub drift_threshold: f32,
94}
95
96impl Default for ClusterManagerConfig {
97    fn default() -> Self {
98        Self {
99            n_clusters: 8,
100            learning_rate: 0.1,
101            drift_threshold: 0.5,
102        }
103    }
104}
105
106/// Aggregate statistics over all clusters managed by [`SemanticClusterManager`].
107#[derive(Debug, Clone)]
108pub struct ClusterManagerStats {
109    /// Total number of clusters.
110    pub total_clusters: usize,
111    /// Total number of document assignments performed.
112    pub total_assignments: u64,
113    /// Mean member count across all clusters.
114    pub avg_cluster_size: f64,
115    /// `cluster_id` of the cluster with the highest `total_drift`, or `None` if all have zero drift.
116    pub most_drifted_cluster: Option<usize>,
117    /// Number of clusters whose `total_drift` exceeds the configured threshold.
118    pub unstable_clusters: usize,
119}
120
121/// Online k-means-style semantic cluster manager.
122///
123/// Documents are assigned to the nearest initialised cluster (by Euclidean distance) and each
124/// assignment incrementally shifts the cluster centroid toward the document embedding.
125pub struct SemanticClusterManager {
126    /// The managed clusters (length == `config.n_clusters`).
127    pub clusters: Vec<SemanticCluster>,
128    /// Manager configuration.
129    pub config: ClusterManagerConfig,
130    /// Total number of successful assignments since creation or last reset.
131    pub total_assignments: u64,
132}
133
134impl SemanticClusterManager {
135    /// Create a new manager with `n_clusters` empty (uninitialized) clusters.
136    pub fn new(config: ClusterManagerConfig) -> Self {
137        let n = config.n_clusters;
138        let clusters = (0..n)
139            .map(|i| SemanticCluster {
140                cluster_id: i,
141                centroid: Vec::new(),
142                member_count: 0,
143                total_drift: 0.0,
144            })
145            .collect();
146        Self {
147            clusters,
148            config,
149            total_assignments: 0,
150        }
151    }
152
153    /// Initialise cluster centroids from the provided list.
154    ///
155    /// Only up to `min(centroids.len(), n_clusters)` clusters are initialised.
156    pub fn initialize_centroids(&mut self, centroids: Vec<Vec<f32>>) {
157        let limit = centroids.len().min(self.config.n_clusters);
158        for (i, centroid) in centroids.into_iter().take(limit).enumerate() {
159            self.clusters[i].centroid = centroid;
160        }
161    }
162
163    /// Assign `doc_id` to the nearest initialised cluster and update the centroid.
164    ///
165    /// A cluster is considered initialised when its centroid is non-empty **and** its
166    /// dimension matches that of `embedding`.
167    ///
168    /// Returns `None` when no valid cluster exists.
169    pub fn assign(&mut self, doc_id: u64, embedding: &[f32]) -> Option<ClusterAssignment> {
170        // Find the nearest valid cluster.
171        let mut best_cluster_id: Option<usize> = None;
172        let mut best_distance = f32::MAX;
173
174        for cluster in &self.clusters {
175            if cluster.centroid.is_empty() || cluster.centroid.len() != embedding.len() {
176                continue;
177            }
178            let dist = cluster.euclidean_distance(embedding);
179            if dist < best_distance {
180                best_distance = dist;
181                best_cluster_id = Some(cluster.cluster_id);
182            }
183        }
184
185        let cluster_id = best_cluster_id?;
186
187        // Update centroid in place.
188        let lr = self.config.learning_rate;
189        self.clusters[cluster_id].update_centroid(embedding, lr);
190
191        self.total_assignments += 1;
192
193        Some(ClusterAssignment {
194            doc_id,
195            cluster_id,
196            distance: best_distance,
197        })
198    }
199
200    /// Return the `cluster_id` of the nearest initialised cluster to `embedding` without
201    /// mutating any state.
202    pub fn nearest_cluster(&self, embedding: &[f32]) -> Option<usize> {
203        let mut best_cluster_id: Option<usize> = None;
204        let mut best_distance = f32::MAX;
205
206        for cluster in &self.clusters {
207            if cluster.centroid.is_empty() || cluster.centroid.len() != embedding.len() {
208                continue;
209            }
210            let dist = cluster.euclidean_distance(embedding);
211            if dist < best_distance {
212                best_distance = dist;
213                best_cluster_id = Some(cluster.cluster_id);
214            }
215        }
216
217        best_cluster_id
218    }
219
220    /// Return a reference to a cluster by its `cluster_id`, or `None` if out of bounds.
221    pub fn cluster(&self, cluster_id: usize) -> Option<&SemanticCluster> {
222        self.clusters.get(cluster_id)
223    }
224
225    /// Compute aggregate statistics over all clusters.
226    pub fn stats(&self) -> ClusterManagerStats {
227        let total_clusters = self.clusters.len();
228
229        let avg_cluster_size = if total_clusters == 0 {
230            0.0
231        } else {
232            let total_members: u64 = self.clusters.iter().map(|c| c.member_count).sum();
233            total_members as f64 / total_clusters as f64
234        };
235
236        // Find the cluster with the highest total_drift.
237        let most_drifted_cluster = self
238            .clusters
239            .iter()
240            .max_by(|a, b| {
241                a.total_drift
242                    .partial_cmp(&b.total_drift)
243                    .unwrap_or(std::cmp::Ordering::Equal)
244            })
245            .and_then(|c| {
246                if c.total_drift > 0.0 {
247                    Some(c.cluster_id)
248                } else {
249                    None
250                }
251            });
252
253        let threshold = self.config.drift_threshold;
254        let unstable_clusters = self
255            .clusters
256            .iter()
257            .filter(|c| c.total_drift > threshold)
258            .count();
259
260        ClusterManagerStats {
261            total_clusters,
262            total_assignments: self.total_assignments,
263            avg_cluster_size,
264            most_drifted_cluster,
265            unstable_clusters,
266        }
267    }
268
269    /// Reset `total_drift` to `0.0` for all clusters.
270    pub fn reset_drift(&mut self) {
271        for cluster in &mut self.clusters {
272            cluster.total_drift = 0.0;
273        }
274    }
275}
276
277// ─── Tests ────────────────────────────────────────────────────────────────────
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn make_config(n_clusters: usize) -> ClusterManagerConfig {
284        ClusterManagerConfig {
285            n_clusters,
286            learning_rate: 0.1,
287            drift_threshold: 0.5,
288        }
289    }
290
291    fn make_manager(n_clusters: usize) -> SemanticClusterManager {
292        SemanticClusterManager::new(make_config(n_clusters))
293    }
294
295    // ── SemanticCluster helpers ──────────────────────────────────────────────
296
297    #[test]
298    fn test_euclidean_distance_basic() {
299        let cluster = SemanticCluster {
300            cluster_id: 0,
301            centroid: vec![0.0, 0.0, 0.0],
302            member_count: 0,
303            total_drift: 0.0,
304        };
305        let dist = cluster.euclidean_distance(&[3.0, 4.0, 0.0]);
306        assert!((dist - 5.0).abs() < 1e-5, "expected 5.0, got {dist}");
307    }
308
309    #[test]
310    fn test_euclidean_distance_zero_when_same() {
311        let cluster = SemanticCluster {
312            cluster_id: 0,
313            centroid: vec![1.0, 2.0, 3.0],
314            member_count: 0,
315            total_drift: 0.0,
316        };
317        let dist = cluster.euclidean_distance(&[1.0, 2.0, 3.0]);
318        assert!(dist.abs() < 1e-6, "expected 0.0, got {dist}");
319    }
320
321    #[test]
322    fn test_euclidean_distance_empty_centroid_returns_zero() {
323        let cluster = SemanticCluster {
324            cluster_id: 0,
325            centroid: vec![],
326            member_count: 0,
327            total_drift: 0.0,
328        };
329        let dist = cluster.euclidean_distance(&[1.0, 2.0]);
330        assert_eq!(dist, 0.0);
331    }
332
333    #[test]
334    fn test_euclidean_distance_empty_embedding_returns_zero() {
335        let cluster = SemanticCluster {
336            cluster_id: 0,
337            centroid: vec![1.0, 2.0],
338            member_count: 0,
339            total_drift: 0.0,
340        };
341        let dist = cluster.euclidean_distance(&[]);
342        assert_eq!(dist, 0.0);
343    }
344
345    #[test]
346    fn test_euclidean_distance_dimension_mismatch_returns_zero() {
347        let cluster = SemanticCluster {
348            cluster_id: 0,
349            centroid: vec![1.0, 2.0],
350            member_count: 0,
351            total_drift: 0.0,
352        };
353        let dist = cluster.euclidean_distance(&[1.0, 2.0, 3.0]);
354        assert_eq!(dist, 0.0);
355    }
356
357    #[test]
358    fn test_update_centroid_shifts_toward_embedding() {
359        let mut cluster = SemanticCluster {
360            cluster_id: 0,
361            centroid: vec![0.0, 0.0],
362            member_count: 0,
363            total_drift: 0.0,
364        };
365        // With lr=1.0 the centroid must equal the new embedding exactly.
366        cluster.update_centroid(&[10.0, 10.0], 1.0);
367        assert!((cluster.centroid[0] - 10.0).abs() < 1e-5);
368        assert!((cluster.centroid[1] - 10.0).abs() < 1e-5);
369    }
370
371    #[test]
372    fn test_update_centroid_ema_formula() {
373        let mut cluster = SemanticCluster {
374            cluster_id: 0,
375            centroid: vec![0.0],
376            member_count: 0,
377            total_drift: 0.0,
378        };
379        cluster.update_centroid(&[1.0], 0.1);
380        // centroid[0] = 0.9*0.0 + 0.1*1.0 = 0.1
381        assert!((cluster.centroid[0] - 0.1).abs() < 1e-6);
382    }
383
384    #[test]
385    fn test_update_centroid_increments_member_count() {
386        let mut cluster = SemanticCluster {
387            cluster_id: 0,
388            centroid: vec![0.0],
389            member_count: 5,
390            total_drift: 0.0,
391        };
392        cluster.update_centroid(&[1.0], 0.1);
393        assert_eq!(cluster.member_count, 6);
394    }
395
396    #[test]
397    fn test_update_centroid_accumulates_drift() {
398        let mut cluster = SemanticCluster {
399            cluster_id: 0,
400            centroid: vec![0.0, 0.0],
401            member_count: 0,
402            total_drift: 0.0,
403        };
404        cluster.update_centroid(&[10.0, 0.0], 0.1);
405        // drift after first update = |new_centroid - old_centroid| = |[1.0, 0.0] - [0.0, 0.0]| = 1.0
406        assert!(
407            (cluster.total_drift - 1.0).abs() < 1e-5,
408            "drift={}",
409            cluster.total_drift
410        );
411
412        cluster.update_centroid(&[10.0, 0.0], 0.1);
413        // centroid now [1.0, 0.0] → [1.9, 0.0], drift addition = 0.9
414        assert!(cluster.total_drift > 1.0);
415    }
416
417    #[test]
418    fn test_update_centroid_noop_on_dimension_mismatch() {
419        let mut cluster = SemanticCluster {
420            cluster_id: 0,
421            centroid: vec![1.0, 2.0],
422            member_count: 3,
423            total_drift: 0.5,
424        };
425        cluster.update_centroid(&[9.0, 9.0, 9.0], 0.5);
426        // Nothing should change.
427        assert_eq!(cluster.member_count, 3);
428        assert!((cluster.total_drift - 0.5).abs() < 1e-6);
429        assert!((cluster.centroid[0] - 1.0).abs() < 1e-6);
430    }
431
432    // ── SemanticClusterManager ───────────────────────────────────────────────
433
434    #[test]
435    fn test_new_creates_correct_number_of_clusters() {
436        let mgr = make_manager(5);
437        assert_eq!(mgr.clusters.len(), 5);
438        for (i, c) in mgr.clusters.iter().enumerate() {
439            assert_eq!(c.cluster_id, i);
440            assert!(c.centroid.is_empty());
441            assert_eq!(c.member_count, 0);
442        }
443    }
444
445    #[test]
446    fn test_initialize_centroids_sets_centroids() {
447        let mut mgr = make_manager(3);
448        let centroids = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
449        mgr.initialize_centroids(centroids.clone());
450        for (i, c) in centroids.iter().enumerate() {
451            assert_eq!(&mgr.clusters[i].centroid, c);
452        }
453    }
454
455    #[test]
456    fn test_initialize_centroids_more_than_n_clusters_is_clamped() {
457        let mut mgr = make_manager(2);
458        let centroids = vec![vec![1.0], vec![2.0], vec![3.0], vec![4.0]];
459        mgr.initialize_centroids(centroids);
460        // Only first 2 should be set.
461        assert_eq!(mgr.clusters[0].centroid, vec![1.0]);
462        assert_eq!(mgr.clusters[1].centroid, vec![2.0]);
463    }
464
465    #[test]
466    fn test_assign_returns_none_when_no_initialised_clusters() {
467        let mut mgr = make_manager(3);
468        let result = mgr.assign(42, &[0.5, 0.5]);
469        assert!(result.is_none());
470    }
471
472    #[test]
473    fn test_assign_returns_none_on_dimension_mismatch() {
474        let mut mgr = make_manager(2);
475        mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
476        // Embedding has different dimension.
477        let result = mgr.assign(1, &[0.5, 0.5, 0.5]);
478        assert!(result.is_none());
479    }
480
481    #[test]
482    fn test_assign_returns_nearest_cluster() {
483        let mut mgr = make_manager(2);
484        // Cluster 0 centroid at (1, 0), cluster 1 at (0, 1).
485        mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
486
487        // Embedding close to cluster 0.
488        let result = mgr.assign(1, &[0.9, 0.1]).expect("should assign");
489        assert_eq!(result.cluster_id, 0);
490
491        // Embedding close to cluster 1.
492        let result2 = mgr.assign(2, &[0.1, 0.9]).expect("should assign");
493        assert_eq!(result2.cluster_id, 1);
494    }
495
496    #[test]
497    fn test_assign_distance_is_correct() {
498        let mut mgr = make_manager(1);
499        mgr.initialize_centroids(vec![vec![0.0, 0.0, 0.0]]);
500        let result = mgr.assign(1, &[3.0, 4.0, 0.0]).expect("should assign");
501        // Euclidean distance from origin = 5.
502        assert!(
503            (result.distance - 5.0).abs() < 1e-5,
504            "dist={}",
505            result.distance
506        );
507    }
508
509    #[test]
510    fn test_assign_increments_total_assignments() {
511        let mut mgr = make_manager(1);
512        mgr.initialize_centroids(vec![vec![0.0]]);
513        assert_eq!(mgr.total_assignments, 0);
514        mgr.assign(1, &[1.0]);
515        assert_eq!(mgr.total_assignments, 1);
516        mgr.assign(2, &[1.0]);
517        assert_eq!(mgr.total_assignments, 2);
518    }
519
520    #[test]
521    fn test_assign_increments_cluster_member_count() {
522        let mut mgr = make_manager(1);
523        mgr.initialize_centroids(vec![vec![0.0]]);
524        mgr.assign(1, &[1.0]);
525        mgr.assign(2, &[2.0]);
526        assert_eq!(mgr.clusters[0].member_count, 2);
527    }
528
529    #[test]
530    fn test_assign_updates_centroid() {
531        let mut mgr = make_manager(1);
532        mgr.initialize_centroids(vec![vec![0.0]]);
533        mgr.assign(1, &[1.0]);
534        // centroid[0] = 0.9*0.0 + 0.1*1.0 = 0.1
535        assert!((mgr.clusters[0].centroid[0] - 0.1).abs() < 1e-5);
536    }
537
538    #[test]
539    fn test_nearest_cluster_no_mutation() {
540        let mut mgr = make_manager(2);
541        mgr.initialize_centroids(vec![vec![1.0, 0.0], vec![0.0, 1.0]]);
542        let before_count = mgr.clusters[0].member_count;
543        let id = mgr.nearest_cluster(&[0.9, 0.1]).expect("should find");
544        assert_eq!(id, 0);
545        assert_eq!(
546            mgr.clusters[0].member_count, before_count,
547            "nearest_cluster must not mutate"
548        );
549        assert_eq!(mgr.total_assignments, 0);
550    }
551
552    #[test]
553    fn test_nearest_cluster_returns_none_for_uninitialised() {
554        let mgr = make_manager(3);
555        let result = mgr.nearest_cluster(&[0.5]);
556        assert!(result.is_none());
557    }
558
559    #[test]
560    fn test_nearest_cluster_returns_correct_id() {
561        let mut mgr = make_manager(3);
562        mgr.initialize_centroids(vec![vec![10.0, 0.0], vec![0.0, 10.0], vec![5.0, 5.0]]);
563        // Closest to cluster 2.
564        let id = mgr.nearest_cluster(&[5.1, 5.1]).expect("should find");
565        assert_eq!(id, 2);
566    }
567
568    #[test]
569    fn test_cluster_getter() {
570        let mut mgr = make_manager(3);
571        mgr.initialize_centroids(vec![vec![7.0]]);
572        let c = mgr.cluster(0).expect("cluster 0 exists");
573        assert_eq!(c.centroid, vec![7.0]);
574        assert!(mgr.cluster(100).is_none());
575    }
576
577    #[test]
578    fn test_stats_total_clusters() {
579        let mgr = make_manager(4);
580        assert_eq!(mgr.stats().total_clusters, 4);
581    }
582
583    #[test]
584    fn test_stats_total_assignments() {
585        let mut mgr = make_manager(1);
586        mgr.initialize_centroids(vec![vec![0.0]]);
587        mgr.assign(1, &[1.0]);
588        mgr.assign(2, &[2.0]);
589        assert_eq!(mgr.stats().total_assignments, 2);
590    }
591
592    #[test]
593    fn test_stats_avg_cluster_size() {
594        let mut mgr = make_manager(2);
595        mgr.initialize_centroids(vec![vec![0.0], vec![10.0]]);
596        // 3 assignments to cluster 0, 1 to cluster 1.
597        mgr.assign(1, &[0.1]);
598        mgr.assign(2, &[0.2]);
599        mgr.assign(3, &[0.3]);
600        mgr.assign(4, &[9.9]);
601        let stats = mgr.stats();
602        // avg = (3 + 1) / 2 = 2.0
603        assert!(
604            (stats.avg_cluster_size - 2.0).abs() < 1e-6,
605            "avg={}",
606            stats.avg_cluster_size
607        );
608    }
609
610    #[test]
611    fn test_stats_most_drifted_cluster() {
612        let mut mgr = make_manager(2);
613        mgr.initialize_centroids(vec![vec![0.0], vec![100.0]]);
614        // Make cluster 1 drift more by sending a very different embedding.
615        for _ in 0..10 {
616            mgr.assign(99, &[0.0]); // near cluster 0, low drift on cluster 0
617        }
618        // Force large drift on cluster 1 by manually setting after the test assignments.
619        mgr.clusters[1].total_drift = 99.0;
620        let stats = mgr.stats();
621        assert_eq!(stats.most_drifted_cluster, Some(1));
622    }
623
624    #[test]
625    fn test_stats_most_drifted_cluster_none_when_all_zero() {
626        let mgr = make_manager(3);
627        let stats = mgr.stats();
628        assert!(stats.most_drifted_cluster.is_none());
629    }
630
631    #[test]
632    fn test_stats_unstable_clusters() {
633        let mut mgr = make_manager(3);
634        mgr.initialize_centroids(vec![vec![0.0], vec![5.0], vec![10.0]]);
635        mgr.clusters[0].total_drift = 0.1;
636        mgr.clusters[1].total_drift = 0.6; // above 0.5
637        mgr.clusters[2].total_drift = 1.5; // above 0.5
638        let stats = mgr.stats();
639        assert_eq!(stats.unstable_clusters, 2);
640    }
641
642    #[test]
643    fn test_reset_drift_zeroes_all() {
644        let mut mgr = make_manager(3);
645        mgr.clusters[0].total_drift = 1.0;
646        mgr.clusters[1].total_drift = 2.0;
647        mgr.clusters[2].total_drift = 3.0;
648        mgr.reset_drift();
649        for c in &mgr.clusters {
650            assert_eq!(c.total_drift, 0.0);
651        }
652    }
653
654    #[test]
655    fn test_default_config() {
656        let cfg = ClusterManagerConfig::default();
657        assert_eq!(cfg.n_clusters, 8);
658        assert!((cfg.learning_rate - 0.1).abs() < 1e-6);
659        assert!((cfg.drift_threshold - 0.5).abs() < 1e-6);
660    }
661
662    #[test]
663    fn test_assign_doc_id_preserved() {
664        let mut mgr = make_manager(1);
665        mgr.initialize_centroids(vec![vec![0.0]]);
666        let result = mgr.assign(12345, &[0.5]).expect("should assign");
667        assert_eq!(result.doc_id, 12345);
668    }
669
670    #[test]
671    fn test_total_drift_accumulates_over_multiple_assigns() {
672        let mut mgr = make_manager(1);
673        mgr.initialize_centroids(vec![vec![0.0, 0.0]]);
674        mgr.assign(1, &[10.0, 10.0]);
675        mgr.assign(2, &[10.0, 10.0]);
676        mgr.assign(3, &[10.0, 10.0]);
677        assert!(mgr.clusters[0].total_drift > 0.0);
678    }
679}
680
681// ═══════════════════════════════════════════════════════════════════════════════
682// Batch K-Means Semantic Cluster Manager
683// ═══════════════════════════════════════════════════════════════════════════════
684
685use std::collections::HashMap;
686
687/// Configuration for batch k-means clustering.
688#[derive(Debug, Clone)]
689pub struct BatchClusterConfig {
690    /// Number of clusters (k). Default: 8.
691    pub num_clusters: usize,
692    /// Maximum number of k-means iterations. Default: 100.
693    pub max_iterations: usize,
694    /// Convergence threshold for centroid movement (L2). Default: 1e-6.
695    pub convergence_threshold: f64,
696    /// Dimensionality of embedding vectors.
697    pub embedding_dim: usize,
698}
699
700impl Default for BatchClusterConfig {
701    fn default() -> Self {
702        Self {
703            num_clusters: 8,
704            max_iterations: 100,
705            convergence_threshold: 1e-6,
706            embedding_dim: 0,
707        }
708    }
709}
710
711/// A cluster produced by batch k-means.
712#[derive(Debug, Clone)]
713pub struct BatchCluster {
714    /// Cluster index (0-based).
715    pub id: usize,
716    /// Centroid vector (mean of member embeddings).
717    pub centroid: Vec<f64>,
718    /// Number of members assigned to this cluster.
719    pub member_count: usize,
720    /// Sum of squared Euclidean distances from each member to the centroid (inertia).
721    pub inertia: f64,
722}
723
724/// Aggregate statistics for [`BatchSemanticClusterManager`].
725#[derive(Debug, Clone)]
726pub struct BatchClusterManagerStats {
727    /// Number of clusters.
728    pub num_clusters: usize,
729    /// Total number of members across all clusters.
730    pub total_members: usize,
731    /// Number of iterations the last `fit` executed.
732    pub iterations_run: usize,
733    /// Whether the last `fit` converged before reaching `max_iterations`.
734    pub converged: bool,
735    /// Sum of inertias across all clusters.
736    pub total_inertia: f64,
737}
738
739/// Batch k-means semantic cluster manager.
740///
741/// Runs Lloyd's k-means algorithm over a set of named embeddings, then supports
742/// prediction (nearest centroid lookup), membership queries, and quality metrics.
743pub struct BatchSemanticClusterManager {
744    config: BatchClusterConfig,
745    clusters: Vec<BatchCluster>,
746    assignments: HashMap<String, usize>,
747    iterations_run: usize,
748    converged: bool,
749}
750
751/// Compute Euclidean distance between two vectors of equal length.
752///
753/// Returns 0.0 if lengths differ or either is empty.
754pub fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
755    if a.len() != b.len() || a.is_empty() {
756        return 0.0;
757    }
758    a.iter()
759        .zip(b.iter())
760        .map(|(x, y)| (x - y) * (x - y))
761        .sum::<f64>()
762        .sqrt()
763}
764
765/// Compute element-wise mean of a set of vectors.
766///
767/// Returns an empty vector if the input is empty.
768pub fn vec_mean(vectors: &[&[f64]]) -> Vec<f64> {
769    if vectors.is_empty() {
770        return Vec::new();
771    }
772    let dim = vectors[0].len();
773    let n = vectors.len() as f64;
774    let mut mean = vec![0.0; dim];
775    for v in vectors {
776        for (i, &val) in v.iter().enumerate() {
777            if i < dim {
778                mean[i] += val;
779            }
780        }
781    }
782    for m in &mut mean {
783        *m /= n;
784    }
785    mean
786}
787
788impl BatchSemanticClusterManager {
789    /// Create a new (unfitted) batch cluster manager.
790    pub fn new(config: BatchClusterConfig) -> Self {
791        Self {
792            config,
793            clusters: Vec::new(),
794            assignments: HashMap::new(),
795            iterations_run: 0,
796            converged: false,
797        }
798    }
799
800    /// Run k-means clustering on the provided embeddings.
801    ///
802    /// Embeddings are `(doc_id, vector)` pairs.  The method initialises centroids
803    /// from the first `k` distinct embeddings and then iterates assignment + update
804    /// steps until convergence or `max_iterations` is reached.
805    ///
806    /// Calling `fit` again resets all previous state.
807    pub fn fit(&mut self, embeddings: &[(String, Vec<f64>)]) -> Result<(), String> {
808        let k = self.config.num_clusters;
809
810        if embeddings.is_empty() {
811            return Err("cannot fit on empty embeddings".to_string());
812        }
813        if embeddings.len() < k {
814            return Err(format!(
815                "need at least {} embeddings but got {}",
816                k,
817                embeddings.len()
818            ));
819        }
820
821        // Validate dimensions.
822        let dim = if self.config.embedding_dim > 0 {
823            self.config.embedding_dim
824        } else {
825            embeddings[0].1.len()
826        };
827        if dim == 0 {
828            return Err("embedding dimension is zero".to_string());
829        }
830        for (id, v) in embeddings {
831            if v.len() != dim {
832                return Err(format!(
833                    "embedding '{}' has dimension {} but expected {}",
834                    id,
835                    v.len(),
836                    dim
837                ));
838            }
839        }
840
841        // Reset state.
842        self.clusters.clear();
843        self.assignments.clear();
844        self.iterations_run = 0;
845        self.converged = false;
846
847        // Initialise centroids from first k distinct embeddings.
848        let mut centroids: Vec<Vec<f64>> = Vec::with_capacity(k);
849        for (_id, v) in embeddings {
850            let is_dup = centroids.iter().any(|c| {
851                c.iter()
852                    .zip(v.iter())
853                    .all(|(a, b)| (a - b).abs() < f64::EPSILON)
854            });
855            if !is_dup {
856                centroids.push(v.clone());
857            }
858            if centroids.len() == k {
859                break;
860            }
861        }
862        if centroids.len() < k {
863            return Err(format!(
864                "need at least {} distinct embeddings but found only {}",
865                k,
866                centroids.len()
867            ));
868        }
869
870        let mut assignments: Vec<usize> = vec![0; embeddings.len()];
871
872        for iter in 0..self.config.max_iterations {
873            // ── Assignment step ──────────────────────────────────────────────
874            for (idx, (_id, v)) in embeddings.iter().enumerate() {
875                let mut best_cluster = 0;
876                let mut best_dist = f64::MAX;
877                for (ci, c) in centroids.iter().enumerate() {
878                    let d = euclidean_distance(v, c);
879                    if d < best_dist {
880                        best_dist = d;
881                        best_cluster = ci;
882                    }
883                }
884                assignments[idx] = best_cluster;
885            }
886
887            // ── Update step ──────────────────────────────────────────────────
888            let mut new_centroids = vec![vec![0.0; dim]; k];
889            let mut counts = vec![0usize; k];
890
891            for (idx, (_id, v)) in embeddings.iter().enumerate() {
892                let ci = assignments[idx];
893                counts[ci] += 1;
894                for (j, &val) in v.iter().enumerate() {
895                    new_centroids[ci][j] += val;
896                }
897            }
898
899            for ci in 0..k {
900                if counts[ci] > 0 {
901                    let n = counts[ci] as f64;
902                    for val in new_centroids[ci].iter_mut() {
903                        *val /= n;
904                    }
905                } else {
906                    // Keep old centroid for empty clusters.
907                    new_centroids[ci] = centroids[ci].clone();
908                }
909            }
910
911            // ── Check convergence ────────────────────────────────────────────
912            let max_movement = centroids
913                .iter()
914                .zip(new_centroids.iter())
915                .map(|(old, new)| euclidean_distance(old, new))
916                .fold(0.0_f64, f64::max);
917
918            centroids = new_centroids;
919            self.iterations_run = iter + 1;
920
921            if max_movement < self.config.convergence_threshold {
922                self.converged = true;
923                break;
924            }
925        }
926
927        // ── Build final clusters and assignments ─────────────────────────────
928        // Re-run assignment with final centroids to ensure consistency.
929        for (idx, (_id, v)) in embeddings.iter().enumerate() {
930            let mut best_cluster = 0;
931            let mut best_dist = f64::MAX;
932            for (ci, c) in centroids.iter().enumerate() {
933                let d = euclidean_distance(v, c);
934                if d < best_dist {
935                    best_dist = d;
936                    best_cluster = ci;
937                }
938            }
939            assignments[idx] = best_cluster;
940        }
941
942        // Compute inertia per cluster.
943        let mut inertias = vec![0.0_f64; k];
944        let mut member_counts = vec![0usize; k];
945        for (idx, (_id, v)) in embeddings.iter().enumerate() {
946            let ci = assignments[idx];
947            member_counts[ci] += 1;
948            let d = euclidean_distance(v, &centroids[ci]);
949            inertias[ci] += d * d;
950        }
951
952        self.clusters = (0..k)
953            .map(|ci| BatchCluster {
954                id: ci,
955                centroid: centroids[ci].clone(),
956                member_count: member_counts[ci],
957                inertia: inertias[ci],
958            })
959            .collect();
960
961        for (idx, (id, _v)) in embeddings.iter().enumerate() {
962            self.assignments.insert(id.clone(), assignments[idx]);
963        }
964
965        Ok(())
966    }
967
968    /// Predict which cluster a new embedding belongs to (nearest centroid).
969    ///
970    /// Returns an error if the manager has not been fitted yet.
971    pub fn predict(&self, embedding: &[f64]) -> Result<usize, String> {
972        if self.clusters.is_empty() {
973            return Err("not fitted yet".to_string());
974        }
975
976        let mut best_cluster = 0;
977        let mut best_dist = f64::MAX;
978        for cluster in &self.clusters {
979            let d = euclidean_distance(embedding, &cluster.centroid);
980            if d < best_dist {
981                best_dist = d;
982                best_cluster = cluster.id;
983            }
984        }
985        Ok(best_cluster)
986    }
987
988    /// Return a reference to a cluster by id, or `None` if out of range.
989    pub fn get_cluster(&self, id: usize) -> Option<&BatchCluster> {
990        self.clusters.get(id)
991    }
992
993    /// Return the cluster assignment for a document, or `None` if unknown.
994    pub fn get_assignment(&self, doc_id: &str) -> Option<usize> {
995        self.assignments.get(doc_id).copied()
996    }
997
998    /// Return the list of document IDs assigned to a given cluster.
999    pub fn cluster_members(&self, cluster_id: usize) -> Vec<String> {
1000        self.assignments
1001            .iter()
1002            .filter(|(_id, &cid)| cid == cluster_id)
1003            .map(|(id, _)| id.clone())
1004            .collect()
1005    }
1006
1007    /// Total inertia (sum of squared distances) across all clusters.
1008    pub fn total_inertia(&self) -> f64 {
1009        self.clusters.iter().map(|c| c.inertia).sum()
1010    }
1011
1012    /// Approximate silhouette score.
1013    ///
1014    /// For each point, computes `(b - a) / max(a, b)` where:
1015    /// - `a` = average Euclidean distance to other members of the same cluster
1016    /// - `b` = average distance to the nearest **other** cluster centroid
1017    ///
1018    /// Returns the mean silhouette across all points.
1019    pub fn silhouette_score_approx(
1020        &self,
1021        embeddings: &[(String, Vec<f64>)],
1022    ) -> Result<f64, String> {
1023        if self.clusters.is_empty() {
1024            return Err("not fitted yet".to_string());
1025        }
1026        if embeddings.is_empty() {
1027            return Err("no embeddings provided".to_string());
1028        }
1029        if self.clusters.len() < 2 {
1030            // Silhouette is undefined for a single cluster.
1031            return Ok(0.0);
1032        }
1033
1034        // Build per-cluster embedding lists.
1035        let k = self.clusters.len();
1036        let mut cluster_vecs: Vec<Vec<&[f64]>> = vec![Vec::new(); k];
1037        for (id, v) in embeddings {
1038            if let Some(&ci) = self.assignments.get(id) {
1039                if ci < k {
1040                    cluster_vecs[ci].push(v.as_slice());
1041                }
1042            }
1043        }
1044
1045        let mut total_sil = 0.0_f64;
1046        let mut count = 0usize;
1047
1048        for (id, v) in embeddings {
1049            let ci = match self.assignments.get(id) {
1050                Some(&c) => c,
1051                None => continue,
1052            };
1053
1054            // a = average distance to own cluster members (excluding self).
1055            let own_members = &cluster_vecs[ci];
1056            let a = if own_members.len() <= 1 {
1057                0.0
1058            } else {
1059                let sum: f64 = own_members.iter().map(|m| euclidean_distance(v, m)).sum();
1060                // sum includes distance to self (0), so denominator is len-1.
1061                sum / (own_members.len() - 1) as f64
1062            };
1063
1064            // b = min over other clusters of distance to that cluster's centroid.
1065            let mut b = f64::MAX;
1066            for cluster in &self.clusters {
1067                if cluster.id == ci {
1068                    continue;
1069                }
1070                let d = euclidean_distance(v, &cluster.centroid);
1071                if d < b {
1072                    b = d;
1073                }
1074            }
1075            if b == f64::MAX {
1076                b = 0.0;
1077            }
1078
1079            let max_ab = a.max(b);
1080            let sil = if max_ab > 0.0 { (b - a) / max_ab } else { 0.0 };
1081
1082            total_sil += sil;
1083            count += 1;
1084        }
1085
1086        if count == 0 {
1087            return Ok(0.0);
1088        }
1089        Ok(total_sil / count as f64)
1090    }
1091
1092    /// Return aggregate statistics about the current clustering state.
1093    pub fn stats(&self) -> BatchClusterManagerStats {
1094        BatchClusterManagerStats {
1095            num_clusters: self.clusters.len(),
1096            total_members: self.clusters.iter().map(|c| c.member_count).sum(),
1097            iterations_run: self.iterations_run,
1098            converged: self.converged,
1099            total_inertia: self.total_inertia(),
1100        }
1101    }
1102}
1103
1104// ─── Batch K-Means Tests ─────────────────────────────────────────────────────
1105
1106#[cfg(test)]
1107mod batch_tests {
1108    use super::*;
1109
1110    fn default_config(k: usize, dim: usize) -> BatchClusterConfig {
1111        BatchClusterConfig {
1112            num_clusters: k,
1113            max_iterations: 100,
1114            convergence_threshold: 1e-6,
1115            embedding_dim: dim,
1116        }
1117    }
1118
1119    fn well_separated_2d(n_per_cluster: usize) -> Vec<(String, Vec<f64>)> {
1120        // Two clusters centred at (0,0) and (100,100).
1121        let mut data = Vec::new();
1122        for i in 0..n_per_cluster {
1123            data.push((
1124                format!("a{}", i),
1125                vec![0.0 + i as f64 * 0.01, 0.0 + i as f64 * 0.01],
1126            ));
1127        }
1128        for i in 0..n_per_cluster {
1129            data.push((
1130                format!("b{}", i),
1131                vec![100.0 + i as f64 * 0.01, 100.0 + i as f64 * 0.01],
1132            ));
1133        }
1134        data
1135    }
1136
1137    // ── 1. Fit with well-separated clusters converges ───────────────────────
1138
1139    #[test]
1140    fn test_batch_fit_well_separated_converges() {
1141        let data = well_separated_2d(20);
1142        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1143        mgr.fit(&data).expect("fit should succeed");
1144        assert!(mgr.converged, "should converge on well-separated data");
1145    }
1146
1147    // ── 2. Predict returns correct cluster for points near centroids ────────
1148
1149    #[test]
1150    fn test_batch_predict_near_centroids() {
1151        let data = well_separated_2d(20);
1152        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1153        mgr.fit(&data).expect("fit should succeed");
1154
1155        let c0 = mgr.predict(&[0.05, 0.05]).expect("predict");
1156        let c1 = mgr.predict(&[100.05, 100.05]).expect("predict");
1157        assert_ne!(c0, c1, "should assign to different clusters");
1158    }
1159
1160    // ── 3. Empty embeddings returns error ───────────────────────────────────
1161
1162    #[test]
1163    fn test_batch_fit_empty_returns_error() {
1164        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1165        let result = mgr.fit(&[]);
1166        assert!(result.is_err());
1167        assert!(result.expect_err("should be error").contains("empty"),);
1168    }
1169
1170    // ── 4. Fewer embeddings than k returns error ────────────────────────────
1171
1172    #[test]
1173    fn test_batch_fit_too_few_embeddings() {
1174        let data = vec![("a".to_string(), vec![1.0, 2.0])];
1175        let mut mgr = BatchSemanticClusterManager::new(default_config(3, 2));
1176        let result = mgr.fit(&data);
1177        assert!(result.is_err());
1178    }
1179
1180    // ── 5. Convergence flag set when movement < threshold ───────────────────
1181
1182    #[test]
1183    fn test_batch_convergence_flag() {
1184        // Single cluster with identical points → immediate convergence.
1185        let data: Vec<(String, Vec<f64>)> = (0..5)
1186            .map(|i| (format!("d{}", i), vec![1.0, 1.0]))
1187            .collect();
1188        let mut mgr = BatchSemanticClusterManager::new(default_config(1, 2));
1189        mgr.fit(&data).expect("fit should succeed");
1190        assert!(mgr.converged);
1191    }
1192
1193    // ── 6. Max iterations respected ─────────────────────────────────────────
1194
1195    #[test]
1196    fn test_batch_max_iterations_respected() {
1197        let data = well_separated_2d(20);
1198        let mut cfg = default_config(2, 2);
1199        cfg.max_iterations = 3;
1200        cfg.convergence_threshold = 0.0; // never converge
1201        let mut mgr = BatchSemanticClusterManager::new(cfg);
1202        mgr.fit(&data).expect("fit should succeed");
1203        assert_eq!(mgr.iterations_run, 3);
1204        assert!(!mgr.converged);
1205    }
1206
1207    // ── 7. Cluster members match assignments ────────────────────────────────
1208
1209    #[test]
1210    fn test_batch_cluster_members_match() {
1211        let data = well_separated_2d(10);
1212        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1213        mgr.fit(&data).expect("fit should succeed");
1214
1215        let mut total = 0;
1216        for ci in 0..2 {
1217            let members = mgr.cluster_members(ci);
1218            for mid in &members {
1219                assert_eq!(
1220                    mgr.get_assignment(mid),
1221                    Some(ci),
1222                    "member {} should be in cluster {}",
1223                    mid,
1224                    ci
1225                );
1226            }
1227            total += members.len();
1228        }
1229        assert_eq!(total, data.len());
1230    }
1231
1232    // ── 8. Total inertia is non-negative ────────────────────────────────────
1233
1234    #[test]
1235    fn test_batch_total_inertia_non_negative() {
1236        let data = well_separated_2d(10);
1237        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1238        mgr.fit(&data).expect("fit");
1239        assert!(mgr.total_inertia() >= 0.0);
1240    }
1241
1242    // ── 9. Inertia decreases with more clusters ─────────────────────────────
1243
1244    #[test]
1245    fn test_batch_inertia_decreases_with_more_clusters() {
1246        let data = well_separated_2d(20);
1247
1248        let mut mgr1 = BatchSemanticClusterManager::new(default_config(1, 2));
1249        mgr1.fit(&data).expect("fit k=1");
1250        let inertia1 = mgr1.total_inertia();
1251
1252        let mut mgr2 = BatchSemanticClusterManager::new(default_config(2, 2));
1253        mgr2.fit(&data).expect("fit k=2");
1254        let inertia2 = mgr2.total_inertia();
1255
1256        assert!(
1257            inertia2 <= inertia1,
1258            "k=2 inertia ({}) should be <= k=1 inertia ({})",
1259            inertia2,
1260            inertia1
1261        );
1262    }
1263
1264    // ── 10. Single-dimension embeddings work ────────────────────────────────
1265
1266    #[test]
1267    fn test_batch_single_dimension() {
1268        let data: Vec<(String, Vec<f64>)> = vec![
1269            ("a".to_string(), vec![0.0]),
1270            ("b".to_string(), vec![1.0]),
1271            ("c".to_string(), vec![100.0]),
1272            ("d".to_string(), vec![101.0]),
1273        ];
1274        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 1));
1275        mgr.fit(&data).expect("fit");
1276        let ca = mgr.get_assignment("a").expect("a assigned");
1277        let cb = mgr.get_assignment("b").expect("b assigned");
1278        let cc = mgr.get_assignment("c").expect("c assigned");
1279        let cd = mgr.get_assignment("d").expect("d assigned");
1280        assert_eq!(ca, cb, "a and b should be in same cluster");
1281        assert_eq!(cc, cd, "c and d should be in same cluster");
1282        assert_ne!(ca, cc, "groups should be different");
1283    }
1284
1285    // ── 11. Multiple fits reset state ───────────────────────────────────────
1286
1287    #[test]
1288    fn test_batch_multiple_fits_reset_state() {
1289        let data1 = well_separated_2d(10);
1290        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1291        mgr.fit(&data1).expect("fit 1");
1292        let inertia1 = mgr.total_inertia();
1293
1294        // Re-fit with different data.
1295        let data2: Vec<(String, Vec<f64>)> = (0..10)
1296            .map(|i| (format!("x{}", i), vec![i as f64, i as f64]))
1297            .collect();
1298        mgr.fit(&data2).expect("fit 2");
1299
1300        // Old assignments should be gone.
1301        assert!(mgr.get_assignment("a0").is_none());
1302        // New assignments present.
1303        assert!(mgr.get_assignment("x0").is_some());
1304        // Inertia likely different.
1305        let _inertia2 = mgr.total_inertia();
1306        let _ = inertia1; // used above
1307    }
1308
1309    // ── 12. get_assignment returns None for unknown doc ──────────────────────
1310
1311    #[test]
1312    fn test_batch_get_assignment_unknown() {
1313        let data = well_separated_2d(5);
1314        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1315        mgr.fit(&data).expect("fit");
1316        assert!(mgr.get_assignment("nonexistent").is_none());
1317    }
1318
1319    // ── 13. Stats reflect current state ─────────────────────────────────────
1320
1321    #[test]
1322    fn test_batch_stats_reflect_state() {
1323        let data = well_separated_2d(10);
1324        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1325        mgr.fit(&data).expect("fit");
1326
1327        let stats = mgr.stats();
1328        assert_eq!(stats.num_clusters, 2);
1329        assert_eq!(stats.total_members, 20);
1330        assert!(stats.iterations_run > 0);
1331        assert!(stats.total_inertia >= 0.0);
1332    }
1333
1334    // ── 14. Predict errors when not fitted ──────────────────────────────────
1335
1336    #[test]
1337    fn test_batch_predict_not_fitted() {
1338        let mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1339        let result = mgr.predict(&[1.0, 2.0]);
1340        assert!(result.is_err());
1341    }
1342
1343    // ── 15. get_cluster returns correct data ────────────────────────────────
1344
1345    #[test]
1346    fn test_batch_get_cluster() {
1347        let data = well_separated_2d(10);
1348        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1349        mgr.fit(&data).expect("fit");
1350
1351        let c = mgr.get_cluster(0).expect("cluster 0 exists");
1352        assert_eq!(c.id, 0);
1353        assert!(!c.centroid.is_empty());
1354        assert!(mgr.get_cluster(999).is_none());
1355    }
1356
1357    // ── 16. Silhouette score in valid range ─────────────────────────────────
1358
1359    #[test]
1360    fn test_batch_silhouette_score_range() {
1361        let data = well_separated_2d(20);
1362        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1363        mgr.fit(&data).expect("fit");
1364
1365        let sil = mgr.silhouette_score_approx(&data).expect("silhouette");
1366        assert!(
1367            (-1.0..=1.0).contains(&sil),
1368            "silhouette {} out of [-1,1]",
1369            sil
1370        );
1371    }
1372
1373    // ── 17. Silhouette score high for well-separated clusters ───────────────
1374
1375    #[test]
1376    fn test_batch_silhouette_score_high_for_separated() {
1377        let data = well_separated_2d(20);
1378        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1379        mgr.fit(&data).expect("fit");
1380
1381        let sil = mgr.silhouette_score_approx(&data).expect("silhouette");
1382        assert!(
1383            sil > 0.5,
1384            "expected high silhouette for separated clusters, got {}",
1385            sil
1386        );
1387    }
1388
1389    // ── 18. Silhouette errors when not fitted ───────────────────────────────
1390
1391    #[test]
1392    fn test_batch_silhouette_not_fitted() {
1393        let mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1394        let result = mgr.silhouette_score_approx(&[]);
1395        assert!(result.is_err());
1396    }
1397
1398    // ── 19. Cluster members returns empty for unused cluster ────────────────
1399
1400    #[test]
1401    fn test_batch_cluster_members_empty_cluster() {
1402        // With well-separated data, one of the clusters beyond 2 will be empty.
1403        let data = well_separated_2d(10);
1404        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1405        mgr.fit(&data).expect("fit");
1406
1407        let members = mgr.cluster_members(999);
1408        assert!(members.is_empty());
1409    }
1410
1411    // ── 20. Euclidean distance helper ───────────────────────────────────────
1412
1413    #[test]
1414    fn test_euclidean_distance_f64() {
1415        let d = euclidean_distance(&[0.0, 0.0], &[3.0, 4.0]);
1416        assert!((d - 5.0).abs() < 1e-10);
1417    }
1418
1419    #[test]
1420    fn test_euclidean_distance_same_point() {
1421        let d = euclidean_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]);
1422        assert!(d.abs() < 1e-15);
1423    }
1424
1425    #[test]
1426    fn test_euclidean_distance_mismatch() {
1427        let d = euclidean_distance(&[1.0], &[1.0, 2.0]);
1428        assert_eq!(d, 0.0);
1429    }
1430
1431    // ── 23. vec_mean helper ─────────────────────────────────────────────────
1432
1433    #[test]
1434    fn test_vec_mean_basic() {
1435        let v1 = vec![2.0, 4.0];
1436        let v2 = vec![4.0, 8.0];
1437        let mean = vec_mean(&[v1.as_slice(), v2.as_slice()]);
1438        assert!((mean[0] - 3.0).abs() < 1e-10);
1439        assert!((mean[1] - 6.0).abs() < 1e-10);
1440    }
1441
1442    #[test]
1443    fn test_vec_mean_empty() {
1444        let mean = vec_mean(&[]);
1445        assert!(mean.is_empty());
1446    }
1447
1448    // ── 25. Dimension mismatch in fit ───────────────────────────────────────
1449
1450    #[test]
1451    fn test_batch_fit_dimension_mismatch() {
1452        let data = vec![
1453            ("a".to_string(), vec![1.0, 2.0]),
1454            ("b".to_string(), vec![3.0, 4.0, 5.0]),
1455        ];
1456        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1457        let result = mgr.fit(&data);
1458        assert!(result.is_err());
1459    }
1460
1461    // ── 26. Zero-dim config detected ────────────────────────────────────────
1462
1463    #[test]
1464    fn test_batch_fit_zero_dim_vectors() {
1465        let data: Vec<(String, Vec<f64>)> =
1466            vec![("a".to_string(), vec![]), ("b".to_string(), vec![])];
1467        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 0));
1468        let result = mgr.fit(&data);
1469        assert!(result.is_err());
1470    }
1471
1472    // ── 27. Three clusters ──────────────────────────────────────────────────
1473
1474    #[test]
1475    fn test_batch_three_clusters() {
1476        let mut data: Vec<(String, Vec<f64>)> = Vec::new();
1477        for i in 0..10 {
1478            data.push((format!("g0_{}", i), vec![0.0 + i as f64 * 0.001, 0.0]));
1479        }
1480        for i in 0..10 {
1481            data.push((format!("g1_{}", i), vec![100.0 + i as f64 * 0.001, 0.0]));
1482        }
1483        for i in 0..10 {
1484            data.push((format!("g2_{}", i), vec![0.0, 100.0 + i as f64 * 0.001]));
1485        }
1486
1487        let mut mgr = BatchSemanticClusterManager::new(default_config(3, 2));
1488        mgr.fit(&data).expect("fit 3 clusters");
1489
1490        // All g0 members should be in same cluster, distinct from g1, g2.
1491        let c0 = mgr.get_assignment("g0_0").expect("g0_0");
1492        let c1 = mgr.get_assignment("g1_0").expect("g1_0");
1493        let c2 = mgr.get_assignment("g2_0").expect("g2_0");
1494        assert_ne!(c0, c1);
1495        assert_ne!(c0, c2);
1496        assert_ne!(c1, c2);
1497    }
1498
1499    // ── 28. Stats after fresh construction ──────────────────────────────────
1500
1501    #[test]
1502    fn test_batch_stats_unfitted() {
1503        let mgr = BatchSemanticClusterManager::new(default_config(4, 3));
1504        let stats = mgr.stats();
1505        assert_eq!(stats.num_clusters, 0);
1506        assert_eq!(stats.total_members, 0);
1507        assert_eq!(stats.iterations_run, 0);
1508        assert!(!stats.converged);
1509        assert_eq!(stats.total_inertia, 0.0);
1510    }
1511
1512    // ── 29. Predict consistency ─────────────────────────────────────────────
1513
1514    #[test]
1515    fn test_batch_predict_consistency() {
1516        let data = well_separated_2d(20);
1517        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1518        mgr.fit(&data).expect("fit");
1519
1520        // Predict on a training point should match its assignment.
1521        for (id, v) in &data {
1522            let predicted = mgr.predict(v).expect("predict");
1523            let assigned = mgr.get_assignment(id).expect("assignment");
1524            assert_eq!(predicted, assigned, "predict({}) != assignment({})", id, id);
1525        }
1526    }
1527
1528    // ── 30. Identical embeddings fewer than k ───────────────────────────────
1529
1530    #[test]
1531    fn test_batch_identical_embeddings_fewer_distinct_than_k() {
1532        // All identical → only 1 distinct, but k=2 → error.
1533        let data: Vec<(String, Vec<f64>)> = (0..10)
1534            .map(|i| (format!("d{}", i), vec![5.0, 5.0]))
1535            .collect();
1536        let mut mgr = BatchSemanticClusterManager::new(default_config(2, 2));
1537        let result = mgr.fit(&data);
1538        assert!(result.is_err());
1539        assert!(result.expect_err("err").contains("distinct"),);
1540    }
1541}