1use std::collections::HashMap;
8
9pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
17 if a.len() != b.len() || a.is_empty() {
18 return 0.0;
19 }
20 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
21 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
22 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
23 if norm_a < 1e-9 || norm_b < 1e-9 {
24 return 0.0;
25 }
26 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
27}
28
29#[derive(Debug, Clone)]
35pub struct TopicModel {
36 pub topic_id: u64,
38 pub centroid: Vec<f32>,
40 pub member_count: u64,
42 pub total_weight: f64,
44 pub label: String,
46}
47
48impl TopicModel {
49 pub fn coherence(&self) -> f64 {
53 self.member_count as f64 / (1.0 + self.total_weight)
54 }
55}
56
57#[derive(Debug, Clone)]
63pub struct TopicAssignment {
64 pub doc_id: u64,
66 pub topic_id: u64,
68 pub confidence: f32,
71 pub assigned_at_secs: u64,
73}
74
75#[derive(Debug, Clone)]
81pub struct ModellerConfig {
82 pub max_topics: usize,
84 pub new_topic_threshold: f32,
87 pub centroid_learning_rate: f32,
90}
91
92impl Default for ModellerConfig {
93 fn default() -> Self {
94 Self {
95 max_topics: 20,
96 new_topic_threshold: 0.5,
97 centroid_learning_rate: 0.1,
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
108pub struct TopicModellerStats {
109 pub total_documents: u64,
111 pub total_topics: usize,
113 pub avg_topic_size: f64,
115 pub largest_topic_members: u64,
117}
118
119pub struct SemanticTopicModeller {
129 pub topics: HashMap<u64, TopicModel>,
131 pub assignments: Vec<TopicAssignment>,
133 pub next_topic_id: u64,
135 pub config: ModellerConfig,
137}
138
139impl SemanticTopicModeller {
140 pub fn new(config: ModellerConfig) -> Self {
142 Self {
143 topics: HashMap::new(),
144 assignments: Vec::new(),
145 next_topic_id: 0,
146 config,
147 }
148 }
149
150 pub fn assign(&mut self, doc_id: u64, embedding: Vec<f32>, now_secs: u64) -> TopicAssignment {
165 if self.topics.is_empty() {
166 return self.create_topic(doc_id, embedding, now_secs);
168 }
169
170 let (best_id, best_sim) = self
172 .topics
173 .iter()
174 .map(|(id, t)| (*id, cosine_sim(&t.centroid, &embedding)))
175 .fold((0u64, f32::NEG_INFINITY), |(bi, bs), (id, s)| {
176 if s > bs {
177 (id, s)
178 } else {
179 (bi, bs)
180 }
181 });
182
183 let below_threshold = best_sim < self.config.new_topic_threshold;
184 let can_create = self.topics.len() < self.config.max_topics;
185
186 if below_threshold && can_create {
187 self.create_topic(doc_id, embedding, now_secs)
188 } else {
189 let lr = self.config.centroid_learning_rate;
191 let topic = self
192 .topics
193 .get_mut(&best_id)
194 .expect("best_id must exist in topics map");
195
196 for (c, e) in topic.centroid.iter_mut().zip(embedding.iter()) {
198 *c = (1.0 - lr) * (*c) + lr * e;
199 }
200 topic.member_count += 1;
201 topic.total_weight += best_sim as f64;
202
203 let assignment = TopicAssignment {
204 doc_id,
205 topic_id: best_id,
206 confidence: best_sim,
207 assigned_at_secs: now_secs,
208 };
209 self.assignments.push(assignment.clone());
210 assignment
211 }
212 }
213
214 pub fn topic(&self, topic_id: u64) -> Option<&TopicModel> {
216 self.topics.get(&topic_id)
217 }
218
219 pub fn assignments_for_doc(&self, doc_id: u64) -> Vec<&TopicAssignment> {
222 let mut result: Vec<&TopicAssignment> = self
223 .assignments
224 .iter()
225 .filter(|a| a.doc_id == doc_id)
226 .collect();
227 result.sort_by_key(|b| std::cmp::Reverse(b.assigned_at_secs));
228 result
229 }
230
231 pub fn top_topics(&self, k: usize) -> Vec<&TopicModel> {
233 let mut topics: Vec<&TopicModel> = self.topics.values().collect();
234 topics.sort_by_key(|b| std::cmp::Reverse(b.member_count));
235 topics.truncate(k);
236 topics
237 }
238
239 pub fn relabel(&mut self, topic_id: u64, label: String) -> bool {
241 match self.topics.get_mut(&topic_id) {
242 Some(t) => {
243 t.label = label;
244 true
245 }
246 None => false,
247 }
248 }
249
250 pub fn stats(&self) -> TopicModellerStats {
252 let total_documents = self.assignments.len() as u64;
253 let total_topics = self.topics.len();
254 let avg_topic_size = if total_topics == 0 {
255 0.0
256 } else {
257 let sum: u64 = self.topics.values().map(|t| t.member_count).sum();
258 sum as f64 / total_topics as f64
259 };
260 let largest_topic_members = self
261 .topics
262 .values()
263 .map(|t| t.member_count)
264 .max()
265 .unwrap_or(0);
266
267 TopicModellerStats {
268 total_documents,
269 total_topics,
270 avg_topic_size,
271 largest_topic_members,
272 }
273 }
274
275 fn create_topic(&mut self, doc_id: u64, embedding: Vec<f32>, now_secs: u64) -> TopicAssignment {
282 let topic_id = self.next_topic_id;
283 self.next_topic_id += 1;
284
285 let model = TopicModel {
286 topic_id,
287 label: format!("topic_{}", topic_id),
288 centroid: embedding,
289 member_count: 1,
290 total_weight: 1.0,
291 };
292 self.topics.insert(topic_id, model);
293
294 let assignment = TopicAssignment {
295 doc_id,
296 topic_id,
297 confidence: 1.0,
298 assigned_at_secs: now_secs,
299 };
300 self.assignments.push(assignment.clone());
301 assignment
302 }
303}
304
305#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn default_modeller() -> SemanticTopicModeller {
314 SemanticTopicModeller::new(ModellerConfig::default())
315 }
316
317 fn unit_vec(dim: usize, val: f32) -> Vec<f32> {
318 let norm = (val * val * dim as f32).sqrt();
319 if norm < 1e-9 {
320 vec![0.0; dim]
321 } else {
322 vec![val / norm; dim]
323 }
324 }
325
326 #[test]
328 fn test_new_starts_empty() {
329 let m = default_modeller();
330 assert!(m.topics.is_empty());
331 assert!(m.assignments.is_empty());
332 assert_eq!(m.next_topic_id, 0);
333 }
334
335 #[test]
337 fn test_assign_first_document_creates_topic() {
338 let mut m = default_modeller();
339 let emb = unit_vec(4, 1.0);
340 let a = m.assign(1, emb, 1000);
341 assert_eq!(m.topics.len(), 1);
342 assert_eq!(a.topic_id, 0);
343 assert_eq!(a.doc_id, 1);
344 assert_eq!(a.assigned_at_secs, 1000);
345 }
346
347 #[test]
349 fn test_assign_similar_joins_existing_topic() {
350 let mut m = default_modeller();
351 let emb1 = vec![1.0_f32, 0.0, 0.0, 0.0];
353 m.assign(1, emb1, 100);
354 let emb2 = vec![0.99_f32, 0.141, 0.0, 0.0];
356 let a2 = m.assign(2, emb2, 200);
357 assert_eq!(m.topics.len(), 1, "should still be one topic");
358 assert_eq!(a2.topic_id, 0);
359 }
360
361 #[test]
363 fn test_assign_dissimilar_creates_new_topic() {
364 let mut m = default_modeller();
365 let emb1 = vec![1.0_f32, 0.0, 0.0, 0.0];
366 m.assign(1, emb1, 100);
367 let emb2 = vec![0.0_f32, 1.0, 0.0, 0.0];
369 let a2 = m.assign(2, emb2, 200);
370 assert_eq!(m.topics.len(), 2);
371 assert_ne!(a2.topic_id, 0);
372 }
373
374 #[test]
376 fn test_max_topics_no_new_creation() {
377 let config = ModellerConfig {
378 max_topics: 2,
379 new_topic_threshold: 0.5,
380 centroid_learning_rate: 0.1,
381 };
382 let mut m = SemanticTopicModeller::new(config);
383 m.assign(1, vec![1.0, 0.0, 0.0], 100);
385 m.assign(2, vec![0.0, 1.0, 0.0], 200);
386 assert_eq!(m.topics.len(), 2);
387
388 let a3 = m.assign(3, vec![0.0, 0.0, 1.0], 300);
390 assert_eq!(m.topics.len(), 2, "no new topic should be created");
391 assert!(m.topics.contains_key(&a3.topic_id));
393 }
394
395 #[test]
397 fn test_centroid_updated_via_learning_rate() {
398 let config = ModellerConfig {
399 max_topics: 5,
400 new_topic_threshold: 0.5,
401 centroid_learning_rate: 0.5,
402 };
403 let mut m = SemanticTopicModeller::new(config);
404 m.assign(1, vec![1.0, 0.0], 100);
406 let emb2 = vec![0.8_f32, 0.6]; m.assign(2, emb2.clone(), 200);
409
410 let topic = m.topics.get(&0).expect("topic 0 should exist");
411 assert!((topic.centroid[0] - 0.9).abs() < 1e-5);
413 assert!((topic.centroid[1] - 0.3).abs() < 1e-5);
414 }
415
416 #[test]
418 fn test_member_count_increments_on_join() {
419 let mut m = default_modeller();
420 m.assign(1, vec![1.0, 0.0], 100);
421 m.assign(2, vec![0.99, 0.141], 200);
423 let topic = m.topics.get(&0).expect("topic 0");
424 assert_eq!(topic.member_count, 2);
425 }
426
427 #[test]
429 fn test_total_weight_accumulates() {
430 let mut m = default_modeller();
431 m.assign(1, vec![1.0, 0.0], 100);
432 m.assign(2, vec![0.99_f32, 0.141], 200);
434 let topic = m.topics.get(&0).expect("topic 0");
435 assert!(topic.total_weight > 1.0);
437 }
438
439 #[test]
441 fn test_confidence_one_for_new_topic() {
442 let mut m = default_modeller();
443 let a = m.assign(1, vec![1.0, 0.0], 100);
444 assert!((a.confidence - 1.0).abs() < 1e-6);
445 }
446
447 #[test]
449 fn test_confidence_equals_similarity_for_existing() {
450 let mut m = default_modeller();
451 let emb1 = vec![1.0_f32, 0.0];
452 m.assign(1, emb1.clone(), 100);
453 let emb2 = vec![0.6_f32, 0.8]; let a2 = m.assign(2, emb2.clone(), 200);
455 let expected_sim = cosine_sim(&emb1, &emb2);
456 assert!((a2.confidence - expected_sim).abs() < 1e-5);
457 }
458
459 #[test]
461 fn test_topic_some_for_existing() {
462 let mut m = default_modeller();
463 m.assign(1, vec![1.0, 0.0], 100);
464 assert!(m.topic(0).is_some());
465 }
466
467 #[test]
469 fn test_topic_none_for_unknown() {
470 let m = default_modeller();
471 assert!(m.topic(999).is_none());
472 }
473
474 #[test]
476 fn test_assignments_for_doc_sorted_desc() {
477 let mut m = default_modeller();
478 m.assign(1, vec![1.0, 0.0], 100);
479 m.assign(1, vec![0.99, 0.141], 300);
480 m.assign(1, vec![0.98, 0.2], 200);
481 let doc_assignments = m.assignments_for_doc(1);
482 assert_eq!(doc_assignments.len(), 3);
483 assert_eq!(doc_assignments[0].assigned_at_secs, 300);
484 assert_eq!(doc_assignments[1].assigned_at_secs, 200);
485 assert_eq!(doc_assignments[2].assigned_at_secs, 100);
486 }
487
488 #[test]
490 fn test_assignments_for_doc_filters_correctly() {
491 let mut m = default_modeller();
492 m.assign(1, vec![1.0, 0.0], 100);
493 m.assign(2, vec![0.0, 1.0], 200);
494 let doc1_assignments = m.assignments_for_doc(1);
495 assert_eq!(doc1_assignments.len(), 1);
496 assert_eq!(doc1_assignments[0].doc_id, 1);
497 }
498
499 #[test]
501 fn test_top_topics_sorted_by_member_count_desc() {
502 let mut m = default_modeller();
503 m.assign(1, vec![1.0, 0.0], 100);
505 m.assign(2, vec![0.99, 0.141], 110);
506 m.assign(3, vec![0.98, 0.2], 120);
507 m.assign(10, vec![0.0, 1.0], 200);
509
510 let top = m.top_topics(10);
511 assert_eq!(top.len(), 2);
512 assert!(top[0].member_count >= top[1].member_count);
513 }
514
515 #[test]
517 fn test_top_topics_capped_at_k() {
518 let mut m = default_modeller();
519 m.assign(1, vec![1.0, 0.0], 100);
520 m.assign(2, vec![0.0, 1.0], 200);
521 let top = m.top_topics(1);
522 assert_eq!(top.len(), 1);
523 }
524
525 #[test]
527 fn test_relabel_sets_label() {
528 let mut m = default_modeller();
529 m.assign(1, vec![1.0, 0.0], 100);
530 let ok = m.relabel(0, "science".to_string());
531 assert!(ok);
532 assert_eq!(m.topic(0).expect("topic 0").label, "science");
533 }
534
535 #[test]
537 fn test_relabel_false_for_unknown() {
538 let mut m = default_modeller();
539 assert!(!m.relabel(99, "ghost".to_string()));
540 }
541
542 #[test]
544 fn test_stats_total_documents() {
545 let mut m = default_modeller();
546 m.assign(1, vec![1.0, 0.0], 100);
547 m.assign(2, vec![0.99, 0.141], 200);
548 let s = m.stats();
549 assert_eq!(s.total_documents, 2);
550 }
551
552 #[test]
554 fn test_stats_total_topics() {
555 let mut m = default_modeller();
556 m.assign(1, vec![1.0, 0.0], 100);
557 m.assign(2, vec![0.0, 1.0], 200);
558 let s = m.stats();
559 assert_eq!(s.total_topics, 2);
560 }
561
562 #[test]
564 fn test_stats_avg_topic_size() {
565 let mut m = default_modeller();
566 m.assign(1, vec![1.0, 0.0], 100);
568 m.assign(2, vec![0.99, 0.141], 110);
569 m.assign(3, vec![0.98, 0.2], 120);
570 m.assign(10, vec![0.0, 1.0], 200);
572 let s = m.stats();
573 assert!((s.avg_topic_size - 2.0).abs() < 1e-6);
575 }
576
577 #[test]
579 fn test_stats_avg_topic_size_empty() {
580 let m = default_modeller();
581 let s = m.stats();
582 assert_eq!(s.avg_topic_size, 0.0);
583 }
584
585 #[test]
587 fn test_stats_largest_topic_members() {
588 let mut m = default_modeller();
589 m.assign(1, vec![1.0, 0.0], 100);
590 m.assign(2, vec![0.99, 0.141], 110);
591 m.assign(3, vec![0.98, 0.2], 120);
592 m.assign(10, vec![0.0, 1.0], 200);
593 let s = m.stats();
594 assert_eq!(s.largest_topic_members, 3);
595 }
596
597 #[test]
599 fn test_stats_largest_topic_members_empty() {
600 let m = default_modeller();
601 let s = m.stats();
602 assert_eq!(s.largest_topic_members, 0);
603 }
604
605 #[test]
607 fn test_topic_coherence_formula() {
608 let t = TopicModel {
609 topic_id: 0,
610 centroid: vec![1.0],
611 member_count: 4,
612 total_weight: 3.0,
613 label: "t".to_string(),
614 };
615 assert!((t.coherence() - 1.0).abs() < 1e-9);
617 }
618
619 #[test]
621 fn test_cosine_sim_identical() {
622 let v = vec![1.0_f32, 2.0, 3.0];
623 assert!((cosine_sim(&v, &v) - 1.0).abs() < 1e-6);
624 }
625
626 #[test]
628 fn test_cosine_sim_orthogonal() {
629 let a = vec![1.0_f32, 0.0];
630 let b = vec![0.0_f32, 1.0];
631 assert!(cosine_sim(&a, &b).abs() < 1e-6);
632 }
633
634 #[test]
636 fn test_cosine_sim_zero_vector() {
637 let a = vec![0.0_f32, 0.0];
638 let b = vec![1.0_f32, 0.0];
639 assert!(cosine_sim(&a, &b).abs() < 1e-6);
640 }
641
642 #[test]
644 fn test_default_label_format() {
645 let mut m = default_modeller();
646 m.assign(1, vec![1.0, 0.0], 100);
647 let topic = m.topic(0).expect("topic 0");
648 assert_eq!(topic.label, "topic_0");
649 }
650
651 #[test]
653 fn test_next_topic_id_increments() {
654 let mut m = default_modeller();
655 m.assign(1, vec![1.0, 0.0, 0.0], 100);
656 m.assign(2, vec![0.0, 1.0, 0.0], 200);
657 m.assign(3, vec![0.0, 0.0, 1.0], 300);
658 assert_eq!(m.next_topic_id, 3);
659 assert_eq!(m.topics.len(), 3);
660 }
661}
662
663#[derive(Debug, Clone)]
669pub struct TopicWord {
670 pub word: String,
672 pub probability: f64,
674}
675
676#[derive(Debug, Clone)]
678pub struct LdaTopic {
679 pub id: usize,
681 pub top_words: Vec<TopicWord>,
683 pub coherence: f64,
685}
686
687#[derive(Debug, Clone)]
689pub struct DocumentTopics {
690 pub doc_id: String,
692 pub topic_distribution: Vec<f64>,
694 pub dominant_topic: usize,
696}
697
698#[derive(Debug, Clone)]
700pub struct ModelDocument {
701 pub doc_id: String,
703 pub word_counts: HashMap<String, u32>,
705}
706
707#[derive(Debug, Clone)]
709pub struct TopicModelConfig {
710 pub n_topics: usize,
712 pub n_top_words: usize,
714 pub alpha: f64,
716 pub beta: f64,
718 pub max_iter: u32,
720 pub seed: u64,
722}
723
724impl Default for TopicModelConfig {
725 fn default() -> Self {
726 Self {
727 n_topics: 10,
728 n_top_words: 10,
729 alpha: 0.1,
730 beta: 0.01,
731 max_iter: 50,
732 seed: 42,
733 }
734 }
735}
736
737#[derive(Debug, Clone)]
739pub struct TopicModelResult {
740 pub topics: Vec<LdaTopic>,
742 pub doc_topic_distributions: Vec<DocumentTopics>,
744 pub perplexity: f64,
746 pub n_docs: usize,
748 pub n_words: usize,
750 pub iterations_run: u32,
752}
753
754#[derive(Debug, Clone, PartialEq)]
756pub enum TopicModelError {
757 InsufficientDocuments {
759 min: usize,
761 got: usize,
763 },
764 EmptyVocabulary,
766 InvalidConfig(String),
768}
769
770impl std::fmt::Display for TopicModelError {
771 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
772 match self {
773 Self::InsufficientDocuments { min, got } => {
774 write!(f, "insufficient documents: need at least {min}, got {got}")
775 }
776 Self::EmptyVocabulary => write!(f, "empty vocabulary"),
777 Self::InvalidConfig(msg) => write!(f, "invalid config: {msg}"),
778 }
779 }
780}
781
782impl std::error::Error for TopicModelError {}
783
784#[derive(Debug, Clone)]
786pub struct TopicModelerStats {
787 pub n_topics: usize,
789 pub vocabulary_size: usize,
791 pub avg_topic_coherence: f64,
793 pub dominant_topic_distribution: Vec<usize>,
795}
796
797#[inline]
802fn xorshift64(state: &mut u64) -> u64 {
803 *state ^= *state << 13;
804 *state ^= *state >> 7;
805 *state ^= *state << 17;
806 *state
807}
808
809pub struct TopicModeler {
818 pub config: TopicModelConfig,
820 pub vocabulary: Vec<String>,
822 pub word_index: HashMap<String, usize>,
824}
825
826impl TopicModeler {
827 pub fn new(config: TopicModelConfig) -> Self {
829 Self {
830 config,
831 vocabulary: Vec::new(),
832 word_index: HashMap::new(),
833 }
834 }
835
836 fn validate_config(&self) -> Result<(), TopicModelError> {
838 if self.config.n_topics == 0 {
839 return Err(TopicModelError::InvalidConfig(
840 "n_topics must be >= 1".to_string(),
841 ));
842 }
843 if self.config.alpha <= 0.0 {
844 return Err(TopicModelError::InvalidConfig(
845 "alpha must be > 0".to_string(),
846 ));
847 }
848 if self.config.beta <= 0.0 {
849 return Err(TopicModelError::InvalidConfig(
850 "beta must be > 0".to_string(),
851 ));
852 }
853 if self.config.seed == 0 {
854 return Err(TopicModelError::InvalidConfig(
855 "seed must be non-zero".to_string(),
856 ));
857 }
858 Ok(())
859 }
860
861 fn build_vocabulary(&mut self, documents: &[ModelDocument]) {
863 let mut words: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
864 for doc in documents {
865 for word in doc.word_counts.keys() {
866 words.insert(word.clone());
867 }
868 }
869 self.vocabulary = words.into_iter().collect();
870 self.word_index = self
871 .vocabulary
872 .iter()
873 .enumerate()
874 .map(|(i, w)| (w.clone(), i))
875 .collect();
876 }
877
878 pub fn fit(
883 &mut self,
884 documents: &[ModelDocument],
885 ) -> Result<TopicModelResult, TopicModelError> {
886 self.validate_config()?;
887
888 let n_docs = documents.len();
889 if n_docs < self.config.n_topics {
890 return Err(TopicModelError::InsufficientDocuments {
891 min: self.config.n_topics,
892 got: n_docs,
893 });
894 }
895
896 self.build_vocabulary(documents);
897 let vocab_size = self.vocabulary.len();
898 if vocab_size == 0 {
899 return Err(TopicModelError::EmptyVocabulary);
900 }
901
902 let k = self.config.n_topics;
903 let alpha = self.config.alpha;
904 let beta = self.config.beta;
905 let v_beta = vocab_size as f64 * beta;
906
907 let mut doc_tokens: Vec<Vec<usize>> = Vec::with_capacity(n_docs);
911 for doc in documents {
912 let mut tokens: Vec<usize> = Vec::new();
913 for (word, &count) in &doc.word_counts {
914 if let Some(&wi) = self.word_index.get(word) {
915 for _ in 0..count {
916 tokens.push(wi);
917 }
918 }
919 }
920 doc_tokens.push(tokens);
921 }
922
923 let mut doc_topic_counts: Vec<Vec<u32>> = vec![vec![0u32; k]; n_docs];
926 let mut topic_word_counts: Vec<Vec<u32>> = vec![vec![0u32; vocab_size]; k];
928 let mut topic_counts: Vec<u32> = vec![0u32; k];
930
931 let mut topic_assignments: Vec<Vec<usize>> = Vec::with_capacity(n_docs);
933
934 let mut rng_state: u64 = self.config.seed;
935
936 for (d, tokens) in doc_tokens.iter().enumerate() {
938 let mut assignments: Vec<usize> = Vec::with_capacity(tokens.len());
939 for &wi in tokens {
940 let t = (xorshift64(&mut rng_state) as usize) % k;
941 assignments.push(t);
942 doc_topic_counts[d][t] += 1;
943 topic_word_counts[t][wi] += 1;
944 topic_counts[t] += 1;
945 }
946 topic_assignments.push(assignments);
947 }
948
949 let mut probs: Vec<f64> = vec![0.0; k];
951 for _iter in 0..self.config.max_iter {
952 for d in 0..n_docs {
953 let n_tokens = doc_tokens[d].len();
954 for pos in 0..n_tokens {
955 let wi = doc_tokens[d][pos];
956 let old_t = topic_assignments[d][pos];
957
958 doc_topic_counts[d][old_t] = doc_topic_counts[d][old_t].saturating_sub(1);
960 topic_word_counts[old_t][wi] = topic_word_counts[old_t][wi].saturating_sub(1);
961 topic_counts[old_t] = topic_counts[old_t].saturating_sub(1);
962
963 let mut cumulative = 0.0_f64;
965 for t in 0..k {
966 let doc_factor = doc_topic_counts[d][t] as f64 + alpha;
967 let word_factor = topic_word_counts[t][wi] as f64 + beta;
968 let norm_factor = topic_counts[t] as f64 + v_beta;
969 let p = doc_factor * word_factor / norm_factor;
970 cumulative += p;
971 probs[t] = cumulative;
972 }
973
974 let total = cumulative;
976 let u = ((xorshift64(&mut rng_state) as f64) / (u64::MAX as f64)) * total;
977 let new_t = probs[..k].iter().position(|&cp| u <= cp).unwrap_or(k - 1);
978
979 topic_assignments[d][pos] = new_t;
981 doc_topic_counts[d][new_t] += 1;
982 topic_word_counts[new_t][wi] += 1;
983 topic_counts[new_t] += 1;
984 }
985 }
986 }
987
988 let n_top = self.config.n_top_words.min(vocab_size);
990 let mut topics: Vec<LdaTopic> = Vec::with_capacity(k);
991 for t in 0..k {
992 let total_t = topic_counts[t] as f64 + v_beta;
993 let mut word_probs: Vec<(usize, f64)> = (0..vocab_size)
995 .map(|w| {
996 let p = (topic_word_counts[t][w] as f64 + beta) / total_t;
997 (w, p)
998 })
999 .collect();
1000 word_probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1001 let top_words: Vec<TopicWord> = word_probs[..n_top]
1002 .iter()
1003 .map(|&(wi, prob)| TopicWord {
1004 word: self.vocabulary[wi].clone(),
1005 probability: prob,
1006 })
1007 .collect();
1008 topics.push(LdaTopic {
1009 id: t,
1010 top_words,
1011 coherence: 0.0, });
1013 }
1014
1015 let mut doc_topic_distributions: Vec<DocumentTopics> = Vec::with_capacity(n_docs);
1017 for d in 0..n_docs {
1018 let n_d: u32 = doc_topic_counts[d].iter().sum();
1019 let denom = n_d as f64 + k as f64 * alpha;
1020 let dist: Vec<f64> = (0..k)
1021 .map(|t| (doc_topic_counts[d][t] as f64 + alpha) / denom)
1022 .collect();
1023 let dominant = dist
1024 .iter()
1025 .enumerate()
1026 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
1027 .map(|(i, _)| i)
1028 .unwrap_or(0);
1029 doc_topic_distributions.push(DocumentTopics {
1030 doc_id: documents[d].doc_id.clone(),
1031 topic_distribution: dist,
1032 dominant_topic: dominant,
1033 });
1034 }
1035
1036 let mut corpus_word_counts: HashMap<String, u32> = HashMap::new();
1038 for doc in documents {
1039 for (word, &count) in &doc.word_counts {
1040 *corpus_word_counts.entry(word.clone()).or_insert(0) += count;
1041 }
1042 }
1043
1044 for topic in &mut topics {
1046 topic.coherence = Self::coherence_score_inner(topic, &corpus_word_counts);
1047 }
1048
1049 let perplexity = Self::compute_perplexity(
1051 documents,
1052 &doc_topic_distributions,
1053 &topic_word_counts,
1054 &topic_counts,
1055 &self.word_index,
1056 vocab_size,
1057 beta,
1058 v_beta,
1059 );
1060
1061 Ok(TopicModelResult {
1062 topics,
1063 doc_topic_distributions,
1064 perplexity,
1065 n_docs,
1066 n_words: vocab_size,
1067 iterations_run: self.config.max_iter,
1068 })
1069 }
1070
1071 pub fn transform(
1077 &mut self,
1078 documents: &[ModelDocument],
1079 result: &TopicModelResult,
1080 ) -> Result<Vec<DocumentTopics>, TopicModelError> {
1081 self.validate_config()?;
1082 if documents.is_empty() {
1083 return Ok(Vec::new());
1084 }
1085 if result.topics.is_empty() {
1086 return Err(TopicModelError::InvalidConfig(
1087 "result contains no topics".to_string(),
1088 ));
1089 }
1090
1091 let k = result.topics.len();
1092 let alpha = self.config.alpha;
1093
1094 let topic_word_prob: Vec<HashMap<&str, f64>> = result
1097 .topics
1098 .iter()
1099 .map(|topic| {
1100 topic
1101 .top_words
1102 .iter()
1103 .map(|tw| (tw.word.as_str(), tw.probability))
1104 .collect()
1105 })
1106 .collect();
1107
1108 let mut rng_state: u64 = self.config.seed.wrapping_add(1);
1109 let mut output: Vec<DocumentTopics> = Vec::with_capacity(documents.len());
1110
1111 for doc in documents {
1112 let tokens: Vec<&str> = doc
1114 .word_counts
1115 .iter()
1116 .flat_map(|(w, &c)| std::iter::repeat_n(w.as_str(), c as usize))
1117 .collect();
1118
1119 let n_tokens = tokens.len();
1120 if n_tokens == 0 {
1121 let uniform = 1.0 / k as f64;
1123 output.push(DocumentTopics {
1124 doc_id: doc.doc_id.clone(),
1125 topic_distribution: vec![uniform; k],
1126 dominant_topic: 0,
1127 });
1128 continue;
1129 }
1130
1131 let mut dt_counts: Vec<u32> = vec![0u32; k];
1133 let mut assignments: Vec<usize> = Vec::with_capacity(n_tokens);
1134 for _ in 0..n_tokens {
1135 let t = (xorshift64(&mut rng_state) as usize) % k;
1136 assignments.push(t);
1137 dt_counts[t] += 1;
1138 }
1139
1140 let mut probs: Vec<f64> = vec![0.0; k];
1142 for _iter in 0..self.config.max_iter {
1143 for pos in 0..n_tokens {
1144 let word = tokens[pos];
1145 let old_t = assignments[pos];
1146 dt_counts[old_t] = dt_counts[old_t].saturating_sub(1);
1147
1148 let mut cumulative = 0.0_f64;
1149 for t in 0..k {
1150 let doc_factor = dt_counts[t] as f64 + alpha;
1151 let word_prob = topic_word_prob[t].get(word).copied().unwrap_or(1e-10_f64);
1152 let p = doc_factor * word_prob;
1153 cumulative += p;
1154 probs[t] = cumulative;
1155 }
1156
1157 let u = ((xorshift64(&mut rng_state) as f64) / (u64::MAX as f64)) * cumulative;
1158 let new_t = probs[..k].iter().position(|&cp| u <= cp).unwrap_or(k - 1);
1159
1160 assignments[pos] = new_t;
1161 dt_counts[new_t] += 1;
1162 }
1163 }
1164
1165 let n_d: u32 = dt_counts.iter().sum();
1166 let denom = n_d as f64 + k as f64 * alpha;
1167 let dist: Vec<f64> = (0..k)
1168 .map(|t| (dt_counts[t] as f64 + alpha) / denom)
1169 .collect();
1170 let dominant = dist
1171 .iter()
1172 .enumerate()
1173 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
1174 .map(|(i, _)| i)
1175 .unwrap_or(0);
1176
1177 output.push(DocumentTopics {
1178 doc_id: doc.doc_id.clone(),
1179 topic_distribution: dist,
1180 dominant_topic: dominant,
1181 });
1182 }
1183
1184 Ok(output)
1185 }
1186
1187 pub fn coherence_score(topic: &LdaTopic, corpus_word_counts: &HashMap<String, u32>) -> f64 {
1193 Self::coherence_score_inner(topic, corpus_word_counts)
1194 }
1195
1196 fn coherence_score_inner(topic: &LdaTopic, corpus_word_counts: &HashMap<String, u32>) -> f64 {
1197 let words = &topic.top_words;
1198 if words.len() < 2 {
1199 return 0.0;
1200 }
1201 let mut sum = 0.0_f64;
1202 let mut count = 0usize;
1203 for (i, wi) in words.iter().enumerate() {
1204 let count_i = corpus_word_counts.get(&wi.word).copied().unwrap_or(0) as f64;
1205 for wj in words.iter().skip(i + 1) {
1206 let count_j = corpus_word_counts.get(&wj.word).copied().unwrap_or(0) as f64;
1207 let co = count_i.min(count_j);
1209 sum += ((co + 1.0) / (count_j + 1.0)).ln();
1210 count += 1;
1211 }
1212 }
1213 if count == 0 {
1214 0.0
1215 } else {
1216 sum / count as f64
1217 }
1218 }
1219
1220 pub fn most_similar_topics(topic_a: usize, topic_b: usize, result: &TopicModelResult) -> f64 {
1225 if topic_a >= result.topics.len() || topic_b >= result.topics.len() {
1226 return 0.0;
1227 }
1228 let ta = &result.topics[topic_a];
1231 let tb = &result.topics[topic_b];
1232
1233 let mut all_words: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1235 for tw in &ta.top_words {
1236 all_words.insert(tw.word.as_str());
1237 }
1238 for tw in &tb.top_words {
1239 all_words.insert(tw.word.as_str());
1240 }
1241
1242 let map_a: HashMap<&str, f64> = ta
1243 .top_words
1244 .iter()
1245 .map(|tw| (tw.word.as_str(), tw.probability))
1246 .collect();
1247 let map_b: HashMap<&str, f64> = tb
1248 .top_words
1249 .iter()
1250 .map(|tw| (tw.word.as_str(), tw.probability))
1251 .collect();
1252
1253 let mut dot = 0.0_f64;
1254 let mut norm_a = 0.0_f64;
1255 let mut norm_b = 0.0_f64;
1256
1257 for word in &all_words {
1258 let pa = map_a.get(word).copied().unwrap_or(0.0);
1259 let pb = map_b.get(word).copied().unwrap_or(0.0);
1260 dot += pa * pb;
1261 norm_a += pa * pa;
1262 norm_b += pb * pb;
1263 }
1264
1265 let denom = norm_a.sqrt() * norm_b.sqrt();
1266 if denom < 1e-12 {
1267 0.0
1268 } else {
1269 (dot / denom).clamp(-1.0, 1.0)
1270 }
1271 }
1272
1273 pub fn top_documents_for_topic(
1277 topic_id: usize,
1278 n: usize,
1279 result: &TopicModelResult,
1280 ) -> Vec<&DocumentTopics> {
1281 if result.topics.is_empty() {
1282 return Vec::new();
1283 }
1284 let mut docs: Vec<&DocumentTopics> = result
1285 .doc_topic_distributions
1286 .iter()
1287 .filter(|d| topic_id < d.topic_distribution.len())
1288 .collect();
1289 docs.sort_by(|a, b| {
1290 b.topic_distribution[topic_id]
1291 .partial_cmp(&a.topic_distribution[topic_id])
1292 .unwrap_or(std::cmp::Ordering::Equal)
1293 });
1294 docs.truncate(n);
1295 docs
1296 }
1297
1298 pub fn stats(result: &TopicModelResult) -> TopicModelerStats {
1300 let n_topics = result.topics.len();
1301 let avg_topic_coherence = if n_topics == 0 {
1302 0.0
1303 } else {
1304 let sum: f64 = result.topics.iter().map(|t| t.coherence).sum();
1305 sum / n_topics as f64
1306 };
1307
1308 let mut dominant_topic_distribution: Vec<usize> = vec![0usize; n_topics];
1309 for doc in &result.doc_topic_distributions {
1310 if doc.dominant_topic < n_topics {
1311 dominant_topic_distribution[doc.dominant_topic] += 1;
1312 }
1313 }
1314
1315 TopicModelerStats {
1316 n_topics,
1317 vocabulary_size: result.n_words,
1318 avg_topic_coherence,
1319 dominant_topic_distribution,
1320 }
1321 }
1322
1323 #[allow(clippy::too_many_arguments)]
1328 fn compute_perplexity(
1329 documents: &[ModelDocument],
1330 doc_dists: &[DocumentTopics],
1331 topic_word_counts: &[Vec<u32>],
1332 topic_counts: &[u32],
1333 word_index: &HashMap<String, usize>,
1334 vocab_size: usize,
1335 beta: f64,
1336 v_beta: f64,
1337 ) -> f64 {
1338 let k = topic_word_counts.len();
1339 let mut log_likelihood = 0.0_f64;
1340 let mut total_tokens: u64 = 0;
1341
1342 for (d, doc) in documents.iter().enumerate() {
1343 let dist = &doc_dists[d].topic_distribution;
1344 for (word, &count) in &doc.word_counts {
1345 if count == 0 {
1346 continue;
1347 }
1348 let wi_opt = word_index.get(word);
1349 let p_word_doc = match wi_opt {
1350 None => 1e-10,
1351 Some(&wi) => {
1352 let p: f64 = (0..k)
1353 .map(|t| {
1354 let p_topic = dist[t];
1355 let p_word_topic = (topic_word_counts[t][wi] as f64 + beta)
1356 / (topic_counts[t] as f64 + v_beta);
1357 p_topic * p_word_topic
1358 })
1359 .sum();
1360 if p <= 0.0 {
1361 1e-10
1362 } else {
1363 p
1364 }
1365 }
1366 };
1367 log_likelihood += count as f64 * p_word_doc.ln();
1368 total_tokens += count as u64;
1369 }
1370 let _ = vocab_size; }
1373
1374 if total_tokens == 0 {
1375 return f64::INFINITY;
1376 }
1377 (-log_likelihood / total_tokens as f64).exp()
1378 }
1379}
1380
1381#[cfg(test)]
1386mod lda_tests {
1387 use crate::topic_modeler::{
1388 xorshift64, LdaTopic, ModelDocument, TopicModelConfig, TopicModelError, TopicModeler,
1389 TopicWord,
1390 };
1391 use std::collections::HashMap;
1392
1393 fn simple_doc(id: &str, words: &[(&str, u32)]) -> ModelDocument {
1394 let mut word_counts = HashMap::new();
1395 for &(w, c) in words {
1396 word_counts.insert(w.to_string(), c);
1397 }
1398 ModelDocument {
1399 doc_id: id.to_string(),
1400 word_counts,
1401 }
1402 }
1403
1404 fn make_corpus() -> Vec<ModelDocument> {
1405 vec![
1407 simple_doc(
1408 "d1",
1409 &[("rust", 5), ("code", 4), ("compile", 3), ("memory", 3)],
1410 ),
1411 simple_doc(
1412 "d2",
1413 &[("rust", 4), ("compiler", 5), ("code", 3), ("type", 3)],
1414 ),
1415 simple_doc(
1416 "d3",
1417 &[("rust", 3), ("memory", 4), ("safe", 5), ("type", 2)],
1418 ),
1419 simple_doc(
1420 "d4",
1421 &[("forest", 5), ("tree", 4), ("leaf", 3), ("nature", 3)],
1422 ),
1423 simple_doc(
1424 "d5",
1425 &[("tree", 4), ("forest", 3), ("river", 5), ("nature", 3)],
1426 ),
1427 simple_doc(
1428 "d6",
1429 &[("leaf", 3), ("nature", 4), ("river", 5), ("forest", 2)],
1430 ),
1431 ]
1432 }
1433
1434 fn default_config_2topics() -> TopicModelConfig {
1435 TopicModelConfig {
1436 n_topics: 2,
1437 n_top_words: 4,
1438 alpha: 0.1,
1439 beta: 0.01,
1440 max_iter: 30,
1441 seed: 42,
1442 }
1443 }
1444
1445 #[test]
1447 fn test_new_empty_vocab() {
1448 let m = TopicModeler::new(TopicModelConfig::default());
1449 assert!(m.vocabulary.is_empty());
1450 assert!(m.word_index.is_empty());
1451 }
1452
1453 #[test]
1455 fn test_fit_n_docs() {
1456 let mut m = TopicModeler::new(default_config_2topics());
1457 let corpus = make_corpus();
1458 let result = m.fit(&corpus).expect("fit failed");
1459 assert_eq!(result.n_docs, 6);
1460 }
1461
1462 #[test]
1464 fn test_fit_n_words() {
1465 let mut m = TopicModeler::new(default_config_2topics());
1466 let corpus = make_corpus();
1467 let result = m.fit(&corpus).expect("fit failed");
1468 assert_eq!(result.n_words, 12);
1470 }
1471
1472 #[test]
1474 fn test_fit_n_topics() {
1475 let mut m = TopicModeler::new(default_config_2topics());
1476 let corpus = make_corpus();
1477 let result = m.fit(&corpus).expect("fit failed");
1478 assert_eq!(result.topics.len(), 2);
1479 }
1480
1481 #[test]
1483 fn test_fit_iterations_run() {
1484 let mut m = TopicModeler::new(default_config_2topics());
1485 let corpus = make_corpus();
1486 let result = m.fit(&corpus).expect("fit failed");
1487 assert_eq!(result.iterations_run, 30);
1488 }
1489
1490 #[test]
1492 fn test_doc_topic_distribution_sums_to_one() {
1493 let mut m = TopicModeler::new(default_config_2topics());
1494 let corpus = make_corpus();
1495 let result = m.fit(&corpus).expect("fit failed");
1496 for doc_dist in &result.doc_topic_distributions {
1497 let sum: f64 = doc_dist.topic_distribution.iter().sum();
1498 assert!((sum - 1.0).abs() < 1e-9, "sum={sum}");
1499 }
1500 }
1501
1502 #[test]
1504 fn test_dominant_topic_in_range() {
1505 let mut m = TopicModeler::new(default_config_2topics());
1506 let corpus = make_corpus();
1507 let result = m.fit(&corpus).expect("fit failed");
1508 for doc_dist in &result.doc_topic_distributions {
1509 assert!(doc_dist.dominant_topic < 2);
1510 }
1511 }
1512
1513 #[test]
1515 fn test_topic_top_words_count() {
1516 let mut m = TopicModeler::new(default_config_2topics());
1517 let corpus = make_corpus();
1518 let result = m.fit(&corpus).expect("fit failed");
1519 for topic in &result.topics {
1520 assert_eq!(topic.top_words.len(), 4);
1521 }
1522 }
1523
1524 #[test]
1526 fn test_top_words_probabilities_positive() {
1527 let mut m = TopicModeler::new(default_config_2topics());
1528 let corpus = make_corpus();
1529 let result = m.fit(&corpus).expect("fit failed");
1530 for topic in &result.topics {
1531 for tw in &topic.top_words {
1532 assert!(
1533 tw.probability > 0.0,
1534 "word={} prob={}",
1535 tw.word,
1536 tw.probability
1537 );
1538 }
1539 }
1540 }
1541
1542 #[test]
1544 fn test_top_words_sorted_descending() {
1545 let mut m = TopicModeler::new(default_config_2topics());
1546 let corpus = make_corpus();
1547 let result = m.fit(&corpus).expect("fit failed");
1548 for topic in &result.topics {
1549 let probs: Vec<f64> = topic.top_words.iter().map(|tw| tw.probability).collect();
1550 for i in 1..probs.len() {
1551 assert!(probs[i - 1] >= probs[i], "not sorted at {i}");
1552 }
1553 }
1554 }
1555
1556 #[test]
1558 fn test_perplexity_finite_positive() {
1559 let mut m = TopicModeler::new(default_config_2topics());
1560 let corpus = make_corpus();
1561 let result = m.fit(&corpus).expect("fit failed");
1562 assert!(result.perplexity.is_finite(), "perplexity not finite");
1563 assert!(result.perplexity > 0.0);
1564 }
1565
1566 #[test]
1568 fn test_fit_insufficient_documents() {
1569 let mut m = TopicModeler::new(TopicModelConfig {
1570 n_topics: 5,
1571 ..Default::default()
1572 });
1573 let corpus = vec![
1574 simple_doc("d1", &[("hello", 1)]),
1575 simple_doc("d2", &[("world", 1)]),
1576 ];
1577 let err = m.fit(&corpus).unwrap_err();
1578 assert_eq!(
1579 err,
1580 TopicModelError::InsufficientDocuments { min: 5, got: 2 }
1581 );
1582 }
1583
1584 #[test]
1586 fn test_fit_empty_vocabulary() {
1587 let mut m = TopicModeler::new(TopicModelConfig {
1588 n_topics: 1,
1589 ..Default::default()
1590 });
1591 let corpus = vec![simple_doc("d1", &[])];
1592 let err = m.fit(&corpus).unwrap_err();
1593 assert_eq!(err, TopicModelError::EmptyVocabulary);
1594 }
1595
1596 #[test]
1598 fn test_invalid_config_n_topics_zero() {
1599 let mut m = TopicModeler::new(TopicModelConfig {
1600 n_topics: 0,
1601 ..Default::default()
1602 });
1603 let corpus = make_corpus();
1604 let err = m.fit(&corpus).unwrap_err();
1605 matches!(err, TopicModelError::InvalidConfig(_));
1606 }
1607
1608 #[test]
1610 fn test_invalid_config_alpha_zero() {
1611 let mut m = TopicModeler::new(TopicModelConfig {
1612 alpha: 0.0,
1613 ..Default::default()
1614 });
1615 let corpus = make_corpus();
1616 let err = m.fit(&corpus).unwrap_err();
1617 matches!(err, TopicModelError::InvalidConfig(_));
1618 }
1619
1620 #[test]
1622 fn test_invalid_config_beta_zero() {
1623 let mut m = TopicModeler::new(TopicModelConfig {
1624 beta: 0.0,
1625 ..Default::default()
1626 });
1627 let corpus = make_corpus();
1628 let err = m.fit(&corpus).unwrap_err();
1629 matches!(err, TopicModelError::InvalidConfig(_));
1630 }
1631
1632 #[test]
1634 fn test_coherence_single_word_zero() {
1635 let topic = LdaTopic {
1636 id: 0,
1637 top_words: vec![TopicWord {
1638 word: "rust".to_string(),
1639 probability: 0.5,
1640 }],
1641 coherence: 0.0,
1642 };
1643 let corpus: HashMap<String, u32> = HashMap::new();
1644 let score = TopicModeler::coherence_score(&topic, &corpus);
1645 assert_eq!(score, 0.0);
1646 }
1647
1648 #[test]
1650 fn test_coherence_multiword_finite() {
1651 let topic = LdaTopic {
1652 id: 0,
1653 top_words: vec![
1654 TopicWord {
1655 word: "rust".to_string(),
1656 probability: 0.4,
1657 },
1658 TopicWord {
1659 word: "code".to_string(),
1660 probability: 0.3,
1661 },
1662 TopicWord {
1663 word: "memory".to_string(),
1664 probability: 0.2,
1665 },
1666 ],
1667 coherence: 0.0,
1668 };
1669 let mut corpus = HashMap::new();
1670 corpus.insert("rust".to_string(), 10u32);
1671 corpus.insert("code".to_string(), 8u32);
1672 corpus.insert("memory".to_string(), 5u32);
1673 let score = TopicModeler::coherence_score(&topic, &corpus);
1674 assert!(score.is_finite());
1675 }
1676
1677 #[test]
1679 fn test_coherence_empty_corpus_finite() {
1680 let topic = LdaTopic {
1681 id: 0,
1682 top_words: vec![
1683 TopicWord {
1684 word: "a".to_string(),
1685 probability: 0.5,
1686 },
1687 TopicWord {
1688 word: "b".to_string(),
1689 probability: 0.5,
1690 },
1691 ],
1692 coherence: 0.0,
1693 };
1694 let corpus: HashMap<String, u32> = HashMap::new();
1695 let score = TopicModeler::coherence_score(&topic, &corpus);
1696 assert!(score.is_finite());
1697 }
1698
1699 #[test]
1701 fn test_most_similar_topics_identical() {
1702 let mut m = TopicModeler::new(default_config_2topics());
1703 let corpus = make_corpus();
1704 let result = m.fit(&corpus).expect("fit failed");
1705 let sim = TopicModeler::most_similar_topics(0, 0, &result);
1706 assert!((sim - 1.0).abs() < 1e-9, "sim={sim}");
1707 }
1708
1709 #[test]
1711 fn test_most_similar_topics_in_range() {
1712 let mut m = TopicModeler::new(default_config_2topics());
1713 let corpus = make_corpus();
1714 let result = m.fit(&corpus).expect("fit failed");
1715 let sim = TopicModeler::most_similar_topics(0, 1, &result);
1716 assert!((-1.0..=1.0).contains(&sim), "sim={sim}");
1717 }
1718
1719 #[test]
1721 fn test_most_similar_topics_out_of_range() {
1722 let mut m = TopicModeler::new(default_config_2topics());
1723 let corpus = make_corpus();
1724 let result = m.fit(&corpus).expect("fit failed");
1725 let sim = TopicModeler::most_similar_topics(0, 99, &result);
1726 assert_eq!(sim, 0.0);
1727 }
1728
1729 #[test]
1731 fn test_top_documents_for_topic_count() {
1732 let mut m = TopicModeler::new(default_config_2topics());
1733 let corpus = make_corpus();
1734 let result = m.fit(&corpus).expect("fit failed");
1735 let top = TopicModeler::top_documents_for_topic(0, 3, &result);
1736 assert_eq!(top.len(), 3);
1737 }
1738
1739 #[test]
1741 fn test_top_documents_for_topic_sorted() {
1742 let mut m = TopicModeler::new(default_config_2topics());
1743 let corpus = make_corpus();
1744 let result = m.fit(&corpus).expect("fit failed");
1745 let top = TopicModeler::top_documents_for_topic(0, 6, &result);
1746 for i in 1..top.len() {
1747 assert!(
1748 top[i - 1].topic_distribution[0] >= top[i].topic_distribution[0],
1749 "not sorted at {i}"
1750 );
1751 }
1752 }
1753
1754 #[test]
1756 fn test_stats_n_topics() {
1757 let mut m = TopicModeler::new(default_config_2topics());
1758 let corpus = make_corpus();
1759 let result = m.fit(&corpus).expect("fit failed");
1760 let stats = TopicModeler::stats(&result);
1761 assert_eq!(stats.n_topics, 2);
1762 }
1763
1764 #[test]
1766 fn test_stats_vocab_size() {
1767 let mut m = TopicModeler::new(default_config_2topics());
1768 let corpus = make_corpus();
1769 let result = m.fit(&corpus).expect("fit failed");
1770 let stats = TopicModeler::stats(&result);
1771 assert_eq!(stats.vocabulary_size, result.n_words);
1772 }
1773
1774 #[test]
1776 fn test_stats_dominant_distribution_sum() {
1777 let mut m = TopicModeler::new(default_config_2topics());
1778 let corpus = make_corpus();
1779 let result = m.fit(&corpus).expect("fit failed");
1780 let stats = TopicModeler::stats(&result);
1781 let total: usize = stats.dominant_topic_distribution.iter().sum();
1782 assert_eq!(total, 6);
1783 }
1784
1785 #[test]
1787 fn test_transform_result_count() {
1788 let mut m = TopicModeler::new(default_config_2topics());
1789 let corpus = make_corpus();
1790 let result = m.fit(&corpus).expect("fit failed");
1791 let new_docs = vec![
1792 simple_doc("new1", &[("rust", 3), ("code", 2)]),
1793 simple_doc("new2", &[("forest", 4), ("tree", 3)]),
1794 ];
1795 let inferred = m.transform(&new_docs, &result).expect("transform failed");
1796 assert_eq!(inferred.len(), 2);
1797 }
1798
1799 #[test]
1801 fn test_transform_distributions_sum() {
1802 let mut m = TopicModeler::new(default_config_2topics());
1803 let corpus = make_corpus();
1804 let result = m.fit(&corpus).expect("fit failed");
1805 let new_docs = vec![simple_doc("new1", &[("rust", 3), ("code", 2)])];
1806 let inferred = m.transform(&new_docs, &result).expect("transform failed");
1807 let sum: f64 = inferred[0].topic_distribution.iter().sum();
1808 assert!((sum - 1.0).abs() < 1e-9, "sum={sum}");
1809 }
1810
1811 #[test]
1813 fn test_transform_empty_doc_uniform() {
1814 let mut m = TopicModeler::new(default_config_2topics());
1815 let corpus = make_corpus();
1816 let result = m.fit(&corpus).expect("fit failed");
1817 let new_docs = vec![simple_doc("empty", &[])];
1818 let inferred = m.transform(&new_docs, &result).expect("transform failed");
1819 let expected = 0.5_f64; for &p in &inferred[0].topic_distribution {
1821 assert!((p - expected).abs() < 1e-9, "p={p}");
1822 }
1823 }
1824
1825 #[test]
1827 fn test_xorshift64_different_seeds() {
1828 let mut s1 = 42u64;
1829 let mut s2 = 123u64;
1830 let v1 = xorshift64(&mut s1);
1831 let v2 = xorshift64(&mut s2);
1832 assert_ne!(v1, v2);
1833 }
1834
1835 #[test]
1837 fn test_xorshift64_deterministic() {
1838 let mut s1 = 99u64;
1839 let mut s2 = 99u64;
1840 for _ in 0..100 {
1841 assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1842 }
1843 }
1844
1845 #[test]
1847 fn test_fit_doc_ids_preserved() {
1848 let mut m = TopicModeler::new(default_config_2topics());
1849 let corpus = make_corpus();
1850 let result = m.fit(&corpus).expect("fit failed");
1851 let ids: Vec<&str> = result
1852 .doc_topic_distributions
1853 .iter()
1854 .map(|d| d.doc_id.as_str())
1855 .collect();
1856 assert!(ids.contains(&"d1"));
1857 assert!(ids.contains(&"d6"));
1858 }
1859
1860 #[test]
1862 fn test_topic_ids_sequential() {
1863 let mut m = TopicModeler::new(default_config_2topics());
1864 let corpus = make_corpus();
1865 let result = m.fit(&corpus).expect("fit failed");
1866 for (i, topic) in result.topics.iter().enumerate() {
1867 assert_eq!(topic.id, i);
1868 }
1869 }
1870
1871 #[test]
1873 fn test_vocabulary_sorted() {
1874 let mut m = TopicModeler::new(default_config_2topics());
1875 let corpus = make_corpus();
1876 m.fit(&corpus).expect("fit failed");
1877 let sorted = {
1878 let mut v = m.vocabulary.clone();
1879 v.sort();
1880 v
1881 };
1882 assert_eq!(m.vocabulary, sorted);
1883 }
1884
1885 #[test]
1887 fn test_perplexity_reasonable() {
1888 let mut m = TopicModeler::new(default_config_2topics());
1889 let corpus = make_corpus();
1890 let result = m.fit(&corpus).expect("fit failed");
1891 assert!(
1892 result.perplexity < 1000.0,
1893 "perplexity={}",
1894 result.perplexity
1895 );
1896 }
1897
1898 #[test]
1900 fn test_error_display_insufficient() {
1901 let err = TopicModelError::InsufficientDocuments { min: 5, got: 2 };
1902 let s = err.to_string();
1903 assert!(s.contains("5"), "msg={s}");
1904 assert!(s.contains("2"), "msg={s}");
1905 }
1906
1907 #[test]
1909 fn test_error_display_empty_vocab() {
1910 let err = TopicModelError::EmptyVocabulary;
1911 let s = err.to_string();
1912 assert!(s.contains("empty"), "msg={s}");
1913 }
1914
1915 #[test]
1917 fn test_error_display_invalid_config() {
1918 let err = TopicModelError::InvalidConfig("test reason".to_string());
1919 let s = err.to_string();
1920 assert!(s.contains("test reason"), "msg={s}");
1921 }
1922
1923 #[test]
1925 fn test_stats_avg_coherence_finite() {
1926 let mut m = TopicModeler::new(default_config_2topics());
1927 let corpus = make_corpus();
1928 let result = m.fit(&corpus).expect("fit failed");
1929 let stats = TopicModeler::stats(&result);
1930 assert!(stats.avg_topic_coherence.is_finite());
1931 }
1932}