Skip to main content

ipfrs_semantic/
hierarchical_topic_model.rs

1//! Hierarchical Topic Model (HTM) — LDA-style topic inference over a tree-structured topic
2//! hierarchy.
3//!
4//! # Overview
5//!
6//! [`HierarchicalTopicModel`] maintains:
7//! - A rooted topic tree (`HtmTopicNode` nodes linked by parent/child IDs).
8//! - A document corpus (`HtmDocument` entries with per-token topic assignments).
9//! - A shared vocabulary (word ↔ index mapping).
10//!
11//! Inference is performed via collapsed Gibbs sampling where, for each token, we sample a new
12//! topic from the set of nodes currently on the document's root-to-leaf path.  After `n_iter`
13//! sweeps the topic tree is updated with final counts.
14//!
15//! # Example
16//!
17//! ```rust
18//! use ipfrs_semantic::hierarchical_topic_model::{HierarchicalTopicModel, HtmModelConfig};
19//!
20//! let cfg = HtmModelConfig {
21//!     max_depth: 3,
22//!     max_children_per_node: 4,
23//!     alpha: 0.1,
24//!     beta: 0.01,
25//!     n_iterations: 50,
26//!     seed: 42,
27//! };
28//! let mut model = HierarchicalTopicModel::new(cfg);
29//!
30//! let _d0 = model.add_document(&["rust", "programming", "language", "fast"]);
31//! let _d1 = model.add_document(&["machine", "learning", "neural", "network"]);
32//!
33//! model.run_inference(20);
34//!
35//! let stats = model.model_stats();
36//! assert!(stats.n_docs >= 2);
37//! ```
38
39use std::cmp::Reverse;
40use std::collections::HashMap;
41
42// ---------------------------------------------------------------------------
43// Type aliases
44// ---------------------------------------------------------------------------
45
46/// Unique identifier for a topic node in the hierarchy.
47pub type HtmTopicNodeId = u64;
48
49/// Unique identifier for a document.
50pub type HtmDocId = u64;
51
52// ---------------------------------------------------------------------------
53// Inline PRNG / hashing utilities
54// ---------------------------------------------------------------------------
55
56/// Xorshift64 pseudo-random number generator (single step).
57#[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/// FNV-1a 64-bit hash of an arbitrary byte slice.
68#[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// ---------------------------------------------------------------------------
79// Configuration
80// ---------------------------------------------------------------------------
81
82/// Configuration parameters for [`HierarchicalTopicModel`].
83#[derive(Debug, Clone)]
84pub struct HtmModelConfig {
85    /// Maximum depth of the topic tree (root is depth 0).
86    pub max_depth: u32,
87    /// Maximum number of child nodes for any single topic node.
88    pub max_children_per_node: usize,
89    /// Dirichlet hyper-parameter α (document–topic prior).
90    pub alpha: f64,
91    /// Dirichlet hyper-parameter β (topic–word prior).
92    pub beta: f64,
93    /// Default number of Gibbs-sampling iterations used by [`HierarchicalTopicModel::run_inference`].
94    pub n_iterations: usize,
95    /// Seed for the internal PRNG.
96    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// ---------------------------------------------------------------------------
113// Topic node
114// ---------------------------------------------------------------------------
115
116/// A single node in the topic tree.
117#[derive(Debug, Clone)]
118pub struct HtmTopicNode {
119    /// Unique node ID (`0` is reserved for the root).
120    pub id: HtmTopicNodeId,
121    /// Parent node ID (`None` for the root).
122    pub parent: Option<HtmTopicNodeId>,
123    /// IDs of child nodes.
124    pub children: Vec<HtmTopicNodeId>,
125    /// Depth of this node (root = 0).
126    pub depth: u32,
127    /// Per-vocabulary-index word counts accumulated from assigned tokens.
128    pub word_counts: Vec<u32>,
129    /// Sum of all word counts (`word_counts.iter().sum()`).
130    pub total_words: u32,
131    /// Optional human-readable label for this topic.
132    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    /// Ensure `word_counts` has at least `vocab_size` entries.
154    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    /// Increment a word count (growing the vector if needed).
161    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    /// Decrement a word count (saturating at 0).
170    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// ---------------------------------------------------------------------------
181// Document
182// ---------------------------------------------------------------------------
183
184/// A document stored in the model.
185#[derive(Debug, Clone)]
186pub struct HtmDocument {
187    /// Unique document ID.
188    pub id: HtmDocId,
189    /// Vocabulary indices of tokens in order of appearance.
190    pub token_indices: Vec<u32>,
191    /// Per-token topic assignment (same length as `token_indices`).
192    pub topic_assignments: Vec<HtmTopicNodeId>,
193    /// Ordered path from root to the document's current leaf topic node.
194    pub path: Vec<HtmTopicNodeId>,
195}
196
197// ---------------------------------------------------------------------------
198// Output types
199// ---------------------------------------------------------------------------
200
201/// Aggregated view of a single topic node, returned by [`HierarchicalTopicModel::get_topic`].
202#[derive(Debug, Clone)]
203pub struct HtmTopic {
204    /// Node ID of this topic.
205    pub id: HtmTopicNodeId,
206    /// Top words ranked by (smoothed) probability, with their scores.
207    pub top_words: Vec<(String, f64)>,
208    /// PMI-based coherence score (set to `0.0` until explicitly computed).
209    pub coherence: f64,
210    /// Number of documents that have at least one token assigned to this topic.
211    pub doc_count: u32,
212    /// Depth of this node in the topic tree.
213    pub depth: u32,
214}
215
216/// Summary statistics for the whole model.
217#[derive(Debug, Clone)]
218pub struct HtmModelStats {
219    /// Total number of topic nodes (including root).
220    pub n_topics: usize,
221    /// Total number of documents.
222    pub n_docs: usize,
223    /// Vocabulary size.
224    pub vocab_size: usize,
225    /// Average PMI coherence across all non-root nodes (or `0.0` if none).
226    pub avg_coherence: f64,
227    /// Maximum depth encountered in the topic tree.
228    pub max_depth: u32,
229}
230
231// ---------------------------------------------------------------------------
232// Main struct
233// ---------------------------------------------------------------------------
234
235/// Hierarchical LDA-style topic model with a tree-structured topic hierarchy.
236///
237/// See the [module-level documentation](self) for a full overview.
238pub struct HierarchicalTopicModel {
239    config: HtmModelConfig,
240    /// All topic nodes, keyed by their ID.
241    topics: HashMap<HtmTopicNodeId, HtmTopicNode>,
242    /// ID of the root topic node (always `0`).
243    root: HtmTopicNodeId,
244    /// All documents, keyed by their ID.
245    documents: HashMap<HtmDocId, HtmDocument>,
246    /// Word → vocabulary index.
247    vocab: HashMap<String, u32>,
248    /// Vocabulary index → word.
249    vocab_inv: Vec<String>,
250    /// Monotonically increasing counter for new topic node IDs.
251    next_topic_id: HtmTopicNodeId,
252    /// Monotonically increasing counter for new document IDs.
253    next_doc_id: HtmDocId,
254    /// Internal PRNG state.
255    rng_state: u64,
256}
257
258impl HierarchicalTopicModel {
259    // -----------------------------------------------------------------------
260    // Construction
261    // -----------------------------------------------------------------------
262
263    /// Create a new model with the given configuration.
264    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    // -----------------------------------------------------------------------
288    // Vocabulary helpers
289    // -----------------------------------------------------------------------
290
291    /// Return the vocabulary index for `word`, creating a new entry if necessary.
292    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        // Grow every existing topic node's word_counts vector.
300        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    // -----------------------------------------------------------------------
308    // Topic tree operations
309    // -----------------------------------------------------------------------
310
311    /// Add a new topic node to the hierarchy.
312    ///
313    /// If `parent` is `None` the root node is used as the parent.  Returns the new node's ID.
314    /// Returns an error string when the parent doesn't exist or depth/children limits are exceeded.
315    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    // -----------------------------------------------------------------------
360    // Document management
361    // -----------------------------------------------------------------------
362
363    /// Add a document given a slice of string tokens.
364    ///
365    /// Tokenisation: tokens are lower-cased; empty tokens are skipped.
366    /// Returns the new document's `HtmDocId`.
367    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        // Build initial path & assignments using the current topic tree.
382        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            // Assign each token to a node on the path (xorshift64-based selection).
389            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            // Update word counts.
398            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    // -----------------------------------------------------------------------
415    // Path helpers
416    // -----------------------------------------------------------------------
417
418    /// Build a root-to-leaf path for a new document using the current tree topology.
419    ///
420    /// Greedily follows the child with the highest `total_words`, creating new leaf nodes when
421    /// the current depth limit hasn't been reached.
422    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(&current) {
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                // Create a new leaf when possible.
442                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(&current) {
448                    parent_node.children.push(new_id);
449                }
450                path.push(new_id);
451                current = new_id;
452            } else {
453                // Choose the child with the highest total_words (ties broken by ID).
454                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    /// Return the full root-to-node path for `node_id` (inclusive, from root to node).
472    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(&current).and_then(|n| n.parent) {
478                Some(pid) => current = pid,
479                None => break,
480            }
481        }
482        path.reverse();
483        path
484    }
485
486    // -----------------------------------------------------------------------
487    // Inference
488    // -----------------------------------------------------------------------
489
490    /// Run collapsed Gibbs sampling for `n_iter` iterations.
491    ///
492    /// Each iteration sweeps all documents and re-samples each token's topic assignment from the
493    /// conditional posterior restricted to nodes on the document's current root-to-leaf path.
494    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                // --- Update path for this document (re-sample leaf) ---
509                self.resample_path(doc_id);
510
511                // --- Per-token sampling ---
512                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                    // Remove this token's contribution from its current topic.
536                    if let Some(node) = self.topics.get_mut(&old_topic) {
537                        node.decrement_word(word_idx);
538                    }
539
540                    // Compute unnormalised probabilities for each node on the path.
541                    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                        // Document–topic count for this path node.
551                        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                    // Sample from `probs`.
560                    let new_topic = self.sample_from_probs(&path, &probs);
561                    new_assignments[token_pos] = new_topic;
562
563                    // Re-add contribution to the newly sampled topic.
564                    if let Some(node) = self.topics.get_mut(&new_topic) {
565                        node.increment_word(word_idx);
566                    }
567                }
568
569                // Persist updated assignments.
570                if let Some(doc) = self.documents.get_mut(&doc_id) {
571                    doc.topic_assignments = new_assignments;
572                }
573            }
574        }
575    }
576
577    /// Re-sample the root-to-leaf path for a document.
578    ///
579    /// We try each possible leaf (all nodes that have no children, or all nodes at max_depth)
580    /// and score the path by the product of per-level document likelihoods.
581    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        // Collect leaf IDs (nodes with no children, excluding root when tree is trivial).
588        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        // Score each leaf path.
603        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        // Sample a path.
625        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    // -----------------------------------------------------------------------
634    // Sampling helpers
635    // -----------------------------------------------------------------------
636
637    /// Categorical sample from a normalised probability distribution over `topics`.
638    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            // Uniform fallback.
642            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    /// Return the index into `probs` sampled proportionally.
657    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    // -----------------------------------------------------------------------
679    // Query / inspection
680    // -----------------------------------------------------------------------
681
682    /// Retrieve an aggregated [`HtmTopic`] for the given node ID.
683    ///
684    /// Returns `None` if the node doesn't exist.
685    /// The top-N words are computed using Laplace-smoothed probabilities (β smoothing).
686    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        // Collect (score, word_idx).
695        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        // Count documents that have at least one token assigned to this topic.
712        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, // filled in by compute_coherence if desired
722            doc_count,
723            depth: node.depth,
724        })
725    }
726
727    /// Return proportional topic distribution along a document's current path.
728    ///
729    /// Each element is `(topic_node_id, proportion)` where `proportion` is the fraction of the
730    /// document's tokens assigned to that node.  Sums to 1.0 (or 0.0 for empty documents).
731    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    /// Return the full topic hierarchy as a flat list of `(depth, node_id, parent_id)` tuples
757    /// sorted by depth then by node ID.
758    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    /// Compute PMI-based coherence for a topic node using its top-`top_n` words.
769    ///
770    /// The coherence score is the average pointwise mutual information for all pairs of top words,
771    /// computed over the document corpus.
772    ///
773    /// Returns `0.0` if the topic node doesn't exist, has fewer than 2 words, or no documents.
774    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        // Select top-N word indices by count.
788        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        // Build per-word document frequency sets.
805        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            // Build set of unique word indices in this document.
810            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            // Co-occurrence.
823            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        // Average PMI over all top-word pairs.
836        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    /// Remove leaf topic nodes that have zero total word counts and no documents assigned.
867    ///
868    /// The root node is never removed.  Pruning is repeated until no further nodes can be removed.
869    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                // Remove from parent's children list.
884                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    /// Return summary statistics for the current model state.
897    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    // -----------------------------------------------------------------------
930    // Internal helpers exposed for testing
931    // -----------------------------------------------------------------------
932
933    /// Return the number of topic nodes (including root).
934    pub fn n_topic_nodes(&self) -> usize {
935        self.topics.len()
936    }
937
938    /// Return a reference to a topic node, if it exists.
939    pub fn topic_node(&self, id: HtmTopicNodeId) -> Option<&HtmTopicNode> {
940        self.topics.get(&id)
941    }
942
943    /// Return the current vocabulary size.
944    pub fn vocab_size(&self) -> usize {
945        self.vocab_inv.len()
946    }
947
948    /// Return the word at a given vocabulary index.
949    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    /// Return the vocabulary index of a word (None if not present).
954    pub fn word_index(&self, word: &str) -> Option<u32> {
955        self.vocab.get(word).copied()
956    }
957
958    /// Retrieve a document by its ID.
959    pub fn get_document(&self, doc_id: HtmDocId) -> Option<&HtmDocument> {
960        self.documents.get(&doc_id)
961    }
962
963    /// Return the ID of the root topic node.
964    pub fn root_id(&self) -> HtmTopicNodeId {
965        self.root
966    }
967
968    /// FNV-1a 64-bit hash helper (exposed for testing / external hashing needs).
969    pub fn hash_bytes(data: &[u8]) -> u64 {
970        fnv1a_64(data)
971    }
972}
973
974// ---------------------------------------------------------------------------
975// Tests
976// ---------------------------------------------------------------------------
977
978#[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    // -------------------------------------------------------------------------
998    // 1. Construction & config
999    // -------------------------------------------------------------------------
1000
1001    #[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    // -------------------------------------------------------------------------
1050    // 2. Vocabulary
1051    // -------------------------------------------------------------------------
1052
1053    #[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    // -------------------------------------------------------------------------
1099    // 3. Document management
1100    // -------------------------------------------------------------------------
1101
1102    #[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    // -------------------------------------------------------------------------
1175    // 4. Topic node management
1176    // -------------------------------------------------------------------------
1177
1178    #[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    // -------------------------------------------------------------------------
1275    // 5. topic_hierarchy
1276    // -------------------------------------------------------------------------
1277
1278    #[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); // root + c1 + c2 + gc1
1310    }
1311
1312    // -------------------------------------------------------------------------
1313    // 6. get_topic
1314    // -------------------------------------------------------------------------
1315
1316    #[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        // With β smoothing sum over all words == 1; top-N is a subset, so sum ≤ 1.
1358        assert!(sum > 0.0 && sum <= 1.0 + 1e-9);
1359    }
1360
1361    // -------------------------------------------------------------------------
1362    // 7. document_topics
1363    // -------------------------------------------------------------------------
1364
1365    #[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        // All proportions should be 0 for empty document.
1401        for (_, p) in &dt {
1402            assert_eq!(*p, 0.0);
1403        }
1404    }
1405
1406    // -------------------------------------------------------------------------
1407    // 8. run_inference
1408    // -------------------------------------------------------------------------
1409
1410    #[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); // should use config.n_iterations
1433        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        // No panic, basic sanity.
1478        assert!(m.n_topic_nodes() >= 5);
1479    }
1480
1481    // -------------------------------------------------------------------------
1482    // 9. prune_empty_topics
1483    // -------------------------------------------------------------------------
1484
1485    #[test]
1486    fn test_prune_removes_empty_leaves() {
1487        let mut m = default_model();
1488        // Manually add an empty leaf.
1489        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        // Ensure word count is on some node.
1510        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        // child had no words, should be pruned.
1535        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    // -------------------------------------------------------------------------
1545    // 10. compute_coherence
1546    // -------------------------------------------------------------------------
1547
1548    #[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    // -------------------------------------------------------------------------
1581    // 11. model_stats
1582    // -------------------------------------------------------------------------
1583
1584    #[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    // -------------------------------------------------------------------------
1631    // 12. xorshift64 / fnv1a_64
1632    // -------------------------------------------------------------------------
1633
1634    #[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        // FNV-1a of empty slice is the offset basis.
1659        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    // -------------------------------------------------------------------------
1678    // 13. path_to_root
1679    // -------------------------------------------------------------------------
1680
1681    #[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    // -------------------------------------------------------------------------
1712    // 14. Integration / stress
1713    // -------------------------------------------------------------------------
1714
1715    #[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        // total_words across all nodes must equal total tokens across all docs (3).
1779        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        // At least one assignment must exist.
1809        assert!(!doc.topic_assignments.is_empty());
1810    }
1811}