Skip to main content

ipfrs_semantic/
semantic_cluster_labeler.rs

1//! Automatic human-readable label assignment for embedding clusters.
2//!
3//! [`SemanticClusterLabeler`] accepts a set of embedding clusters (each represented
4//! by a centroid vector and a membership list) and assigns the best matching
5//! human-readable label via five complementary strategies:
6//!
7//! * **CentroidNearest** – picks the prototype whose embedding is most similar to
8//!   the cluster centroid.
9//! * **TfIdfKeywords** – extracts the most discriminative terms from documents
10//!   associated with member embeddings and produces a concise keyword label.
11//! * **EmbeddingVoting** – each member embedding independently votes for the
12//!   nearest prototype; the majority vote wins.
13//! * **NearestPrototype** – synonym for CentroidNearest with an explicit prototype
14//!   store; exists as a separate variant for configuration clarity.
15//! * **HybridRanking** – combines scores from all available methods and returns
16//!   the highest-confidence candidate.
17
18use std::collections::{HashMap, VecDeque};
19
20// ---------------------------------------------------------------------------
21// Type aliases
22// ---------------------------------------------------------------------------
23
24/// Unique identifier for a [`SclCluster`].
25pub type SclClusterId = u64;
26
27/// Convenience alias: the full labeler struct.
28pub type SclSemanticClusterLabeler = SemanticClusterLabeler;
29
30// ---------------------------------------------------------------------------
31// Errors
32// ---------------------------------------------------------------------------
33
34/// Errors produced by [`SemanticClusterLabeler`].
35#[derive(Debug, Clone, PartialEq)]
36pub enum SclError {
37    /// No cluster with the given id exists.
38    ClusterNotFound(SclClusterId),
39    /// No prototype embeddings registered; cannot run CentroidNearest or NearestPrototype.
40    NoPrototypes,
41    /// No keyword documents registered; cannot run TfIdfKeywords.
42    NoDocuments,
43    /// Cannot merge a cluster with itself.
44    SelfMerge(SclClusterId),
45    /// Centroid vector is empty.
46    EmptyCentroid,
47    /// All candidates scored below `min_confidence`.
48    BelowConfidenceThreshold { best: f64, threshold: f64 },
49    /// The second cluster (b) was not found during merge.
50    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// ---------------------------------------------------------------------------
75// Labeling method enum
76// ---------------------------------------------------------------------------
77
78/// Strategy used to assign a label to a cluster.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
80pub enum SclLabelingMethod {
81    /// Pick the prototype whose embedding is nearest to the cluster centroid.
82    CentroidNearest,
83    /// Extract TF-IDF keywords from documents attached to cluster members.
84    TfIdfKeywords,
85    /// Each member votes for the nearest prototype; majority wins.
86    EmbeddingVoting,
87    /// Explicitly use the named-prototype store (alias of CentroidNearest with
88    /// richer semantics for configuration).
89    NearestPrototype,
90    /// Fuse all available methods and pick the highest aggregate score.
91    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// ---------------------------------------------------------------------------
108// Configuration
109// ---------------------------------------------------------------------------
110
111/// Configuration for [`SemanticClusterLabeler`].
112#[derive(Debug, Clone)]
113pub struct SclLabelerConfig {
114    /// Maximum number of label candidates kept per labeling call.
115    pub max_labels_per_cluster: usize,
116    /// Minimum cosine-similarity / confidence required to accept a label.
117    pub min_confidence: f64,
118    /// Default labeling method when `label_cluster` is called without specifying one.
119    pub method: SclLabelingMethod,
120    /// Number of top keywords to include in a TF-IDF label.
121    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// ---------------------------------------------------------------------------
136// Core data types
137// ---------------------------------------------------------------------------
138
139/// A single embedding cluster.
140#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
141pub struct SclCluster {
142    /// Unique cluster id.
143    pub id: SclClusterId,
144    /// Mean embedding of all members.
145    pub centroid: Vec<f64>,
146    /// IDs of embeddings that belong to this cluster.
147    pub members: Vec<u64>,
148    /// Currently assigned human-readable label.
149    pub label: Option<String>,
150    /// Confidence score of the current label assignment.
151    pub confidence: f64,
152    /// Top keywords extracted for this cluster.
153    pub keywords: Vec<String>,
154    /// UNIX-epoch creation timestamp (seconds).
155    pub created_at: u64,
156    /// Centroid at the time the label was last assigned (used for drift detection).
157    pub(crate) labeled_centroid: Option<Vec<f64>>,
158}
159
160/// Per-label usage statistics kept in the vocabulary.
161#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
162pub struct SclLabelStats {
163    /// The label string.
164    pub label: String,
165    /// Total times this label was successfully assigned to any cluster.
166    pub use_count: u32,
167    /// Running average of the confidence scores at assignment time.
168    pub avg_confidence: f64,
169    /// Clusters that currently carry this label.
170    pub cluster_ids: Vec<SclClusterId>,
171}
172
173/// One entry in the labeling history ring-buffer.
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
175pub struct SclLabelingRecord {
176    /// UNIX-epoch timestamp of the event.
177    pub ts: u64,
178    /// Target cluster.
179    pub cluster_id: SclClusterId,
180    /// Label before this event (if any).
181    pub old_label: Option<String>,
182    /// New label assigned.
183    pub new_label: String,
184    /// Method that produced this label.
185    pub method: SclLabelingMethod,
186    /// Confidence score.
187    pub confidence: f64,
188}
189
190/// A candidate label with its score and provenance.
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192pub struct SclLabelCandidate {
193    /// Candidate label string.
194    pub label: String,
195    /// Score in [0, 1].
196    pub score: f64,
197    /// Which method produced this candidate.
198    pub source: SclLabelingMethod,
199}
200
201/// Aggregate statistics for the labeler.
202#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
203pub struct SclLabelerStats {
204    /// Total clusters registered.
205    pub total_clusters: usize,
206    /// Clusters that currently have a label.
207    pub labeled_clusters: usize,
208    /// Distinct labels in the vocabulary.
209    pub vocab_size: usize,
210    /// Prototype embeddings registered.
211    pub prototype_count: usize,
212    /// Documents registered for TF-IDF.
213    pub document_count: usize,
214    /// Labeling events stored in history.
215    pub history_len: usize,
216    /// Average confidence across all labeled clusters.
217    pub avg_confidence: f64,
218}
219
220// ---------------------------------------------------------------------------
221// Internal helpers
222// ---------------------------------------------------------------------------
223
224/// TF-IDF document record (internal).
225#[derive(Debug, Clone)]
226struct SclDocument {
227    /// Embedding id that owns this document.
228    embedding_id: u64,
229    /// Lowercased, whitespace-split tokens.
230    tokens: Vec<String>,
231}
232
233/// Named prototype (internal).
234#[derive(Debug, Clone)]
235struct SclPrototype {
236    label: String,
237    embedding: Vec<f64>,
238}
239
240// ---------------------------------------------------------------------------
241// Cosine similarity & xorshift RNG helpers
242// ---------------------------------------------------------------------------
243
244/// Cosine similarity between two equal-length slices; returns 0 if either is zero-length
245/// or either norm is zero.
246#[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/// Xorshift64 PRNG — fast deterministic pseudo-random u64.
259#[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
269/// Return current time as UNIX seconds (monotonic fallback returns 0).
270fn 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
277// ---------------------------------------------------------------------------
278// Capacity constant
279// ---------------------------------------------------------------------------
280
281const MAX_HISTORY: usize = 500;
282
283// ---------------------------------------------------------------------------
284// SemanticClusterLabeler
285// ---------------------------------------------------------------------------
286
287/// Automatic labeler that assigns human-readable strings to embedding clusters.
288///
289/// # Example
290///
291/// ```rust
292/// use ipfrs_semantic::semantic_cluster_labeler::{
293///     SemanticClusterLabeler, SclLabelerConfig, SclLabelingMethod,
294/// };
295///
296/// let config = SclLabelerConfig {
297///     min_confidence: 0.05,
298///     ..Default::default()
299/// };
300/// let mut labeler = SemanticClusterLabeler::new(config);
301///
302/// // Register labelled prototypes
303/// labeler.add_prototype("science", vec![0.9, 0.1, 0.0]);
304/// labeler.add_prototype("sports",  vec![0.0, 0.9, 0.1]);
305///
306/// // Create a cluster
307/// let id = labeler.add_cluster(vec![0.85, 0.15, 0.0], vec![1, 2, 3]);
308///
309/// // Assign a label
310/// let candidate = labeler.label_cluster(id, SclLabelingMethod::CentroidNearest).unwrap();
311/// assert_eq!(candidate.label, "science");
312/// ```
313pub struct SemanticClusterLabeler {
314    /// All registered clusters keyed by id.
315    clusters: HashMap<SclClusterId, SclCluster>,
316    /// Label vocabulary with usage statistics.
317    vocab: HashMap<String, SclLabelStats>,
318    /// Bounded ring-buffer of labeling events.
319    history: VecDeque<SclLabelingRecord>,
320    /// Named prototype embeddings.
321    prototypes: Vec<SclPrototype>,
322    /// Documents attached to embeddings for TF-IDF.
323    documents: Vec<SclDocument>,
324    /// Mapping from embedding id to member cluster id (for fast member → cluster lookup).
325    member_index: HashMap<u64, SclClusterId>,
326    /// Labeler configuration.
327    config: SclLabelerConfig,
328    /// Next cluster id counter.
329    next_id: SclClusterId,
330}
331
332impl SemanticClusterLabeler {
333    // -----------------------------------------------------------------------
334    // Construction
335    // -----------------------------------------------------------------------
336
337    /// Create a new labeler with the supplied configuration.
338    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    /// Create a labeler with default configuration.
352    pub fn with_defaults() -> Self {
353        Self::new(SclLabelerConfig::default())
354    }
355
356    // -----------------------------------------------------------------------
357    // Cluster management
358    // -----------------------------------------------------------------------
359
360    /// Register a new cluster and return its id.
361    ///
362    /// # Errors
363    /// Returns [`SclError::EmptyCentroid`] when `centroid` is empty.
364    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    /// Remove a cluster by id.  Returns `true` if the cluster existed.
387    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            // Remove cluster from vocab
393            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    /// Merge cluster `b` into cluster `a`, returning `a`'s id on success.
405    ///
406    /// The centroid is recomputed as the mean of both centroids weighted by
407    /// member count.  Cluster `b` is removed.
408    ///
409    /// # Errors
410    /// - [`SclError::SelfMerge`] when `a == b`
411    /// - [`SclError::ClusterNotFound`] when `a` is not found
412    /// - [`SclError::MergeTargetNotFound`] when `b` is not found
413    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        // Extract b first to avoid borrow issues
422        let cluster_b = self
423            .clusters
424            .remove(&b)
425            .ok_or(SclError::MergeTargetNotFound(b))?;
426
427        // Check whether `a` exists; if not, re-insert `b` and return the error.
428        if !self.clusters.contains_key(&a) {
429            self.clusters.insert(b, cluster_b);
430            return Err(SclError::ClusterNotFound(a));
431        }
432
433        // Safety: we just confirmed `a` exists.
434        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        // Weighted centroid
444        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; // label invalidated by merge
458        cluster_a.confidence = 0.0;
459        cluster_a.labeled_centroid = None;
460
461        // Transfer members
462        for &m in &cluster_b.members {
463            self.member_index.insert(m, a);
464        }
465        cluster_a.members.extend(cluster_b.members);
466
467        // Update vocab: remove b from its label's cluster list
468        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    // -----------------------------------------------------------------------
478    // Prototype & document registration
479    // -----------------------------------------------------------------------
480
481    /// Register a named prototype embedding.
482    /// If a prototype with the same label already exists it is replaced.
483    pub fn add_prototype(&mut self, label: &str, embedding: Vec<f64>) {
484        // Replace existing prototype with same label
485        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    /// Attach a text document to an embedding id for TF-IDF keyword extraction.
496    ///
497    /// Whitespace tokenisation is applied; tokens are lower-cased and
498    /// non-alphabetic characters are stripped.
499    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    // -----------------------------------------------------------------------
518    // Labeling
519    // -----------------------------------------------------------------------
520
521    /// Assign a label to a single cluster using the specified method.
522    ///
523    /// On success the cluster's `label` and `confidence` fields are updated,
524    /// the vocabulary statistics are refreshed, and a [`SclLabelingRecord`]
525    /// is appended to the history.
526    pub fn label_cluster(
527        &mut self,
528        id: SclClusterId,
529        method: SclLabelingMethod,
530    ) -> Result<SclLabelCandidate, SclError> {
531        // Collect what we need from the cluster without holding a mutable ref
532        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(&centroid)?
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(&centroid, &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        // Apply label to cluster
557        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        // Update vocabulary
570        self.update_vocab(id, &candidate.label, candidate.score, &old_label);
571
572        // Append history
573        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    /// Label every cluster using the given method.
586    /// Clusters that fail to meet `min_confidence` are silently skipped.
587    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    /// Re-label any cluster whose centroid has drifted more than `threshold`
602    /// (cosine distance) since it was last labeled.
603    ///
604    /// Returns the number of clusters re-labeled.
605    pub fn relabel_if_drifted(&mut self, threshold: f64) -> usize {
606        // Collect clusters that need re-labeling without borrowing self mutably
607        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                // cosine distance = 1 - similarity
615                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    // -----------------------------------------------------------------------
634    // Query helpers
635    // -----------------------------------------------------------------------
636
637    /// Return a human-readable one-line summary for a cluster.
638    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    /// Return a snapshot of labeler-wide statistics.
651    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    /// Return immutable access to all clusters.
675    pub fn clusters(&self) -> &HashMap<SclClusterId, SclCluster> {
676        &self.clusters
677    }
678
679    /// Return immutable access to the vocabulary.
680    pub fn vocab(&self) -> &HashMap<String, SclLabelStats> {
681        &self.vocab
682    }
683
684    /// Return a slice of the labeling history (oldest first).
685    pub fn history(&self) -> &VecDeque<SclLabelingRecord> {
686        &self.history
687    }
688
689    /// Look up a cluster by id.
690    pub fn get_cluster(&self, id: SclClusterId) -> Option<&SclCluster> {
691        self.clusters.get(&id)
692    }
693
694    /// Return the current configuration.
695    pub fn config(&self) -> &SclLabelerConfig {
696        &self.config
697    }
698
699    /// Update the configuration (does not relabel existing clusters).
700    pub fn set_config(&mut self, config: SclLabelerConfig) {
701        self.config = config;
702    }
703
704    /// Update only the centroid of a cluster.  Clears `labeled_centroid` so
705    /// the next call to `relabel_if_drifted` will detect the change.
706    ///
707    /// Returns `true` if the cluster was found and updated.
708    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            // Keep labeled_centroid as-is so drift can be measured
712            true
713        } else {
714            false
715        }
716    }
717
718    /// Add additional member ids to an existing cluster.
719    ///
720    /// Returns `true` when the cluster was found.
721    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    // -----------------------------------------------------------------------
736    // Private labeling strategies
737    // -----------------------------------------------------------------------
738
739    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        // Collect member documents
767        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        // Compute TF within cluster documents
782        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        // Compute IDF across all documents
798        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        // TF-IDF score per term
809        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        // Confidence: normalised score of the top keyword * coverage
829        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        // For each member embedding id we need its embedding.
846        // Since we don't store raw member embeddings (only centroid), we use a
847        // deterministic pseudo-embedding derived from the member id for voting.
848        // In production the caller would supply member embeddings; here we
849        // approximate by treating each member id as a seed for a jittered prototype.
850        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            // Deterministic noise based on member id
855            rng ^= member_id;
856            xorshift64(&mut rng);
857            // Pick best prototype with slight noise to simulate real embeddings
858            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        // Gather candidates from all available sub-methods
900        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        // Fuse: average scores per label, weight by source diversity
921        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                // Primary: fused score; secondary: source diversity
935                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        // Determine the dominant source method
947        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    // -----------------------------------------------------------------------
966    // Vocabulary / history helpers
967    // -----------------------------------------------------------------------
968
969    fn update_vocab(
970        &mut self,
971        cluster_id: SclClusterId,
972        new_label: &str,
973        confidence: f64,
974        old_label: &Option<String>,
975    ) {
976        // Remove cluster from old label's cluster_ids
977        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        // Update or insert new label entry
984        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        // Exponential moving average for confidence
996        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// ---------------------------------------------------------------------------
1013// Tests
1014// ---------------------------------------------------------------------------
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019
1020    // -----------------------------------------------------------------------
1021    // Helper builders
1022    // -----------------------------------------------------------------------
1023
1024    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    // -----------------------------------------------------------------------
1041    // cosine_similarity
1042    // -----------------------------------------------------------------------
1043
1044    #[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        // Both empty → norm 0 → returns 0
1075        assert_eq!(cosine_similarity(&[], &[]), 0.0);
1076    }
1077
1078    // -----------------------------------------------------------------------
1079    // xorshift64
1080    // -----------------------------------------------------------------------
1081
1082    #[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    // -----------------------------------------------------------------------
1098    // SemanticClusterLabeler – construction
1099    // -----------------------------------------------------------------------
1100
1101    #[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    // -----------------------------------------------------------------------
1122    // add_cluster / remove_cluster
1123    // -----------------------------------------------------------------------
1124
1125    #[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        // Verifying indirectly: adding the same members to a new cluster works
1160        let id2 = l.add_cluster(vec![0.5], vec![10, 20]);
1161        assert!(l.get_cluster(id2).is_some());
1162    }
1163
1164    // -----------------------------------------------------------------------
1165    // merge_clusters
1166    // -----------------------------------------------------------------------
1167
1168    #[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]); // 2 members
1187        let b = l.add_cluster(vec![0.0, 1.0], vec![3, 4, 5, 6]); // 4 members
1188        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        // centroid[0] = (1.0*2 + 0.0*4)/6 = 0.333...
1194        assert!((c.centroid[0] - 1.0 / 3.0).abs() < 1e-9);
1195        // centroid[1] = (0.0*2 + 1.0*4)/6 = 0.666...
1196        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        // a = 9999 doesn't exist
1211        // The current implementation will return MergeTargetNotFound because b
1212        // is removed first, then a lookup fails.  Accept either error variant.
1213        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    // -----------------------------------------------------------------------
1228    // add_prototype / add_keyword_doc
1229    // -----------------------------------------------------------------------
1230
1231    #[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    // -----------------------------------------------------------------------
1254    // label_cluster – CentroidNearest
1255    // -----------------------------------------------------------------------
1256
1257    #[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    // -----------------------------------------------------------------------
1308    // label_cluster – NearestPrototype
1309    // -----------------------------------------------------------------------
1310
1311    #[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        // Both use the same underlying function
1319        assert_eq!(c1.label, "science");
1320    }
1321
1322    // -----------------------------------------------------------------------
1323    // label_cluster – TfIdfKeywords
1324    // -----------------------------------------------------------------------
1325
1326    #[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        // Label should be a non-empty string of keywords
1335        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); // doc for embedding 99
1352        let id = l.add_cluster(vec![1.0], vec![1, 2]); // members 1,2 have no docs
1353                                                       // Should fail because members have no documents
1354        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    // -----------------------------------------------------------------------
1372    // label_cluster – EmbeddingVoting
1373    // -----------------------------------------------------------------------
1374
1375    #[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    // -----------------------------------------------------------------------
1398    // label_cluster – HybridRanking
1399    // -----------------------------------------------------------------------
1400
1401    #[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    // -----------------------------------------------------------------------
1435    // label_cluster updates cluster state
1436    // -----------------------------------------------------------------------
1437
1438    #[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    // -----------------------------------------------------------------------
1476    // Vocabulary
1477    // -----------------------------------------------------------------------
1478
1479    #[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    // -----------------------------------------------------------------------
1518    // History
1519    // -----------------------------------------------------------------------
1520
1521    #[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        // Create 510 clusters and label each
1534        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        // Shift centroid toward sports, relabel
1548        l.get_cluster(id); // read-only check
1549        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    // -----------------------------------------------------------------------
1561    // label_all
1562    // -----------------------------------------------------------------------
1563
1564    #[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, // very high → most will be skipped
1578            ..Default::default()
1579        });
1580        l.add_prototype("test", vec![1.0, 0.0]);
1581        l.add_cluster(vec![0.5, 0.5], vec![1]); // moderate similarity
1582        let results = l.label_all(SclLabelingMethod::CentroidNearest);
1583        // Either 0 or 1 results depending on exact score, but should not panic
1584        assert!(results.len() <= 1);
1585    }
1586
1587    // -----------------------------------------------------------------------
1588    // relabel_if_drifted
1589    // -----------------------------------------------------------------------
1590
1591    #[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        // Shift centroid sharply
1608        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]); // not labeled
1621        let recount = l.relabel_if_drifted(0.0);
1622        assert_eq!(recount, 0);
1623    }
1624
1625    // -----------------------------------------------------------------------
1626    // cluster_summary
1627    // -----------------------------------------------------------------------
1628
1629    #[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    // -----------------------------------------------------------------------
1659    // labeler_stats
1660    // -----------------------------------------------------------------------
1661
1662    #[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    // -----------------------------------------------------------------------
1693    // update_centroid / add_members
1694    // -----------------------------------------------------------------------
1695
1696    #[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]); // 1 already present
1734        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    // -----------------------------------------------------------------------
1750    // SclError display
1751    // -----------------------------------------------------------------------
1752
1753    #[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    // -----------------------------------------------------------------------
1776    // SclLabelingMethod display
1777    // -----------------------------------------------------------------------
1778
1779    #[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    // -----------------------------------------------------------------------
1795    // SclLabelerConfig defaults
1796    // -----------------------------------------------------------------------
1797
1798    #[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    // -----------------------------------------------------------------------
1807    // set_config
1808    // -----------------------------------------------------------------------
1809
1810    #[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    // -----------------------------------------------------------------------
1822    // Minimum confidence threshold enforcement
1823    // -----------------------------------------------------------------------
1824
1825    #[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        // Prototype orthogonal to centroid → cosine ~ 0
1832        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    // -----------------------------------------------------------------------
1842    // Multi-cluster vocab management on remove
1843    // -----------------------------------------------------------------------
1844
1845    #[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    // -----------------------------------------------------------------------
1858    // Serialization round-trip (SclCluster)
1859    // -----------------------------------------------------------------------
1860
1861    #[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    // -----------------------------------------------------------------------
1881    // Keyword extraction quality
1882    // -----------------------------------------------------------------------
1883
1884    #[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        // At most top_k_words words in the label
1898        let word_count = c.label.split_whitespace().count();
1899        assert!(word_count <= 2);
1900    }
1901
1902    // -----------------------------------------------------------------------
1903    // History record ordering
1904    // -----------------------------------------------------------------------
1905
1906    #[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    // -----------------------------------------------------------------------
1921    // Type alias existence
1922    // -----------------------------------------------------------------------
1923
1924    #[test]
1925    fn test_type_alias_scl_semantic_cluster_labeler() {
1926        let _: SclSemanticClusterLabeler = SemanticClusterLabeler::with_defaults();
1927    }
1928
1929    // -----------------------------------------------------------------------
1930    // Cluster `created_at` is populated
1931    // -----------------------------------------------------------------------
1932
1933    #[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        // created_at should be a plausible Unix timestamp (> year 2000)
1941        assert!(c.created_at > 946_684_800);
1942    }
1943}