1use std::cmp::Reverse;
40use std::collections::HashMap;
41
42pub type HtmTopicNodeId = u64;
48
49pub type HtmDocId = u64;
51
52#[inline]
58fn xorshift64(state: &mut u64) -> u64 {
59 let mut x = *state;
60 x ^= x << 13;
61 x ^= x >> 7;
62 x ^= x << 17;
63 *state = x;
64 x
65}
66
67#[inline]
69fn fnv1a_64(data: &[u8]) -> u64 {
70 let mut h: u64 = 14_695_981_039_346_656_037;
71 for &b in data {
72 h ^= b as u64;
73 h = h.wrapping_mul(1_099_511_628_211);
74 }
75 h
76}
77
78#[derive(Debug, Clone)]
84pub struct HtmModelConfig {
85 pub max_depth: u32,
87 pub max_children_per_node: usize,
89 pub alpha: f64,
91 pub beta: f64,
93 pub n_iterations: usize,
95 pub seed: u64,
97}
98
99impl Default for HtmModelConfig {
100 fn default() -> Self {
101 Self {
102 max_depth: 3,
103 max_children_per_node: 8,
104 alpha: 0.1,
105 beta: 0.01,
106 n_iterations: 100,
107 seed: 12_345,
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
118pub struct HtmTopicNode {
119 pub id: HtmTopicNodeId,
121 pub parent: Option<HtmTopicNodeId>,
123 pub children: Vec<HtmTopicNodeId>,
125 pub depth: u32,
127 pub word_counts: Vec<u32>,
129 pub total_words: u32,
131 pub label: Option<String>,
133}
134
135impl HtmTopicNode {
136 fn new(
137 id: HtmTopicNodeId,
138 parent: Option<HtmTopicNodeId>,
139 depth: u32,
140 vocab_size: usize,
141 ) -> Self {
142 Self {
143 id,
144 parent,
145 children: Vec::new(),
146 depth,
147 word_counts: vec![0u32; vocab_size],
148 total_words: 0,
149 label: None,
150 }
151 }
152
153 fn ensure_vocab_size(&mut self, vocab_size: usize) {
155 if self.word_counts.len() < vocab_size {
156 self.word_counts.resize(vocab_size, 0);
157 }
158 }
159
160 fn increment_word(&mut self, word_idx: usize) {
162 if word_idx >= self.word_counts.len() {
163 self.word_counts.resize(word_idx + 1, 0);
164 }
165 self.word_counts[word_idx] = self.word_counts[word_idx].saturating_add(1);
166 self.total_words = self.total_words.saturating_add(1);
167 }
168
169 fn decrement_word(&mut self, word_idx: usize) {
171 if word_idx < self.word_counts.len() && self.word_counts[word_idx] > 0 {
172 self.word_counts[word_idx] -= 1;
173 if self.total_words > 0 {
174 self.total_words -= 1;
175 }
176 }
177 }
178}
179
180#[derive(Debug, Clone)]
186pub struct HtmDocument {
187 pub id: HtmDocId,
189 pub token_indices: Vec<u32>,
191 pub topic_assignments: Vec<HtmTopicNodeId>,
193 pub path: Vec<HtmTopicNodeId>,
195}
196
197#[derive(Debug, Clone)]
203pub struct HtmTopic {
204 pub id: HtmTopicNodeId,
206 pub top_words: Vec<(String, f64)>,
208 pub coherence: f64,
210 pub doc_count: u32,
212 pub depth: u32,
214}
215
216#[derive(Debug, Clone)]
218pub struct HtmModelStats {
219 pub n_topics: usize,
221 pub n_docs: usize,
223 pub vocab_size: usize,
225 pub avg_coherence: f64,
227 pub max_depth: u32,
229}
230
231pub struct HierarchicalTopicModel {
239 config: HtmModelConfig,
240 topics: HashMap<HtmTopicNodeId, HtmTopicNode>,
242 root: HtmTopicNodeId,
244 documents: HashMap<HtmDocId, HtmDocument>,
246 vocab: HashMap<String, u32>,
248 vocab_inv: Vec<String>,
250 next_topic_id: HtmTopicNodeId,
252 next_doc_id: HtmDocId,
254 rng_state: u64,
256}
257
258impl HierarchicalTopicModel {
259 pub fn new(config: HtmModelConfig) -> Self {
265 let seed = config.seed;
266 let root_node = HtmTopicNode::new(0, None, 0, 0);
267 let mut topics = HashMap::new();
268 topics.insert(0, root_node);
269
270 Self {
271 config,
272 topics,
273 root: 0,
274 documents: HashMap::new(),
275 vocab: HashMap::new(),
276 vocab_inv: Vec::new(),
277 next_topic_id: 1,
278 next_doc_id: 0,
279 rng_state: if seed == 0 {
280 6_364_136_223_846_793_005
281 } else {
282 seed
283 },
284 }
285 }
286
287 fn get_or_insert_word(&mut self, word: &str) -> u32 {
293 if let Some(&idx) = self.vocab.get(word) {
294 return idx;
295 }
296 let idx = self.vocab_inv.len() as u32;
297 self.vocab_inv.push(word.to_owned());
298 self.vocab.insert(word.to_owned(), idx);
299 let new_size = self.vocab_inv.len();
301 for node in self.topics.values_mut() {
302 node.ensure_vocab_size(new_size);
303 }
304 idx
305 }
306
307 pub fn add_topic_node(
316 &mut self,
317 parent: Option<HtmTopicNodeId>,
318 label: Option<String>,
319 ) -> Result<HtmTopicNodeId, String> {
320 let parent_id = parent.unwrap_or(self.root);
321
322 let (parent_depth, parent_children_count) = {
323 let p = self
324 .topics
325 .get(&parent_id)
326 .ok_or_else(|| format!("parent topic node {parent_id} does not exist"))?;
327 (p.depth, p.children.len())
328 };
329
330 if parent_depth + 1 > self.config.max_depth {
331 return Err(format!(
332 "cannot create node at depth {}; max_depth is {}",
333 parent_depth + 1,
334 self.config.max_depth
335 ));
336 }
337 if parent_children_count >= self.config.max_children_per_node {
338 return Err(format!(
339 "parent node {parent_id} already has {} children (max {})",
340 parent_children_count, self.config.max_children_per_node
341 ));
342 }
343
344 let new_id = self.next_topic_id;
345 self.next_topic_id += 1;
346
347 let vocab_size = self.vocab_inv.len();
348 let mut node = HtmTopicNode::new(new_id, Some(parent_id), parent_depth + 1, vocab_size);
349 node.label = label;
350
351 self.topics.insert(new_id, node);
352 if let Some(p) = self.topics.get_mut(&parent_id) {
353 p.children.push(new_id);
354 }
355
356 Ok(new_id)
357 }
358
359 pub fn add_document(&mut self, tokens: &[&str]) -> HtmDocId {
368 let mut token_indices: Vec<u32> = Vec::with_capacity(tokens.len());
369 for &tok in tokens {
370 let lower = tok.to_lowercase();
371 if lower.is_empty() {
372 continue;
373 }
374 let idx = self.get_or_insert_word(&lower);
375 token_indices.push(idx);
376 }
377
378 let doc_id = self.next_doc_id;
379 self.next_doc_id += 1;
380
381 let path = self.build_initial_path();
383 let n_tokens = token_indices.len();
384 let mut topic_assignments: Vec<HtmTopicNodeId> = Vec::with_capacity(n_tokens);
385
386 let path_len = path.len();
387 for &token_idx in &token_indices {
388 let r = xorshift64(&mut self.rng_state);
390 let node_id = if path_len > 0 {
391 path[r as usize % path_len]
392 } else {
393 self.root
394 };
395 topic_assignments.push(node_id);
396
397 let word_idx = token_idx as usize;
399 if let Some(node) = self.topics.get_mut(&node_id) {
400 node.increment_word(word_idx);
401 }
402 }
403
404 let doc = HtmDocument {
405 id: doc_id,
406 token_indices,
407 topic_assignments,
408 path,
409 };
410 self.documents.insert(doc_id, doc);
411 doc_id
412 }
413
414 fn build_initial_path(&mut self) -> Vec<HtmTopicNodeId> {
423 let mut path = Vec::new();
424 let mut current = self.root;
425 path.push(current);
426
427 loop {
428 let (depth, children) = {
429 let node = match self.topics.get(¤t) {
430 Some(n) => n,
431 None => break,
432 };
433 (node.depth, node.children.clone())
434 };
435
436 if depth >= self.config.max_depth {
437 break;
438 }
439
440 if children.is_empty() {
441 let new_id = self.next_topic_id;
443 self.next_topic_id += 1;
444 let vocab_size = self.vocab_inv.len();
445 let child = HtmTopicNode::new(new_id, Some(current), depth + 1, vocab_size);
446 self.topics.insert(new_id, child);
447 if let Some(parent_node) = self.topics.get_mut(¤t) {
448 parent_node.children.push(new_id);
449 }
450 path.push(new_id);
451 current = new_id;
452 } else {
453 let best = children
455 .iter()
456 .filter_map(|&cid| self.topics.get(&cid).map(|n| (cid, n.total_words)))
457 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
458
459 match best {
460 Some((bid, _)) => {
461 path.push(bid);
462 current = bid;
463 }
464 None => break,
465 }
466 }
467 }
468 path
469 }
470
471 fn path_to_root(&self, node_id: HtmTopicNodeId) -> Vec<HtmTopicNodeId> {
473 let mut path = Vec::new();
474 let mut current = node_id;
475 loop {
476 path.push(current);
477 match self.topics.get(¤t).and_then(|n| n.parent) {
478 Some(pid) => current = pid,
479 None => break,
480 }
481 }
482 path.reverse();
483 path
484 }
485
486 pub fn run_inference(&mut self, n_iter: usize) {
495 let iterations = if n_iter == 0 {
496 self.config.n_iterations
497 } else {
498 n_iter
499 };
500 let vocab_size = self.vocab_inv.len();
501 let beta = self.config.beta;
502 let alpha = self.config.alpha;
503
504 for _iter in 0..iterations {
505 let doc_ids: Vec<HtmDocId> = self.documents.keys().copied().collect();
506
507 for doc_id in doc_ids {
508 self.resample_path(doc_id);
510
511 let (token_indices, old_assignments, path) = {
513 let doc = match self.documents.get(&doc_id) {
514 Some(d) => d,
515 None => continue,
516 };
517 (
518 doc.token_indices.clone(),
519 doc.topic_assignments.clone(),
520 doc.path.clone(),
521 )
522 };
523
524 let path_len = path.len();
525 if path_len == 0 {
526 continue;
527 }
528
529 let mut new_assignments = old_assignments.clone();
530
531 for token_pos in 0..token_indices.len() {
532 let word_idx = token_indices[token_pos] as usize;
533 let old_topic = old_assignments[token_pos];
534
535 if let Some(node) = self.topics.get_mut(&old_topic) {
537 node.decrement_word(word_idx);
538 }
539
540 let mut probs: Vec<f64> = Vec::with_capacity(path_len);
542 for &topic_id in &path {
543 let (wc, tw) = match self.topics.get(&topic_id) {
544 Some(n) => {
545 let wc = n.word_counts.get(word_idx).copied().unwrap_or(0) as f64;
546 (wc, n.total_words as f64)
547 }
548 None => (0.0, 0.0),
549 };
550 let doc_topic_count =
552 old_assignments.iter().filter(|&&t| t == topic_id).count() as f64;
553
554 let p_word_given_topic = (wc + beta) / (tw + vocab_size as f64 * beta);
555 let p_topic_given_doc = doc_topic_count + alpha;
556 probs.push(p_word_given_topic * p_topic_given_doc);
557 }
558
559 let new_topic = self.sample_from_probs(&path, &probs);
561 new_assignments[token_pos] = new_topic;
562
563 if let Some(node) = self.topics.get_mut(&new_topic) {
565 node.increment_word(word_idx);
566 }
567 }
568
569 if let Some(doc) = self.documents.get_mut(&doc_id) {
571 doc.topic_assignments = new_assignments;
572 }
573 }
574 }
575 }
576
577 fn resample_path(&mut self, doc_id: HtmDocId) {
582 let token_indices = match self.documents.get(&doc_id) {
583 Some(d) => d.token_indices.clone(),
584 None => return,
585 };
586
587 let leaf_ids: Vec<HtmTopicNodeId> = self
589 .topics
590 .values()
591 .filter(|n| n.children.is_empty())
592 .map(|n| n.id)
593 .collect();
594
595 if leaf_ids.is_empty() {
596 return;
597 }
598
599 let vocab_size = self.vocab_inv.len();
600 let beta = self.config.beta;
601
602 let mut scores: Vec<f64> = Vec::with_capacity(leaf_ids.len());
604 let mut paths: Vec<Vec<HtmTopicNodeId>> = Vec::with_capacity(leaf_ids.len());
605
606 for &leaf in &leaf_ids {
607 let path = self.path_to_root(leaf);
608 let mut log_score = 0.0_f64;
609
610 for &topic_id in &path {
611 if let Some(node) = self.topics.get(&topic_id) {
612 for &wi in &token_indices {
613 let wc = node.word_counts.get(wi as usize).copied().unwrap_or(0) as f64;
614 let tw = node.total_words as f64;
615 let p = (wc + beta) / (tw + vocab_size as f64 * beta);
616 log_score += p.ln();
617 }
618 }
619 }
620 scores.push(log_score.exp());
621 paths.push(path);
622 }
623
624 let chosen_idx = self.sample_index_from_raw_probs(&scores);
626 let new_path = paths[chosen_idx].clone();
627
628 if let Some(doc) = self.documents.get_mut(&doc_id) {
629 doc.path = new_path;
630 }
631 }
632
633 fn sample_from_probs(&mut self, topics: &[HtmTopicNodeId], probs: &[f64]) -> HtmTopicNodeId {
639 let total: f64 = probs.iter().sum();
640 if total <= 0.0 || topics.is_empty() {
641 let r = xorshift64(&mut self.rng_state);
643 return topics[r as usize % topics.len()];
644 }
645 let threshold = (xorshift64(&mut self.rng_state) as f64 / u64::MAX as f64) * total;
646 let mut cumulative = 0.0_f64;
647 for (i, &p) in probs.iter().enumerate() {
648 cumulative += p;
649 if cumulative >= threshold {
650 return topics[i];
651 }
652 }
653 *topics.last().unwrap_or(&self.root)
654 }
655
656 fn sample_index_from_raw_probs(&mut self, probs: &[f64]) -> usize {
658 let total: f64 = probs.iter().sum();
659 if total <= 0.0 || probs.is_empty() {
660 let r = xorshift64(&mut self.rng_state);
661 return if probs.is_empty() {
662 0
663 } else {
664 r as usize % probs.len()
665 };
666 }
667 let threshold = (xorshift64(&mut self.rng_state) as f64 / u64::MAX as f64) * total;
668 let mut cumulative = 0.0_f64;
669 for (i, &p) in probs.iter().enumerate() {
670 cumulative += p;
671 if cumulative >= threshold {
672 return i;
673 }
674 }
675 probs.len() - 1
676 }
677
678 pub fn get_topic(&self, id: HtmTopicNodeId) -> Option<HtmTopic> {
687 let node = self.topics.get(&id)?;
688 let vocab_size = self.vocab_inv.len();
689 let top_n = 10_usize.min(vocab_size);
690
691 let beta = self.config.beta;
692 let total = node.total_words as f64 + vocab_size as f64 * beta;
693
694 let mut scored: Vec<(f64, usize)> = (0..vocab_size)
696 .map(|wi| {
697 let count = node.word_counts.get(wi).copied().unwrap_or(0) as f64;
698 let score = (count + beta) / total;
699 (score, wi)
700 })
701 .collect();
702
703 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
704
705 let top_words: Vec<(String, f64)> = scored
706 .into_iter()
707 .take(top_n)
708 .filter_map(|(score, wi)| self.vocab_inv.get(wi).map(|w| (w.clone(), score)))
709 .collect();
710
711 let doc_count = self
713 .documents
714 .values()
715 .filter(|doc| doc.topic_assignments.contains(&id))
716 .count() as u32;
717
718 Some(HtmTopic {
719 id,
720 top_words,
721 coherence: 0.0, doc_count,
723 depth: node.depth,
724 })
725 }
726
727 pub fn document_topics(&self, doc_id: HtmDocId) -> Vec<(HtmTopicNodeId, f64)> {
732 let doc = match self.documents.get(&doc_id) {
733 Some(d) => d,
734 None => return Vec::new(),
735 };
736
737 let n_tokens = doc.topic_assignments.len();
738 if n_tokens == 0 {
739 return doc.path.iter().map(|&t| (t, 0.0)).collect();
740 }
741
742 let mut counts: HashMap<HtmTopicNodeId, u32> = HashMap::new();
743 for &t in &doc.topic_assignments {
744 *counts.entry(t).or_insert(0) += 1;
745 }
746
747 doc.path
748 .iter()
749 .map(|&t| {
750 let c = counts.get(&t).copied().unwrap_or(0) as f64;
751 (t, c / n_tokens as f64)
752 })
753 .collect()
754 }
755
756 pub fn topic_hierarchy(&self) -> Vec<(u32, HtmTopicNodeId, Option<HtmTopicNodeId>)> {
759 let mut entries: Vec<(u32, HtmTopicNodeId, Option<HtmTopicNodeId>)> = self
760 .topics
761 .values()
762 .map(|n| (n.depth, n.id, n.parent))
763 .collect();
764 entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
765 entries
766 }
767
768 pub fn compute_coherence(&self, topic_id: HtmTopicNodeId, top_n: usize) -> f64 {
775 let node = match self.topics.get(&topic_id) {
776 Some(n) => n,
777 None => return 0.0,
778 };
779
780 let vocab_size = self.vocab_inv.len();
781 if vocab_size < 2 || self.documents.is_empty() {
782 return 0.0;
783 }
784
785 let effective_top_n = top_n.min(vocab_size);
786
787 let mut scored: Vec<(u32, usize)> = (0..vocab_size)
789 .map(|wi| (node.word_counts.get(wi).copied().unwrap_or(0), wi))
790 .collect();
791 scored.sort_by_key(|b| Reverse(b.0));
792 let top_indices: Vec<usize> = scored
793 .into_iter()
794 .take(effective_top_n)
795 .map(|(_, wi)| wi)
796 .collect();
797
798 if top_indices.len() < 2 {
799 return 0.0;
800 }
801
802 let n_docs = self.documents.len() as f64;
803
804 let mut doc_freq: Vec<f64> = vec![0.0; vocab_size];
806 let mut co_occur: HashMap<(usize, usize), f64> = HashMap::new();
807
808 for doc in self.documents.values() {
809 let mut present: Vec<bool> = vec![false; vocab_size];
811 for &wi in &doc.token_indices {
812 let wi = wi as usize;
813 if wi < vocab_size {
814 present[wi] = true;
815 }
816 }
817 for &wi in &top_indices {
818 if present[wi] {
819 doc_freq[wi] += 1.0;
820 }
821 }
822 for i in 0..top_indices.len() {
824 for j in (i + 1)..top_indices.len() {
825 let wi = top_indices[i];
826 let wj = top_indices[j];
827 if present[wi] && present[wj] {
828 let key = (wi.min(wj), wi.max(wj));
829 *co_occur.entry(key).or_insert(0.0) += 1.0;
830 }
831 }
832 }
833 }
834
835 let mut total_pmi = 0.0_f64;
837 let mut n_pairs = 0_u64;
838
839 for i in 0..top_indices.len() {
840 for j in (i + 1)..top_indices.len() {
841 let wi = top_indices[i];
842 let wj = top_indices[j];
843 let df_i = doc_freq[wi];
844 let df_j = doc_freq[wj];
845 if df_i < 1.0 || df_j < 1.0 {
846 continue;
847 }
848 let key = (wi.min(wj), wi.max(wj));
849 let co = co_occur.get(&key).copied().unwrap_or(0.0);
850 if co < 1.0 {
851 continue;
852 }
853 let pmi = (co * n_docs / (df_i * df_j)).ln();
854 total_pmi += pmi;
855 n_pairs += 1;
856 }
857 }
858
859 if n_pairs == 0 {
860 0.0
861 } else {
862 total_pmi / n_pairs as f64
863 }
864 }
865
866 pub fn prune_empty_topics(&mut self) {
870 loop {
871 let candidates: Vec<HtmTopicNodeId> = self
872 .topics
873 .values()
874 .filter(|n| n.id != self.root && n.children.is_empty() && n.total_words == 0)
875 .map(|n| n.id)
876 .collect();
877
878 if candidates.is_empty() {
879 break;
880 }
881
882 for node_id in candidates {
883 if let Some(node) = self.topics.get(&node_id) {
885 if let Some(parent_id) = node.parent {
886 if let Some(parent) = self.topics.get_mut(&parent_id) {
887 parent.children.retain(|&c| c != node_id);
888 }
889 }
890 }
891 self.topics.remove(&node_id);
892 }
893 }
894 }
895
896 pub fn model_stats(&self) -> HtmModelStats {
898 let n_topics = self.topics.len();
899 let n_docs = self.documents.len();
900 let vocab_size = self.vocab_inv.len();
901 let max_depth = self.topics.values().map(|n| n.depth).max().unwrap_or(0);
902
903 let non_root_ids: Vec<HtmTopicNodeId> = self
904 .topics
905 .keys()
906 .copied()
907 .filter(|&id| id != self.root)
908 .collect();
909
910 let avg_coherence = if non_root_ids.is_empty() {
911 0.0
912 } else {
913 let total: f64 = non_root_ids
914 .iter()
915 .map(|&id| self.compute_coherence(id, 10))
916 .sum();
917 total / non_root_ids.len() as f64
918 };
919
920 HtmModelStats {
921 n_topics,
922 n_docs,
923 vocab_size,
924 avg_coherence,
925 max_depth,
926 }
927 }
928
929 pub fn n_topic_nodes(&self) -> usize {
935 self.topics.len()
936 }
937
938 pub fn topic_node(&self, id: HtmTopicNodeId) -> Option<&HtmTopicNode> {
940 self.topics.get(&id)
941 }
942
943 pub fn vocab_size(&self) -> usize {
945 self.vocab_inv.len()
946 }
947
948 pub fn word_at(&self, idx: u32) -> Option<&str> {
950 self.vocab_inv.get(idx as usize).map(|s| s.as_str())
951 }
952
953 pub fn word_index(&self, word: &str) -> Option<u32> {
955 self.vocab.get(word).copied()
956 }
957
958 pub fn get_document(&self, doc_id: HtmDocId) -> Option<&HtmDocument> {
960 self.documents.get(&doc_id)
961 }
962
963 pub fn root_id(&self) -> HtmTopicNodeId {
965 self.root
966 }
967
968 pub fn hash_bytes(data: &[u8]) -> u64 {
970 fnv1a_64(data)
971 }
972}
973
974#[cfg(test)]
979mod tests {
980 use super::*;
981
982 fn default_model() -> HierarchicalTopicModel {
983 HierarchicalTopicModel::new(HtmModelConfig::default())
984 }
985
986 fn small_model() -> HierarchicalTopicModel {
987 HierarchicalTopicModel::new(HtmModelConfig {
988 max_depth: 2,
989 max_children_per_node: 4,
990 alpha: 0.1,
991 beta: 0.01,
992 n_iterations: 10,
993 seed: 7,
994 })
995 }
996
997 #[test]
1002 fn test_default_config() {
1003 let cfg = HtmModelConfig::default();
1004 assert_eq!(cfg.max_depth, 3);
1005 assert_eq!(cfg.n_iterations, 100);
1006 }
1007
1008 #[test]
1009 fn test_model_initial_state() {
1010 let m = default_model();
1011 assert_eq!(m.n_topic_nodes(), 1, "only root initially");
1012 assert_eq!(m.root_id(), 0);
1013 assert_eq!(m.vocab_size(), 0);
1014 assert_eq!(m.documents.len(), 0);
1015 }
1016
1017 #[test]
1018 fn test_root_node_properties() {
1019 let m = default_model();
1020 let root = m.topic_node(0).expect("root must exist");
1021 assert!(root.parent.is_none());
1022 assert_eq!(root.depth, 0);
1023 assert!(root.children.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_custom_seed() {
1028 let cfg = HtmModelConfig {
1029 seed: 99,
1030 ..Default::default()
1031 };
1032 let m = HierarchicalTopicModel::new(cfg);
1033 assert_eq!(m.rng_state, 99);
1034 }
1035
1036 #[test]
1037 fn test_zero_seed_replaced() {
1038 let cfg = HtmModelConfig {
1039 seed: 0,
1040 ..Default::default()
1041 };
1042 let m = HierarchicalTopicModel::new(cfg);
1043 assert_ne!(
1044 m.rng_state, 0,
1045 "seed 0 should be replaced by a non-zero default"
1046 );
1047 }
1048
1049 #[test]
1054 fn test_vocab_grows_on_add_document() {
1055 let mut m = default_model();
1056 m.add_document(&["hello", "world"]);
1057 assert_eq!(m.vocab_size(), 2);
1058 }
1059
1060 #[test]
1061 fn test_vocab_deduplicates_words() {
1062 let mut m = default_model();
1063 m.add_document(&["rust", "rust", "rust"]);
1064 assert_eq!(m.vocab_size(), 1);
1065 }
1066
1067 #[test]
1068 fn test_vocab_lowercase_normalisation() {
1069 let mut m = default_model();
1070 m.add_document(&["Rust", "RUST", "rust"]);
1071 assert_eq!(m.vocab_size(), 1);
1072 assert_eq!(m.word_index("rust"), Some(0));
1073 }
1074
1075 #[test]
1076 fn test_vocab_index_lookup() {
1077 let mut m = default_model();
1078 m.add_document(&["alpha", "beta", "gamma"]);
1079 assert!(m.word_index("alpha").is_some());
1080 assert!(m.word_index("delta").is_none());
1081 }
1082
1083 #[test]
1084 fn test_word_at() {
1085 let mut m = default_model();
1086 m.add_document(&["one", "two"]);
1087 let idx0 = m.word_index("one").expect("test: 'one' must be in vocab");
1088 assert_eq!(m.word_at(idx0), Some("one"));
1089 }
1090
1091 #[test]
1092 fn test_empty_tokens_skipped() {
1093 let mut m = default_model();
1094 m.add_document(&["", "hello", ""]);
1095 assert_eq!(m.vocab_size(), 1);
1096 }
1097
1098 #[test]
1103 fn test_add_document_returns_sequential_ids() {
1104 let mut m = default_model();
1105 let d0 = m.add_document(&["foo"]);
1106 let d1 = m.add_document(&["bar"]);
1107 assert_eq!(d0, 0);
1108 assert_eq!(d1, 1);
1109 }
1110
1111 #[test]
1112 fn test_get_document_after_add() {
1113 let mut m = default_model();
1114 let id = m.add_document(&["hello", "world"]);
1115 let doc = m.get_document(id).expect("document must exist");
1116 assert_eq!(doc.id, id);
1117 assert_eq!(doc.token_indices.len(), 2);
1118 }
1119
1120 #[test]
1121 fn test_document_path_non_empty() {
1122 let mut m = default_model();
1123 let id = m.add_document(&["a", "b", "c"]);
1124 let doc = m
1125 .get_document(id)
1126 .expect("test: document must exist after add");
1127 assert!(!doc.path.is_empty());
1128 }
1129
1130 #[test]
1131 fn test_document_path_starts_at_root() {
1132 let mut m = default_model();
1133 let id = m.add_document(&["x"]);
1134 let doc = m
1135 .get_document(id)
1136 .expect("test: document must exist after add");
1137 assert_eq!(doc.path[0], m.root_id());
1138 }
1139
1140 #[test]
1141 fn test_assignments_length_matches_tokens() {
1142 let mut m = default_model();
1143 let tokens = ["a", "b", "c", "d", "e"];
1144 let id = m.add_document(&tokens);
1145 let doc = m
1146 .get_document(id)
1147 .expect("test: document must exist after add");
1148 assert_eq!(doc.topic_assignments.len(), tokens.len());
1149 }
1150
1151 #[test]
1152 fn test_assignments_valid_topic_ids() {
1153 let mut m = default_model();
1154 let id = m.add_document(&["rust", "fast", "safe"]);
1155 let doc = m
1156 .get_document(id)
1157 .expect("test: document must exist after add");
1158 for &t in &doc.topic_assignments {
1159 assert!(m.topic_node(t).is_some(), "assigned topic {t} must exist");
1160 }
1161 }
1162
1163 #[test]
1164 fn test_empty_document_adds_cleanly() {
1165 let mut m = default_model();
1166 let id = m.add_document(&[]);
1167 let doc = m
1168 .get_document(id)
1169 .expect("test: document must exist after add");
1170 assert!(doc.token_indices.is_empty());
1171 assert!(doc.topic_assignments.is_empty());
1172 }
1173
1174 #[test]
1179 fn test_add_topic_node_to_root() {
1180 let mut m = default_model();
1181 let id = m.add_topic_node(None, None).expect("should succeed");
1182 assert!(m.topic_node(id).is_some());
1183 assert_eq!(m.n_topic_nodes(), 2);
1184 }
1185
1186 #[test]
1187 fn test_add_topic_node_depth() {
1188 let mut m = default_model();
1189 let child = m
1190 .add_topic_node(None, None)
1191 .expect("test: add topic node to root should succeed");
1192 let grandchild = m
1193 .add_topic_node(Some(child), None)
1194 .expect("test: add grandchild node should succeed");
1195 assert_eq!(
1196 m.topic_node(grandchild)
1197 .expect("test: grandchild must exist")
1198 .depth,
1199 2
1200 );
1201 }
1202
1203 #[test]
1204 fn test_add_topic_node_registers_parent() {
1205 let mut m = default_model();
1206 let child = m
1207 .add_topic_node(None, None)
1208 .expect("test: add topic node to root should succeed");
1209 assert_eq!(
1210 m.topic_node(child).expect("test: child must exist").parent,
1211 Some(0)
1212 );
1213 }
1214
1215 #[test]
1216 fn test_add_topic_node_parent_children_updated() {
1217 let mut m = default_model();
1218 let child = m
1219 .add_topic_node(None, None)
1220 .expect("test: add topic node to root should succeed");
1221 assert!(m
1222 .topic_node(0)
1223 .expect("test: root must exist")
1224 .children
1225 .contains(&child));
1226 }
1227
1228 #[test]
1229 fn test_add_topic_node_with_label() {
1230 let mut m = default_model();
1231 let id = m
1232 .add_topic_node(None, Some("science".to_owned()))
1233 .expect("test: add topic node to root should succeed");
1234 assert_eq!(
1235 m.topic_node(id).expect("test: node must exist").label,
1236 Some("science".to_owned())
1237 );
1238 }
1239
1240 #[test]
1241 fn test_add_topic_node_depth_limit() {
1242 let mut m = HierarchicalTopicModel::new(HtmModelConfig {
1243 max_depth: 1,
1244 ..Default::default()
1245 });
1246 let child = m
1247 .add_topic_node(None, None)
1248 .expect("test: add topic node to root should succeed");
1249 let result = m.add_topic_node(Some(child), None);
1250 assert!(result.is_err(), "depth limit must be enforced");
1251 }
1252
1253 #[test]
1254 fn test_add_topic_node_children_limit() {
1255 let mut m = HierarchicalTopicModel::new(HtmModelConfig {
1256 max_children_per_node: 2,
1257 ..Default::default()
1258 });
1259 m.add_topic_node(None, None)
1260 .expect("test: first child within limit");
1261 m.add_topic_node(None, None)
1262 .expect("test: second child within limit");
1263 let result = m.add_topic_node(None, None);
1264 assert!(result.is_err(), "children limit must be enforced");
1265 }
1266
1267 #[test]
1268 fn test_add_topic_node_invalid_parent() {
1269 let mut m = default_model();
1270 let result = m.add_topic_node(Some(9999), None);
1271 assert!(result.is_err());
1272 }
1273
1274 #[test]
1279 fn test_hierarchy_contains_root() {
1280 let m = default_model();
1281 let h = m.topic_hierarchy();
1282 assert!(h.iter().any(|&(_, id, _)| id == 0));
1283 }
1284
1285 #[test]
1286 fn test_hierarchy_sorted_by_depth() {
1287 let mut m = default_model();
1288 m.add_topic_node(None, None)
1289 .expect("test: add node should succeed");
1290 let h = m.topic_hierarchy();
1291 for i in 1..h.len() {
1292 assert!(h[i - 1].0 <= h[i].0, "hierarchy must be sorted by depth");
1293 }
1294 }
1295
1296 #[test]
1297 fn test_hierarchy_full_tree() {
1298 let mut m = default_model();
1299 let c1 = m
1300 .add_topic_node(None, None)
1301 .expect("test: add c1 should succeed");
1302 let _c2 = m
1303 .add_topic_node(None, None)
1304 .expect("test: add c2 should succeed");
1305 let _gc1 = m
1306 .add_topic_node(Some(c1), None)
1307 .expect("test: add grandchild should succeed");
1308 let h = m.topic_hierarchy();
1309 assert_eq!(h.len(), 4); }
1311
1312 #[test]
1317 fn test_get_topic_root() {
1318 let mut m = default_model();
1319 m.add_document(&["hello", "world"]);
1320 let t = m.get_topic(0);
1321 assert!(t.is_some());
1322 }
1323
1324 #[test]
1325 fn test_get_topic_nonexistent() {
1326 let m = default_model();
1327 assert!(m.get_topic(9999).is_none());
1328 }
1329
1330 #[test]
1331 fn test_get_topic_depth() {
1332 let m = default_model();
1333 let t = m.get_topic(0).expect("test: root topic must exist");
1334 assert_eq!(t.depth, 0);
1335 }
1336
1337 #[test]
1338 fn test_get_topic_top_words_len() {
1339 let mut m = default_model();
1340 m.add_document(&["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"]);
1341 m.run_inference(5);
1342 let t = m
1343 .get_topic(0)
1344 .expect("test: root topic must exist after inference");
1345 assert!(t.top_words.len() <= 10);
1346 }
1347
1348 #[test]
1349 fn test_get_topic_top_words_sum_to_one_approx() {
1350 let mut m = default_model();
1351 m.add_document(&["a", "b", "c"]);
1352 m.run_inference(5);
1353 let t = m
1354 .get_topic(0)
1355 .expect("test: root topic must exist after inference");
1356 let sum: f64 = t.top_words.iter().map(|(_, s)| s).sum();
1357 assert!(sum > 0.0 && sum <= 1.0 + 1e-9);
1359 }
1360
1361 #[test]
1366 fn test_document_topics_sum_to_one() {
1367 let mut m = default_model();
1368 let id = m.add_document(&["rust", "fast", "memory"]);
1369 m.run_inference(5);
1370 let dt = m.document_topics(id);
1371 let sum: f64 = dt.iter().map(|(_, p)| p).sum();
1372 assert!(
1373 (sum - 1.0).abs() < 1e-9 || sum == 0.0,
1374 "proportions must sum to 1"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_document_topics_nonexistent() {
1380 let m = default_model();
1381 let dt = m.document_topics(9999);
1382 assert!(dt.is_empty());
1383 }
1384
1385 #[test]
1386 fn test_document_topics_valid_topic_ids() {
1387 let mut m = default_model();
1388 let id = m.add_document(&["one", "two", "three"]);
1389 m.run_inference(3);
1390 for (tid, _) in m.document_topics(id) {
1391 assert!(m.topic_node(tid).is_some());
1392 }
1393 }
1394
1395 #[test]
1396 fn test_document_topics_empty_doc() {
1397 let mut m = default_model();
1398 let id = m.add_document(&[]);
1399 let dt = m.document_topics(id);
1400 for (_, p) in &dt {
1402 assert_eq!(*p, 0.0);
1403 }
1404 }
1405
1406 #[test]
1411 fn test_run_inference_does_not_panic() {
1412 let mut m = small_model();
1413 m.add_document(&["a", "b", "c"]);
1414 m.run_inference(5);
1415 }
1416
1417 #[test]
1418 fn test_run_inference_keeps_assignment_length() {
1419 let mut m = small_model();
1420 let id = m.add_document(&["x", "y", "z", "w"]);
1421 m.run_inference(10);
1422 let doc = m
1423 .get_document(id)
1424 .expect("test: document must exist after inference");
1425 assert_eq!(doc.topic_assignments.len(), 4);
1426 }
1427
1428 #[test]
1429 fn test_run_inference_zero_iters() {
1430 let mut m = small_model();
1431 let id = m.add_document(&["foo"]);
1432 m.run_inference(0); let doc = m
1434 .get_document(id)
1435 .expect("test: document must exist after zero-iter inference");
1436 assert!(!doc.topic_assignments.is_empty());
1437 }
1438
1439 #[test]
1440 fn test_run_inference_multiple_documents() {
1441 let mut m = small_model();
1442 let ids: Vec<_> = (0..5)
1443 .map(|i| m.add_document(&[&format!("word{i}"), "common"]))
1444 .collect();
1445 m.run_inference(10);
1446 for id in ids {
1447 let doc = m
1448 .get_document(id)
1449 .expect("test: document must exist after multi-doc inference");
1450 assert_eq!(doc.topic_assignments.len(), 2);
1451 }
1452 }
1453
1454 #[test]
1455 fn test_inference_with_explicit_topic_tree() {
1456 let mut m = HierarchicalTopicModel::new(HtmModelConfig {
1457 max_depth: 2,
1458 max_children_per_node: 4,
1459 alpha: 0.5,
1460 beta: 0.1,
1461 n_iterations: 10,
1462 seed: 42,
1463 });
1464 let t1 = m
1465 .add_topic_node(None, Some("topic_a".into()))
1466 .expect("test: add topic_a should succeed");
1467 let t2 = m
1468 .add_topic_node(None, Some("topic_b".into()))
1469 .expect("test: add topic_b should succeed");
1470 m.add_topic_node(Some(t1), None)
1471 .expect("test: add child of topic_a should succeed");
1472 m.add_topic_node(Some(t2), None)
1473 .expect("test: add child of topic_b should succeed");
1474 m.add_document(&["science", "physics", "math"]);
1475 m.add_document(&["art", "music", "painting"]);
1476 m.run_inference(5);
1477 assert!(m.n_topic_nodes() >= 5);
1479 }
1480
1481 #[test]
1486 fn test_prune_removes_empty_leaves() {
1487 let mut m = default_model();
1488 let _leaf = m
1490 .add_topic_node(None, None)
1491 .expect("test: add empty leaf should succeed");
1492 let before = m.n_topic_nodes();
1493 m.prune_empty_topics();
1494 let after = m.n_topic_nodes();
1495 assert!(after <= before);
1496 }
1497
1498 #[test]
1499 fn test_prune_never_removes_root() {
1500 let mut m = default_model();
1501 m.prune_empty_topics();
1502 assert!(m.topic_node(0).is_some());
1503 }
1504
1505 #[test]
1506 fn test_prune_keeps_nonempty_nodes() {
1507 let mut m = default_model();
1508 let id = m.add_document(&["hello"]);
1509 let doc = m
1511 .get_document(id)
1512 .expect("test: document must exist before pruning");
1513 let assigned_topic = doc.topic_assignments[0];
1514 let before_count = m
1515 .topic_node(assigned_topic)
1516 .map(|n| n.total_words)
1517 .unwrap_or(0);
1518 m.prune_empty_topics();
1519 if before_count > 0 {
1520 assert!(
1521 m.topic_node(assigned_topic).is_some(),
1522 "non-empty node must survive pruning"
1523 );
1524 }
1525 }
1526
1527 #[test]
1528 fn test_prune_parent_updated() {
1529 let mut m = default_model();
1530 let child = m
1531 .add_topic_node(None, None)
1532 .expect("test: add child node should succeed");
1533 m.prune_empty_topics();
1534 if m.topic_node(child).is_none() {
1536 assert!(!m
1537 .topic_node(0)
1538 .expect("test: root must exist after pruning")
1539 .children
1540 .contains(&child));
1541 }
1542 }
1543
1544 #[test]
1549 fn test_coherence_root_no_docs() {
1550 let m = default_model();
1551 let c = m.compute_coherence(0, 5);
1552 assert_eq!(c, 0.0);
1553 }
1554
1555 #[test]
1556 fn test_coherence_nonexistent_topic() {
1557 let m = default_model();
1558 let c = m.compute_coherence(999, 5);
1559 assert_eq!(c, 0.0);
1560 }
1561
1562 #[test]
1563 fn test_coherence_returns_finite() {
1564 let mut m = small_model();
1565 m.add_document(&["alpha", "beta", "gamma", "delta", "alpha", "beta"]);
1566 m.add_document(&["gamma", "delta", "epsilon", "zeta", "alpha"]);
1567 m.run_inference(5);
1568 let c = m.compute_coherence(0, 5);
1569 assert!(c.is_finite());
1570 }
1571
1572 #[test]
1573 fn test_coherence_top_n_zero() {
1574 let mut m = small_model();
1575 m.add_document(&["a", "b"]);
1576 let c = m.compute_coherence(0, 0);
1577 assert_eq!(c, 0.0);
1578 }
1579
1580 #[test]
1585 fn test_model_stats_empty() {
1586 let m = default_model();
1587 let s = m.model_stats();
1588 assert_eq!(s.n_docs, 0);
1589 assert_eq!(s.vocab_size, 0);
1590 assert_eq!(s.n_topics, 1);
1591 }
1592
1593 #[test]
1594 fn test_model_stats_n_docs() {
1595 let mut m = default_model();
1596 m.add_document(&["a"]);
1597 m.add_document(&["b"]);
1598 assert_eq!(m.model_stats().n_docs, 2);
1599 }
1600
1601 #[test]
1602 fn test_model_stats_vocab_size() {
1603 let mut m = default_model();
1604 m.add_document(&["rust", "safe", "fast"]);
1605 assert_eq!(m.model_stats().vocab_size, 3);
1606 }
1607
1608 #[test]
1609 fn test_model_stats_max_depth() {
1610 let mut m = default_model();
1611 let c = m
1612 .add_topic_node(None, None)
1613 .expect("test: add child node should succeed");
1614 let _gc = m
1615 .add_topic_node(Some(c), None)
1616 .expect("test: add grandchild node should succeed");
1617 let s = m.model_stats();
1618 assert!(s.max_depth >= 2);
1619 }
1620
1621 #[test]
1622 fn test_model_stats_avg_coherence_finite() {
1623 let mut m = small_model();
1624 m.add_document(&["one", "two", "three"]);
1625 m.run_inference(5);
1626 let s = m.model_stats();
1627 assert!(s.avg_coherence.is_finite());
1628 }
1629
1630 #[test]
1635 fn test_xorshift64_not_zero_from_nonzero_state() {
1636 let mut state = 1u64;
1637 let v = xorshift64(&mut state);
1638 assert_ne!(v, 0);
1639 }
1640
1641 #[test]
1642 fn test_xorshift64_different_states_differ() {
1643 let mut s1 = 1u64;
1644 let mut s2 = 2u64;
1645 assert_ne!(xorshift64(&mut s1), xorshift64(&mut s2));
1646 }
1647
1648 #[test]
1649 fn test_xorshift64_advances_state() {
1650 let mut state = 42u64;
1651 let before = state;
1652 xorshift64(&mut state);
1653 assert_ne!(state, before);
1654 }
1655
1656 #[test]
1657 fn test_fnv1a_64_known_value() {
1658 let h = fnv1a_64(&[]);
1660 assert_eq!(h, 14_695_981_039_346_656_037u64);
1661 }
1662
1663 #[test]
1664 fn test_fnv1a_64_differs_for_different_inputs() {
1665 let h1 = fnv1a_64(b"hello");
1666 let h2 = fnv1a_64(b"world");
1667 assert_ne!(h1, h2);
1668 }
1669
1670 #[test]
1671 fn test_hash_bytes_deterministic() {
1672 let h1 = HierarchicalTopicModel::hash_bytes(b"test");
1673 let h2 = HierarchicalTopicModel::hash_bytes(b"test");
1674 assert_eq!(h1, h2);
1675 }
1676
1677 #[test]
1682 fn test_path_to_root_single_node() {
1683 let m = default_model();
1684 let path = m.path_to_root(0);
1685 assert_eq!(path, vec![0]);
1686 }
1687
1688 #[test]
1689 fn test_path_to_root_child() {
1690 let mut m = default_model();
1691 let child = m
1692 .add_topic_node(None, None)
1693 .expect("test: add child node should succeed");
1694 let path = m.path_to_root(child);
1695 assert_eq!(path, vec![0, child]);
1696 }
1697
1698 #[test]
1699 fn test_path_to_root_grandchild() {
1700 let mut m = default_model();
1701 let c = m
1702 .add_topic_node(None, None)
1703 .expect("test: add child node should succeed");
1704 let gc = m
1705 .add_topic_node(Some(c), None)
1706 .expect("test: add grandchild node should succeed");
1707 let path = m.path_to_root(gc);
1708 assert_eq!(path, vec![0, c, gc]);
1709 }
1710
1711 #[test]
1716 fn test_large_document_set() {
1717 let mut m = HierarchicalTopicModel::new(HtmModelConfig {
1718 max_depth: 2,
1719 max_children_per_node: 4,
1720 alpha: 0.1,
1721 beta: 0.01,
1722 n_iterations: 5,
1723 seed: 1,
1724 });
1725 let words = ["a", "b", "c", "d", "e", "f", "g", "h"];
1726 for i in 0..20 {
1727 let tokens: Vec<&str> = words[..((i % 4) + 2)].to_vec();
1728 m.add_document(&tokens);
1729 }
1730 m.run_inference(5);
1731 let stats = m.model_stats();
1732 assert_eq!(stats.n_docs, 20);
1733 }
1734
1735 #[test]
1736 fn test_repeated_inference_stable() {
1737 let mut m = small_model();
1738 m.add_document(&["rust", "is", "great"]);
1739 m.run_inference(5);
1740 m.run_inference(5);
1741 let stats = m.model_stats();
1742 assert_eq!(stats.n_docs, 1);
1743 }
1744
1745 #[test]
1746 fn test_many_topics_coherence_finite() {
1747 let mut m = HierarchicalTopicModel::new(HtmModelConfig {
1748 max_depth: 3,
1749 max_children_per_node: 3,
1750 alpha: 0.1,
1751 beta: 0.01,
1752 n_iterations: 5,
1753 seed: 55,
1754 });
1755 for i in 0..10 {
1756 m.add_document(&[&format!("word{}", i % 5), &format!("topic{}", i % 3)]);
1757 }
1758 m.run_inference(5);
1759 let stats = m.model_stats();
1760 assert!(stats.avg_coherence.is_finite());
1761 }
1762
1763 #[test]
1764 fn test_document_topic_proportions_non_negative() {
1765 let mut m = small_model();
1766 let id = m.add_document(&["x", "y", "z"]);
1767 m.run_inference(10);
1768 for (_, p) in m.document_topics(id) {
1769 assert!(p >= 0.0);
1770 }
1771 }
1772
1773 #[test]
1774 fn test_total_words_consistent_after_inference() {
1775 let mut m = small_model();
1776 m.add_document(&["a", "b", "c"]);
1777 m.run_inference(5);
1778 let total_in_nodes: u32 = m.topics.values().map(|n| n.total_words).sum();
1780 let total_in_docs: u32 = m
1781 .documents
1782 .values()
1783 .map(|d| d.token_indices.len() as u32)
1784 .sum();
1785 assert_eq!(total_in_nodes, total_in_docs);
1786 }
1787
1788 #[test]
1789 fn test_prune_idempotent() {
1790 let mut m = default_model();
1791 m.add_document(&["hello"]);
1792 m.run_inference(5);
1793 m.prune_empty_topics();
1794 let n1 = m.n_topic_nodes();
1795 m.prune_empty_topics();
1796 let n2 = m.n_topic_nodes();
1797 assert_eq!(n1, n2);
1798 }
1799
1800 #[test]
1801 fn test_get_topic_doc_count() {
1802 let mut m = small_model();
1803 let id = m.add_document(&["machine", "learning"]);
1804 m.run_inference(5);
1805 let doc = m
1806 .get_document(id)
1807 .expect("test: document must exist after inference");
1808 assert!(!doc.topic_assignments.is_empty());
1810 }
1811}