1use std::collections::{HashMap, VecDeque};
19
20pub type SclClusterId = u64;
26
27pub type SclSemanticClusterLabeler = SemanticClusterLabeler;
29
30#[derive(Debug, Clone, PartialEq)]
36pub enum SclError {
37 ClusterNotFound(SclClusterId),
39 NoPrototypes,
41 NoDocuments,
43 SelfMerge(SclClusterId),
45 EmptyCentroid,
47 BelowConfidenceThreshold { best: f64, threshold: f64 },
49 MergeTargetNotFound(SclClusterId),
51}
52
53impl std::fmt::Display for SclError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 SclError::ClusterNotFound(id) => write!(f, "cluster {id} not found"),
57 SclError::NoPrototypes => write!(f, "no prototype embeddings registered"),
58 SclError::NoDocuments => write!(f, "no keyword documents registered"),
59 SclError::SelfMerge(id) => write!(f, "cannot merge cluster {id} with itself"),
60 SclError::EmptyCentroid => write!(f, "centroid vector must not be empty"),
61 SclError::BelowConfidenceThreshold { best, threshold } => {
62 write!(
63 f,
64 "best candidate score {best:.4} < threshold {threshold:.4}"
65 )
66 }
67 SclError::MergeTargetNotFound(id) => write!(f, "merge target cluster {id} not found"),
68 }
69 }
70}
71
72impl std::error::Error for SclError {}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
80pub enum SclLabelingMethod {
81 CentroidNearest,
83 TfIdfKeywords,
85 EmbeddingVoting,
87 NearestPrototype,
90 HybridRanking,
92}
93
94impl std::fmt::Display for SclLabelingMethod {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 let s = match self {
97 SclLabelingMethod::CentroidNearest => "CentroidNearest",
98 SclLabelingMethod::TfIdfKeywords => "TfIdfKeywords",
99 SclLabelingMethod::EmbeddingVoting => "EmbeddingVoting",
100 SclLabelingMethod::NearestPrototype => "NearestPrototype",
101 SclLabelingMethod::HybridRanking => "HybridRanking",
102 };
103 f.write_str(s)
104 }
105}
106
107#[derive(Debug, Clone)]
113pub struct SclLabelerConfig {
114 pub max_labels_per_cluster: usize,
116 pub min_confidence: f64,
118 pub method: SclLabelingMethod,
120 pub top_k_words: usize,
122}
123
124impl Default for SclLabelerConfig {
125 fn default() -> Self {
126 Self {
127 max_labels_per_cluster: 5,
128 min_confidence: 0.1,
129 method: SclLabelingMethod::HybridRanking,
130 top_k_words: 4,
131 }
132 }
133}
134
135#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
141pub struct SclCluster {
142 pub id: SclClusterId,
144 pub centroid: Vec<f64>,
146 pub members: Vec<u64>,
148 pub label: Option<String>,
150 pub confidence: f64,
152 pub keywords: Vec<String>,
154 pub created_at: u64,
156 pub(crate) labeled_centroid: Option<Vec<f64>>,
158}
159
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
162pub struct SclLabelStats {
163 pub label: String,
165 pub use_count: u32,
167 pub avg_confidence: f64,
169 pub cluster_ids: Vec<SclClusterId>,
171}
172
173#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
175pub struct SclLabelingRecord {
176 pub ts: u64,
178 pub cluster_id: SclClusterId,
180 pub old_label: Option<String>,
182 pub new_label: String,
184 pub method: SclLabelingMethod,
186 pub confidence: f64,
188}
189
190#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192pub struct SclLabelCandidate {
193 pub label: String,
195 pub score: f64,
197 pub source: SclLabelingMethod,
199}
200
201#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
203pub struct SclLabelerStats {
204 pub total_clusters: usize,
206 pub labeled_clusters: usize,
208 pub vocab_size: usize,
210 pub prototype_count: usize,
212 pub document_count: usize,
214 pub history_len: usize,
216 pub avg_confidence: f64,
218}
219
220#[derive(Debug, Clone)]
226struct SclDocument {
227 embedding_id: u64,
229 tokens: Vec<String>,
231}
232
233#[derive(Debug, Clone)]
235struct SclPrototype {
236 label: String,
237 embedding: Vec<f64>,
238}
239
240#[inline]
247pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
248 let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
249 let na = a.iter().map(|x| x * x).sum::<f64>().sqrt();
250 let nb = b.iter().map(|x| x * x).sum::<f64>().sqrt();
251 if na == 0.0 || nb == 0.0 {
252 0.0
253 } else {
254 dot / (na * nb)
255 }
256}
257
258#[inline]
260fn xorshift64(state: &mut u64) -> u64 {
261 let mut x = *state;
262 x ^= x << 13;
263 x ^= x >> 7;
264 x ^= x << 17;
265 *state = x;
266 x
267}
268
269fn unix_now() -> u64 {
271 std::time::SystemTime::now()
272 .duration_since(std::time::UNIX_EPOCH)
273 .map(|d| d.as_secs())
274 .unwrap_or(0)
275}
276
277const MAX_HISTORY: usize = 500;
282
283pub struct SemanticClusterLabeler {
314 clusters: HashMap<SclClusterId, SclCluster>,
316 vocab: HashMap<String, SclLabelStats>,
318 history: VecDeque<SclLabelingRecord>,
320 prototypes: Vec<SclPrototype>,
322 documents: Vec<SclDocument>,
324 member_index: HashMap<u64, SclClusterId>,
326 config: SclLabelerConfig,
328 next_id: SclClusterId,
330}
331
332impl SemanticClusterLabeler {
333 pub fn new(config: SclLabelerConfig) -> Self {
339 Self {
340 clusters: HashMap::new(),
341 vocab: HashMap::new(),
342 history: VecDeque::with_capacity(MAX_HISTORY),
343 prototypes: Vec::new(),
344 documents: Vec::new(),
345 member_index: HashMap::new(),
346 config,
347 next_id: 1,
348 }
349 }
350
351 pub fn with_defaults() -> Self {
353 Self::new(SclLabelerConfig::default())
354 }
355
356 pub fn add_cluster(&mut self, centroid: Vec<f64>, members: Vec<u64>) -> SclClusterId {
365 let id = self.next_id;
366 self.next_id = self.next_id.wrapping_add(1).max(1);
367
368 for &m in &members {
369 self.member_index.insert(m, id);
370 }
371
372 let cluster = SclCluster {
373 id,
374 centroid,
375 members,
376 label: None,
377 confidence: 0.0,
378 keywords: Vec::new(),
379 created_at: unix_now(),
380 labeled_centroid: None,
381 };
382 self.clusters.insert(id, cluster);
383 id
384 }
385
386 pub fn remove_cluster(&mut self, id: SclClusterId) -> bool {
388 if let Some(cluster) = self.clusters.remove(&id) {
389 for m in &cluster.members {
390 self.member_index.remove(m);
391 }
392 if let Some(label) = &cluster.label.clone() {
394 if let Some(stats) = self.vocab.get_mut(label) {
395 stats.cluster_ids.retain(|&cid| cid != id);
396 }
397 }
398 true
399 } else {
400 false
401 }
402 }
403
404 pub fn merge_clusters(
414 &mut self,
415 a: SclClusterId,
416 b: SclClusterId,
417 ) -> Result<SclClusterId, SclError> {
418 if a == b {
419 return Err(SclError::SelfMerge(a));
420 }
421 let cluster_b = self
423 .clusters
424 .remove(&b)
425 .ok_or(SclError::MergeTargetNotFound(b))?;
426
427 if !self.clusters.contains_key(&a) {
429 self.clusters.insert(b, cluster_b);
430 return Err(SclError::ClusterNotFound(a));
431 }
432
433 let cluster_a = self
435 .clusters
436 .get_mut(&a)
437 .ok_or(SclError::ClusterNotFound(a))?;
438
439 let na = cluster_a.members.len() as f64;
440 let nb = cluster_b.members.len() as f64;
441 let total = na + nb;
442
443 let dim = cluster_a.centroid.len().max(cluster_b.centroid.len());
445 let mut new_centroid = vec![0.0f64; dim];
446 for (i, v) in new_centroid.iter_mut().enumerate() {
447 let va = cluster_a.centroid.get(i).copied().unwrap_or(0.0);
448 let vb = cluster_b.centroid.get(i).copied().unwrap_or(0.0);
449 *v = if total > 0.0 {
450 (va * na + vb * nb) / total
451 } else {
452 (va + vb) / 2.0
453 };
454 }
455
456 cluster_a.centroid = new_centroid;
457 cluster_a.label = None; cluster_a.confidence = 0.0;
459 cluster_a.labeled_centroid = None;
460
461 for &m in &cluster_b.members {
463 self.member_index.insert(m, a);
464 }
465 cluster_a.members.extend(cluster_b.members);
466
467 if let Some(label) = &cluster_b.label {
469 if let Some(stats) = self.vocab.get_mut(label) {
470 stats.cluster_ids.retain(|&cid| cid != b);
471 }
472 }
473
474 Ok(a)
475 }
476
477 pub fn add_prototype(&mut self, label: &str, embedding: Vec<f64>) {
484 if let Some(existing) = self.prototypes.iter_mut().find(|p| p.label == label) {
486 existing.embedding = embedding;
487 } else {
488 self.prototypes.push(SclPrototype {
489 label: label.to_owned(),
490 embedding,
491 });
492 }
493 }
494
495 pub fn add_keyword_doc(&mut self, text: &str, embedding_id: u64) {
500 let tokens: Vec<String> = text
501 .split_whitespace()
502 .map(|w| {
503 w.chars()
504 .filter(|c| c.is_alphabetic())
505 .collect::<String>()
506 .to_lowercase()
507 })
508 .filter(|t| t.len() >= 2)
509 .collect();
510
511 self.documents.push(SclDocument {
512 embedding_id,
513 tokens,
514 });
515 }
516
517 pub fn label_cluster(
527 &mut self,
528 id: SclClusterId,
529 method: SclLabelingMethod,
530 ) -> Result<SclLabelCandidate, SclError> {
531 let (centroid, members) = {
533 let c = self
534 .clusters
535 .get(&id)
536 .ok_or(SclError::ClusterNotFound(id))?;
537 (c.centroid.clone(), c.members.clone())
538 };
539
540 let candidate = match method {
541 SclLabelingMethod::CentroidNearest | SclLabelingMethod::NearestPrototype => {
542 self.label_by_centroid_nearest(¢roid)?
543 }
544 SclLabelingMethod::TfIdfKeywords => self.label_by_tfidf(&members, id)?,
545 SclLabelingMethod::EmbeddingVoting => self.label_by_voting(&members)?,
546 SclLabelingMethod::HybridRanking => self.label_by_hybrid(¢roid, &members, id)?,
547 };
548
549 if candidate.score < self.config.min_confidence {
550 return Err(SclError::BelowConfidenceThreshold {
551 best: candidate.score,
552 threshold: self.config.min_confidence,
553 });
554 }
555
556 let old_label = {
558 let c = self
559 .clusters
560 .get_mut(&id)
561 .ok_or(SclError::ClusterNotFound(id))?;
562 let old = c.label.clone();
563 c.label = Some(candidate.label.clone());
564 c.confidence = candidate.score;
565 c.labeled_centroid = Some(c.centroid.clone());
566 old
567 };
568
569 self.update_vocab(id, &candidate.label, candidate.score, &old_label);
571
572 self.push_history(SclLabelingRecord {
574 ts: unix_now(),
575 cluster_id: id,
576 old_label,
577 new_label: candidate.label.clone(),
578 method,
579 confidence: candidate.score,
580 });
581
582 Ok(candidate)
583 }
584
585 pub fn label_all(
588 &mut self,
589 method: SclLabelingMethod,
590 ) -> HashMap<SclClusterId, SclLabelCandidate> {
591 let ids: Vec<SclClusterId> = self.clusters.keys().copied().collect();
592 let mut results = HashMap::with_capacity(ids.len());
593 for id in ids {
594 if let Ok(candidate) = self.label_cluster(id, method) {
595 results.insert(id, candidate);
596 }
597 }
598 results
599 }
600
601 pub fn relabel_if_drifted(&mut self, threshold: f64) -> usize {
606 let drifted: Vec<SclClusterId> = self
608 .clusters
609 .values()
610 .filter_map(|c| {
611 c.label.as_ref()?;
612 let prev = c.labeled_centroid.as_ref()?;
613 let sim = cosine_similarity(&c.centroid, prev);
614 if 1.0 - sim > threshold {
616 Some(c.id)
617 } else {
618 None
619 }
620 })
621 .collect();
622
623 let method = self.config.method;
624 let mut count = 0usize;
625 for id in drifted {
626 if self.label_cluster(id, method).is_ok() {
627 count += 1;
628 }
629 }
630 count
631 }
632
633 pub fn cluster_summary(&self, id: SclClusterId) -> Option<String> {
639 let c = self.clusters.get(&id)?;
640 let label = c.label.as_deref().unwrap_or("<unlabeled>");
641 Some(format!(
642 "Cluster {} | label=\"{}\" | members={} | confidence={:.3}",
643 id,
644 label,
645 c.members.len(),
646 c.confidence
647 ))
648 }
649
650 pub fn labeler_stats(&self) -> SclLabelerStats {
652 let labeled = self.clusters.values().filter(|c| c.label.is_some()).count();
653 let avg_confidence = if labeled == 0 {
654 0.0
655 } else {
656 self.clusters
657 .values()
658 .filter(|c| c.label.is_some())
659 .map(|c| c.confidence)
660 .sum::<f64>()
661 / labeled as f64
662 };
663 SclLabelerStats {
664 total_clusters: self.clusters.len(),
665 labeled_clusters: labeled,
666 vocab_size: self.vocab.len(),
667 prototype_count: self.prototypes.len(),
668 document_count: self.documents.len(),
669 history_len: self.history.len(),
670 avg_confidence,
671 }
672 }
673
674 pub fn clusters(&self) -> &HashMap<SclClusterId, SclCluster> {
676 &self.clusters
677 }
678
679 pub fn vocab(&self) -> &HashMap<String, SclLabelStats> {
681 &self.vocab
682 }
683
684 pub fn history(&self) -> &VecDeque<SclLabelingRecord> {
686 &self.history
687 }
688
689 pub fn get_cluster(&self, id: SclClusterId) -> Option<&SclCluster> {
691 self.clusters.get(&id)
692 }
693
694 pub fn config(&self) -> &SclLabelerConfig {
696 &self.config
697 }
698
699 pub fn set_config(&mut self, config: SclLabelerConfig) {
701 self.config = config;
702 }
703
704 pub fn update_centroid(&mut self, id: SclClusterId, centroid: Vec<f64>) -> bool {
709 if let Some(c) = self.clusters.get_mut(&id) {
710 c.centroid = centroid;
711 true
713 } else {
714 false
715 }
716 }
717
718 pub fn add_members(&mut self, id: SclClusterId, new_members: &[u64]) -> bool {
722 if let Some(c) = self.clusters.get_mut(&id) {
723 for &m in new_members {
724 if !c.members.contains(&m) {
725 c.members.push(m);
726 self.member_index.insert(m, id);
727 }
728 }
729 true
730 } else {
731 false
732 }
733 }
734
735 fn label_by_centroid_nearest(&self, centroid: &[f64]) -> Result<SclLabelCandidate, SclError> {
740 if self.prototypes.is_empty() {
741 return Err(SclError::NoPrototypes);
742 }
743 let best = self
744 .prototypes
745 .iter()
746 .map(|p| (p.label.as_str(), cosine_similarity(centroid, &p.embedding)))
747 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
748
749 let (label, score) = best.ok_or(SclError::NoPrototypes)?;
750 Ok(SclLabelCandidate {
751 label: label.to_owned(),
752 score: score.max(0.0),
753 source: SclLabelingMethod::CentroidNearest,
754 })
755 }
756
757 fn label_by_tfidf(
758 &mut self,
759 members: &[u64],
760 _cluster_id: SclClusterId,
761 ) -> Result<SclLabelCandidate, SclError> {
762 if self.documents.is_empty() {
763 return Err(SclError::NoDocuments);
764 }
765
766 let member_set: std::collections::HashSet<u64> = members.iter().copied().collect();
768 let member_docs: Vec<&SclDocument> = self
769 .documents
770 .iter()
771 .filter(|d| member_set.contains(&d.embedding_id))
772 .collect();
773
774 if member_docs.is_empty() {
775 return Err(SclError::NoDocuments);
776 }
777
778 let total_docs = self.documents.len() as f64;
779 let num_member_docs = member_docs.len() as f64;
780
781 let mut tf: HashMap<&str, f64> = HashMap::new();
783 let mut token_count = 0usize;
784 for doc in &member_docs {
785 for token in &doc.tokens {
786 *tf.entry(token.as_str()).or_insert(0.0) += 1.0;
787 token_count += 1;
788 }
789 }
790 if token_count == 0 {
791 return Err(SclError::NoDocuments);
792 }
793 for v in tf.values_mut() {
794 *v /= token_count as f64;
795 }
796
797 let all_docs = &self.documents;
799 let mut df: HashMap<&str, f64> = HashMap::new();
800 for doc in all_docs {
801 let seen: std::collections::HashSet<&str> =
802 doc.tokens.iter().map(String::as_str).collect();
803 for token in seen {
804 *df.entry(token).or_insert(0.0) += 1.0;
805 }
806 }
807
808 let mut tfidf: Vec<(&str, f64)> = tf
810 .iter()
811 .map(|(&term, &term_tf)| {
812 let doc_freq = df.get(term).copied().unwrap_or(1.0);
813 let idf = ((total_docs + 1.0) / (doc_freq + 1.0)).ln() + 1.0;
814 (term, term_tf * idf)
815 })
816 .collect();
817
818 tfidf.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
819
820 let top_k = self.config.top_k_words.min(tfidf.len());
821 if top_k == 0 {
822 return Err(SclError::NoDocuments);
823 }
824
825 let keywords: Vec<&str> = tfidf[..top_k].iter().map(|(t, _)| *t).collect();
826 let label = keywords.join(" ");
827
828 let top_score = tfidf[0].1;
830 let coverage = num_member_docs / total_docs;
831 let score = (top_score / (top_score + 1.0)) * (0.5 + 0.5 * coverage);
832
833 Ok(SclLabelCandidate {
834 label,
835 score,
836 source: SclLabelingMethod::TfIdfKeywords,
837 })
838 }
839
840 fn label_by_voting(&self, members: &[u64]) -> Result<SclLabelCandidate, SclError> {
841 if self.prototypes.is_empty() {
842 return Err(SclError::NoPrototypes);
843 }
844
845 let mut votes: HashMap<&str, (u32, f64)> = HashMap::new();
851 let mut rng: u64 = 0xABCD_1234_5678_EF01;
852
853 for &member_id in members {
854 rng ^= member_id;
856 xorshift64(&mut rng);
857 let best_label = self
859 .prototypes
860 .iter()
861 .map(|p| {
862 let noise = (xorshift64(&mut rng) as f64 / u64::MAX as f64) * 0.05;
863 let sim = cosine_similarity(&p.embedding, &p.embedding) * (1.0 - noise);
864 (p.label.as_str(), sim)
865 })
866 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
867
868 if let Some((label, sim)) = best_label {
869 let entry = votes.entry(label).or_insert((0, 0.0));
870 entry.0 += 1;
871 entry.1 += sim;
872 }
873 }
874
875 let total_votes = members.len() as f64;
876 let best = votes
877 .iter()
878 .max_by_key(|(_, (count, _))| *count)
879 .ok_or(SclError::NoPrototypes)?;
880
881 let (label, (count, sim_sum)) = best;
882 let score = (*count as f64 / total_votes) * (*sim_sum / *count as f64);
883
884 Ok(SclLabelCandidate {
885 label: (*label).to_owned(),
886 score: score.max(0.0),
887 source: SclLabelingMethod::EmbeddingVoting,
888 })
889 }
890
891 fn label_by_hybrid(
892 &mut self,
893 centroid: &[f64],
894 members: &[u64],
895 cluster_id: SclClusterId,
896 ) -> Result<SclLabelCandidate, SclError> {
897 let mut candidates: Vec<SclLabelCandidate> = Vec::new();
898
899 if !self.prototypes.is_empty() {
901 if let Ok(c) = self.label_by_centroid_nearest(centroid) {
902 candidates.push(c);
903 }
904 if !members.is_empty() {
905 if let Ok(c) = self.label_by_voting(members) {
906 candidates.push(c);
907 }
908 }
909 }
910 if !self.documents.is_empty() {
911 if let Ok(c) = self.label_by_tfidf(members, cluster_id) {
912 candidates.push(c);
913 }
914 }
915
916 if candidates.is_empty() {
917 return Err(SclError::NoPrototypes);
918 }
919
920 let mut fused: HashMap<String, (f64, usize)> = HashMap::new();
922 for c in &candidates {
923 let entry = fused.entry(c.label.clone()).or_insert((0.0, 0));
924 entry.0 += c.score;
925 entry.1 += 1;
926 }
927
928 let best = fused
929 .iter()
930 .map(|(label, (total_score, count))| {
931 (label.as_str(), total_score / *count as f64, *count)
932 })
933 .max_by(|a, b| {
934 let score_cmp = a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal);
936 if score_cmp == std::cmp::Ordering::Equal {
937 a.2.cmp(&b.2)
938 } else {
939 score_cmp
940 }
941 })
942 .ok_or(SclError::NoPrototypes)?;
943
944 let (label, score, _) = best;
945
946 let source = candidates
948 .iter()
949 .filter(|c| c.label == label)
950 .max_by(|a, b| {
951 a.score
952 .partial_cmp(&b.score)
953 .unwrap_or(std::cmp::Ordering::Equal)
954 })
955 .map(|c| c.source)
956 .unwrap_or(SclLabelingMethod::HybridRanking);
957
958 Ok(SclLabelCandidate {
959 label: label.to_owned(),
960 score,
961 source,
962 })
963 }
964
965 fn update_vocab(
970 &mut self,
971 cluster_id: SclClusterId,
972 new_label: &str,
973 confidence: f64,
974 old_label: &Option<String>,
975 ) {
976 if let Some(old) = old_label {
978 if let Some(stats) = self.vocab.get_mut(old) {
979 stats.cluster_ids.retain(|&cid| cid != cluster_id);
980 }
981 }
982
983 let entry = self
985 .vocab
986 .entry(new_label.to_owned())
987 .or_insert_with(|| SclLabelStats {
988 label: new_label.to_owned(),
989 use_count: 0,
990 avg_confidence: 0.0,
991 cluster_ids: Vec::new(),
992 });
993
994 entry.use_count += 1;
995 let alpha = 0.2f64;
997 entry.avg_confidence = alpha * confidence + (1.0 - alpha) * entry.avg_confidence;
998
999 if !entry.cluster_ids.contains(&cluster_id) {
1000 entry.cluster_ids.push(cluster_id);
1001 }
1002 }
1003
1004 fn push_history(&mut self, record: SclLabelingRecord) {
1005 if self.history.len() >= MAX_HISTORY {
1006 self.history.pop_front();
1007 }
1008 self.history.push_back(record);
1009 }
1010}
1011
1012#[cfg(test)]
1017mod tests {
1018 use super::*;
1019
1020 fn labeler_with_protos() -> SemanticClusterLabeler {
1025 let mut l = SemanticClusterLabeler::with_defaults();
1026 l.add_prototype("science", vec![1.0, 0.0, 0.0]);
1027 l.add_prototype("sports", vec![0.0, 1.0, 0.0]);
1028 l.add_prototype("politics", vec![0.0, 0.0, 1.0]);
1029 l
1030 }
1031
1032 fn add_docs(l: &mut SemanticClusterLabeler) {
1033 l.add_keyword_doc("machine learning neural network science", 1);
1034 l.add_keyword_doc("science experiment laboratory physics", 2);
1035 l.add_keyword_doc("football soccer sports match", 3);
1036 l.add_keyword_doc("sports basketball game tournament", 4);
1037 l.add_keyword_doc("election vote politics government", 5);
1038 }
1039
1040 #[test]
1045 fn test_cosine_identical() {
1046 let v = vec![1.0, 2.0, 3.0];
1047 let sim = cosine_similarity(&v, &v);
1048 assert!((sim - 1.0).abs() < 1e-9);
1049 }
1050
1051 #[test]
1052 fn test_cosine_orthogonal() {
1053 let a = vec![1.0, 0.0];
1054 let b = vec![0.0, 1.0];
1055 assert!((cosine_similarity(&a, &b)).abs() < 1e-9);
1056 }
1057
1058 #[test]
1059 fn test_cosine_opposite() {
1060 let a = vec![1.0, 0.0];
1061 let b = vec![-1.0, 0.0];
1062 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-9);
1063 }
1064
1065 #[test]
1066 fn test_cosine_zero_vector() {
1067 let a = vec![0.0, 0.0];
1068 let b = vec![1.0, 2.0];
1069 assert_eq!(cosine_similarity(&a, &b), 0.0);
1070 }
1071
1072 #[test]
1073 fn test_cosine_empty_slices() {
1074 assert_eq!(cosine_similarity(&[], &[]), 0.0);
1076 }
1077
1078 #[test]
1083 fn test_xorshift64_changes_state() {
1084 let mut state: u64 = 1;
1085 let v1 = xorshift64(&mut state);
1086 let v2 = xorshift64(&mut state);
1087 assert_ne!(v1, v2);
1088 }
1089
1090 #[test]
1091 fn test_xorshift64_deterministic() {
1092 let mut s1: u64 = 42;
1093 let mut s2: u64 = 42;
1094 assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1095 }
1096
1097 #[test]
1102 fn test_with_defaults_creates_empty_labeler() {
1103 let l = SemanticClusterLabeler::with_defaults();
1104 let stats = l.labeler_stats();
1105 assert_eq!(stats.total_clusters, 0);
1106 assert_eq!(stats.vocab_size, 0);
1107 }
1108
1109 #[test]
1110 fn test_new_respects_config() {
1111 let cfg = SclLabelerConfig {
1112 min_confidence: 0.5,
1113 top_k_words: 3,
1114 ..Default::default()
1115 };
1116 let l = SemanticClusterLabeler::new(cfg);
1117 assert!((l.config().min_confidence - 0.5).abs() < 1e-9);
1118 assert_eq!(l.config().top_k_words, 3);
1119 }
1120
1121 #[test]
1126 fn test_add_cluster_returns_unique_ids() {
1127 let mut l = SemanticClusterLabeler::with_defaults();
1128 let id1 = l.add_cluster(vec![1.0, 0.0], vec![1]);
1129 let id2 = l.add_cluster(vec![0.0, 1.0], vec![2]);
1130 assert_ne!(id1, id2);
1131 }
1132
1133 #[test]
1134 fn test_add_cluster_tracked_in_stats() {
1135 let mut l = SemanticClusterLabeler::with_defaults();
1136 l.add_cluster(vec![1.0, 0.0], vec![1, 2]);
1137 assert_eq!(l.labeler_stats().total_clusters, 1);
1138 }
1139
1140 #[test]
1141 fn test_remove_cluster_returns_true_when_found() {
1142 let mut l = SemanticClusterLabeler::with_defaults();
1143 let id = l.add_cluster(vec![1.0], vec![1]);
1144 assert!(l.remove_cluster(id));
1145 assert_eq!(l.labeler_stats().total_clusters, 0);
1146 }
1147
1148 #[test]
1149 fn test_remove_cluster_returns_false_when_missing() {
1150 let mut l = SemanticClusterLabeler::with_defaults();
1151 assert!(!l.remove_cluster(9999));
1152 }
1153
1154 #[test]
1155 fn test_remove_cluster_clears_member_index() {
1156 let mut l = SemanticClusterLabeler::with_defaults();
1157 let id = l.add_cluster(vec![1.0], vec![10, 20]);
1158 l.remove_cluster(id);
1159 let id2 = l.add_cluster(vec![0.5], vec![10, 20]);
1161 assert!(l.get_cluster(id2).is_some());
1162 }
1163
1164 #[test]
1169 fn test_merge_clusters_basic() {
1170 let mut l = SemanticClusterLabeler::with_defaults();
1171 let a = l.add_cluster(vec![1.0, 0.0], vec![1, 2]);
1172 let b = l.add_cluster(vec![0.0, 1.0], vec![3, 4]);
1173 let result = l.merge_clusters(a, b);
1174 assert!(result.is_ok());
1175 assert_eq!(result.expect("test: merge_clusters should succeed"), a);
1176 assert_eq!(l.labeler_stats().total_clusters, 1);
1177 let merged = l
1178 .get_cluster(a)
1179 .expect("test: merged cluster a should exist");
1180 assert_eq!(merged.members.len(), 4);
1181 }
1182
1183 #[test]
1184 fn test_merge_clusters_centroid_weighted() {
1185 let mut l = SemanticClusterLabeler::with_defaults();
1186 let a = l.add_cluster(vec![1.0, 0.0], vec![1, 2]); let b = l.add_cluster(vec![0.0, 1.0], vec![3, 4, 5, 6]); l.merge_clusters(a, b)
1189 .expect("test: merge_clusters should succeed");
1190 let c = l
1191 .get_cluster(a)
1192 .expect("test: merged cluster a should exist");
1193 assert!((c.centroid[0] - 1.0 / 3.0).abs() < 1e-9);
1195 assert!((c.centroid[1] - 2.0 / 3.0).abs() < 1e-9);
1197 }
1198
1199 #[test]
1200 fn test_merge_self_error() {
1201 let mut l = SemanticClusterLabeler::with_defaults();
1202 let id = l.add_cluster(vec![1.0], vec![1]);
1203 assert_eq!(l.merge_clusters(id, id), Err(SclError::SelfMerge(id)));
1204 }
1205
1206 #[test]
1207 fn test_merge_missing_a_error() {
1208 let mut l = SemanticClusterLabeler::with_defaults();
1209 let b = l.add_cluster(vec![1.0], vec![1]);
1210 let result = l.merge_clusters(9999, b);
1214 assert!(result.is_err());
1215 }
1216
1217 #[test]
1218 fn test_merge_missing_b_error() {
1219 let mut l = SemanticClusterLabeler::with_defaults();
1220 let a = l.add_cluster(vec![1.0], vec![1]);
1221 assert_eq!(
1222 l.merge_clusters(a, 9999),
1223 Err(SclError::MergeTargetNotFound(9999))
1224 );
1225 }
1226
1227 #[test]
1232 fn test_add_prototype_counted_in_stats() {
1233 let mut l = SemanticClusterLabeler::with_defaults();
1234 l.add_prototype("test", vec![1.0, 0.0]);
1235 assert_eq!(l.labeler_stats().prototype_count, 1);
1236 }
1237
1238 #[test]
1239 fn test_add_prototype_replaces_existing() {
1240 let mut l = SemanticClusterLabeler::with_defaults();
1241 l.add_prototype("a", vec![1.0, 0.0]);
1242 l.add_prototype("a", vec![0.5, 0.5]);
1243 assert_eq!(l.labeler_stats().prototype_count, 1);
1244 }
1245
1246 #[test]
1247 fn test_add_keyword_doc_counted_in_stats() {
1248 let mut l = SemanticClusterLabeler::with_defaults();
1249 l.add_keyword_doc("hello world", 1);
1250 assert_eq!(l.labeler_stats().document_count, 1);
1251 }
1252
1253 #[test]
1258 fn test_label_centroid_nearest_science() {
1259 let mut l = labeler_with_protos();
1260 let id = l.add_cluster(vec![0.9, 0.1, 0.0], vec![1]);
1261 let c = l
1262 .label_cluster(id, SclLabelingMethod::CentroidNearest)
1263 .expect("test: label_cluster should succeed for science-like centroid");
1264 assert_eq!(c.label, "science");
1265 assert!(c.score > 0.0);
1266 }
1267
1268 #[test]
1269 fn test_label_centroid_nearest_sports() {
1270 let mut l = labeler_with_protos();
1271 let id = l.add_cluster(vec![0.0, 0.95, 0.05], vec![1]);
1272 let c = l
1273 .label_cluster(id, SclLabelingMethod::CentroidNearest)
1274 .expect("test: label_cluster should succeed for sports-like centroid");
1275 assert_eq!(c.label, "sports");
1276 }
1277
1278 #[test]
1279 fn test_label_centroid_nearest_politics() {
1280 let mut l = labeler_with_protos();
1281 let id = l.add_cluster(vec![0.0, 0.0, 1.0], vec![1]);
1282 let c = l
1283 .label_cluster(id, SclLabelingMethod::CentroidNearest)
1284 .expect("test: label_cluster should succeed for politics-like centroid");
1285 assert_eq!(c.label, "politics");
1286 }
1287
1288 #[test]
1289 fn test_label_centroid_nearest_no_protos_error() {
1290 let mut l = SemanticClusterLabeler::with_defaults();
1291 let id = l.add_cluster(vec![1.0, 0.0], vec![1]);
1292 assert!(matches!(
1293 l.label_cluster(id, SclLabelingMethod::CentroidNearest),
1294 Err(SclError::NoPrototypes)
1295 ));
1296 }
1297
1298 #[test]
1299 fn test_label_centroid_nearest_missing_cluster() {
1300 let mut l = labeler_with_protos();
1301 assert!(matches!(
1302 l.label_cluster(9999, SclLabelingMethod::CentroidNearest),
1303 Err(SclError::ClusterNotFound(9999))
1304 ));
1305 }
1306
1307 #[test]
1312 fn test_label_nearest_prototype_equivalent_to_centroid() {
1313 let mut l = labeler_with_protos();
1314 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1315 let c1 = l
1316 .label_cluster(id, SclLabelingMethod::NearestPrototype)
1317 .expect("test: label_cluster with NearestPrototype should succeed");
1318 assert_eq!(c1.label, "science");
1320 }
1321
1322 #[test]
1327 fn test_label_tfidf_returns_keyword_label() {
1328 let mut l = SemanticClusterLabeler::with_defaults();
1329 add_docs(&mut l);
1330 let id = l.add_cluster(vec![1.0], vec![1, 2]);
1331 let c = l
1332 .label_cluster(id, SclLabelingMethod::TfIdfKeywords)
1333 .expect("test: label_cluster with TfIdfKeywords should succeed when docs exist");
1334 assert!(!c.label.is_empty());
1336 }
1337
1338 #[test]
1339 fn test_label_tfidf_no_docs_error() {
1340 let mut l = SemanticClusterLabeler::with_defaults();
1341 let id = l.add_cluster(vec![1.0], vec![1]);
1342 assert!(matches!(
1343 l.label_cluster(id, SclLabelingMethod::TfIdfKeywords),
1344 Err(SclError::NoDocuments)
1345 ));
1346 }
1347
1348 #[test]
1349 fn test_label_tfidf_member_not_in_docs_error() {
1350 let mut l = SemanticClusterLabeler::with_defaults();
1351 l.add_keyword_doc("science experiment", 99); let id = l.add_cluster(vec![1.0], vec![1, 2]); assert!(matches!(
1355 l.label_cluster(id, SclLabelingMethod::TfIdfKeywords),
1356 Err(SclError::NoDocuments)
1357 ));
1358 }
1359
1360 #[test]
1361 fn test_label_tfidf_score_in_range() {
1362 let mut l = SemanticClusterLabeler::with_defaults();
1363 add_docs(&mut l);
1364 let id = l.add_cluster(vec![1.0], vec![1, 2]);
1365 let c = l
1366 .label_cluster(id, SclLabelingMethod::TfIdfKeywords)
1367 .expect("test: label_cluster with TfIdfKeywords should succeed");
1368 assert!(c.score >= 0.0 && c.score <= 1.0);
1369 }
1370
1371 #[test]
1376 fn test_label_voting_basic() {
1377 let mut l = labeler_with_protos();
1378 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1, 2, 3]);
1379 let c = l
1380 .label_cluster(id, SclLabelingMethod::EmbeddingVoting)
1381 .expect(
1382 "test: label_cluster with EmbeddingVoting should succeed when prototypes exist",
1383 );
1384 assert!(!c.label.is_empty());
1385 }
1386
1387 #[test]
1388 fn test_label_voting_no_protos_error() {
1389 let mut l = SemanticClusterLabeler::with_defaults();
1390 let id = l.add_cluster(vec![1.0], vec![1]);
1391 assert!(matches!(
1392 l.label_cluster(id, SclLabelingMethod::EmbeddingVoting),
1393 Err(SclError::NoPrototypes)
1394 ));
1395 }
1396
1397 #[test]
1402 fn test_label_hybrid_with_protos_only() {
1403 let mut l = labeler_with_protos();
1404 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1405 let c = l
1406 .label_cluster(id, SclLabelingMethod::HybridRanking)
1407 .expect("test: label_cluster with HybridRanking should succeed with prototypes");
1408 assert_eq!(c.label, "science");
1409 }
1410
1411 #[test]
1412 fn test_label_hybrid_with_docs_and_protos() {
1413 let mut l = labeler_with_protos();
1414 add_docs(&mut l);
1415 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1, 2]);
1416 let c = l
1417 .label_cluster(id, SclLabelingMethod::HybridRanking)
1418 .expect(
1419 "test: label_cluster with HybridRanking should succeed with docs and prototypes",
1420 );
1421 assert!(!c.label.is_empty());
1422 }
1423
1424 #[test]
1425 fn test_label_hybrid_no_methods_error() {
1426 let mut l = SemanticClusterLabeler::with_defaults();
1427 let id = l.add_cluster(vec![1.0], vec![1]);
1428 assert!(matches!(
1429 l.label_cluster(id, SclLabelingMethod::HybridRanking),
1430 Err(SclError::NoPrototypes)
1431 ));
1432 }
1433
1434 #[test]
1439 fn test_label_cluster_sets_cluster_label() {
1440 let mut l = labeler_with_protos();
1441 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1442 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1443 .expect("test: label_cluster should succeed");
1444 let c = l
1445 .get_cluster(id)
1446 .expect("test: cluster should exist after labeling");
1447 assert_eq!(c.label.as_deref(), Some("science"));
1448 }
1449
1450 #[test]
1451 fn test_label_cluster_sets_confidence() {
1452 let mut l = labeler_with_protos();
1453 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1454 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1455 .expect("test: label_cluster should succeed");
1456 let c = l
1457 .get_cluster(id)
1458 .expect("test: cluster should exist after labeling");
1459 assert!(c.confidence > 0.0);
1460 }
1461
1462 #[test]
1463 fn test_label_cluster_sets_labeled_centroid() {
1464 let mut l = labeler_with_protos();
1465 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1466 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1467 .expect("test: label_cluster should succeed");
1468 assert!(l
1469 .get_cluster(id)
1470 .expect("test: cluster should exist after labeling")
1471 .labeled_centroid
1472 .is_some());
1473 }
1474
1475 #[test]
1480 fn test_vocab_updated_after_labeling() {
1481 let mut l = labeler_with_protos();
1482 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1483 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1484 .expect("test: label_cluster should succeed");
1485 assert!(l.vocab().contains_key("science"));
1486 }
1487
1488 #[test]
1489 fn test_vocab_use_count_increments() {
1490 let mut l = labeler_with_protos();
1491 let id1 = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1492 let id2 = l.add_cluster(vec![0.99, 0.01, 0.0], vec![2]);
1493 l.label_cluster(id1, SclLabelingMethod::CentroidNearest)
1494 .expect("test: label_cluster id1 should succeed");
1495 l.label_cluster(id2, SclLabelingMethod::CentroidNearest)
1496 .expect("test: label_cluster id2 should succeed");
1497 let stats = l
1498 .vocab()
1499 .get("science")
1500 .expect("test: science should be in vocab");
1501 assert_eq!(stats.use_count, 2);
1502 }
1503
1504 #[test]
1505 fn test_vocab_cluster_ids_tracked() {
1506 let mut l = labeler_with_protos();
1507 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1508 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1509 .expect("test: label_cluster should succeed");
1510 let stats = l
1511 .vocab()
1512 .get("science")
1513 .expect("test: science should be in vocab");
1514 assert!(stats.cluster_ids.contains(&id));
1515 }
1516
1517 #[test]
1522 fn test_history_appended_on_label() {
1523 let mut l = labeler_with_protos();
1524 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1525 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1526 .expect("test: label_cluster should succeed");
1527 assert_eq!(l.history().len(), 1);
1528 }
1529
1530 #[test]
1531 fn test_history_bounded_at_500() {
1532 let mut l = labeler_with_protos();
1533 for i in 0..510u64 {
1535 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![i]);
1536 let _ = l.label_cluster(id, SclLabelingMethod::CentroidNearest);
1537 }
1538 assert!(l.history().len() <= MAX_HISTORY);
1539 }
1540
1541 #[test]
1542 fn test_history_records_old_label() {
1543 let mut l = labeler_with_protos();
1544 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1545 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1546 .expect("test: first label_cluster should succeed");
1547 l.get_cluster(id); l.update_centroid(id, vec![0.0, 1.0, 0.0]);
1550 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1551 .expect("test: second label_cluster should succeed");
1552 let last = l
1553 .history()
1554 .back()
1555 .expect("test: history should have at least one record");
1556 assert_eq!(last.old_label.as_deref(), Some("science"));
1557 assert_eq!(last.new_label, "sports");
1558 }
1559
1560 #[test]
1565 fn test_label_all_labels_multiple_clusters() {
1566 let mut l = labeler_with_protos();
1567 l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1568 l.add_cluster(vec![0.0, 1.0, 0.0], vec![2]);
1569 l.add_cluster(vec![0.0, 0.0, 1.0], vec![3]);
1570 let results = l.label_all(SclLabelingMethod::CentroidNearest);
1571 assert_eq!(results.len(), 3);
1572 }
1573
1574 #[test]
1575 fn test_label_all_skips_below_confidence() {
1576 let mut l = SemanticClusterLabeler::new(SclLabelerConfig {
1577 min_confidence: 0.99, ..Default::default()
1579 });
1580 l.add_prototype("test", vec![1.0, 0.0]);
1581 l.add_cluster(vec![0.5, 0.5], vec![1]); let results = l.label_all(SclLabelingMethod::CentroidNearest);
1583 assert!(results.len() <= 1);
1585 }
1586
1587 #[test]
1592 fn test_relabel_if_drifted_no_drift() {
1593 let mut l = labeler_with_protos();
1594 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1595 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1596 .expect("test: label_cluster should succeed");
1597 let recount = l.relabel_if_drifted(0.5);
1598 assert_eq!(recount, 0);
1599 }
1600
1601 #[test]
1602 fn test_relabel_if_drifted_detects_large_shift() {
1603 let mut l = labeler_with_protos();
1604 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1605 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1606 .expect("test: label_cluster should succeed");
1607 l.update_centroid(id, vec![0.0, 0.0, 1.0]);
1609 let recount = l.relabel_if_drifted(0.1);
1610 assert!(recount >= 1);
1611 let c = l
1612 .get_cluster(id)
1613 .expect("test: cluster should exist after drift detection");
1614 assert_eq!(c.label.as_deref(), Some("politics"));
1615 }
1616
1617 #[test]
1618 fn test_relabel_if_drifted_unlabeled_ignored() {
1619 let mut l = labeler_with_protos();
1620 l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]); let recount = l.relabel_if_drifted(0.0);
1622 assert_eq!(recount, 0);
1623 }
1624
1625 #[test]
1630 fn test_cluster_summary_returns_some() {
1631 let mut l = labeler_with_protos();
1632 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1, 2]);
1633 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1634 .expect("test: label_cluster should succeed");
1635 let s = l
1636 .cluster_summary(id)
1637 .expect("test: cluster_summary should return Some for existing labeled cluster");
1638 assert!(s.contains("science"));
1639 assert!(s.contains("members=2"));
1640 }
1641
1642 #[test]
1643 fn test_cluster_summary_unlabeled() {
1644 let mut l = SemanticClusterLabeler::with_defaults();
1645 let id = l.add_cluster(vec![1.0], vec![]);
1646 let s = l
1647 .cluster_summary(id)
1648 .expect("test: cluster_summary should return Some for existing cluster");
1649 assert!(s.contains("<unlabeled>"));
1650 }
1651
1652 #[test]
1653 fn test_cluster_summary_missing_id_returns_none() {
1654 let l = SemanticClusterLabeler::with_defaults();
1655 assert!(l.cluster_summary(9999).is_none());
1656 }
1657
1658 #[test]
1663 fn test_labeler_stats_counts_correctly() {
1664 let mut l = labeler_with_protos();
1665 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1666 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1667 .expect("test: label_cluster should succeed");
1668 let stats = l.labeler_stats();
1669 assert_eq!(stats.total_clusters, 1);
1670 assert_eq!(stats.labeled_clusters, 1);
1671 assert!(stats.vocab_size > 0);
1672 }
1673
1674 #[test]
1675 fn test_labeler_stats_avg_confidence() {
1676 let mut l = labeler_with_protos();
1677 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1678 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1679 .expect("test: label_cluster should succeed");
1680 let stats = l.labeler_stats();
1681 assert!(stats.avg_confidence > 0.0);
1682 }
1683
1684 #[test]
1685 fn test_labeler_stats_empty() {
1686 let l = SemanticClusterLabeler::with_defaults();
1687 let stats = l.labeler_stats();
1688 assert_eq!(stats.avg_confidence, 0.0);
1689 assert_eq!(stats.history_len, 0);
1690 }
1691
1692 #[test]
1697 fn test_update_centroid_returns_true_when_found() {
1698 let mut l = SemanticClusterLabeler::with_defaults();
1699 let id = l.add_cluster(vec![1.0, 0.0], vec![]);
1700 assert!(l.update_centroid(id, vec![0.5, 0.5]));
1701 assert_eq!(
1702 l.get_cluster(id)
1703 .expect("test: cluster should exist after update_centroid")
1704 .centroid,
1705 vec![0.5, 0.5]
1706 );
1707 }
1708
1709 #[test]
1710 fn test_update_centroid_returns_false_when_missing() {
1711 let mut l = SemanticClusterLabeler::with_defaults();
1712 assert!(!l.update_centroid(9999, vec![1.0]));
1713 }
1714
1715 #[test]
1716 fn test_add_members_increases_count() {
1717 let mut l = SemanticClusterLabeler::with_defaults();
1718 let id = l.add_cluster(vec![1.0], vec![1, 2]);
1719 assert!(l.add_members(id, &[3, 4, 5]));
1720 assert_eq!(
1721 l.get_cluster(id)
1722 .expect("test: cluster should exist after add_members")
1723 .members
1724 .len(),
1725 5
1726 );
1727 }
1728
1729 #[test]
1730 fn test_add_members_no_duplicates() {
1731 let mut l = SemanticClusterLabeler::with_defaults();
1732 let id = l.add_cluster(vec![1.0], vec![1]);
1733 l.add_members(id, &[1, 2]); assert_eq!(
1735 l.get_cluster(id)
1736 .expect("test: cluster should exist after add_members dedup")
1737 .members
1738 .len(),
1739 2
1740 );
1741 }
1742
1743 #[test]
1744 fn test_add_members_returns_false_when_missing() {
1745 let mut l = SemanticClusterLabeler::with_defaults();
1746 assert!(!l.add_members(9999, &[1]));
1747 }
1748
1749 #[test]
1754 fn test_error_display_cluster_not_found() {
1755 let e = SclError::ClusterNotFound(42);
1756 assert!(e.to_string().contains("42"));
1757 }
1758
1759 #[test]
1760 fn test_error_display_self_merge() {
1761 let e = SclError::SelfMerge(7);
1762 assert!(e.to_string().contains("7"));
1763 }
1764
1765 #[test]
1766 fn test_error_display_below_confidence() {
1767 let e = SclError::BelowConfidenceThreshold {
1768 best: 0.05,
1769 threshold: 0.10,
1770 };
1771 let s = e.to_string();
1772 assert!(s.contains("0.0500") || s.contains("0.05"));
1773 }
1774
1775 #[test]
1780 fn test_method_display_all_variants() {
1781 use SclLabelingMethod::*;
1782 let variants = [
1783 CentroidNearest,
1784 TfIdfKeywords,
1785 EmbeddingVoting,
1786 NearestPrototype,
1787 HybridRanking,
1788 ];
1789 for v in &variants {
1790 assert!(!v.to_string().is_empty());
1791 }
1792 }
1793
1794 #[test]
1799 fn test_config_default_reasonable_values() {
1800 let c = SclLabelerConfig::default();
1801 assert!(c.max_labels_per_cluster > 0);
1802 assert!(c.min_confidence >= 0.0 && c.min_confidence < 1.0);
1803 assert!(c.top_k_words > 0);
1804 }
1805
1806 #[test]
1811 fn test_set_config_updates_config() {
1812 let mut l = SemanticClusterLabeler::with_defaults();
1813 let new_cfg = SclLabelerConfig {
1814 min_confidence: 0.42,
1815 ..Default::default()
1816 };
1817 l.set_config(new_cfg);
1818 assert!((l.config().min_confidence - 0.42).abs() < 1e-9);
1819 }
1820
1821 #[test]
1826 fn test_label_rejects_below_threshold() {
1827 let mut l = SemanticClusterLabeler::new(SclLabelerConfig {
1828 min_confidence: 0.99,
1829 ..Default::default()
1830 });
1831 l.add_prototype("far", vec![0.0, 1.0]);
1833 let id = l.add_cluster(vec![1.0, 0.0], vec![1]);
1834 let result = l.label_cluster(id, SclLabelingMethod::CentroidNearest);
1835 assert!(matches!(
1836 result,
1837 Err(SclError::BelowConfidenceThreshold { .. })
1838 ));
1839 }
1840
1841 #[test]
1846 fn test_remove_clears_vocab_cluster_id() {
1847 let mut l = labeler_with_protos();
1848 let id = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1849 l.label_cluster(id, SclLabelingMethod::CentroidNearest)
1850 .expect("test: label_cluster should succeed");
1851 l.remove_cluster(id);
1852 if let Some(stats) = l.vocab().get("science") {
1853 assert!(!stats.cluster_ids.contains(&id));
1854 }
1855 }
1856
1857 #[test]
1862 fn test_sclcluster_serde_roundtrip() {
1863 let cluster = SclCluster {
1864 id: 1,
1865 centroid: vec![0.1, 0.2],
1866 members: vec![10, 20],
1867 label: Some("test".into()),
1868 confidence: 0.8,
1869 keywords: vec!["word".into()],
1870 created_at: 0,
1871 labeled_centroid: None,
1872 };
1873 let json = serde_json::to_string(&cluster).expect("test: serialization failed");
1874 let decoded: SclCluster =
1875 serde_json::from_str(&json).expect("test: deserialization failed");
1876 assert_eq!(decoded.id, 1);
1877 assert_eq!(decoded.label.as_deref(), Some("test"));
1878 }
1879
1880 #[test]
1885 fn test_tfidf_top_k_respected() {
1886 let mut l = SemanticClusterLabeler::new(SclLabelerConfig {
1887 top_k_words: 2,
1888 min_confidence: 0.0,
1889 ..Default::default()
1890 });
1891 l.add_keyword_doc("alpha beta gamma delta", 1);
1892 l.add_keyword_doc("alpha beta gamma", 2);
1893 let id = l.add_cluster(vec![1.0], vec![1, 2]);
1894 let c = l
1895 .label_cluster(id, SclLabelingMethod::TfIdfKeywords)
1896 .expect("test: label_cluster with TfIdfKeywords should succeed");
1897 let word_count = c.label.split_whitespace().count();
1899 assert!(word_count <= 2);
1900 }
1901
1902 #[test]
1907 fn test_history_records_are_ordered_by_insertion() {
1908 let mut l = labeler_with_protos();
1909 let id1 = l.add_cluster(vec![1.0, 0.0, 0.0], vec![1]);
1910 let id2 = l.add_cluster(vec![0.0, 1.0, 0.0], vec![2]);
1911 l.label_cluster(id1, SclLabelingMethod::CentroidNearest)
1912 .expect("test: label_cluster id1 should succeed");
1913 l.label_cluster(id2, SclLabelingMethod::CentroidNearest)
1914 .expect("test: label_cluster id2 should succeed");
1915 let history: Vec<_> = l.history().iter().collect();
1916 assert_eq!(history[0].cluster_id, id1);
1917 assert_eq!(history[1].cluster_id, id2);
1918 }
1919
1920 #[test]
1925 fn test_type_alias_scl_semantic_cluster_labeler() {
1926 let _: SclSemanticClusterLabeler = SemanticClusterLabeler::with_defaults();
1927 }
1928
1929 #[test]
1934 fn test_created_at_nonzero_on_modern_system() {
1935 let mut l = SemanticClusterLabeler::with_defaults();
1936 let id = l.add_cluster(vec![1.0], vec![]);
1937 let c = l
1938 .get_cluster(id)
1939 .expect("test: cluster should exist after add_cluster");
1940 assert!(c.created_at > 946_684_800);
1942 }
1943}