Skip to main content

ipfrs_semantic/
topic_modeler.rs

1//! Semantic Topic Modeller — online clustering approach for latent topic modelling.
2//!
3//! Models latent topics from a collection of embeddings using a simple online
4//! clustering approach, assigning documents to topics and tracking topic drift
5//! over time.
6
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Cosine similarity helper
11// ---------------------------------------------------------------------------
12
13/// Computes the cosine similarity between two vectors.
14///
15/// Returns `0.0` when either vector has zero norm.
16pub 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// ---------------------------------------------------------------------------
30// TopicModel
31// ---------------------------------------------------------------------------
32
33/// A latent topic represented as a running centroid of assigned embeddings.
34#[derive(Debug, Clone)]
35pub struct TopicModel {
36    /// Unique identifier for this topic.
37    pub topic_id: u64,
38    /// Running mean of all embeddings assigned to this topic.
39    pub centroid: Vec<f32>,
40    /// Number of documents assigned to this topic.
41    pub member_count: u64,
42    /// Sum of assignment weights (cosine similarities at assignment time).
43    pub total_weight: f64,
44    /// Human-readable label (defaults to `"topic_<id>"`).
45    pub label: String,
46}
47
48impl TopicModel {
49    /// Coherence measure: `member_count / (1.0 + total_weight)`.
50    ///
51    /// A higher value indicates a more focused (tight) topic.
52    pub fn coherence(&self) -> f64 {
53        self.member_count as f64 / (1.0 + self.total_weight)
54    }
55}
56
57// ---------------------------------------------------------------------------
58// TopicAssignment
59// ---------------------------------------------------------------------------
60
61/// Records the assignment of a document to a topic at a point in time.
62#[derive(Debug, Clone)]
63pub struct TopicAssignment {
64    /// Document identifier.
65    pub doc_id: u64,
66    /// Topic the document was assigned to.
67    pub topic_id: u64,
68    /// Cosine similarity to the topic centroid at assignment time.
69    /// `1.0` when the document created a brand-new topic.
70    pub confidence: f32,
71    /// Unix timestamp (seconds) when the assignment was made.
72    pub assigned_at_secs: u64,
73}
74
75// ---------------------------------------------------------------------------
76// ModellerConfig
77// ---------------------------------------------------------------------------
78
79/// Configuration for [`SemanticTopicModeller`].
80#[derive(Debug, Clone)]
81pub struct ModellerConfig {
82    /// Maximum number of distinct topics allowed.  Default: `20`.
83    pub max_topics: usize,
84    /// If the maximum cosine similarity between an embedding and every existing
85    /// topic centroid is below this threshold, a new topic is created.  Default: `0.5`.
86    pub new_topic_threshold: f32,
87    /// Learning rate for the online centroid update rule:
88    /// `centroid = (1 - lr) * centroid + lr * embedding`.  Default: `0.1`.
89    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// ---------------------------------------------------------------------------
103// TopicModellerStats
104// ---------------------------------------------------------------------------
105
106/// Aggregate statistics for a [`SemanticTopicModeller`].
107#[derive(Debug, Clone)]
108pub struct TopicModellerStats {
109    /// Total number of documents that have been assigned (including re-assigns).
110    pub total_documents: u64,
111    /// Current number of distinct topics.
112    pub total_topics: usize,
113    /// Average member count across all topics.  `0.0` when there are no topics.
114    pub avg_topic_size: f64,
115    /// Member count of the largest topic.  `0` when there are no topics.
116    pub largest_topic_members: u64,
117}
118
119// ---------------------------------------------------------------------------
120// SemanticTopicModeller
121// ---------------------------------------------------------------------------
122
123/// Online topic modeller that clusters embeddings into latent topics.
124///
125/// New documents are assigned to the closest existing topic (by cosine
126/// similarity).  If the best similarity is below [`ModellerConfig::new_topic_threshold`]
127/// and the topic limit has not been reached, a fresh topic is created.
128pub struct SemanticTopicModeller {
129    /// All topics keyed by `topic_id`.
130    pub topics: HashMap<u64, TopicModel>,
131    /// All assignment records in insertion order.
132    pub assignments: Vec<TopicAssignment>,
133    /// Monotonically increasing counter used to allocate topic IDs.
134    pub next_topic_id: u64,
135    /// Modeller configuration.
136    pub config: ModellerConfig,
137}
138
139impl SemanticTopicModeller {
140    /// Creates a new, empty topic modeller with the given configuration.
141    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    /// Assigns `embedding` (associated with `doc_id`) to a topic.
151    ///
152    /// # Algorithm
153    ///
154    /// 1. If there are no topics yet, always create the first topic.
155    /// 2. Find the topic whose centroid has the highest cosine similarity to
156    ///    `embedding`.
157    /// 3. If `max_sim < new_topic_threshold` **and** the number of topics is
158    ///    below `max_topics`, create a new topic with `centroid = embedding`.
159    /// 4. Otherwise assign to the best topic and update its centroid via the
160    ///    online update rule:
161    ///    `centroid = (1 - lr) * centroid + lr * embedding`.
162    ///
163    /// Returns the [`TopicAssignment`] that was recorded.
164    pub fn assign(&mut self, doc_id: u64, embedding: Vec<f32>, now_secs: u64) -> TopicAssignment {
165        if self.topics.is_empty() {
166            // Always create the very first topic.
167            return self.create_topic(doc_id, embedding, now_secs);
168        }
169
170        // Find best existing topic.
171        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            // Assign to best existing topic and update centroid.
190            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            // Online centroid update: centroid = (1-lr)*centroid + lr*embedding
197            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    /// Returns a reference to a topic by ID, or `None` if not found.
215    pub fn topic(&self, topic_id: u64) -> Option<&TopicModel> {
216        self.topics.get(&topic_id)
217    }
218
219    /// Returns all assignments for the given document, sorted by
220    /// `assigned_at_secs` descending (most recent first).
221    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    /// Returns up to `k` topics sorted by `member_count` descending.
232    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    /// Renames a topic.  Returns `false` if the topic ID does not exist.
240    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    /// Returns aggregate statistics for the current state of the modeller.
251    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    // -----------------------------------------------------------------------
276    // Private helpers
277    // -----------------------------------------------------------------------
278
279    /// Allocates a new topic ID, inserts a new [`TopicModel`], records the
280    /// assignment, and returns the [`TopicAssignment`].
281    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// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[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    // 1. new() starts empty
327    #[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    // 2. assign first document creates first topic
336    #[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    // 3. assign similar document joins existing topic
348    #[test]
349    fn test_assign_similar_joins_existing_topic() {
350        let mut m = default_modeller();
351        // First document.
352        let emb1 = vec![1.0_f32, 0.0, 0.0, 0.0];
353        m.assign(1, emb1, 100);
354        // Very similar second document.
355        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    // 4. assign dissimilar document creates new topic
362    #[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        // Orthogonal vector — similarity is 0.0, well below threshold 0.5.
368        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    // 5. assign at max_topics assigns to closest existing topic (never creates new)
375    #[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        // Create exactly 2 topics with orthogonal vectors.
384        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        // Completely dissimilar (also orthogonal to both), but max_topics reached.
389        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        // Should be assigned to the closest of the two existing topics.
392        assert!(m.topics.contains_key(&a3.topic_id));
393    }
394
395    // 6. centroid updated via learning rate
396    #[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        // Seed with a vector.
405        m.assign(1, vec![1.0, 0.0], 100);
406        // Assign a similar but different vector.
407        let emb2 = vec![0.8_f32, 0.6]; // sim ≈ 0.8
408        m.assign(2, emb2.clone(), 200);
409
410        let topic = m.topics.get(&0).expect("topic 0 should exist");
411        // After update: centroid = 0.5 * [1,0] + 0.5 * [0.8, 0.6] = [0.9, 0.3]
412        assert!((topic.centroid[0] - 0.9).abs() < 1e-5);
413        assert!((topic.centroid[1] - 0.3).abs() < 1e-5);
414    }
415
416    // 7. member_count increments on join
417    #[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        // Similar — joins topic 0.
422        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    // 8. total_weight accumulates
428    #[test]
429    fn test_total_weight_accumulates() {
430        let mut m = default_modeller();
431        m.assign(1, vec![1.0, 0.0], 100);
432        // Sim between [1,0] and [0.99, 0.141] ≈ 0.99
433        m.assign(2, vec![0.99_f32, 0.141], 200);
434        let topic = m.topics.get(&0).expect("topic 0");
435        // total_weight starts at 1.0 (from creation) then adds the similarity.
436        assert!(topic.total_weight > 1.0);
437    }
438
439    // 9. confidence = 1.0 for new topic
440    #[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    // 10. confidence = similarity for existing topic
448    #[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]; // cosine sim to [1,0] = 0.6
454        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    // 11. topic() Some for existing id
460    #[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    // 12. topic() None for unknown id
468    #[test]
469    fn test_topic_none_for_unknown() {
470        let m = default_modeller();
471        assert!(m.topic(999).is_none());
472    }
473
474    // 13. assignments_for_doc returns correct assignments sorted desc
475    #[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    // 14. assignments_for_doc filters by doc_id
489    #[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    // 15. top_topics sorted by member_count desc
500    #[test]
501    fn test_top_topics_sorted_by_member_count_desc() {
502        let mut m = default_modeller();
503        // Topic 0: 3 members.
504        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        // Topic 1: 1 member.
508        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    // 16. top_topics capped at k
516    #[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    // 17. relabel sets label
526    #[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    // 18. relabel returns false for unknown topic
536    #[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    // 19. stats total_documents
543    #[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    // 20. stats total_topics
553    #[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    // 21. stats avg_topic_size
563    #[test]
564    fn test_stats_avg_topic_size() {
565        let mut m = default_modeller();
566        // Topic 0: 3 docs.
567        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        // Topic 1: 1 doc.
571        m.assign(10, vec![0.0, 1.0], 200);
572        let s = m.stats();
573        // (3 + 1) / 2 = 2.0
574        assert!((s.avg_topic_size - 2.0).abs() < 1e-6);
575    }
576
577    // 22. stats avg_topic_size is 0.0 when no topics
578    #[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    // 23. stats largest_topic_members
586    #[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    // 24. stats largest_topic_members is 0 when no topics
598    #[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    // 25. coherence formula
606    #[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        // 4 / (1 + 3) = 1.0
616        assert!((t.coherence() - 1.0).abs() < 1e-9);
617    }
618
619    // 26. cosine_sim identical vectors → 1.0
620    #[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    // 27. cosine_sim orthogonal vectors → 0.0
627    #[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    // 28. cosine_sim zero vector → 0.0
635    #[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    // 29. default label format
643    #[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    // 30. next_topic_id increments with each new topic
652    #[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// ===========================================================================
664// LDA-Based TopicModeler — Collapsed Gibbs Sampling
665// ===========================================================================
666
667/// A word and its probability within a topic.
668#[derive(Debug, Clone)]
669pub struct TopicWord {
670    /// The word string.
671    pub word: String,
672    /// Probability of this word in the topic.
673    pub probability: f64,
674}
675
676/// A latent topic with its top words and coherence score.
677#[derive(Debug, Clone)]
678pub struct LdaTopic {
679    /// Unique topic identifier (0-indexed).
680    pub id: usize,
681    /// Top-K words by probability.
682    pub top_words: Vec<TopicWord>,
683    /// Topic coherence score (PMI-based).
684    pub coherence: f64,
685}
686
687/// Topic probability distribution for a single document.
688#[derive(Debug, Clone)]
689pub struct DocumentTopics {
690    /// Document identifier.
691    pub doc_id: String,
692    /// Probability vector over all topics; sums to approximately 1.0.
693    pub topic_distribution: Vec<f64>,
694    /// Index of the dominant (argmax) topic.
695    pub dominant_topic: usize,
696}
697
698/// Bag-of-words representation of a document.
699#[derive(Debug, Clone)]
700pub struct ModelDocument {
701    /// Document identifier.
702    pub doc_id: String,
703    /// Word → count mapping.
704    pub word_counts: HashMap<String, u32>,
705}
706
707/// Hyperparameters for the LDA topic model.
708#[derive(Debug, Clone)]
709pub struct TopicModelConfig {
710    /// Number of topics to discover.
711    pub n_topics: usize,
712    /// Number of top words to include per topic.
713    pub n_top_words: usize,
714    /// Document-topic Dirichlet prior.
715    pub alpha: f64,
716    /// Topic-word Dirichlet prior.
717    pub beta: f64,
718    /// Maximum number of Gibbs sampling iterations.
719    pub max_iter: u32,
720    /// Seed for the xorshift64 PRNG.
721    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/// Full output of a fitted topic model.
738#[derive(Debug, Clone)]
739pub struct TopicModelResult {
740    /// Discovered topics.
741    pub topics: Vec<LdaTopic>,
742    /// Per-document topic distributions.
743    pub doc_topic_distributions: Vec<DocumentTopics>,
744    /// Model perplexity on the training corpus.
745    pub perplexity: f64,
746    /// Number of documents used for fitting.
747    pub n_docs: usize,
748    /// Vocabulary size.
749    pub n_words: usize,
750    /// Number of Gibbs iterations actually run.
751    pub iterations_run: u32,
752}
753
754/// Error type for LDA topic modeling operations.
755#[derive(Debug, Clone, PartialEq)]
756pub enum TopicModelError {
757    /// Fewer documents than required.
758    InsufficientDocuments {
759        /// Minimum required.
760        min: usize,
761        /// Actually provided.
762        got: usize,
763    },
764    /// The combined vocabulary of all documents is empty.
765    EmptyVocabulary,
766    /// A configuration parameter is invalid.
767    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/// Aggregate statistics derived from a fitted model result.
785#[derive(Debug, Clone)]
786pub struct TopicModelerStats {
787    /// Number of topics in the model.
788    pub n_topics: usize,
789    /// Vocabulary size used during fitting.
790    pub vocabulary_size: usize,
791    /// Mean coherence across all topics.
792    pub avg_topic_coherence: f64,
793    /// For each topic index, the number of documents whose dominant topic is that index.
794    pub dominant_topic_distribution: Vec<usize>,
795}
796
797// ---------------------------------------------------------------------------
798// Xorshift64 PRNG (inline, no external crate)
799// ---------------------------------------------------------------------------
800
801#[inline]
802fn xorshift64(state: &mut u64) -> u64 {
803    *state ^= *state << 13;
804    *state ^= *state >> 7;
805    *state ^= *state << 17;
806    *state
807}
808
809// ---------------------------------------------------------------------------
810// TopicModeler
811// ---------------------------------------------------------------------------
812
813/// LDA-based topic modeler using collapsed Gibbs sampling.
814///
815/// Build the vocabulary from a corpus, run Gibbs sampling, and produce
816/// per-topic word distributions and per-document topic distributions.
817pub struct TopicModeler {
818    /// Model configuration.
819    pub config: TopicModelConfig,
820    /// Sorted vocabulary list (index → word).
821    pub vocabulary: Vec<String>,
822    /// Word → vocabulary index lookup.
823    pub word_index: HashMap<String, usize>,
824}
825
826impl TopicModeler {
827    /// Creates a new `TopicModeler` with the given configuration.
828    pub fn new(config: TopicModelConfig) -> Self {
829        Self {
830            config,
831            vocabulary: Vec::new(),
832            word_index: HashMap::new(),
833        }
834    }
835
836    /// Validate configuration parameters.
837    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    /// Build vocabulary from a collection of documents, updating internal state.
862    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    /// Fit the topic model to `documents` using collapsed Gibbs sampling.
879    ///
880    /// Returns a [`TopicModelResult`] with topic word distributions, per-document
881    /// topic distributions, perplexity, and metadata.
882    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        // Expand each document into a flat list of (word_index) tokens, tracking
908        // which document each token belongs to.
909        // doc_tokens[d] = Vec of word indices (with repetition per count)
910        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        // Count matrices
924        // doc_topic_counts[d][t] — tokens in doc d assigned to topic t
925        let mut doc_topic_counts: Vec<Vec<u32>> = vec![vec![0u32; k]; n_docs];
926        // topic_word_counts[t][w] — times word w assigned to topic t
927        let mut topic_word_counts: Vec<Vec<u32>> = vec![vec![0u32; vocab_size]; k];
928        // topic_counts[t] — total tokens assigned to topic t
929        let mut topic_counts: Vec<u32> = vec![0u32; k];
930
931        // topic_assignments[d][token_pos] — current topic assignment
932        let mut topic_assignments: Vec<Vec<usize>> = Vec::with_capacity(n_docs);
933
934        let mut rng_state: u64 = self.config.seed;
935
936        // Initialise assignments randomly
937        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        // Gibbs sampling iterations
950        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                    // Remove current assignment
959                    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                    // Compute conditional probabilities
964                    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                    // Sample new topic
975                    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                    // Reassign
980                    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        // Build topic word distributions
989        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            // Build (word_index, probability) pairs
994            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, // filled below
1012            });
1013        }
1014
1015        // Build document topic distributions
1016        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        // Compute corpus-level word counts for coherence
1037        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        // Fill coherence scores
1045        for topic in &mut topics {
1046            topic.coherence = Self::coherence_score_inner(topic, &corpus_word_counts);
1047        }
1048
1049        // Compute perplexity
1050        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    /// Infer topic distributions for `documents` given an already fitted model.
1072    ///
1073    /// Runs a short round of Gibbs sampling on the new documents, keeping
1074    /// `topic_word_counts` frozen (using only the fitted model's topic-word
1075    /// distributions as priors).
1076    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        // Reconstruct topic-word probability lookup from result
1095        // topic_word_prob[t][word] = probability
1096        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            // Flatten tokens
1113            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                // Empty document: uniform distribution
1122                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            // Initialise doc-topic counts
1132            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            // Short inference iterations (use max_iter from config)
1141            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    /// Compute a simplified PMI-based coherence score for a topic.
1188    ///
1189    /// For each pair of top words (w_i, w_j), adds `log((co_count + 1) / (count_j + 1))`
1190    /// where `co_count` is approximated as `min(count_i, count_j)` (simplified co-occurrence).
1191    /// Returns the mean over all pairs; returns `0.0` for single-word topics.
1192    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                // Simplified co-occurrence: min of individual counts
1208                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    /// Compute cosine similarity between the word-probability distributions of two topics.
1221    ///
1222    /// Both topics must exist in `result.topics`.  Returns `0.0` if either topic id
1223    /// is out of bounds or the distributions are zero.
1224    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        // Build dense probability vectors indexed by vocabulary position using top_words
1229        // We use all top_words probabilities as sparse vectors
1230        let ta = &result.topics[topic_a];
1231        let tb = &result.topics[topic_b];
1232
1233        // Collect all word keys from both topics
1234        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    /// Return references to the top `n` documents ranked by `topic_distribution[topic_id]`.
1274    ///
1275    /// Returns an empty slice if `topic_id` is out of range.
1276    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    /// Compute aggregate statistics from a fitted model result.
1299    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    // -----------------------------------------------------------------------
1324    // Private: perplexity computation
1325    // -----------------------------------------------------------------------
1326
1327    #[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            // also account for words in vocabulary not in this doc (zero contribution)
1371            let _ = vocab_size; // used in v_beta passed in
1372        }
1373
1374        if total_tokens == 0 {
1375            return f64::INFINITY;
1376        }
1377        (-log_likelihood / total_tokens as f64).exp()
1378    }
1379}
1380
1381// ===========================================================================
1382// LDA TopicModeler tests
1383// ===========================================================================
1384
1385#[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        // Two clear clusters: tech and nature
1406        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    // 1. new() initialises empty vocabulary
1446    #[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    // 2. fit returns correct n_docs
1454    #[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    // 3. fit returns correct n_words (vocabulary size)
1463    #[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        // unique words: rust, code, compile, memory, compiler, type, safe, forest, tree, leaf, nature, river
1469        assert_eq!(result.n_words, 12);
1470    }
1471
1472    // 4. fit returns correct number of topics
1473    #[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    // 5. fit iterations_run equals max_iter
1482    #[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    // 6. topic distributions sum to ~1.0
1491    #[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    // 7. dominant_topic is within valid range
1503    #[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    // 8. each topic has n_top_words words
1514    #[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    // 9. top_words probabilities are positive
1525    #[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    // 10. top_words are sorted descending by probability
1543    #[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    // 11. perplexity is finite and positive
1557    #[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    // 12. fit error on insufficient documents
1567    #[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    // 13. fit error on empty vocabulary
1585    #[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    // 14. InvalidConfig: n_topics = 0
1597    #[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    // 15. InvalidConfig: alpha = 0
1609    #[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    // 16. InvalidConfig: beta = 0
1621    #[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    // 17. coherence_score returns 0.0 for single-word topic
1633    #[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    // 18. coherence_score returns finite value for multi-word topic
1649    #[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    // 19. coherence_score empty corpus → still finite
1678    #[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    // 20. most_similar_topics: identical topics → 1.0
1700    #[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    // 21. most_similar_topics: two different topics ∈ [-1, 1]
1710    #[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    // 22. most_similar_topics: out-of-range id → 0.0
1720    #[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    // 23. top_documents_for_topic returns at most n docs
1730    #[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    // 24. top_documents_for_topic sorted by topic probability descending
1740    #[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    // 25. stats n_topics matches result
1755    #[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    // 26. stats vocabulary_size matches result.n_words
1765    #[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    // 27. stats dominant_topic_distribution sums to n_docs
1775    #[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    // 28. transform returns one result per input document
1786    #[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    // 29. transform distributions sum to ~1.0
1800    #[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    // 30. transform empty document → uniform distribution
1812    #[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; // 1.0 / 2 topics
1820        for &p in &inferred[0].topic_distribution {
1821            assert!((p - expected).abs() < 1e-9, "p={p}");
1822        }
1823    }
1824
1825    // 31. xorshift64: different seeds produce different values
1826    #[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    // 32. xorshift64: sequence is deterministic
1836    #[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    // 33. doc_id preserved in fit output
1846    #[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    // 34. topic ids are 0..n_topics
1861    #[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    // 35. vocabulary is sorted alphabetically
1872    #[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    // 36. fit result perplexity is reasonable (< 1000 for small corpus)
1886    #[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    // 37. TopicModelError Display for InsufficientDocuments
1899    #[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    // 38. TopicModelError Display for EmptyVocabulary
1908    #[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    // 39. TopicModelError Display for InvalidConfig
1916    #[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    // 40. stats avg_topic_coherence is finite
1924    #[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}