Skip to main content

ipfrs_semantic/
topic_model_extractor.rs

1//! Topic Model Extractor — production-quality collapsed Gibbs sampling LDA.
2//!
3//! Implements Latent Dirichlet Allocation (LDA) via collapsed Gibbs sampling for
4//! unsupervised topic discovery over text corpora.  All randomness is driven by
5//! an xorshift64 PRNG so the implementation is 100 % pure-Rust with no `rand`
6//! dependency.
7
8use std::collections::HashMap;
9
10// ---------------------------------------------------------------------------
11// PRNG — xorshift64
12// ---------------------------------------------------------------------------
13
14#[inline]
15fn xorshift64(state: &mut u64) -> u64 {
16    let mut x = *state;
17    x ^= x << 13;
18    x ^= x >> 7;
19    x ^= x << 17;
20    *state = x;
21    x
22}
23
24#[inline]
25fn xorshift_f64(state: &mut u64) -> f64 {
26    (xorshift64(state) >> 11) as f64 / (1u64 << 53) as f64
27}
28
29// ---------------------------------------------------------------------------
30// Error type
31// ---------------------------------------------------------------------------
32
33/// Errors that can be returned by [`TopicModelExtractor`].
34#[derive(Debug, Clone, PartialEq, thiserror::Error)]
35pub enum ExtractorError {
36    /// The corpus has fewer documents than can support topic modelling.
37    #[error("insufficient documents: got {0}, need at least 2")]
38    InsufficientDocuments(usize),
39    /// The vocabulary is empty after filtering.
40    #[error("vocabulary is empty after filtering")]
41    VocabularyEmpty,
42    /// A configuration parameter is invalid.
43    #[error("invalid configuration: {0}")]
44    InvalidConfiguration(String),
45    /// The model has not been fitted yet.
46    #[error("model has not been fitted; call fit() first")]
47    ModelNotFitted,
48    /// An unknown topic id was requested.
49    #[error("topic id {0} is out of range")]
50    TopicOutOfRange(usize),
51    /// An unknown document id was requested.
52    #[error("document id '{0}' not found")]
53    DocumentNotFound(String),
54}
55
56// ---------------------------------------------------------------------------
57// Public configuration
58// ---------------------------------------------------------------------------
59
60/// Configuration for [`TopicModelExtractor`].
61#[derive(Debug, Clone)]
62pub struct ExtractorConfig {
63    /// Number of topics to extract.
64    pub num_topics: usize,
65    /// Dirichlet prior on the document–topic distribution (α).
66    pub alpha: f64,
67    /// Dirichlet prior on the topic–word distribution (β).
68    pub beta: f64,
69    /// Number of Gibbs sampling iterations.
70    pub num_iterations: u32,
71    /// Maximum vocabulary size (most-frequent words are kept).
72    pub vocab_size_limit: usize,
73    /// Minimum per-word corpus frequency to retain a word.
74    pub min_word_freq: u32,
75    /// Exclude words that appear in more than this fraction of documents.
76    pub max_doc_freq_pct: f64,
77}
78
79impl Default for ExtractorConfig {
80    fn default() -> Self {
81        Self {
82            num_topics: 10,
83            alpha: 0.1,
84            beta: 0.01,
85            num_iterations: 1000,
86            vocab_size_limit: 50_000,
87            min_word_freq: 2,
88            max_doc_freq_pct: 0.95,
89        }
90    }
91}
92
93// ---------------------------------------------------------------------------
94// Core data structures
95// ---------------------------------------------------------------------------
96
97/// A word and its probability / raw count within a topic.
98#[derive(Debug, Clone, PartialEq)]
99pub struct ExtractorTopicWord {
100    /// Surface form of the word.
101    pub word: String,
102    /// Normalised probability p(word | topic).
103    pub probability: f64,
104    /// Raw count of assignments to this topic.
105    pub count: u32,
106}
107
108/// A single latent topic produced by the extractor.
109#[derive(Debug, Clone)]
110pub struct ExtractorTopic {
111    /// Zero-based topic identifier.
112    pub id: usize,
113    /// Top words ranked by probability (highest first).
114    pub top_words: Vec<ExtractorTopicWord>,
115    /// Mean pairwise PMI of the top-10 words — higher is more coherent.
116    pub coherence: f64,
117    /// Fraction of corpus tokens assigned to this topic.
118    pub prevalence: f64,
119    /// Optional human-readable label.
120    pub label: Option<String>,
121}
122
123/// Per-document topic distribution produced by the extractor.
124#[derive(Debug, Clone)]
125pub struct ExtractorDocumentTopics {
126    /// Document identifier supplied at fit time.
127    pub doc_id: String,
128    /// Normalised topic distribution θ_d (sums to 1.0).
129    pub topic_distribution: Vec<f64>,
130    /// Index of the most probable topic.
131    pub dominant_topic: usize,
132    /// Probability of the dominant topic.
133    pub dominant_probability: f64,
134}
135
136/// Aggregate model statistics.
137#[derive(Debug, Clone)]
138pub struct ModelStats {
139    /// Number of topics.
140    pub num_topics: usize,
141    /// Vocabulary size after filtering.
142    pub vocab_size: usize,
143    /// Number of documents in the fitted corpus.
144    pub num_docs: usize,
145    /// Total number of tokens in the corpus.
146    pub total_tokens: u64,
147    /// Mean topic coherence across all topics.
148    pub avg_topic_coherence: f64,
149    /// Model perplexity on the training corpus.
150    pub perplexity: f64,
151    /// Number of Gibbs iterations actually executed.
152    pub iterations_run: u32,
153}
154
155// ---------------------------------------------------------------------------
156// Internal types
157// ---------------------------------------------------------------------------
158
159/// Assignment of a single token to a topic during Gibbs sampling.
160#[derive(Debug, Clone, Copy)]
161struct WordAssignment {
162    word_idx: usize,
163    topic_id: usize,
164}
165
166// ---------------------------------------------------------------------------
167// TopicModelExtractor
168// ---------------------------------------------------------------------------
169
170/// Production-quality collapsed Gibbs sampling LDA topic extractor.
171///
172/// # Example
173/// ```rust
174/// use ipfrs_semantic::topic_model_extractor::{TopicModelExtractor, ExtractorConfig};
175///
176/// let config = ExtractorConfig { num_topics: 3, num_iterations: 100, ..Default::default() };
177/// let mut extractor = TopicModelExtractor::new(config);
178///
179/// let docs = vec![
180///     ("d1", "rust programming language systems"),
181///     ("d2", "python data science machine learning"),
182///     ("d3", "rust memory safety ownership"),
183///     ("d4", "python neural network deep learning"),
184///     ("d5", "rust async await future tokio"),
185///     ("d6", "machine learning gradient descent optimisation"),
186/// ];
187/// extractor.fit(&docs).unwrap();
188/// let topics = extractor.topics().unwrap();
189/// assert_eq!(topics.len(), 3);
190/// ```
191#[derive(Debug)]
192pub struct TopicModelExtractor {
193    config: ExtractorConfig,
194
195    // Vocabulary
196    vocab: HashMap<String, usize>, // word → index
197    vocab_rev: Vec<String>,        // index → word
198
199    // Corpus
200    doc_ids: Vec<String>,
201    /// Tokenised corpus: outer = documents, inner = tokens (as word indices).
202    corpus: Vec<Vec<usize>>,
203
204    // Gibbs state
205    /// word-topic assignments per document, mirroring `corpus` layout.
206    assignments: Vec<Vec<WordAssignment>>,
207    /// doc_topic_counts[doc_idx][topic_id]
208    doc_topic_counts: Vec<Vec<u32>>,
209    /// topic_word_counts[topic_id][word_idx]
210    topic_word_counts: Vec<Vec<u32>>,
211    /// topic_counts[topic_id] — total tokens assigned
212    topic_counts: Vec<u32>,
213
214    // Co-occurrence for coherence computation
215    /// word_doc_freq[word_idx] — number of docs containing this word
216    word_doc_freq: Vec<u32>,
217    /// co_occur[(min_w, max_w)] — number of docs where both words appear
218    co_occur: HashMap<(usize, usize), u32>,
219
220    // Topic labels
221    labels: Vec<Option<String>>,
222
223    // Model state
224    fitted: bool,
225    iterations_run: u32,
226
227    // PRNG state
228    rng_state: u64,
229}
230
231impl TopicModelExtractor {
232    /// Create a new extractor with the given configuration.
233    pub fn new(config: ExtractorConfig) -> Self {
234        Self {
235            rng_state: 0xDEAD_BEEF_CAFE_1337,
236            config,
237            vocab: HashMap::new(),
238            vocab_rev: Vec::new(),
239            doc_ids: Vec::new(),
240            corpus: Vec::new(),
241            assignments: Vec::new(),
242            doc_topic_counts: Vec::new(),
243            topic_word_counts: Vec::new(),
244            topic_counts: Vec::new(),
245            word_doc_freq: Vec::new(),
246            co_occur: HashMap::new(),
247            labels: Vec::new(),
248            fitted: false,
249            iterations_run: 0,
250        }
251    }
252
253    // -----------------------------------------------------------------------
254    // Public API
255    // -----------------------------------------------------------------------
256
257    /// Fit the model on a slice of `(doc_id, text)` pairs.
258    pub fn fit(&mut self, docs: &[(&str, &str)]) -> Result<(), ExtractorError> {
259        // --- Validate configuration -----------------------------------------
260        if self.config.num_topics == 0 {
261            return Err(ExtractorError::InvalidConfiguration(
262                "num_topics must be ≥ 1".into(),
263            ));
264        }
265        if self.config.alpha <= 0.0 {
266            return Err(ExtractorError::InvalidConfiguration(
267                "alpha must be > 0".into(),
268            ));
269        }
270        if self.config.beta <= 0.0 {
271            return Err(ExtractorError::InvalidConfiguration(
272                "beta must be > 0".into(),
273            ));
274        }
275        if docs.len() < 2 {
276            return Err(ExtractorError::InsufficientDocuments(docs.len()));
277        }
278
279        // --- Reset state ----------------------------------------------------
280        self.fitted = false;
281        self.vocab.clear();
282        self.vocab_rev.clear();
283        self.doc_ids.clear();
284        self.corpus.clear();
285        self.assignments.clear();
286        self.co_occur.clear();
287
288        let k = self.config.num_topics;
289
290        // --- Step 1: raw tokenisation ---------------------------------------
291        let raw_tokens: Vec<Vec<String>> = docs
292            .iter()
293            .map(|(_, text)| {
294                text.split_whitespace()
295                    .map(|w| {
296                        w.to_lowercase()
297                            .trim_matches(|c: char| !c.is_alphanumeric())
298                            .to_string()
299                    })
300                    .filter(|w| !w.is_empty())
301                    .collect()
302            })
303            .collect();
304
305        // --- Step 2: global word frequencies --------------------------------
306        let mut global_freq: HashMap<String, u32> = HashMap::new();
307        let mut doc_appears: HashMap<String, u32> = HashMap::new();
308        let n_docs = docs.len() as f64;
309
310        for tokens in &raw_tokens {
311            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
312            for w in tokens {
313                *global_freq.entry(w.clone()).or_insert(0) += 1;
314                if seen.insert(w.as_str()) {
315                    *doc_appears.entry(w.clone()).or_insert(0) += 1;
316                }
317            }
318        }
319
320        // --- Step 3: build vocabulary with frequency filters ----------------
321        let max_doc_count = (self.config.max_doc_freq_pct * n_docs).ceil() as u32;
322        let mut word_freq_list: Vec<(String, u32)> = global_freq
323            .into_iter()
324            .filter(|(w, freq)| {
325                *freq >= self.config.min_word_freq
326                    && doc_appears.get(w).copied().unwrap_or(0) <= max_doc_count
327            })
328            .collect();
329
330        // Sort descending by frequency, then limit to vocab_size_limit
331        word_freq_list.sort_unstable_by_key(|a| std::cmp::Reverse(a.1));
332        word_freq_list.truncate(self.config.vocab_size_limit);
333
334        if word_freq_list.is_empty() {
335            return Err(ExtractorError::VocabularyEmpty);
336        }
337
338        for (idx, (word, _)) in word_freq_list.iter().enumerate() {
339            self.vocab.insert(word.clone(), idx);
340            self.vocab_rev.push(word.clone());
341        }
342        let v = self.vocab_rev.len();
343
344        // --- Step 4: encode corpus ------------------------------------------
345        for ((doc_id, _), tokens) in docs.iter().zip(raw_tokens.iter()) {
346            let encoded: Vec<usize> = tokens
347                .iter()
348                .filter_map(|w| self.vocab.get(w).copied())
349                .collect();
350            self.doc_ids.push(doc_id.to_string());
351            self.corpus.push(encoded);
352        }
353
354        // --- Step 5: co-occurrence counts for coherence ---------------------
355        self.word_doc_freq = vec![0u32; v];
356        for tokens in &self.corpus {
357            let unique: std::collections::HashSet<usize> = tokens.iter().copied().collect();
358            let mut sorted: Vec<usize> = unique.into_iter().collect();
359            sorted.sort_unstable();
360            for &wi in &sorted {
361                self.word_doc_freq[wi] += 1;
362            }
363            for i in 0..sorted.len() {
364                for j in (i + 1)..sorted.len() {
365                    let key = (sorted[i], sorted[j]);
366                    *self.co_occur.entry(key).or_insert(0) += 1;
367                }
368            }
369        }
370
371        // --- Step 6: initialise count matrices ------------------------------
372        let n_docs_usize = self.corpus.len();
373        self.doc_topic_counts = vec![vec![0u32; k]; n_docs_usize];
374        self.topic_word_counts = vec![vec![0u32; v]; k];
375        self.topic_counts = vec![0u32; k];
376        self.assignments = Vec::with_capacity(n_docs_usize);
377
378        // --- Step 7: random initial assignment ------------------------------
379        for (d, tokens) in self.corpus.iter().enumerate() {
380            let mut doc_assignments: Vec<WordAssignment> = Vec::with_capacity(tokens.len());
381            for &wi in tokens {
382                let t = (xorshift64(&mut self.rng_state) as usize) % k;
383                self.doc_topic_counts[d][t] += 1;
384                self.topic_word_counts[t][wi] += 1;
385                self.topic_counts[t] += 1;
386                doc_assignments.push(WordAssignment {
387                    word_idx: wi,
388                    topic_id: t,
389                });
390            }
391            self.assignments.push(doc_assignments);
392        }
393
394        // --- Step 8: collapsed Gibbs sampling -------------------------------
395        let alpha = self.config.alpha;
396        let beta = self.config.beta;
397        let v_f64 = v as f64;
398        let mut probs = vec![0.0f64; k];
399
400        for _iter in 0..self.config.num_iterations {
401            for d in 0..n_docs_usize {
402                let n_tokens = self.assignments[d].len();
403                for n in 0..n_tokens {
404                    let wa = self.assignments[d][n];
405                    let old_t = wa.topic_id;
406                    let wi = wa.word_idx;
407
408                    // Remove current assignment
409                    self.doc_topic_counts[d][old_t] -= 1;
410                    self.topic_word_counts[old_t][wi] -= 1;
411                    self.topic_counts[old_t] -= 1;
412
413                    // Compute unnormalised conditional distribution
414                    let mut cumsum = 0.0f64;
415                    for (t, prob_slot) in probs[..k].iter_mut().enumerate() {
416                        let n_dk = self.doc_topic_counts[d][t] as f64;
417                        let n_kw = self.topic_word_counts[t][wi] as f64;
418                        let n_k = self.topic_counts[t] as f64;
419                        let p = (n_dk + alpha) * (n_kw + beta) / (n_k + v_f64 * beta);
420                        cumsum += p;
421                        *prob_slot = cumsum;
422                    }
423
424                    // Sample new topic
425                    let u = xorshift_f64(&mut self.rng_state) * cumsum;
426                    let new_t = probs[..k].partition_point(|&p| p < u).min(k - 1);
427
428                    // Restore with new assignment
429                    self.doc_topic_counts[d][new_t] += 1;
430                    self.topic_word_counts[new_t][wi] += 1;
431                    self.topic_counts[new_t] += 1;
432                    self.assignments[d][n] = WordAssignment {
433                        word_idx: wi,
434                        topic_id: new_t,
435                    };
436                }
437            }
438        }
439
440        self.labels = vec![None; k];
441        self.fitted = true;
442        self.iterations_run = self.config.num_iterations;
443        Ok(())
444    }
445
446    // -----------------------------------------------------------------------
447    // Accessors
448    // -----------------------------------------------------------------------
449
450    /// Return all topics sorted by prevalence descending.
451    pub fn topics(&self) -> Result<Vec<ExtractorTopic>, ExtractorError> {
452        self.require_fitted()?;
453        let k = self.config.num_topics;
454        let v = self.vocab_rev.len();
455        let total_tokens: u64 = self.topic_counts.iter().map(|&c| c as u64).sum();
456        let v_f64 = v as f64;
457        let beta = self.config.beta;
458        let n_docs = self.corpus.len() as f64;
459
460        let mut topics: Vec<ExtractorTopic> = (0..k)
461            .map(|t| {
462                // top-words
463                let denom = self.topic_counts[t] as f64 + v_f64 * beta;
464                let mut words: Vec<ExtractorTopicWord> = (0..v)
465                    .map(|wi| {
466                        let cnt = self.topic_word_counts[t][wi];
467                        ExtractorTopicWord {
468                            word: self.vocab_rev[wi].clone(),
469                            probability: (cnt as f64 + beta) / denom,
470                            count: cnt,
471                        }
472                    })
473                    .collect();
474                words.sort_unstable_by(|a, b| {
475                    b.probability
476                        .partial_cmp(&a.probability)
477                        .unwrap_or(std::cmp::Ordering::Equal)
478                });
479
480                let prevalence = if total_tokens == 0 {
481                    0.0
482                } else {
483                    self.topic_counts[t] as f64 / total_tokens as f64
484                };
485
486                // coherence — mean pairwise PMI of top-10 words
487                let top10: Vec<usize> = words
488                    .iter()
489                    .take(10)
490                    .filter_map(|tw| self.vocab.get(&tw.word).copied())
491                    .collect();
492                let coherence = self.mean_pmi(&top10, n_docs);
493
494                let top_words: Vec<ExtractorTopicWord> = words.into_iter().take(50).collect();
495                ExtractorTopic {
496                    id: t,
497                    top_words,
498                    coherence,
499                    prevalence,
500                    label: self.labels[t].clone(),
501                }
502            })
503            .collect();
504
505        topics.sort_unstable_by(|a, b| {
506            b.prevalence
507                .partial_cmp(&a.prevalence)
508                .unwrap_or(std::cmp::Ordering::Equal)
509        });
510        Ok(topics)
511    }
512
513    /// Return the topic distribution for a document that was in the training corpus.
514    pub fn document_topics(&self, doc_id: &str) -> Result<ExtractorDocumentTopics, ExtractorError> {
515        self.require_fitted()?;
516        let d = self
517            .doc_ids
518            .iter()
519            .position(|id| id == doc_id)
520            .ok_or_else(|| ExtractorError::DocumentNotFound(doc_id.to_string()))?;
521        Ok(self.build_doc_topics(d, doc_id))
522    }
523
524    /// Infer topics for a new (unseen) document using 5 Gibbs passes.
525    pub fn infer_topics(&self, text: &str) -> Result<ExtractorDocumentTopics, ExtractorError> {
526        self.require_fitted()?;
527        let k = self.config.num_topics;
528        let v = self.vocab_rev.len();
529        let alpha = self.config.alpha;
530        let beta = self.config.beta;
531        let v_f64 = v as f64;
532
533        let tokens: Vec<usize> = text
534            .split_whitespace()
535            .map(|w| {
536                w.to_lowercase()
537                    .trim_matches(|c: char| !c.is_alphanumeric())
538                    .to_string()
539            })
540            .filter_map(|w| self.vocab.get(&w).copied())
541            .collect();
542
543        if tokens.is_empty() {
544            // Return uniform distribution
545            let prob = 1.0 / k as f64;
546            return Ok(ExtractorDocumentTopics {
547                doc_id: "<new>".to_string(),
548                topic_distribution: vec![prob; k],
549                dominant_topic: 0,
550                dominant_probability: prob,
551            });
552        }
553
554        // Local counts for this new document
555        let mut local_doc_topic = vec![0u32; k];
556        let mut local_assignments: Vec<usize> = Vec::with_capacity(tokens.len());
557
558        // Use a local mutable RNG seed derived from token hash
559        let mut rng = 0xFEED_C0DE_1234_5678u64;
560        for _ in &tokens {
561            xorshift64(&mut rng);
562        }
563
564        // Initial assignment
565        for _ in &tokens {
566            let t = (xorshift64(&mut rng) as usize) % k;
567            local_doc_topic[t] += 1;
568            local_assignments.push(t);
569        }
570
571        // 5 Gibbs passes
572        let mut probs = vec![0.0f64; k];
573        for _ in 0..5 {
574            for (n, &wi) in tokens.iter().enumerate() {
575                let old_t = local_assignments[n];
576                local_doc_topic[old_t] -= 1;
577
578                let mut cumsum = 0.0f64;
579                for t in 0..k {
580                    let n_dk = local_doc_topic[t] as f64;
581                    let n_kw = self.topic_word_counts[t][wi] as f64;
582                    let n_k = self.topic_counts[t] as f64;
583                    let p = (n_dk + alpha) * (n_kw + beta) / (n_k + v_f64 * beta);
584                    cumsum += p;
585                    probs[t] = cumsum;
586                }
587
588                let u = xorshift_f64(&mut rng) * cumsum;
589                let new_t = probs[..k].partition_point(|&p| p < u).min(k - 1);
590                local_doc_topic[new_t] += 1;
591                local_assignments[n] = new_t;
592            }
593        }
594
595        // Normalise
596        let total: f64 = local_doc_topic.iter().map(|&c| c as f64 + alpha).sum();
597        let dist: Vec<f64> = local_doc_topic
598            .iter()
599            .map(|&c| (c as f64 + alpha) / total)
600            .collect();
601
602        let (dominant_topic, dominant_probability) =
603            dist.iter()
604                .copied()
605                .enumerate()
606                .fold(
607                    (0usize, 0.0f64),
608                    |(bi, bp), (i, p)| {
609                        if p > bp {
610                            (i, p)
611                        } else {
612                            (bi, bp)
613                        }
614                    },
615                );
616
617        Ok(ExtractorDocumentTopics {
618            doc_id: "<new>".to_string(),
619            topic_distribution: dist,
620            dominant_topic,
621            dominant_probability,
622        })
623    }
624
625    /// Return the top-n words for a given topic.
626    pub fn top_words(
627        &self,
628        topic_id: usize,
629        n: usize,
630    ) -> Result<Vec<ExtractorTopicWord>, ExtractorError> {
631        self.require_fitted()?;
632        let k = self.config.num_topics;
633        if topic_id >= k {
634            return Err(ExtractorError::TopicOutOfRange(topic_id));
635        }
636        let v = self.vocab_rev.len();
637        let v_f64 = v as f64;
638        let beta = self.config.beta;
639        let denom = self.topic_counts[topic_id] as f64 + v_f64 * beta;
640        let mut words: Vec<ExtractorTopicWord> = (0..v)
641            .map(|wi| {
642                let cnt = self.topic_word_counts[topic_id][wi];
643                ExtractorTopicWord {
644                    word: self.vocab_rev[wi].clone(),
645                    probability: (cnt as f64 + beta) / denom,
646                    count: cnt,
647                }
648            })
649            .collect();
650        words.sort_unstable_by(|a, b| {
651            b.probability
652                .partial_cmp(&a.probability)
653                .unwrap_or(std::cmp::Ordering::Equal)
654        });
655        words.truncate(n);
656        Ok(words)
657    }
658
659    /// Return topics most similar to `topic_id` ranked by cosine similarity.
660    pub fn similar_topics(
661        &self,
662        topic_id: usize,
663        top_k: usize,
664    ) -> Result<Vec<(usize, f64)>, ExtractorError> {
665        self.require_fitted()?;
666        let k = self.config.num_topics;
667        if topic_id >= k {
668            return Err(ExtractorError::TopicOutOfRange(topic_id));
669        }
670        let v = self.vocab_rev.len();
671        // Build normalised word distributions for all topics
672        let distributions: Vec<Vec<f64>> = (0..k)
673            .map(|t| {
674                let total: f64 = self.topic_counts[t] as f64 + v as f64 * self.config.beta;
675                (0..v)
676                    .map(|wi| (self.topic_word_counts[t][wi] as f64 + self.config.beta) / total)
677                    .collect()
678            })
679            .collect();
680
681        let ref_dist = &distributions[topic_id];
682        let mut sims: Vec<(usize, f64)> = (0..k)
683            .filter(|&t| t != topic_id)
684            .map(|t| {
685                let sim = cosine_sim_f64(ref_dist, &distributions[t]);
686                (t, sim)
687            })
688            .collect();
689        sims.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
690        sims.truncate(top_k);
691        Ok(sims)
692    }
693
694    /// Assign a human-readable label to a topic.
695    pub fn assign_label(&mut self, topic_id: usize, label: String) -> Result<(), ExtractorError> {
696        self.require_fitted()?;
697        if topic_id >= self.config.num_topics {
698            return Err(ExtractorError::TopicOutOfRange(topic_id));
699        }
700        self.labels[topic_id] = Some(label);
701        Ok(())
702    }
703
704    /// Return aggregate model statistics including perplexity.
705    pub fn stats(&self) -> Result<ModelStats, ExtractorError> {
706        self.require_fitted()?;
707        let k = self.config.num_topics;
708        let v = self.vocab_rev.len();
709        let n_docs = self.corpus.len();
710        let total_tokens: u64 = self.topic_counts.iter().map(|&c| c as u64).sum();
711        let v_f64 = v as f64;
712        let beta = self.config.beta;
713        let alpha = self.config.alpha;
714        let _n_docs_f = n_docs as f64;
715
716        // Log-likelihood
717        let mut log_lik = 0.0f64;
718        for d in 0..n_docs {
719            let doc_total: f64 = self.doc_topic_counts[d]
720                .iter()
721                .map(|&c| c as f64)
722                .sum::<f64>()
723                + k as f64 * alpha;
724            for t in 0..k {
725                let n_dt = self.doc_topic_counts[d][t] as f64 + alpha;
726                let p_t = n_dt / doc_total;
727                let denom = self.topic_counts[t] as f64 + v_f64 * beta;
728                for wi in 0..v {
729                    let n_tw = self.topic_word_counts[t][wi] as f64 + beta;
730                    let p_w_t = n_tw / denom;
731                    // Accumulate weighted
732                    log_lik += self.topic_word_counts[t][wi] as f64 * (p_t * p_w_t).ln();
733                }
734            }
735        }
736
737        let perplexity = if total_tokens == 0 {
738            f64::INFINITY
739        } else {
740            (-log_lik / total_tokens as f64).exp()
741        };
742
743        // Average coherence
744        let coherence_sum: f64 = (0..k)
745            .map(|t| {
746                let denom = self.topic_counts[t] as f64 + v_f64 * beta;
747                let mut words: Vec<(usize, f64)> = (0..v)
748                    .map(|wi| {
749                        let p = (self.topic_word_counts[t][wi] as f64 + beta) / denom;
750                        (wi, p)
751                    })
752                    .collect();
753                words.sort_unstable_by(|a, b| {
754                    b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
755                });
756                let top10: Vec<usize> = words.iter().take(10).map(|(wi, _)| *wi).collect();
757                self.mean_pmi(&top10, n_docs as f64)
758            })
759            .sum();
760        let avg_topic_coherence = coherence_sum / k as f64;
761
762        Ok(ModelStats {
763            num_topics: k,
764            vocab_size: v,
765            num_docs: n_docs,
766            total_tokens,
767            avg_topic_coherence,
768            perplexity,
769            iterations_run: self.iterations_run,
770        })
771    }
772
773    // -----------------------------------------------------------------------
774    // Private helpers
775    // -----------------------------------------------------------------------
776
777    fn require_fitted(&self) -> Result<(), ExtractorError> {
778        if self.fitted {
779            Ok(())
780        } else {
781            Err(ExtractorError::ModelNotFitted)
782        }
783    }
784
785    fn build_doc_topics(&self, d: usize, doc_id: &str) -> ExtractorDocumentTopics {
786        let alpha = self.config.alpha;
787        let total: f64 = self.doc_topic_counts[d]
788            .iter()
789            .map(|&c| c as f64 + alpha)
790            .sum();
791        let dist: Vec<f64> = self.doc_topic_counts[d]
792            .iter()
793            .map(|&c| (c as f64 + alpha) / total)
794            .collect();
795
796        let (dominant_topic, dominant_probability) =
797            dist.iter()
798                .copied()
799                .enumerate()
800                .fold(
801                    (0usize, 0.0f64),
802                    |(bi, bp), (i, p)| {
803                        if p > bp {
804                            (i, p)
805                        } else {
806                            (bi, bp)
807                        }
808                    },
809                );
810        ExtractorDocumentTopics {
811            doc_id: doc_id.to_string(),
812            topic_distribution: dist,
813            dominant_topic,
814            dominant_probability,
815        }
816    }
817
818    /// Compute mean pairwise PMI for a set of word indices.
819    fn mean_pmi(&self, word_indices: &[usize], n_docs: f64) -> f64 {
820        if word_indices.len() < 2 || n_docs == 0.0 {
821            return 0.0;
822        }
823        let mut sum = 0.0f64;
824        let mut count = 0u32;
825        let log_n = n_docs.ln();
826
827        let mut sorted = word_indices.to_vec();
828        sorted.sort_unstable();
829        sorted.dedup();
830
831        for i in 0..sorted.len() {
832            for j in (i + 1)..sorted.len() {
833                let wi = sorted[i];
834                let wj = sorted[j];
835                let df_i = self.word_doc_freq.get(wi).copied().unwrap_or(0) as f64;
836                let df_j = self.word_doc_freq.get(wj).copied().unwrap_or(0) as f64;
837                if df_i == 0.0 || df_j == 0.0 {
838                    count += 1;
839                    continue;
840                }
841                let cooc = self
842                    .co_occur
843                    .get(&(wi.min(wj), wi.max(wj)))
844                    .copied()
845                    .unwrap_or(0) as f64;
846                if cooc == 0.0 {
847                    // PMI is -∞; use a floor value
848                    sum -= 20.0;
849                } else {
850                    let pmi = (cooc * n_docs).ln() - df_i.ln() - df_j.ln() + log_n;
851                    sum += pmi;
852                }
853                count += 1;
854            }
855        }
856        if count == 0 {
857            0.0
858        } else {
859            sum / count as f64
860        }
861    }
862}
863
864// ---------------------------------------------------------------------------
865// f64 cosine similarity
866// ---------------------------------------------------------------------------
867
868fn cosine_sim_f64(a: &[f64], b: &[f64]) -> f64 {
869    if a.len() != b.len() || a.is_empty() {
870        return 0.0;
871    }
872    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
873    let na: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
874    let nb: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
875    if na < 1e-15 || nb < 1e-15 {
876        return 0.0;
877    }
878    (dot / (na * nb)).clamp(-1.0, 1.0)
879}
880
881// ---------------------------------------------------------------------------
882// Type aliases for name-collision avoidance
883// ---------------------------------------------------------------------------
884
885/// Type alias for [`ExtractorTopicWord`] — avoids collision with `TopicWord` from `topic_modeler`.
886pub type TmeTopicWord = ExtractorTopicWord;
887/// Type alias for [`ExtractorDocumentTopics`] — avoids collision with `DocumentTopics` from `topic_modeler`.
888pub type TmeDocumentTopics = ExtractorDocumentTopics;
889/// Type alias for [`ExtractorTopic`] — convenience alias.
890pub type TmeTopic = ExtractorTopic;
891/// Type alias for [`ExtractorError`] — convenience alias.
892pub type TmeError = ExtractorError;
893
894// ---------------------------------------------------------------------------
895// Tests
896// ---------------------------------------------------------------------------
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    // -----------------------------------------------------------------------
903    // Helper: small corpus
904    // -----------------------------------------------------------------------
905
906    fn small_corpus() -> Vec<(&'static str, &'static str)> {
907        vec![
908            ("d01", "rust programming language systems memory safety"),
909            ("d02", "python data science machine learning neural network"),
910            ("d03", "rust memory safety ownership borrow checker"),
911            ("d04", "python neural network deep learning tensorflow"),
912            ("d05", "rust async await future tokio runtime"),
913            ("d06", "machine learning gradient descent optimisation loss"),
914            ("d07", "rust cargo crates ecosystem package manager"),
915            ("d08", "data science statistics probability distribution"),
916            ("d09", "rust trait object polymorphism generic lifetime"),
917            (
918                "d10",
919                "deep learning convolutional network image recognition",
920            ),
921            ("d11", "distributed systems consensus raft paxos protocol"),
922            (
923                "d12",
924                "natural language processing text classification token",
925            ),
926            ("d13", "graph database query language traversal algorithm"),
927            (
928                "d14",
929                "cloud computing container orchestration kubernetes pod",
930            ),
931            (
932                "d15",
933                "blockchain decentralised ledger consensus hash cryptography",
934            ),
935        ]
936    }
937
938    fn make_fitted() -> TopicModelExtractor {
939        let cfg = ExtractorConfig {
940            num_topics: 3,
941            num_iterations: 50,
942            min_word_freq: 1,
943            ..Default::default()
944        };
945        let mut e = TopicModelExtractor::new(cfg);
946        e.fit(&small_corpus()).expect("fit failed");
947        e
948    }
949
950    // -----------------------------------------------------------------------
951    // 1. fit succeeds on small corpus
952    // -----------------------------------------------------------------------
953    #[test]
954    fn test_fit_succeeds() {
955        let mut e = TopicModelExtractor::new(ExtractorConfig {
956            num_topics: 3,
957            num_iterations: 20,
958            min_word_freq: 1,
959            ..Default::default()
960        });
961        e.fit(&small_corpus()).expect("fit should succeed");
962        assert!(e.fitted);
963    }
964
965    // -----------------------------------------------------------------------
966    // 2. fit fails on empty input
967    // -----------------------------------------------------------------------
968    #[test]
969    fn test_fit_insufficient_documents() {
970        let mut e = TopicModelExtractor::new(ExtractorConfig::default());
971        let result = e.fit(&[("d1", "hello world")]);
972        assert!(matches!(
973            result,
974            Err(ExtractorError::InsufficientDocuments(1))
975        ));
976    }
977
978    // -----------------------------------------------------------------------
979    // 3. fit fails on empty docs slice
980    // -----------------------------------------------------------------------
981    #[test]
982    fn test_fit_zero_documents() {
983        let mut e = TopicModelExtractor::new(ExtractorConfig::default());
984        let result = e.fit(&[]);
985        assert!(matches!(
986            result,
987            Err(ExtractorError::InsufficientDocuments(0))
988        ));
989    }
990
991    // -----------------------------------------------------------------------
992    // 4. empty vocabulary after filtering
993    // -----------------------------------------------------------------------
994    #[test]
995    fn test_empty_vocabulary() {
996        let cfg = ExtractorConfig {
997            num_topics: 2,
998            min_word_freq: 9999, // nothing passes
999            ..Default::default()
1000        };
1001        let mut e = TopicModelExtractor::new(cfg);
1002        let docs = vec![("d1", "hello"), ("d2", "world")];
1003        let result = e.fit(&docs);
1004        assert!(matches!(result, Err(ExtractorError::VocabularyEmpty)));
1005    }
1006
1007    // -----------------------------------------------------------------------
1008    // 5. topics count matches config
1009    // -----------------------------------------------------------------------
1010    #[test]
1011    fn test_topic_count() {
1012        let e = make_fitted();
1013        let topics = e.topics().expect("topics should work");
1014        assert_eq!(topics.len(), 3);
1015    }
1016
1017    // -----------------------------------------------------------------------
1018    // 6. topic prevalences sum to ≈1
1019    // -----------------------------------------------------------------------
1020    #[test]
1021    fn test_prevalences_sum_to_one() {
1022        let e = make_fitted();
1023        let topics = e.topics().expect("test: topics() should succeed after fit");
1024        let sum: f64 = topics.iter().map(|t| t.prevalence).sum();
1025        assert!((sum - 1.0).abs() < 1e-9, "sum={}", sum);
1026    }
1027
1028    // -----------------------------------------------------------------------
1029    // 7. topics are sorted by prevalence descending
1030    // -----------------------------------------------------------------------
1031    #[test]
1032    fn test_topics_sorted_descending() {
1033        let e = make_fitted();
1034        let topics = e.topics().expect("test: topics() should succeed after fit");
1035        for w in topics.windows(2) {
1036            assert!(w[0].prevalence >= w[1].prevalence);
1037        }
1038    }
1039
1040    // -----------------------------------------------------------------------
1041    // 8. document_topics returns distribution that sums to 1
1042    // -----------------------------------------------------------------------
1043    #[test]
1044    fn test_doc_distribution_sums_to_one() {
1045        let e = make_fitted();
1046        let dt = e
1047            .document_topics("d01")
1048            .expect("test: document_topics should find d01");
1049        let sum: f64 = dt.topic_distribution.iter().sum();
1050        assert!((sum - 1.0).abs() < 1e-9, "sum={}", sum);
1051    }
1052
1053    // -----------------------------------------------------------------------
1054    // 9. document_topics returns distribution of correct length
1055    // -----------------------------------------------------------------------
1056    #[test]
1057    fn test_doc_distribution_length() {
1058        let e = make_fitted();
1059        let dt = e
1060            .document_topics("d01")
1061            .expect("test: document_topics should find d01");
1062        assert_eq!(dt.topic_distribution.len(), 3);
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // 10. document_topics dominant_probability is the max
1067    // -----------------------------------------------------------------------
1068    #[test]
1069    fn test_doc_dominant_probability_is_max() {
1070        let e = make_fitted();
1071        let dt = e
1072            .document_topics("d01")
1073            .expect("test: document_topics should find d01");
1074        let max = dt
1075            .topic_distribution
1076            .iter()
1077            .cloned()
1078            .fold(f64::NEG_INFINITY, f64::max);
1079        assert!((dt.dominant_probability - max).abs() < 1e-12);
1080    }
1081
1082    // -----------------------------------------------------------------------
1083    // 11. document_topics dominant_topic index is correct
1084    // -----------------------------------------------------------------------
1085    #[test]
1086    fn test_doc_dominant_topic_index() {
1087        let e = make_fitted();
1088        let dt = e
1089            .document_topics("d01")
1090            .expect("test: document_topics should find d01");
1091        assert_eq!(
1092            dt.topic_distribution[dt.dominant_topic],
1093            dt.dominant_probability
1094        );
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // 12. document_topics unknown id returns error
1099    // -----------------------------------------------------------------------
1100    #[test]
1101    fn test_doc_topics_unknown_id() {
1102        let e = make_fitted();
1103        let result = e.document_topics("nonexistent_doc");
1104        assert!(matches!(result, Err(ExtractorError::DocumentNotFound(_))));
1105    }
1106
1107    // -----------------------------------------------------------------------
1108    // 13. infer_topics on new text sums to 1
1109    // -----------------------------------------------------------------------
1110    #[test]
1111    fn test_infer_topics_sums_to_one() {
1112        let e = make_fitted();
1113        let dt = e
1114            .infer_topics("rust programming memory safety")
1115            .expect("test: infer_topics should succeed");
1116        let sum: f64 = dt.topic_distribution.iter().sum();
1117        assert!((sum - 1.0).abs() < 1e-9, "sum={}", sum);
1118    }
1119
1120    // -----------------------------------------------------------------------
1121    // 14. infer_topics distribution has correct length
1122    // -----------------------------------------------------------------------
1123    #[test]
1124    fn test_infer_topics_length() {
1125        let e = make_fitted();
1126        let dt = e
1127            .infer_topics("rust memory safety")
1128            .expect("test: infer_topics should succeed");
1129        assert_eq!(dt.topic_distribution.len(), 3);
1130    }
1131
1132    // -----------------------------------------------------------------------
1133    // 15. infer_topics on empty text returns uniform
1134    // -----------------------------------------------------------------------
1135    #[test]
1136    fn test_infer_empty_text() {
1137        let e = make_fitted();
1138        let dt = e
1139            .infer_topics("")
1140            .expect("test: infer_topics on empty text should return uniform distribution");
1141        let sum: f64 = dt.topic_distribution.iter().sum();
1142        assert!((sum - 1.0).abs() < 1e-9, "sum={}", sum);
1143    }
1144
1145    // -----------------------------------------------------------------------
1146    // 16. infer_topics returns correct doc_id marker
1147    // -----------------------------------------------------------------------
1148    #[test]
1149    fn test_infer_doc_id_marker() {
1150        let e = make_fitted();
1151        let dt = e
1152            .infer_topics("some text")
1153            .expect("test: infer_topics should succeed on known-vocabulary text");
1154        assert_eq!(dt.doc_id, "<new>");
1155    }
1156
1157    // -----------------------------------------------------------------------
1158    // 17. all probabilities in distribution are non-negative
1159    // -----------------------------------------------------------------------
1160    #[test]
1161    fn test_dist_non_negative() {
1162        let e = make_fitted();
1163        let dt = e
1164            .infer_topics("rust async runtime")
1165            .expect("test: infer_topics should succeed");
1166        for &p in &dt.topic_distribution {
1167            assert!(p >= 0.0, "negative probability: {}", p);
1168        }
1169    }
1170
1171    // -----------------------------------------------------------------------
1172    // 18. similar_topics returns top_k results
1173    // -----------------------------------------------------------------------
1174    #[test]
1175    fn test_similar_topics_count() {
1176        let e = make_fitted();
1177        let sims = e
1178            .similar_topics(0, 2)
1179            .expect("test: similar_topics should succeed for valid topic id");
1180        assert!(sims.len() <= 2);
1181    }
1182
1183    // -----------------------------------------------------------------------
1184    // 19. similar_topics excludes the query topic
1185    // -----------------------------------------------------------------------
1186    #[test]
1187    fn test_similar_topics_no_self() {
1188        let e = make_fitted();
1189        let sims = e
1190            .similar_topics(0, 10)
1191            .expect("test: similar_topics should succeed for valid topic id");
1192        for (tid, _) in &sims {
1193            assert_ne!(*tid, 0);
1194        }
1195    }
1196
1197    // -----------------------------------------------------------------------
1198    // 20. similar_topics sorted descending
1199    // -----------------------------------------------------------------------
1200    #[test]
1201    fn test_similar_topics_sorted() {
1202        let e = make_fitted();
1203        let sims = e
1204            .similar_topics(0, 10)
1205            .expect("test: similar_topics should succeed for valid topic id");
1206        for w in sims.windows(2) {
1207            assert!(w[0].1 >= w[1].1);
1208        }
1209    }
1210
1211    // -----------------------------------------------------------------------
1212    // 21. similar_topics scores in [-1, 1]
1213    // -----------------------------------------------------------------------
1214    #[test]
1215    fn test_similar_topics_scores_range() {
1216        let e = make_fitted();
1217        let sims = e
1218            .similar_topics(0, 10)
1219            .expect("test: similar_topics should succeed for valid topic id");
1220        for (_, sim) in &sims {
1221            assert!(*sim >= -1.0 && *sim <= 1.0, "sim={}", sim);
1222        }
1223    }
1224
1225    // -----------------------------------------------------------------------
1226    // 22. similar_topics out of range returns error
1227    // -----------------------------------------------------------------------
1228    #[test]
1229    fn test_similar_topics_out_of_range() {
1230        let e = make_fitted();
1231        let result = e.similar_topics(99, 2);
1232        assert!(matches!(result, Err(ExtractorError::TopicOutOfRange(99))));
1233    }
1234
1235    // -----------------------------------------------------------------------
1236    // 23. assign_label sets label
1237    // -----------------------------------------------------------------------
1238    #[test]
1239    fn test_assign_label() {
1240        let mut e = make_fitted();
1241        e.assign_label(0, "tech-rust".to_string())
1242            .expect("test: assign_label should succeed for valid topic id");
1243        let topics = e.topics().expect("test: topics() should succeed after fit");
1244        // find the original topic 0
1245        let labelled = topics
1246            .iter()
1247            .find(|t| t.label.as_deref() == Some("tech-rust"));
1248        assert!(labelled.is_some());
1249    }
1250
1251    // -----------------------------------------------------------------------
1252    // 24. assign_label out of range returns error
1253    // -----------------------------------------------------------------------
1254    #[test]
1255    fn test_assign_label_out_of_range() {
1256        let mut e = make_fitted();
1257        let result = e.assign_label(99, "label".to_string());
1258        assert!(matches!(result, Err(ExtractorError::TopicOutOfRange(99))));
1259    }
1260
1261    // -----------------------------------------------------------------------
1262    // 25. label is preserved in topics() output
1263    // -----------------------------------------------------------------------
1264    #[test]
1265    fn test_label_persists_in_topics() {
1266        let mut e = make_fitted();
1267        e.assign_label(1, "ml-python".to_string())
1268            .expect("test: assign_label should succeed for valid topic id");
1269        let topics = e.topics().expect("test: topics() should succeed after fit");
1270        let with_label: Vec<_> = topics.iter().filter(|t| t.label.is_some()).collect();
1271        assert_eq!(with_label.len(), 1);
1272        assert_eq!(with_label[0].label.as_deref(), Some("ml-python"));
1273    }
1274
1275    // -----------------------------------------------------------------------
1276    // 26. stats() returns correct topic count
1277    // -----------------------------------------------------------------------
1278    #[test]
1279    fn test_stats_num_topics() {
1280        let e = make_fitted();
1281        let s = e.stats().expect("test: stats() for num_topics check");
1282        assert_eq!(s.num_topics, 3);
1283    }
1284
1285    // -----------------------------------------------------------------------
1286    // 27. stats() returns correct doc count
1287    // -----------------------------------------------------------------------
1288    #[test]
1289    fn test_stats_num_docs() {
1290        let e = make_fitted();
1291        let s = e.stats().expect("test: stats() for num_docs check");
1292        assert_eq!(s.num_docs, small_corpus().len());
1293    }
1294
1295    // -----------------------------------------------------------------------
1296    // 28. stats() total_tokens > 0
1297    // -----------------------------------------------------------------------
1298    #[test]
1299    fn test_stats_total_tokens() {
1300        let e = make_fitted();
1301        let s = e.stats().expect("test: stats() for total_tokens check");
1302        assert!(s.total_tokens > 0);
1303    }
1304
1305    // -----------------------------------------------------------------------
1306    // 29. perplexity is finite and positive
1307    // -----------------------------------------------------------------------
1308    #[test]
1309    fn test_perplexity_finite_positive() {
1310        let e = make_fitted();
1311        let s = e.stats().expect("test: stats() for perplexity check");
1312        assert!(s.perplexity.is_finite(), "perplexity={}", s.perplexity);
1313        assert!(s.perplexity > 0.0, "perplexity={}", s.perplexity);
1314    }
1315
1316    // -----------------------------------------------------------------------
1317    // 30. iterations_run matches config
1318    // -----------------------------------------------------------------------
1319    #[test]
1320    fn test_iterations_run() {
1321        let e = make_fitted();
1322        let s = e.stats().expect("test: stats() for iterations_run check");
1323        assert_eq!(s.iterations_run, 50);
1324    }
1325
1326    // -----------------------------------------------------------------------
1327    // 31. top_words returns correct n
1328    // -----------------------------------------------------------------------
1329    #[test]
1330    fn test_top_words_count() {
1331        let e = make_fitted();
1332        let words = e.top_words(0, 5).expect("test: top_words for topic 0");
1333        assert_eq!(words.len(), 5);
1334    }
1335
1336    // -----------------------------------------------------------------------
1337    // 32. top_words sorted by probability descending
1338    // -----------------------------------------------------------------------
1339    #[test]
1340    fn test_top_words_sorted() {
1341        let e = make_fitted();
1342        let words = e
1343            .top_words(0, 10)
1344            .expect("test: top_words sorted order check");
1345        for w in words.windows(2) {
1346            assert!(w[0].probability >= w[1].probability);
1347        }
1348    }
1349
1350    // -----------------------------------------------------------------------
1351    // 33. top_words probabilities are positive
1352    // -----------------------------------------------------------------------
1353    #[test]
1354    fn test_top_words_positive_probs() {
1355        let e = make_fitted();
1356        let words = e
1357            .top_words(0, 10)
1358            .expect("test: top_words positive probabilities check");
1359        for tw in &words {
1360            assert!(tw.probability > 0.0);
1361        }
1362    }
1363
1364    // -----------------------------------------------------------------------
1365    // 34. top_words out-of-range topic returns error
1366    // -----------------------------------------------------------------------
1367    #[test]
1368    fn test_top_words_out_of_range() {
1369        let e = make_fitted();
1370        let result = e.top_words(99, 5);
1371        assert!(matches!(result, Err(ExtractorError::TopicOutOfRange(99))));
1372    }
1373
1374    // -----------------------------------------------------------------------
1375    // 35. operations before fit return ModelNotFitted
1376    // -----------------------------------------------------------------------
1377    #[test]
1378    fn test_not_fitted_errors() {
1379        let e = TopicModelExtractor::new(ExtractorConfig::default());
1380        assert!(matches!(e.topics(), Err(ExtractorError::ModelNotFitted)));
1381        assert!(matches!(
1382            e.document_topics("x"),
1383            Err(ExtractorError::ModelNotFitted)
1384        ));
1385        assert!(matches!(
1386            e.infer_topics("text"),
1387            Err(ExtractorError::ModelNotFitted)
1388        ));
1389        assert!(matches!(
1390            e.similar_topics(0, 1),
1391            Err(ExtractorError::ModelNotFitted)
1392        ));
1393        assert!(matches!(e.stats(), Err(ExtractorError::ModelNotFitted)));
1394        assert!(matches!(
1395            e.top_words(0, 5),
1396            Err(ExtractorError::ModelNotFitted)
1397        ));
1398    }
1399
1400    // -----------------------------------------------------------------------
1401    // 36. invalid alpha configuration
1402    // -----------------------------------------------------------------------
1403    #[test]
1404    fn test_invalid_alpha() {
1405        let cfg = ExtractorConfig {
1406            alpha: -1.0,
1407            num_topics: 2,
1408            min_word_freq: 1,
1409            ..Default::default()
1410        };
1411        let mut e = TopicModelExtractor::new(cfg);
1412        let result = e.fit(&small_corpus());
1413        assert!(matches!(
1414            result,
1415            Err(ExtractorError::InvalidConfiguration(_))
1416        ));
1417    }
1418
1419    // -----------------------------------------------------------------------
1420    // 37. invalid beta configuration
1421    // -----------------------------------------------------------------------
1422    #[test]
1423    fn test_invalid_beta() {
1424        let cfg = ExtractorConfig {
1425            beta: 0.0,
1426            num_topics: 2,
1427            min_word_freq: 1,
1428            ..Default::default()
1429        };
1430        let mut e = TopicModelExtractor::new(cfg);
1431        let result = e.fit(&small_corpus());
1432        assert!(matches!(
1433            result,
1434            Err(ExtractorError::InvalidConfiguration(_))
1435        ));
1436    }
1437
1438    // -----------------------------------------------------------------------
1439    // 38. zero num_topics configuration
1440    // -----------------------------------------------------------------------
1441    #[test]
1442    fn test_zero_num_topics() {
1443        let cfg = ExtractorConfig {
1444            num_topics: 0,
1445            min_word_freq: 1,
1446            ..Default::default()
1447        };
1448        let mut e = TopicModelExtractor::new(cfg);
1449        let result = e.fit(&small_corpus());
1450        assert!(matches!(
1451            result,
1452            Err(ExtractorError::InvalidConfiguration(_))
1453        ));
1454    }
1455
1456    // -----------------------------------------------------------------------
1457    // 39. vocab_size_limit is respected
1458    // -----------------------------------------------------------------------
1459    #[test]
1460    fn test_vocab_size_limit() {
1461        let cfg = ExtractorConfig {
1462            num_topics: 2,
1463            num_iterations: 20,
1464            vocab_size_limit: 5,
1465            min_word_freq: 1,
1466            ..Default::default()
1467        };
1468        let mut e = TopicModelExtractor::new(cfg);
1469        e.fit(&small_corpus())
1470            .expect("test: fit on small corpus for vocab_size_limit check");
1471        let s = e.stats().expect("test: stats() for vocab_size_limit check");
1472        assert!(s.vocab_size <= 5, "vocab_size={}", s.vocab_size);
1473    }
1474
1475    // -----------------------------------------------------------------------
1476    // 40. max_doc_freq_pct filter works
1477    // -----------------------------------------------------------------------
1478    #[test]
1479    fn test_max_doc_freq_filter() {
1480        // Every document contains "common", so with 0.01 it should be filtered
1481        let docs: Vec<(&str, &str)> = vec![
1482            ("d1", "common rust programming"),
1483            ("d2", "common python data"),
1484            ("d3", "common deep learning"),
1485            ("d4", "common kubernetes cloud"),
1486            ("d5", "common blockchain protocol"),
1487        ];
1488        let cfg = ExtractorConfig {
1489            num_topics: 2,
1490            num_iterations: 10,
1491            min_word_freq: 1,
1492            max_doc_freq_pct: 0.5, // common appears in 100% > 50%
1493            ..Default::default()
1494        };
1495        let mut e = TopicModelExtractor::new(cfg);
1496        // Should succeed but "common" might be filtered
1497        let result = e.fit(&docs);
1498        // May fail with VocabularyEmpty or succeed — just verify no panic
1499        let _ = result;
1500    }
1501
1502    // -----------------------------------------------------------------------
1503    // 41. fit is repeatable (call fit twice)
1504    // -----------------------------------------------------------------------
1505    #[test]
1506    fn test_refit() {
1507        let mut e = TopicModelExtractor::new(ExtractorConfig {
1508            num_topics: 2,
1509            num_iterations: 10,
1510            min_word_freq: 1,
1511            ..Default::default()
1512        });
1513        e.fit(&small_corpus())
1514            .expect("test: first fit on small corpus for refit test");
1515        e.fit(&small_corpus())
1516            .expect("test: second fit on small corpus for refit test");
1517        assert!(e.fitted);
1518        let s = e.stats().expect("test: stats() after refit");
1519        assert_eq!(s.num_topics, 2);
1520    }
1521
1522    // -----------------------------------------------------------------------
1523    // 42. document distribution probabilities are all in [0,1]
1524    // -----------------------------------------------------------------------
1525    #[test]
1526    fn test_doc_distribution_values_in_range() {
1527        let e = make_fitted();
1528        let dt = e
1529            .document_topics("d05")
1530            .expect("test: document_topics for d05 value range check");
1531        for &p in &dt.topic_distribution {
1532            assert!((0.0..=1.0).contains(&p), "p={}", p);
1533        }
1534    }
1535
1536    // -----------------------------------------------------------------------
1537    // 43. all training docs have reachable distributions
1538    // -----------------------------------------------------------------------
1539    #[test]
1540    fn test_all_training_docs_accessible() {
1541        let e = make_fitted();
1542        for (doc_id, _) in &small_corpus() {
1543            let result = e.document_topics(doc_id);
1544            assert!(result.is_ok(), "failed for {}", doc_id);
1545        }
1546    }
1547
1548    // -----------------------------------------------------------------------
1549    // 44. stats vocab_size > 0
1550    // -----------------------------------------------------------------------
1551    #[test]
1552    fn test_stats_vocab_size_nonzero() {
1553        let e = make_fitted();
1554        let s = e.stats().expect("test: stats() for vocab_size check");
1555        assert!(s.vocab_size > 0);
1556    }
1557
1558    // -----------------------------------------------------------------------
1559    // 45. top_words count fields are non-negative
1560    // -----------------------------------------------------------------------
1561    #[test]
1562    fn test_top_words_count_nonneg() {
1563        let e = make_fitted();
1564        let words = e
1565            .top_words(0, 20)
1566            .expect("test: top_words for topic 0 count nonneg check");
1567        for tw in &words {
1568            // count is u32, trivially non-negative — just verify field exists
1569            let _ = tw.count;
1570        }
1571        // Verify probability * topic_total_count is coherent
1572        assert!(!words.is_empty());
1573    }
1574
1575    // -----------------------------------------------------------------------
1576    // 46. ExtractorTopic has valid coherence (not NaN)
1577    // -----------------------------------------------------------------------
1578    #[test]
1579    fn test_topic_coherence_not_nan() {
1580        let e = make_fitted();
1581        let topics = e.topics().expect("test: topics() for coherence NaN check");
1582        for t in &topics {
1583            assert!(!t.coherence.is_nan(), "topic {} coherence is NaN", t.id);
1584        }
1585    }
1586
1587    // -----------------------------------------------------------------------
1588    // 47. TmeTopicWord alias works
1589    // -----------------------------------------------------------------------
1590    #[test]
1591    fn test_tme_topic_word_alias() {
1592        let tw: TmeTopicWord = ExtractorTopicWord {
1593            word: "rust".to_string(),
1594            probability: 0.5,
1595            count: 10,
1596        };
1597        assert_eq!(tw.word, "rust");
1598    }
1599
1600    // -----------------------------------------------------------------------
1601    // 48. TmeDocumentTopics alias works
1602    // -----------------------------------------------------------------------
1603    #[test]
1604    fn test_tme_document_topics_alias() {
1605        let e = make_fitted();
1606        let dt: TmeDocumentTopics = e
1607            .document_topics("d01")
1608            .expect("test: document_topics as TmeDocumentTopics alias");
1609        assert_eq!(dt.doc_id, "d01");
1610    }
1611
1612    // -----------------------------------------------------------------------
1613    // 49. single-topic model works
1614    // -----------------------------------------------------------------------
1615    #[test]
1616    fn test_single_topic() {
1617        let cfg = ExtractorConfig {
1618            num_topics: 1,
1619            num_iterations: 10,
1620            min_word_freq: 1,
1621            ..Default::default()
1622        };
1623        let mut e = TopicModelExtractor::new(cfg);
1624        e.fit(&small_corpus())
1625            .expect("test: fit on small corpus for single-topic test");
1626        let topics = e.topics().expect("test: topics() for single-topic model");
1627        assert_eq!(topics.len(), 1);
1628        assert!((topics[0].prevalence - 1.0).abs() < 1e-9);
1629    }
1630
1631    // -----------------------------------------------------------------------
1632    // 50. similar_topics with top_k=0 returns empty
1633    // -----------------------------------------------------------------------
1634    #[test]
1635    fn test_similar_topics_zero_k() {
1636        let e = make_fitted();
1637        let sims = e
1638            .similar_topics(0, 0)
1639            .expect("test: similar_topics with top_k=0");
1640        assert!(sims.is_empty());
1641    }
1642
1643    // -----------------------------------------------------------------------
1644    // 51. xorshift64 produces different values
1645    // -----------------------------------------------------------------------
1646    #[test]
1647    fn test_xorshift_different_values() {
1648        let mut state = 12345u64;
1649        let v1 = xorshift64(&mut state);
1650        let v2 = xorshift64(&mut state);
1651        let v3 = xorshift64(&mut state);
1652        assert_ne!(v1, v2);
1653        assert_ne!(v2, v3);
1654    }
1655
1656    // -----------------------------------------------------------------------
1657    // 52. xorshift_f64 in [0, 1)
1658    // -----------------------------------------------------------------------
1659    #[test]
1660    fn test_xorshift_f64_range() {
1661        let mut state = 99999u64;
1662        for _ in 0..1000 {
1663            let v = xorshift_f64(&mut state);
1664            assert!((0.0..1.0).contains(&v), "v={}", v);
1665        }
1666    }
1667}