Skip to main content

ipfrs_semantic/
synonym_expander.rs

1//! # Semantic Synonym Expander
2//!
3//! Builds and queries a synonym graph for vocabulary expansion in semantic search.
4//! Supports weighted synonymy relationships and multi-hop BFS expansion.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8/// Describes the semantic relationship type between two terms in the synonym graph.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum SynonymRelation {
11    /// Identical meaning (e.g., "automobile" ↔ "car").
12    Exact,
13    /// Close but not identical meaning (e.g., "happy" ↔ "joyful").
14    Near,
15    /// Source is more specific than target (e.g., "spaniel" → "dog").
16    Broader,
17    /// Source is more general than target (e.g., "dog" → "spaniel").
18    Narrower,
19}
20
21/// A single directed synonym edge in the graph.
22#[derive(Clone, Debug)]
23pub struct SynonymEdge {
24    /// Target term this edge points to.
25    pub target: String,
26    /// Semantic relationship from the source to the target.
27    pub relation: SynonymRelation,
28    /// Confidence/strength of the synonym relationship in [0.0, 1.0].
29    pub weight: f32,
30}
31
32/// Configuration for the [`SemanticSynonymExpander`].
33#[derive(Clone, Debug)]
34pub struct ExpanderConfig {
35    /// Maximum BFS depth when expanding from a query term.
36    pub max_hops: usize,
37    /// Minimum edge weight to follow during expansion.
38    pub min_weight: f32,
39    /// Maximum number of expanded terms to return per query.
40    pub max_expansions: usize,
41}
42
43impl Default for ExpanderConfig {
44    fn default() -> Self {
45        Self {
46            max_hops: 2,
47            min_weight: 0.5,
48            max_expansions: 20,
49        }
50    }
51}
52
53/// A single term produced by [`SemanticSynonymExpander::expand`].
54#[derive(Clone, Debug)]
55pub struct ExpandedTerm {
56    /// The expanded synonym term.
57    pub term: String,
58    /// Relation from the immediate predecessor on the expansion path.
59    pub relation: SynonymRelation,
60    /// Product of edge weights along the path from the query term.
61    pub cumulative_weight: f32,
62    /// Number of hops from the query term.
63    pub hops: usize,
64}
65
66/// Aggregate statistics tracked by the expander.
67#[derive(Clone, Debug, Default)]
68pub struct SynonymExpanderStats {
69    /// Number of unique terms currently in the graph.
70    pub total_terms: usize,
71    /// Total synonym edges currently in the graph.
72    pub total_edges: usize,
73    /// Total number of calls to [`SemanticSynonymExpander::expand`].
74    pub total_expand_calls: u64,
75    /// Total number of terms returned across all expand calls.
76    pub total_terms_expanded: u64,
77}
78
79/// BFS frontier entry used internally during expansion.
80struct FrontierEntry {
81    term: String,
82    hops: usize,
83    cumulative_weight: f32,
84    relation: SynonymRelation,
85}
86
87/// Synonym graph for vocabulary expansion in semantic search.
88///
89/// Maintains a weighted directed adjacency list; `add_synonym` automatically
90/// inserts bidirectional edges with correctly mirrored relationship types.
91pub struct SemanticSynonymExpander {
92    /// Adjacency list: term → list of outgoing synonym edges.
93    pub graph: HashMap<String, Vec<SynonymEdge>>,
94    /// Expander configuration.
95    pub config: ExpanderConfig,
96    /// Runtime statistics.
97    pub stats: SynonymExpanderStats,
98}
99
100impl SemanticSynonymExpander {
101    /// Create a new expander with the given configuration.
102    pub fn new(config: ExpanderConfig) -> Self {
103        Self {
104            graph: HashMap::new(),
105            config,
106            stats: SynonymExpanderStats::default(),
107        }
108    }
109
110    /// Add a bidirectional synonym edge between `from` and `to`.
111    ///
112    /// The reverse edge uses the mirrored relation:
113    /// - `Broader` ↔ `Narrower`
114    /// - `Exact` ↔ `Exact`
115    /// - `Near` ↔ `Near`
116    ///
117    /// Duplicate edges (same target + same relation) are silently skipped;
118    /// `stats.total_edges` is incremented only for edges actually inserted.
119    pub fn add_synonym(&mut self, from: &str, to: &str, relation: SynonymRelation, weight: f32) {
120        let reverse_relation = match relation {
121            SynonymRelation::Broader => SynonymRelation::Narrower,
122            SynonymRelation::Narrower => SynonymRelation::Broader,
123            other => other,
124        };
125
126        // Forward edge: from → to
127        let forward_added = Self::insert_edge(
128            &mut self.graph,
129            from,
130            SynonymEdge {
131                target: to.to_owned(),
132                relation,
133                weight,
134            },
135        );
136
137        // Reverse edge: to → from
138        let reverse_added = Self::insert_edge(
139            &mut self.graph,
140            to,
141            SynonymEdge {
142                target: from.to_owned(),
143                relation: reverse_relation,
144                weight,
145            },
146        );
147
148        self.stats.total_edges += forward_added as usize + reverse_added as usize;
149        self.stats.total_terms = self.graph.len();
150    }
151
152    /// Insert `edge` into `graph[node]`, skipping if a duplicate (same target + relation) exists.
153    /// Returns `true` if the edge was actually inserted.
154    fn insert_edge(
155        graph: &mut HashMap<String, Vec<SynonymEdge>>,
156        node: &str,
157        edge: SynonymEdge,
158    ) -> bool {
159        let edges = graph.entry(node.to_owned()).or_default();
160        let is_dup = edges
161            .iter()
162            .any(|e| e.target == edge.target && e.relation == edge.relation);
163        if is_dup {
164            return false;
165        }
166        edges.push(edge);
167        true
168    }
169
170    /// BFS expansion from `term` up to `config.max_hops` hops.
171    ///
172    /// Only follows edges whose weight >= `config.min_weight` and whose relation
173    /// is in `allowed_relations` (if `Some`).  Results are sorted by
174    /// `cumulative_weight` descending, then alphabetically by term, and
175    /// truncated to `config.max_expansions`.
176    pub fn expand(
177        &mut self,
178        term: &str,
179        allowed_relations: Option<&[SynonymRelation]>,
180    ) -> Vec<ExpandedTerm> {
181        self.stats.total_expand_calls += 1;
182
183        if !self.graph.contains_key(term) {
184            return Vec::new();
185        }
186
187        let mut visited: HashSet<String> = HashSet::new();
188        visited.insert(term.to_owned());
189
190        let mut queue: VecDeque<FrontierEntry> = VecDeque::new();
191
192        // Seed the BFS with neighbours of the query term
193        if let Some(edges) = self.graph.get(term) {
194            for edge in edges {
195                if edge.weight < self.config.min_weight {
196                    continue;
197                }
198                if let Some(allowed) = allowed_relations {
199                    if !allowed.contains(&edge.relation) {
200                        continue;
201                    }
202                }
203                if visited.insert(edge.target.clone()) {
204                    queue.push_back(FrontierEntry {
205                        term: edge.target.clone(),
206                        hops: 1,
207                        cumulative_weight: edge.weight,
208                        relation: edge.relation,
209                    });
210                }
211            }
212        }
213
214        let mut results: Vec<ExpandedTerm> = Vec::new();
215
216        while let Some(entry) = queue.pop_front() {
217            results.push(ExpandedTerm {
218                term: entry.term.clone(),
219                relation: entry.relation,
220                cumulative_weight: entry.cumulative_weight,
221                hops: entry.hops,
222            });
223
224            if entry.hops >= self.config.max_hops {
225                continue;
226            }
227
228            // Expand further from this node
229            if let Some(edges) = self.graph.get(&entry.term) {
230                // Collect to avoid borrow-checker issues
231                let candidates: Vec<_> = edges
232                    .iter()
233                    .filter(|e| {
234                        e.weight >= self.config.min_weight
235                            && allowed_relations
236                                .map(|a| a.contains(&e.relation))
237                                .unwrap_or(true)
238                            && !visited.contains(&e.target)
239                    })
240                    .map(|e| (e.target.clone(), e.relation, e.weight))
241                    .collect();
242
243                for (target, relation, weight) in candidates {
244                    if visited.insert(target.clone()) {
245                        queue.push_back(FrontierEntry {
246                            term: target,
247                            hops: entry.hops + 1,
248                            cumulative_weight: entry.cumulative_weight * weight,
249                            relation,
250                        });
251                    }
252                }
253            }
254        }
255
256        // Sort: cumulative_weight descending, then term alphabetically
257        results.sort_by(|a, b| {
258            b.cumulative_weight
259                .partial_cmp(&a.cumulative_weight)
260                .unwrap_or(std::cmp::Ordering::Equal)
261                .then_with(|| a.term.cmp(&b.term))
262        });
263
264        results.truncate(self.config.max_expansions);
265
266        self.stats.total_terms_expanded += results.len() as u64;
267        results
268    }
269
270    /// Remove `term` from the graph and all incoming edges from other nodes.
271    pub fn remove_term(&mut self, term: &str) {
272        self.graph.remove(term);
273        for edges in self.graph.values_mut() {
274            edges.retain(|e| e.target != term);
275        }
276        self.stats.total_terms = self.graph.len();
277        self.stats.total_edges = self.graph.values().map(|v| v.len()).sum();
278    }
279
280    /// Return a reference to the current statistics.
281    pub fn stats(&self) -> &SynonymExpanderStats {
282        &self.stats
283    }
284
285    /// Return the number of unique terms currently in the graph.
286    pub fn term_count(&self) -> usize {
287        self.graph.len()
288    }
289}
290
291// ---------------------------------------------------------------------------
292// Tests
293// ---------------------------------------------------------------------------
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn default_expander() -> SemanticSynonymExpander {
300        SemanticSynonymExpander::new(ExpanderConfig::default())
301    }
302
303    // -----------------------------------------------------------------------
304    // add_synonym — basic bidirectionality
305    // -----------------------------------------------------------------------
306
307    #[test]
308    fn test_add_synonym_bidirectional_exact() {
309        let mut exp = default_expander();
310        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
311        // car → automobile
312        assert!(exp.graph["car"].iter().any(|e| e.target == "automobile"));
313        // automobile → car
314        assert!(exp.graph["automobile"].iter().any(|e| e.target == "car"));
315    }
316
317    #[test]
318    fn test_add_synonym_bidirectional_near() {
319        let mut exp = default_expander();
320        exp.add_synonym("happy", "joyful", SynonymRelation::Near, 0.8);
321        let fwd = exp.graph["happy"]
322            .iter()
323            .find(|e| e.target == "joyful")
324            .expect("forward edge missing");
325        let rev = exp.graph["joyful"]
326            .iter()
327            .find(|e| e.target == "happy")
328            .expect("reverse edge missing");
329        assert_eq!(fwd.relation, SynonymRelation::Near);
330        assert_eq!(rev.relation, SynonymRelation::Near);
331    }
332
333    #[test]
334    fn test_add_synonym_broader_reverse_is_narrower() {
335        let mut exp = default_expander();
336        exp.add_synonym("spaniel", "dog", SynonymRelation::Broader, 0.9);
337        let rev = exp.graph["dog"]
338            .iter()
339            .find(|e| e.target == "spaniel")
340            .expect("reverse edge missing");
341        assert_eq!(rev.relation, SynonymRelation::Narrower);
342    }
343
344    #[test]
345    fn test_add_synonym_narrower_reverse_is_broader() {
346        let mut exp = default_expander();
347        exp.add_synonym("dog", "spaniel", SynonymRelation::Narrower, 0.9);
348        let rev = exp.graph["spaniel"]
349            .iter()
350            .find(|e| e.target == "dog")
351            .expect("reverse edge missing");
352        assert_eq!(rev.relation, SynonymRelation::Broader);
353    }
354
355    #[test]
356    fn test_add_synonym_weight_preserved() {
357        let mut exp = default_expander();
358        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.75);
359        assert!((exp.graph["a"][0].weight - 0.75).abs() < f32::EPSILON);
360        assert!((exp.graph["b"][0].weight - 0.75).abs() < f32::EPSILON);
361    }
362
363    // -----------------------------------------------------------------------
364    // duplicate edge skipping
365    // -----------------------------------------------------------------------
366
367    #[test]
368    fn test_add_synonym_no_duplicate_edges() {
369        let mut exp = default_expander();
370        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
371        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
372        // Each direction should still have exactly one edge
373        assert_eq!(exp.graph["car"].len(), 1);
374        assert_eq!(exp.graph["automobile"].len(), 1);
375    }
376
377    #[test]
378    fn test_add_synonym_duplicate_does_not_increment_stats() {
379        let mut exp = default_expander();
380        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
381        let edges_after_first = exp.stats.total_edges;
382        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
383        assert_eq!(exp.stats.total_edges, edges_after_first);
384    }
385
386    #[test]
387    fn test_add_synonym_different_relation_is_not_duplicate() {
388        let mut exp = default_expander();
389        exp.add_synonym("word", "synonym", SynonymRelation::Exact, 0.9);
390        exp.add_synonym("word", "synonym", SynonymRelation::Near, 0.7);
391        // Two distinct relations → two edges in each direction
392        assert_eq!(exp.graph["word"].len(), 2);
393    }
394
395    // -----------------------------------------------------------------------
396    // stats: total_terms and total_edges
397    // -----------------------------------------------------------------------
398
399    #[test]
400    fn test_stats_total_terms_and_edges() {
401        let mut exp = default_expander();
402        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
403        assert_eq!(exp.stats.total_terms, 2);
404        assert_eq!(exp.stats.total_edges, 2); // one forward + one reverse
405    }
406
407    #[test]
408    fn test_stats_multiple_synonyms() {
409        let mut exp = default_expander();
410        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
411        exp.add_synonym("a", "c", SynonymRelation::Near, 0.8);
412        assert_eq!(exp.stats.total_terms, 3);
413        assert_eq!(exp.stats.total_edges, 4); // 2 forward + 2 reverse
414    }
415
416    // -----------------------------------------------------------------------
417    // expand — basic
418    // -----------------------------------------------------------------------
419
420    #[test]
421    fn test_expand_empty_for_unknown_term() {
422        let mut exp = default_expander();
423        let results = exp.expand("ghost", None);
424        assert!(results.is_empty());
425    }
426
427    #[test]
428    fn test_expand_single_hop() {
429        let mut exp = default_expander();
430        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
431        let results = exp.expand("car", None);
432        assert_eq!(results.len(), 1);
433        assert_eq!(results[0].term, "automobile");
434        assert_eq!(results[0].hops, 1);
435        assert!((results[0].cumulative_weight - 0.9).abs() < 1e-5);
436    }
437
438    #[test]
439    fn test_expand_does_not_include_query_term() {
440        let mut exp = default_expander();
441        exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
442        let results = exp.expand("car", None);
443        assert!(!results.iter().any(|r| r.term == "car"));
444    }
445
446    // -----------------------------------------------------------------------
447    // expand — BFS depth and max_hops
448    // -----------------------------------------------------------------------
449
450    #[test]
451    fn test_expand_bfs_up_to_max_hops() {
452        let config = ExpanderConfig {
453            max_hops: 2,
454            ..ExpanderConfig::default()
455        };
456        let mut exp = SemanticSynonymExpander::new(config);
457        // chain: a → b → c → d
458        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
459        exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
460        exp.add_synonym("c", "d", SynonymRelation::Exact, 0.9);
461        let results = exp.expand("a", None);
462        let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
463        assert!(terms.contains(&"b"), "hop-1 term b must be present");
464        assert!(terms.contains(&"c"), "hop-2 term c must be present");
465        // d is 3 hops from a → should NOT appear with max_hops=2
466        assert!(
467            !terms.contains(&"d"),
468            "hop-3 term d must be absent with max_hops=2"
469        );
470    }
471
472    #[test]
473    fn test_expand_max_hops_one() {
474        let config = ExpanderConfig {
475            max_hops: 1,
476            ..ExpanderConfig::default()
477        };
478        let mut exp = SemanticSynonymExpander::new(config);
479        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
480        exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
481        let results = exp.expand("a", None);
482        let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
483        assert!(terms.contains(&"b"));
484        assert!(!terms.contains(&"c"));
485    }
486
487    // -----------------------------------------------------------------------
488    // expand — min_weight filtering
489    // -----------------------------------------------------------------------
490
491    #[test]
492    fn test_expand_filters_by_min_weight() {
493        let config = ExpanderConfig {
494            min_weight: 0.7,
495            ..ExpanderConfig::default()
496        };
497        let mut exp = SemanticSynonymExpander::new(config);
498        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9); // passes
499        exp.add_synonym("a", "c", SynonymRelation::Near, 0.4); // below threshold
500        let results = exp.expand("a", None);
501        let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
502        assert!(terms.contains(&"b"));
503        assert!(!terms.contains(&"c"));
504    }
505
506    #[test]
507    fn test_expand_min_weight_exact_boundary() {
508        let config = ExpanderConfig {
509            min_weight: 0.5,
510            ..ExpanderConfig::default()
511        };
512        let mut exp = SemanticSynonymExpander::new(config);
513        exp.add_synonym("x", "y", SynonymRelation::Exact, 0.5); // exactly at boundary
514        let results = exp.expand("x", None);
515        assert!(
516            !results.is_empty(),
517            "edge at exactly min_weight should be followed"
518        );
519    }
520
521    // -----------------------------------------------------------------------
522    // expand — allowed_relations filtering
523    // -----------------------------------------------------------------------
524
525    #[test]
526    fn test_expand_filters_by_allowed_relations() {
527        let mut exp = default_expander();
528        exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
529        exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
530        // Only follow Narrower edges
531        let results = exp.expand("fruit", Some(&[SynonymRelation::Narrower]));
532        let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
533        assert!(terms.contains(&"apple"));
534        assert!(!terms.contains(&"food"));
535    }
536
537    #[test]
538    fn test_expand_allowed_relations_none_means_all() {
539        let mut exp = default_expander();
540        exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
541        exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
542        let results = exp.expand("fruit", None);
543        // Should include both directions
544        assert!(results.len() >= 2);
545    }
546
547    // -----------------------------------------------------------------------
548    // expand — cumulative_weight
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn test_expand_cumulative_weight_product() {
553        let config = ExpanderConfig {
554            max_hops: 3,
555            ..ExpanderConfig::default()
556        };
557        let mut exp = SemanticSynonymExpander::new(config);
558        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.8);
559        exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
560        let results = exp.expand("a", None);
561        let c = results
562            .iter()
563            .find(|r| r.term == "c")
564            .expect("c should be found");
565        let expected = 0.8_f32 * 0.9;
566        assert!(
567            (c.cumulative_weight - expected).abs() < 1e-5,
568            "expected cumulative_weight ~ {expected}, got {}",
569            c.cumulative_weight
570        );
571        assert_eq!(c.hops, 2);
572    }
573
574    // -----------------------------------------------------------------------
575    // expand — max_expansions truncation
576    // -----------------------------------------------------------------------
577
578    #[test]
579    fn test_expand_truncates_at_max_expansions() {
580        let config = ExpanderConfig {
581            max_expansions: 3,
582            ..ExpanderConfig::default()
583        };
584        let mut exp = SemanticSynonymExpander::new(config);
585        for i in 0..10 {
586            exp.add_synonym("root", &format!("term{i}"), SynonymRelation::Near, 0.9);
587        }
588        let results = exp.expand("root", None);
589        assert!(results.len() <= 3, "results exceeded max_expansions");
590    }
591
592    // -----------------------------------------------------------------------
593    // expand — sorting
594    // -----------------------------------------------------------------------
595
596    #[test]
597    fn test_expand_sorted_by_weight_descending() {
598        let mut exp = default_expander();
599        exp.add_synonym("root", "high", SynonymRelation::Exact, 0.95);
600        exp.add_synonym("root", "low", SynonymRelation::Near, 0.6);
601        let results = exp.expand("root", None);
602        // High-weight term should come first
603        assert_eq!(results[0].term, "high");
604    }
605
606    #[test]
607    fn test_expand_sorted_alphabetically_on_weight_tie() {
608        let mut exp = default_expander();
609        exp.add_synonym("root", "beta", SynonymRelation::Exact, 0.8);
610        exp.add_synonym("root", "alpha", SynonymRelation::Exact, 0.8);
611        let results = exp.expand("root", None);
612        // Same weight → alphabetical
613        assert_eq!(results[0].term, "alpha");
614        assert_eq!(results[1].term, "beta");
615    }
616
617    // -----------------------------------------------------------------------
618    // expand — stats
619    // -----------------------------------------------------------------------
620
621    #[test]
622    fn test_expand_increments_total_expand_calls() {
623        let mut exp = default_expander();
624        exp.expand("unknown", None);
625        exp.expand("unknown", None);
626        assert_eq!(exp.stats.total_expand_calls, 2);
627    }
628
629    #[test]
630    fn test_expand_accumulates_total_terms_expanded() {
631        let mut exp = default_expander();
632        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
633        exp.add_synonym("a", "c", SynonymRelation::Exact, 0.9);
634        let r = exp.expand("a", None);
635        assert_eq!(exp.stats.total_terms_expanded, r.len() as u64);
636        exp.expand("a", None);
637        assert_eq!(exp.stats.total_terms_expanded, 2 * r.len() as u64);
638    }
639
640    // -----------------------------------------------------------------------
641    // remove_term
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn test_remove_term_removes_node() {
646        let mut exp = default_expander();
647        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
648        exp.remove_term("b");
649        assert!(!exp.graph.contains_key("b"));
650    }
651
652    #[test]
653    fn test_remove_term_removes_incoming_edges() {
654        let mut exp = default_expander();
655        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
656        exp.remove_term("b");
657        // a should no longer have an edge to b
658        assert!(!exp.graph["a"].iter().any(|e| e.target == "b"));
659    }
660
661    #[test]
662    fn test_remove_term_updates_stats() {
663        let mut exp = default_expander();
664        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
665        exp.remove_term("b");
666        assert_eq!(exp.stats.total_terms, 1); // only "a" remains
667        assert_eq!(exp.stats.total_edges, 0); // no more edges
668    }
669
670    #[test]
671    fn test_remove_term_unknown_is_noop() {
672        let mut exp = default_expander();
673        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
674        let terms_before = exp.stats.total_terms;
675        let edges_before = exp.stats.total_edges;
676        exp.remove_term("ghost");
677        assert_eq!(exp.stats.total_terms, terms_before);
678        assert_eq!(exp.stats.total_edges, edges_before);
679    }
680
681    // -----------------------------------------------------------------------
682    // term_count
683    // -----------------------------------------------------------------------
684
685    #[test]
686    fn test_term_count() {
687        let mut exp = default_expander();
688        assert_eq!(exp.term_count(), 0);
689        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
690        assert_eq!(exp.term_count(), 2);
691    }
692
693    // -----------------------------------------------------------------------
694    // stats accessor
695    // -----------------------------------------------------------------------
696
697    #[test]
698    fn test_stats_accessor_returns_ref() {
699        let exp = default_expander();
700        let s = exp.stats();
701        assert_eq!(s.total_expand_calls, 0);
702    }
703
704    // -----------------------------------------------------------------------
705    // ExpanderConfig defaults
706    // -----------------------------------------------------------------------
707
708    #[test]
709    fn test_expander_config_defaults() {
710        let cfg = ExpanderConfig::default();
711        assert_eq!(cfg.max_hops, 2);
712        assert!((cfg.min_weight - 0.5).abs() < f32::EPSILON);
713        assert_eq!(cfg.max_expansions, 20);
714    }
715
716    // -----------------------------------------------------------------------
717    // Multi-hop path not revisited
718    // -----------------------------------------------------------------------
719
720    #[test]
721    fn test_expand_no_revisit_cycle() {
722        let mut exp = default_expander();
723        // a ↔ b ↔ a is already prevented by visited set
724        // Triangle: a - b - c - a
725        exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
726        exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
727        exp.add_synonym("c", "a", SynonymRelation::Exact, 0.9);
728        let results = exp.expand("a", None);
729        // Should not contain "a" and should not loop infinitely
730        assert!(!results.iter().any(|r| r.term == "a"));
731    }
732}