1use std::collections::HashMap;
9
10#[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#[derive(Debug, Clone, PartialEq, thiserror::Error)]
35pub enum ExtractorError {
36 #[error("insufficient documents: got {0}, need at least 2")]
38 InsufficientDocuments(usize),
39 #[error("vocabulary is empty after filtering")]
41 VocabularyEmpty,
42 #[error("invalid configuration: {0}")]
44 InvalidConfiguration(String),
45 #[error("model has not been fitted; call fit() first")]
47 ModelNotFitted,
48 #[error("topic id {0} is out of range")]
50 TopicOutOfRange(usize),
51 #[error("document id '{0}' not found")]
53 DocumentNotFound(String),
54}
55
56#[derive(Debug, Clone)]
62pub struct ExtractorConfig {
63 pub num_topics: usize,
65 pub alpha: f64,
67 pub beta: f64,
69 pub num_iterations: u32,
71 pub vocab_size_limit: usize,
73 pub min_word_freq: u32,
75 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#[derive(Debug, Clone, PartialEq)]
99pub struct ExtractorTopicWord {
100 pub word: String,
102 pub probability: f64,
104 pub count: u32,
106}
107
108#[derive(Debug, Clone)]
110pub struct ExtractorTopic {
111 pub id: usize,
113 pub top_words: Vec<ExtractorTopicWord>,
115 pub coherence: f64,
117 pub prevalence: f64,
119 pub label: Option<String>,
121}
122
123#[derive(Debug, Clone)]
125pub struct ExtractorDocumentTopics {
126 pub doc_id: String,
128 pub topic_distribution: Vec<f64>,
130 pub dominant_topic: usize,
132 pub dominant_probability: f64,
134}
135
136#[derive(Debug, Clone)]
138pub struct ModelStats {
139 pub num_topics: usize,
141 pub vocab_size: usize,
143 pub num_docs: usize,
145 pub total_tokens: u64,
147 pub avg_topic_coherence: f64,
149 pub perplexity: f64,
151 pub iterations_run: u32,
153}
154
155#[derive(Debug, Clone, Copy)]
161struct WordAssignment {
162 word_idx: usize,
163 topic_id: usize,
164}
165
166#[derive(Debug)]
192pub struct TopicModelExtractor {
193 config: ExtractorConfig,
194
195 vocab: HashMap<String, usize>, vocab_rev: Vec<String>, doc_ids: Vec<String>,
201 corpus: Vec<Vec<usize>>,
203
204 assignments: Vec<Vec<WordAssignment>>,
207 doc_topic_counts: Vec<Vec<u32>>,
209 topic_word_counts: Vec<Vec<u32>>,
211 topic_counts: Vec<u32>,
213
214 word_doc_freq: Vec<u32>,
217 co_occur: HashMap<(usize, usize), u32>,
219
220 labels: Vec<Option<String>>,
222
223 fitted: bool,
225 iterations_run: u32,
226
227 rng_state: u64,
229}
230
231impl TopicModelExtractor {
232 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 pub fn fit(&mut self, docs: &[(&str, &str)]) -> Result<(), ExtractorError> {
259 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut local_doc_topic = vec![0u32; k];
556 let mut local_assignments: Vec<usize> = Vec::with_capacity(tokens.len());
557
558 let mut rng = 0xFEED_C0DE_1234_5678u64;
560 for _ in &tokens {
561 xorshift64(&mut rng);
562 }
563
564 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 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 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 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 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 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 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 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 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 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 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 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 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 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
864fn 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
881pub type TmeTopicWord = ExtractorTopicWord;
887pub type TmeDocumentTopics = ExtractorDocumentTopics;
889pub type TmeTopic = ExtractorTopic;
891pub type TmeError = ExtractorError;
893
894#[cfg(test)]
899mod tests {
900 use super::*;
901
902 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 #[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 #[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 #[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 #[test]
995 fn test_empty_vocabulary() {
996 let cfg = ExtractorConfig {
997 num_topics: 2,
998 min_word_freq: 9999, ..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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let labelled = topics
1246 .iter()
1247 .find(|t| t.label.as_deref() == Some("tech-rust"));
1248 assert!(labelled.is_some());
1249 }
1250
1251 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1479 fn test_max_doc_freq_filter() {
1480 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, ..Default::default()
1494 };
1495 let mut e = TopicModelExtractor::new(cfg);
1496 let result = e.fit(&docs);
1498 let _ = result;
1500 }
1501
1502 #[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 #[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 #[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 #[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 #[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 let _ = tw.count;
1570 }
1571 assert!(!words.is_empty());
1573 }
1574
1575 #[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 #[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 #[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 #[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 #[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 #[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 #[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}