Skip to main content

ipfrs_semantic/
query_expander.rs

1//! # Semantic Query Expander
2//!
3//! Expands semantic search queries by generating related query variants —
4//! synonyms, paraphrases, sub-queries — to improve recall without sacrificing precision.
5//!
6//! ## Overview
7//!
8//! The [`SemanticQueryExpander`] accepts a query string and an [`ExpansionStrategy`],
9//! then produces an [`ExpandedQuery`] containing the original query plus generated variants.
10//! Variants are drawn from a [`TermEntry`] registry that encodes lexical relations
11//! ([`TermRelation`]) between terms, each annotated with a relevance weight.
12//!
13//! ## Example
14//!
15//! ```rust
16//! use ipfrs_semantic::query_expander::{
17//!     ExpansionStrategy, SemanticQueryExpander, TermEntry, TermRelation,
18//! };
19//!
20//! let mut expander = SemanticQueryExpander::new(5);
21//! expander.register_term(TermEntry {
22//!     term: "car".to_string(),
23//!     related_term: "automobile".to_string(),
24//!     relation: TermRelation::Synonym,
25//!     weight: 0.9,
26//! });
27//!
28//! let expanded = expander.expand("car", ExpansionStrategy::Synonyms);
29//! assert_eq!(expanded.original, "car");
30//! assert!(!expanded.expansions.is_empty());
31//! ```
32
33/// Expansion strategy controlling how query variants are generated.
34#[derive(Clone, Debug, PartialEq)]
35pub enum ExpansionStrategy {
36    /// Replace keywords with synonyms from the registry.
37    Synonyms,
38    /// Add specificity terms (hyponyms) to the query.
39    Narrowing,
40    /// Remove specificity terms, generalising via hypernyms.
41    Broadening,
42    /// Add "NOT" exclusion terms derived from antonyms.
43    Negation,
44    /// Combine [`ExpansionStrategy::Synonyms`] and [`ExpansionStrategy::Narrowing`], then dedup.
45    Combination,
46}
47
48/// Lexical / semantic relation between two terms.
49#[derive(Clone, Debug, PartialEq)]
50pub enum TermRelation {
51    /// The related term is a synonym of the source term.
52    Synonym,
53    /// The related term is a broader concept (e.g. "vehicle" for "car").
54    Hypernym,
55    /// The related term is a narrower concept (e.g. "sedan" for "car").
56    Hyponym,
57    /// The related term is an antonym of the source term.
58    Antonym,
59}
60
61/// A single entry in the term-relation registry.
62#[derive(Clone, Debug, PartialEq)]
63pub struct TermEntry {
64    /// The source term.
65    pub term: String,
66    /// The term related to [`TermEntry::term`] via [`TermEntry::relation`].
67    pub related_term: String,
68    /// The type of relation between the two terms.
69    pub relation: TermRelation,
70    /// Relevance weight in the range \[0.0, 1.0\].
71    pub weight: f32,
72}
73
74/// Result of a query expansion operation.
75#[derive(Clone, Debug, PartialEq)]
76pub struct ExpandedQuery {
77    /// The original query string, unchanged.
78    pub original: String,
79    /// Generated query variants (excluding the original).
80    pub expansions: Vec<String>,
81    /// The strategy used to produce the expansions.
82    pub strategy: ExpansionStrategy,
83}
84
85impl ExpandedQuery {
86    /// Total number of query variants including the original.
87    ///
88    /// Always ≥ 1 because the original is always counted.
89    #[must_use]
90    pub fn total_variants(&self) -> usize {
91        self.expansions.len() + 1
92    }
93}
94
95/// Runtime statistics for a [`SemanticQueryExpander`].
96#[derive(Clone, Debug, PartialEq)]
97pub struct ExpanderStats {
98    /// Total number of `expand()` calls made so far.
99    pub total_expansions: u64,
100    /// Average number of variant strings generated per `expand()` call.
101    ///
102    /// Returns `0.0` when `total_expansions == 0`.
103    pub avg_variants_per_query: f64,
104    /// Number of entries currently in the term registry.
105    pub registry_size: usize,
106}
107
108/// Expands semantic search queries using a registry of term relations.
109///
110/// The expander maintains a list of [`TermEntry`] records that encode relations
111/// (synonym, hypernym, hyponym, antonym) between pairs of terms.  Given a raw
112/// query string and an [`ExpansionStrategy`], [`SemanticQueryExpander::expand`]
113/// produces an [`ExpandedQuery`] with up to `max_expansions` variants.
114pub struct SemanticQueryExpander {
115    /// All known term-relation entries.
116    pub registry: Vec<TermEntry>,
117    /// Maximum number of expansion variants returned per call (default 5).
118    pub max_expansions: usize,
119    /// Cumulative count of successful `expand()` calls.
120    pub total_expansions: u64,
121    /// Cumulative count of expansion *variants* generated (not counting the original).
122    pub total_variants_generated: u64,
123}
124
125impl SemanticQueryExpander {
126    /// Create a new expander with an empty registry.
127    ///
128    /// # Arguments
129    ///
130    /// * `max_expansions` – maximum number of variant strings returned by each
131    ///   [`SemanticQueryExpander::expand`] call.  Must be ≥ 1; a value of 0
132    ///   means no variants are ever produced.
133    #[must_use]
134    pub fn new(max_expansions: usize) -> Self {
135        Self {
136            registry: Vec::new(),
137            max_expansions,
138            total_expansions: 0,
139            total_variants_generated: 0,
140        }
141    }
142
143    /// Append a new [`TermEntry`] to the registry.
144    pub fn register_term(&mut self, entry: TermEntry) {
145        self.registry.push(entry);
146    }
147
148    /// Expand `query` according to `strategy`, returning an [`ExpandedQuery`].
149    ///
150    /// Internally, the method:
151    /// 1. Looks up matching entries in the registry (case-insensitive term match).
152    /// 2. Formats candidate strings according to the strategy.
153    /// 3. Sorts candidates by weight (descending), then deduplicates.
154    /// 4. Caps the result at [`SemanticQueryExpander::max_expansions`].
155    /// 5. Updates cumulative statistics.
156    pub fn expand(&mut self, query: &str, strategy: ExpansionStrategy) -> ExpandedQuery {
157        let expansions = match &strategy {
158            ExpansionStrategy::Synonyms => self.expand_synonyms(query),
159            ExpansionStrategy::Narrowing => self.expand_narrowing(query),
160            ExpansionStrategy::Broadening => self.expand_broadening(query),
161            ExpansionStrategy::Negation => self.expand_negation(query),
162            ExpansionStrategy::Combination => self.expand_combination(query),
163        };
164
165        self.total_expansions += 1;
166        self.total_variants_generated += expansions.len() as u64;
167
168        ExpandedQuery {
169            original: query.to_string(),
170            expansions,
171            strategy,
172        }
173    }
174
175    /// Return a snapshot of the current runtime statistics.
176    #[must_use]
177    pub fn stats(&self) -> ExpanderStats {
178        let avg_variants_per_query = if self.total_expansions == 0 {
179            0.0
180        } else {
181            self.total_variants_generated as f64 / self.total_expansions as f64
182        };
183
184        ExpanderStats {
185            total_expansions: self.total_expansions,
186            avg_variants_per_query,
187            registry_size: self.registry.len(),
188        }
189    }
190
191    // -----------------------------------------------------------------------
192    // Private helpers
193    // -----------------------------------------------------------------------
194
195    /// Collect registry entries whose `term` matches `query` (case-insensitive)
196    /// and whose `relation` equals `target_relation`, sorted by weight descending.
197    fn matching_entries<'a>(
198        &'a self,
199        query: &str,
200        target_relation: &TermRelation,
201    ) -> Vec<&'a TermEntry> {
202        let query_lower = query.to_lowercase();
203        let mut entries: Vec<&TermEntry> = self
204            .registry
205            .iter()
206            .filter(|e| e.term.to_lowercase() == query_lower && &e.relation == target_relation)
207            .collect();
208
209        // Sort descending by weight; NaN weights sort to the end.
210        entries.sort_by(|a, b| {
211            b.weight
212                .partial_cmp(&a.weight)
213                .unwrap_or(std::cmp::Ordering::Equal)
214        });
215        entries
216    }
217
218    /// Format candidates and cap at `max_expansions`, deduplicating.
219    fn cap_and_dedup(&self, candidates: Vec<String>) -> Vec<String> {
220        let mut seen = std::collections::HashSet::new();
221        candidates
222            .into_iter()
223            .filter(|s| seen.insert(s.clone()))
224            .take(self.max_expansions)
225            .collect()
226    }
227
228    fn expand_synonyms(&self, query: &str) -> Vec<String> {
229        let entries = self.matching_entries(query, &TermRelation::Synonym);
230        let candidates: Vec<String> = entries
231            .into_iter()
232            .map(|e| format!("{} \u{2192} {}", query, e.related_term))
233            .collect();
234        self.cap_and_dedup(candidates)
235    }
236
237    fn expand_narrowing(&self, query: &str) -> Vec<String> {
238        let entries = self.matching_entries(query, &TermRelation::Hyponym);
239        let candidates: Vec<String> = entries
240            .into_iter()
241            .map(|e| format!("{} {}", query, e.related_term))
242            .collect();
243        self.cap_and_dedup(candidates)
244    }
245
246    fn expand_broadening(&self, query: &str) -> Vec<String> {
247        let entries = self.matching_entries(query, &TermRelation::Hypernym);
248        let candidates: Vec<String> = entries
249            .into_iter()
250            .map(|e| e.related_term.clone())
251            .collect();
252        self.cap_and_dedup(candidates)
253    }
254
255    fn expand_negation(&self, query: &str) -> Vec<String> {
256        let entries = self.matching_entries(query, &TermRelation::Antonym);
257        let candidates: Vec<String> = entries
258            .into_iter()
259            .map(|e| format!("{} NOT {}", query, e.related_term))
260            .collect();
261        self.cap_and_dedup(candidates)
262    }
263
264    fn expand_combination(&self, query: &str) -> Vec<String> {
265        // Run Synonyms and Narrowing internally without touching stats.
266        let syn = self.expand_synonyms(query);
267        let narrow = self.expand_narrowing(query);
268
269        // Merge in order: synonyms first, then narrowing, then dedup + cap.
270        let merged: Vec<String> = syn.into_iter().chain(narrow).collect();
271        self.cap_and_dedup(merged)
272    }
273}
274
275// ===========================================================================
276// Vector-based Semantic Query Expander
277// ===========================================================================
278
279use std::collections::HashMap;
280
281/// Configuration for the [`VectorQueryExpander`].
282#[derive(Debug, Clone)]
283pub struct VectorExpanderConfig {
284    /// Maximum number of expansion terms per query (default 5).
285    pub max_expansions: usize,
286    /// Minimum cosine similarity for a synonym to be included (default 0.7).
287    pub similarity_threshold: f64,
288    /// Multiplicative decay factor applied per rank position (default 0.8).
289    pub weight_decay: f64,
290}
291
292impl Default for VectorExpanderConfig {
293    fn default() -> Self {
294        Self {
295            max_expansions: 5,
296            similarity_threshold: 0.7,
297            weight_decay: 0.8,
298        }
299    }
300}
301
302/// Result of a vector-based query expansion.
303#[derive(Debug, Clone)]
304pub struct VectorExpandedQuery {
305    /// The original query vector, unchanged.
306    pub original: Vec<f64>,
307    /// Individual expansion terms with their vectors and weights.
308    pub expansions: Vec<VectorQueryExpansion>,
309    /// Weighted combination of original + expansions, L2-normalised.
310    pub combined: Vec<f64>,
311}
312
313/// A single expansion term produced by [`VectorQueryExpander`].
314#[derive(Debug, Clone)]
315pub struct VectorQueryExpansion {
316    /// The synonym term string.
317    pub term: String,
318    /// The embedding vector for this term.
319    pub vector: Vec<f64>,
320    /// The computed weight (similarity × decay^rank).
321    pub weight: f64,
322    /// Cosine similarity between this term's vector and the original query vector.
323    pub similarity_to_original: f64,
324}
325
326/// Runtime statistics for a [`VectorQueryExpander`].
327#[derive(Debug, Clone)]
328pub struct VectorExpanderStats {
329    /// Number of distinct terms that have at least one synonym registered.
330    pub total_terms: usize,
331    /// Total number of synonym entries across all terms.
332    pub total_synonyms: usize,
333    /// Cumulative count of `expand_query` calls.
334    pub expansions_performed: u64,
335}
336
337/// Vector-based semantic query expander.
338///
339/// Maintains a synonym registry where each term maps to a list of
340/// `(synonym_string, embedding_vector)` pairs.  Given a query term and its
341/// embedding, the expander finds synonyms whose cosine similarity exceeds
342/// [`VectorExpanderConfig::similarity_threshold`], weights them by
343/// `similarity × decay^rank`, and returns a combined (L2-normalised) vector.
344pub struct VectorQueryExpander {
345    config: VectorExpanderConfig,
346    synonym_map: HashMap<String, Vec<(String, Vec<f64>)>>,
347    expansions_performed: u64,
348}
349
350impl VectorQueryExpander {
351    /// Create a new expander with the given configuration and an empty synonym
352    /// registry.
353    #[must_use]
354    pub fn new(config: VectorExpanderConfig) -> Self {
355        Self {
356            config,
357            synonym_map: HashMap::new(),
358            expansions_performed: 0,
359        }
360    }
361
362    /// Register a synonym for `term` together with its embedding vector.
363    pub fn add_synonym(&mut self, term: &str, synonym: &str, vector: Vec<f64>) {
364        self.synonym_map
365            .entry(term.to_string())
366            .or_default()
367            .push((synonym.to_string(), vector));
368    }
369
370    /// Expand a query by finding matching synonyms, weighting them, and
371    /// producing a combined vector.
372    ///
373    /// Steps:
374    /// 1. Look up synonyms for `query_term`.
375    /// 2. Compute cosine similarity of each synonym vector to `query_vector`.
376    /// 3. Filter by `similarity_threshold`.
377    /// 4. Sort descending by similarity.
378    /// 5. Take at most `max_expansions`.
379    /// 6. Weight each expansion by `similarity × decay^rank` (rank starts at 0).
380    /// 7. Combine vectors via weighted average and L2-normalise.
381    pub fn expand_query(&mut self, query_term: &str, query_vector: &[f64]) -> VectorExpandedQuery {
382        self.expansions_performed += 1;
383
384        let synonyms = match self.synonym_map.get(query_term) {
385            Some(s) => s,
386            None => {
387                let combined = Self::l2_normalize(query_vector);
388                return VectorExpandedQuery {
389                    original: query_vector.to_vec(),
390                    expansions: Vec::new(),
391                    combined,
392                };
393            }
394        };
395
396        // Compute similarities and filter.
397        let mut candidates: Vec<(String, Vec<f64>, f64)> = synonyms
398            .iter()
399            .filter_map(|(name, vec)| {
400                let sim = Self::cosine_similarity(query_vector, vec);
401                if sim >= self.config.similarity_threshold {
402                    Some((name.clone(), vec.clone(), sim))
403                } else {
404                    None
405                }
406            })
407            .collect();
408
409        // Sort descending by similarity.
410        candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
411
412        // Cap at max_expansions.
413        candidates.truncate(self.config.max_expansions);
414
415        // Build expansion entries with decay-weighted weights.
416        let expansions: Vec<VectorQueryExpansion> = candidates
417            .iter()
418            .enumerate()
419            .map(|(rank, (term, vec, sim))| {
420                let weight = sim * self.config.weight_decay.powi(rank as i32);
421                VectorQueryExpansion {
422                    term: term.clone(),
423                    vector: vec.clone(),
424                    weight,
425                    similarity_to_original: *sim,
426                }
427            })
428            .collect();
429
430        // Build weighted-vector pairs for combine_vectors.
431        let weighted: Vec<(Vec<f64>, f64)> = expansions
432            .iter()
433            .map(|e| (e.vector.clone(), e.weight))
434            .collect();
435
436        let combined = Self::combine_vectors(query_vector, &weighted);
437
438        VectorExpandedQuery {
439            original: query_vector.to_vec(),
440            expansions,
441            combined,
442        }
443    }
444
445    /// Weighted average of `original` and `expansions`, then L2-normalise.
446    ///
447    /// Formula: `(original + Σ(weight_i × expansion_i)) / (1 + Σ weight_i)`
448    #[must_use]
449    pub fn combine_vectors(original: &[f64], expansions: &[(Vec<f64>, f64)]) -> Vec<f64> {
450        let dim = original.len();
451        let mut result = original.to_vec();
452        let mut total_weight: f64 = 1.0;
453
454        for (vec, w) in expansions {
455            let len = vec.len().min(dim);
456            for i in 0..len {
457                result[i] += w * vec[i];
458            }
459            total_weight += w;
460        }
461
462        if total_weight.abs() > f64::EPSILON {
463            for v in &mut result {
464                *v /= total_weight;
465            }
466        }
467
468        Self::l2_normalize(&result)
469    }
470
471    /// Cosine similarity between two vectors.
472    ///
473    /// Returns 0.0 when either vector has zero magnitude.
474    #[must_use]
475    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
476        let len = a.len().min(b.len());
477        let mut dot = 0.0_f64;
478        let mut norm_a = 0.0_f64;
479        let mut norm_b = 0.0_f64;
480        for i in 0..len {
481            dot += a[i] * b[i];
482            norm_a += a[i] * a[i];
483            norm_b += b[i] * b[i];
484        }
485        let denom = norm_a.sqrt() * norm_b.sqrt();
486        if denom < f64::EPSILON {
487            0.0
488        } else {
489            dot / denom
490        }
491    }
492
493    /// Remove a specific synonym for `term`. Returns `true` if found and removed.
494    pub fn remove_synonym(&mut self, term: &str, synonym: &str) -> bool {
495        if let Some(synonyms) = self.synonym_map.get_mut(term) {
496            let before = synonyms.len();
497            synonyms.retain(|(s, _)| s != synonym);
498            let removed = synonyms.len() < before;
499            // Clean up empty entries.
500            if synonyms.is_empty() {
501                self.synonym_map.remove(term);
502            }
503            removed
504        } else {
505            false
506        }
507    }
508
509    /// Number of synonyms registered for `term` (0 if unknown).
510    #[must_use]
511    pub fn synonym_count(&self, term: &str) -> usize {
512        self.synonym_map.get(term).map_or(0, |v| v.len())
513    }
514
515    /// Remove all synonym entries.
516    pub fn clear_synonyms(&mut self) {
517        self.synonym_map.clear();
518    }
519
520    /// Return a snapshot of the current runtime statistics.
521    #[must_use]
522    pub fn stats(&self) -> VectorExpanderStats {
523        let total_synonyms: usize = self.synonym_map.values().map(|v| v.len()).sum();
524        VectorExpanderStats {
525            total_terms: self.synonym_map.len(),
526            total_synonyms,
527            expansions_performed: self.expansions_performed,
528        }
529    }
530
531    // -----------------------------------------------------------------------
532    // Private helpers
533    // -----------------------------------------------------------------------
534
535    /// L2-normalise a vector.  Returns a zero vector when the norm is ≈ 0.
536    fn l2_normalize(v: &[f64]) -> Vec<f64> {
537        let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
538        if norm < f64::EPSILON {
539            vec![0.0; v.len()]
540        } else {
541            v.iter().map(|x| x / norm).collect()
542        }
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Tests
548// ---------------------------------------------------------------------------
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    // -----------------------------------------------------------------------
555    // Helpers
556    // -----------------------------------------------------------------------
557
558    fn make_entry(term: &str, related: &str, relation: TermRelation, weight: f32) -> TermEntry {
559        TermEntry {
560            term: term.to_string(),
561            related_term: related.to_string(),
562            relation,
563            weight,
564        }
565    }
566
567    fn expander_with_car_data(max: usize) -> SemanticQueryExpander {
568        let mut e = SemanticQueryExpander::new(max);
569        // Synonyms for "car"
570        e.register_term(make_entry("car", "automobile", TermRelation::Synonym, 0.9));
571        e.register_term(make_entry("car", "vehicle", TermRelation::Synonym, 0.7));
572        e.register_term(make_entry("car", "motorcar", TermRelation::Synonym, 0.5));
573        // Hyponyms (narrowing)
574        e.register_term(make_entry("car", "sedan", TermRelation::Hyponym, 0.85));
575        e.register_term(make_entry("car", "coupe", TermRelation::Hyponym, 0.80));
576        e.register_term(make_entry("car", "suv", TermRelation::Hyponym, 0.75));
577        // Hypernyms (broadening)
578        e.register_term(make_entry("car", "transport", TermRelation::Hypernym, 0.8));
579        e.register_term(make_entry("car", "machine", TermRelation::Hypernym, 0.6));
580        // Antonyms (negation)
581        e.register_term(make_entry("car", "bicycle", TermRelation::Antonym, 0.7));
582        e.register_term(make_entry("car", "train", TermRelation::Antonym, 0.65));
583        e
584    }
585
586    // -----------------------------------------------------------------------
587    // Construction
588    // -----------------------------------------------------------------------
589
590    #[test]
591    fn test_new_starts_empty_registry() {
592        let e = SemanticQueryExpander::new(5);
593        assert!(e.registry.is_empty());
594    }
595
596    #[test]
597    fn test_new_sets_max_expansions() {
598        let e = SemanticQueryExpander::new(10);
599        assert_eq!(e.max_expansions, 10);
600    }
601
602    #[test]
603    fn test_new_counters_zero() {
604        let e = SemanticQueryExpander::new(5);
605        assert_eq!(e.total_expansions, 0);
606        assert_eq!(e.total_variants_generated, 0);
607    }
608
609    // -----------------------------------------------------------------------
610    // Registry
611    // -----------------------------------------------------------------------
612
613    #[test]
614    fn test_register_term_adds_entry() {
615        let mut e = SemanticQueryExpander::new(5);
616        e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
617        assert_eq!(e.registry.len(), 1);
618    }
619
620    #[test]
621    fn test_register_multiple_terms() {
622        let mut e = SemanticQueryExpander::new(5);
623        e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
624        e.register_term(make_entry("cat", "feline", TermRelation::Synonym, 0.9));
625        assert_eq!(e.registry.len(), 2);
626    }
627
628    // -----------------------------------------------------------------------
629    // Synonyms
630    // -----------------------------------------------------------------------
631
632    #[test]
633    fn test_expand_synonyms_finds_related_terms() {
634        let mut e = expander_with_car_data(5);
635        let result = e.expand("car", ExpansionStrategy::Synonyms);
636        assert!(!result.expansions.is_empty());
637        // All expansions should contain the arrow separator
638        for exp in &result.expansions {
639            assert!(exp.contains('\u{2192}'), "expansion missing arrow: {exp}");
640        }
641    }
642
643    #[test]
644    fn test_expand_synonyms_respects_max_cap() {
645        // Only allow 2 expansions
646        let mut e = expander_with_car_data(2);
647        let result = e.expand("car", ExpansionStrategy::Synonyms);
648        assert!(
649            result.expansions.len() <= 2,
650            "got {} expansions, expected ≤ 2",
651            result.expansions.len()
652        );
653    }
654
655    #[test]
656    fn test_expand_synonyms_sorts_by_weight_desc() {
657        let mut e = expander_with_car_data(5);
658        let result = e.expand("car", ExpansionStrategy::Synonyms);
659        // First expansion should correspond to the highest-weight synonym (automobile, 0.9)
660        assert!(
661            result.expansions[0].contains("automobile"),
662            "first synonym should be 'automobile' (weight 0.9), got: {}",
663            result.expansions[0]
664        );
665    }
666
667    // -----------------------------------------------------------------------
668    // Narrowing
669    // -----------------------------------------------------------------------
670
671    #[test]
672    fn test_expand_narrowing_adds_hyponyms() {
673        let mut e = expander_with_car_data(5);
674        let result = e.expand("car", ExpansionStrategy::Narrowing);
675        assert!(!result.expansions.is_empty());
676        // Format is "{query} {related_term}"
677        for exp in &result.expansions {
678            assert!(
679                exp.starts_with("car "),
680                "narrowing expansion should start with 'car ': {exp}"
681            );
682        }
683    }
684
685    #[test]
686    fn test_expand_narrowing_sorts_by_weight() {
687        let mut e = expander_with_car_data(5);
688        let result = e.expand("car", ExpansionStrategy::Narrowing);
689        // sedan has weight 0.85 — highest hyponym
690        assert!(
691            result.expansions[0].contains("sedan"),
692            "first narrowing expansion should be 'car sedan', got: {}",
693            result.expansions[0]
694        );
695    }
696
697    // -----------------------------------------------------------------------
698    // Broadening
699    // -----------------------------------------------------------------------
700
701    #[test]
702    fn test_expand_broadening_uses_hypernyms() {
703        let mut e = expander_with_car_data(5);
704        let result = e.expand("car", ExpansionStrategy::Broadening);
705        assert!(!result.expansions.is_empty());
706        // Broadening returns the hypernym alone (no query prefix)
707        assert!(
708            result.expansions.iter().any(|s| s == "transport"),
709            "expected 'transport' in broadening expansions: {:?}",
710            result.expansions
711        );
712    }
713
714    #[test]
715    fn test_expand_broadening_no_query_prefix() {
716        let mut e = expander_with_car_data(5);
717        let result = e.expand("car", ExpansionStrategy::Broadening);
718        for exp in &result.expansions {
719            assert!(
720                !exp.starts_with("car"),
721                "broadening expansion must not start with query: {exp}"
722            );
723        }
724    }
725
726    // -----------------------------------------------------------------------
727    // Negation
728    // -----------------------------------------------------------------------
729
730    #[test]
731    fn test_expand_negation_formats_not_correctly() {
732        let mut e = expander_with_car_data(5);
733        let result = e.expand("car", ExpansionStrategy::Negation);
734        assert!(!result.expansions.is_empty());
735        for exp in &result.expansions {
736            assert!(
737                exp.contains(" NOT "),
738                "negation expansion missing 'NOT': {exp}"
739            );
740        }
741    }
742
743    #[test]
744    fn test_expand_negation_contains_antonym() {
745        let mut e = expander_with_car_data(5);
746        let result = e.expand("car", ExpansionStrategy::Negation);
747        assert!(
748            result.expansions.iter().any(|s| s.contains("bicycle")),
749            "expected 'bicycle' antonym in negation: {:?}",
750            result.expansions
751        );
752    }
753
754    // -----------------------------------------------------------------------
755    // Combination
756    // -----------------------------------------------------------------------
757
758    #[test]
759    fn test_expand_combination_merges_synonyms_and_narrowing() {
760        let mut e = expander_with_car_data(10);
761        let result = e.expand("car", ExpansionStrategy::Combination);
762        // Should contain both a synonym-style expansion and a narrowing-style expansion
763        let has_arrow = result.expansions.iter().any(|s| s.contains('\u{2192}'));
764        let has_space = result
765            .expansions
766            .iter()
767            .any(|s| s.starts_with("car ") && !s.contains('\u{2192}'));
768        assert!(has_arrow, "combination should include synonym expansions");
769        assert!(has_space, "combination should include narrowing expansions");
770    }
771
772    #[test]
773    fn test_expand_combination_deduplicates() {
774        let mut e = SemanticQueryExpander::new(10);
775        // Register the same related term via two strategies
776        e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
777        e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.8));
778        let result = e.expand("dog", ExpansionStrategy::Combination);
779        // Both produce identical strings → should be deduped
780        let count = result
781            .expansions
782            .iter()
783            .filter(|s| s.contains("canine"))
784            .count();
785        assert_eq!(count, 1, "duplicate expansions not removed");
786    }
787
788    #[test]
789    fn test_expand_combination_caps_at_max_expansions() {
790        let mut e = expander_with_car_data(3);
791        let result = e.expand("car", ExpansionStrategy::Combination);
792        assert!(
793            result.expansions.len() <= 3,
794            "combination exceeded max_expansions cap"
795        );
796    }
797
798    // -----------------------------------------------------------------------
799    // total_variants
800    // -----------------------------------------------------------------------
801
802    #[test]
803    fn test_total_variants_includes_original() {
804        let mut e = expander_with_car_data(5);
805        let result = e.expand("car", ExpansionStrategy::Synonyms);
806        assert_eq!(
807            result.total_variants(),
808            result.expansions.len() + 1,
809            "total_variants should be expansions.len() + 1"
810        );
811    }
812
813    #[test]
814    fn test_total_variants_minimum_one() {
815        let mut e = SemanticQueryExpander::new(5);
816        let result = e.expand("unknown_term", ExpansionStrategy::Synonyms);
817        assert_eq!(result.total_variants(), 1);
818    }
819
820    // -----------------------------------------------------------------------
821    // Unknown / empty queries
822    // -----------------------------------------------------------------------
823
824    #[test]
825    fn test_unknown_query_returns_empty_expansions() {
826        let mut e = expander_with_car_data(5);
827        let result = e.expand("zzzunknown", ExpansionStrategy::Synonyms);
828        assert!(
829            result.expansions.is_empty(),
830            "unknown term should produce no expansions"
831        );
832    }
833
834    // -----------------------------------------------------------------------
835    // Case-insensitive matching
836    // -----------------------------------------------------------------------
837
838    #[test]
839    fn test_case_insensitive_matching() {
840        let mut e = expander_with_car_data(5);
841        let lower = e.expand("car", ExpansionStrategy::Synonyms);
842        let upper = e.expand("CAR", ExpansionStrategy::Synonyms);
843        assert_eq!(
844            lower.expansions.len(),
845            upper.expansions.len(),
846            "case should not affect number of matches"
847        );
848    }
849
850    // -----------------------------------------------------------------------
851    // Stats
852    // -----------------------------------------------------------------------
853
854    #[test]
855    fn test_stats_total_expansions_increments() {
856        let mut e = expander_with_car_data(5);
857        e.expand("car", ExpansionStrategy::Synonyms);
858        e.expand("car", ExpansionStrategy::Narrowing);
859        assert_eq!(e.stats().total_expansions, 2);
860    }
861
862    #[test]
863    fn test_stats_avg_variants_zero_when_no_expansions() {
864        let e = SemanticQueryExpander::new(5);
865        assert_eq!(e.stats().avg_variants_per_query, 0.0);
866    }
867
868    #[test]
869    fn test_stats_avg_variants_computed() {
870        let mut e = expander_with_car_data(5);
871        let r1 = e.expand("car", ExpansionStrategy::Synonyms);
872        let r2 = e.expand("car", ExpansionStrategy::Narrowing);
873        let expected = (r1.expansions.len() + r2.expansions.len()) as f64 / 2.0;
874        let stats = e.stats();
875        assert!(
876            (stats.avg_variants_per_query - expected).abs() < 1e-9,
877            "avg mismatch: got {}, expected {expected}",
878            stats.avg_variants_per_query
879        );
880    }
881
882    #[test]
883    fn test_stats_registry_size_correct() {
884        let e = expander_with_car_data(5);
885        // expander_with_car_data registers 10 entries
886        assert_eq!(e.stats().registry_size, 10);
887    }
888
889    #[test]
890    fn test_multiple_calls_accumulate_stats() {
891        let mut e = expander_with_car_data(5);
892        for _ in 0..5 {
893            e.expand("car", ExpansionStrategy::Synonyms);
894        }
895        assert_eq!(e.stats().total_expansions, 5);
896    }
897
898    // -----------------------------------------------------------------------
899    // Weight ordering
900    // -----------------------------------------------------------------------
901
902    #[test]
903    fn test_weight_ordering_respected() {
904        let mut e = SemanticQueryExpander::new(5);
905        e.register_term(make_entry("fruit", "berry", TermRelation::Hyponym, 0.4));
906        e.register_term(make_entry("fruit", "apple", TermRelation::Hyponym, 0.95));
907        e.register_term(make_entry("fruit", "mango", TermRelation::Hyponym, 0.7));
908        let result = e.expand("fruit", ExpansionStrategy::Narrowing);
909        assert!(
910            result.expansions[0].contains("apple"),
911            "highest-weight hyponym 'apple' should be first: {:?}",
912            result.expansions
913        );
914    }
915
916    // -----------------------------------------------------------------------
917    // Strategy / original stored in ExpandedQuery
918    // -----------------------------------------------------------------------
919
920    #[test]
921    fn test_strategy_stored_in_expanded_query() {
922        let mut e = expander_with_car_data(5);
923        let result = e.expand("car", ExpansionStrategy::Broadening);
924        assert_eq!(result.strategy, ExpansionStrategy::Broadening);
925    }
926
927    #[test]
928    fn test_original_stored_in_expanded_query() {
929        let mut e = expander_with_car_data(5);
930        let result = e.expand("car", ExpansionStrategy::Synonyms);
931        assert_eq!(result.original, "car");
932    }
933
934    // =======================================================================
935    // VectorQueryExpander tests
936    // =======================================================================
937
938    fn default_vec_config() -> VectorExpanderConfig {
939        VectorExpanderConfig::default()
940    }
941
942    /// Helper: create a unit vector along dimension `dim` of length `len`.
943    fn unit_vec(dim: usize, len: usize) -> Vec<f64> {
944        let mut v = vec![0.0; len];
945        if dim < len {
946            v[dim] = 1.0;
947        }
948        v
949    }
950
951    /// Helper: create a vector with given values (already a simple wrapper).
952    fn vec_of(vals: &[f64]) -> Vec<f64> {
953        vals.to_vec()
954    }
955
956    // -- cosine_similarity --------------------------------------------------
957
958    #[test]
959    fn test_vec_cosine_parallel_vectors() {
960        let a = vec_of(&[1.0, 0.0, 0.0]);
961        let b = vec_of(&[2.0, 0.0, 0.0]);
962        let sim = VectorQueryExpander::cosine_similarity(&a, &b);
963        assert!(
964            (sim - 1.0).abs() < 1e-9,
965            "parallel vectors should have similarity 1.0, got {sim}"
966        );
967    }
968
969    #[test]
970    fn test_vec_cosine_orthogonal_vectors() {
971        let a = unit_vec(0, 3);
972        let b = unit_vec(1, 3);
973        let sim = VectorQueryExpander::cosine_similarity(&a, &b);
974        assert!(
975            sim.abs() < 1e-9,
976            "orthogonal vectors should have similarity 0.0, got {sim}"
977        );
978    }
979
980    #[test]
981    fn test_vec_cosine_antiparallel() {
982        let a = vec_of(&[1.0, 0.0]);
983        let b = vec_of(&[-1.0, 0.0]);
984        let sim = VectorQueryExpander::cosine_similarity(&a, &b);
985        assert!(
986            (sim + 1.0).abs() < 1e-9,
987            "anti-parallel vectors should have similarity -1.0, got {sim}"
988        );
989    }
990
991    #[test]
992    fn test_vec_cosine_zero_vector() {
993        let a = vec_of(&[0.0, 0.0]);
994        let b = vec_of(&[1.0, 1.0]);
995        let sim = VectorQueryExpander::cosine_similarity(&a, &b);
996        assert!(
997            sim.abs() < 1e-9,
998            "zero vector should produce similarity 0.0, got {sim}"
999        );
1000    }
1001
1002    #[test]
1003    fn test_vec_cosine_identical_vectors() {
1004        let a = vec_of(&[0.3, 0.4, 0.5]);
1005        let sim = VectorQueryExpander::cosine_similarity(&a, &a);
1006        assert!(
1007            (sim - 1.0).abs() < 1e-9,
1008            "identical vectors should have similarity 1.0, got {sim}"
1009        );
1010    }
1011
1012    // -- expand_query: known synonyms ---------------------------------------
1013
1014    #[test]
1015    fn test_vec_expand_with_known_synonyms() {
1016        let mut exp = VectorQueryExpander::new(default_vec_config());
1017        let qv = vec_of(&[1.0, 0.0, 0.0]);
1018        // Add a synonym with high similarity
1019        exp.add_synonym("cat", "feline", vec_of(&[0.9, 0.1, 0.0]));
1020        let result = exp.expand_query("cat", &qv);
1021        assert_eq!(result.expansions.len(), 1);
1022        assert_eq!(result.expansions[0].term, "feline");
1023    }
1024
1025    #[test]
1026    fn test_vec_expand_no_synonyms_returns_original() {
1027        let mut exp = VectorQueryExpander::new(default_vec_config());
1028        let qv = vec_of(&[1.0, 0.0, 0.0]);
1029        let result = exp.expand_query("unknown", &qv);
1030        assert!(result.expansions.is_empty());
1031        // Combined should be L2-normalised original
1032        let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1033        assert!(
1034            (norm - 1.0).abs() < 1e-9,
1035            "combined vector should be normalised, norm = {norm}"
1036        );
1037    }
1038
1039    // -- similarity threshold filtering -------------------------------------
1040
1041    #[test]
1042    fn test_vec_similarity_threshold_filters_low() {
1043        let cfg = VectorExpanderConfig {
1044            similarity_threshold: 0.9,
1045            ..default_vec_config()
1046        };
1047        let mut exp = VectorQueryExpander::new(cfg);
1048        let qv = vec_of(&[1.0, 0.0, 0.0]);
1049        // This synonym has moderate similarity (~0.707)
1050        exp.add_synonym("x", "y", vec_of(&[1.0, 1.0, 0.0]));
1051        let result = exp.expand_query("x", &qv);
1052        assert!(
1053            result.expansions.is_empty(),
1054            "synonym below threshold should be filtered"
1055        );
1056    }
1057
1058    #[test]
1059    fn test_vec_similarity_threshold_allows_high() {
1060        let cfg = VectorExpanderConfig {
1061            similarity_threshold: 0.5,
1062            ..default_vec_config()
1063        };
1064        let mut exp = VectorQueryExpander::new(cfg);
1065        let qv = vec_of(&[1.0, 0.0, 0.0]);
1066        // Similarity ~0.707, above 0.5
1067        exp.add_synonym("x", "y", vec_of(&[1.0, 1.0, 0.0]));
1068        let result = exp.expand_query("x", &qv);
1069        assert_eq!(result.expansions.len(), 1);
1070    }
1071
1072    // -- weight decay ordering ----------------------------------------------
1073
1074    #[test]
1075    fn test_vec_weight_decay_ordering() {
1076        let cfg = VectorExpanderConfig {
1077            similarity_threshold: 0.0,
1078            weight_decay: 0.5,
1079            max_expansions: 10,
1080        };
1081        let mut exp = VectorQueryExpander::new(cfg);
1082        let qv = vec_of(&[1.0, 0.0, 0.0]);
1083        // Two synonyms: first has higher similarity
1084        exp.add_synonym("q", "a", vec_of(&[0.95, 0.05, 0.0]));
1085        exp.add_synonym("q", "b", vec_of(&[0.8, 0.2, 0.0]));
1086        let result = exp.expand_query("q", &qv);
1087        assert!(result.expansions.len() >= 2);
1088        // Rank 0 should have higher weight than rank 1 (decay applied)
1089        assert!(
1090            result.expansions[0].weight > result.expansions[1].weight,
1091            "rank-0 weight {} should exceed rank-1 weight {}",
1092            result.expansions[0].weight,
1093            result.expansions[1].weight
1094        );
1095    }
1096
1097    #[test]
1098    fn test_vec_weight_decay_values() {
1099        let cfg = VectorExpanderConfig {
1100            similarity_threshold: 0.0,
1101            weight_decay: 0.8,
1102            max_expansions: 10,
1103        };
1104        let mut exp = VectorQueryExpander::new(cfg);
1105        let qv = unit_vec(0, 3);
1106        exp.add_synonym("q", "a", vec_of(&[1.0, 0.0, 0.0])); // sim=1.0
1107        exp.add_synonym("q", "b", vec_of(&[1.0, 0.0, 0.0])); // sim=1.0
1108
1109        let result = exp.expand_query("q", &qv);
1110        assert!(result.expansions.len() >= 2);
1111        // rank 0: weight = 1.0 * 0.8^0 = 1.0
1112        assert!(
1113            (result.expansions[0].weight - 1.0).abs() < 1e-9,
1114            "rank-0 weight should be 1.0, got {}",
1115            result.expansions[0].weight
1116        );
1117        // rank 1: weight = 1.0 * 0.8^1 = 0.8
1118        assert!(
1119            (result.expansions[1].weight - 0.8).abs() < 1e-9,
1120            "rank-1 weight should be 0.8, got {}",
1121            result.expansions[1].weight
1122        );
1123    }
1124
1125    // -- combined vector normalisation --------------------------------------
1126
1127    #[test]
1128    fn test_vec_combined_is_normalized() {
1129        let mut exp = VectorQueryExpander::new(default_vec_config());
1130        let qv = vec_of(&[3.0, 4.0, 0.0]);
1131        exp.add_synonym("t", "s1", vec_of(&[3.0, 4.0, 1.0]));
1132        let result = exp.expand_query("t", &qv);
1133        let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1134        assert!(
1135            (norm - 1.0).abs() < 1e-9,
1136            "combined vector L2 norm should be ~1.0, got {norm}"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_vec_combined_no_expansions_still_normalized() {
1142        let mut exp = VectorQueryExpander::new(default_vec_config());
1143        let qv = vec_of(&[3.0, 4.0]);
1144        let result = exp.expand_query("none", &qv);
1145        let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1146        assert!(
1147            (norm - 1.0).abs() < 1e-9,
1148            "combined vector should be normalised even with 0 expansions, norm = {norm}"
1149        );
1150    }
1151
1152    // -- max_expansions cap -------------------------------------------------
1153
1154    #[test]
1155    fn test_vec_max_expansions_cap() {
1156        let cfg = VectorExpanderConfig {
1157            max_expansions: 2,
1158            similarity_threshold: 0.0,
1159            ..default_vec_config()
1160        };
1161        let mut exp = VectorQueryExpander::new(cfg);
1162        let qv = unit_vec(0, 3);
1163        for i in 0..5 {
1164            exp.add_synonym("t", &format!("s{i}"), vec_of(&[1.0, 0.01 * i as f64, 0.0]));
1165        }
1166        let result = exp.expand_query("t", &qv);
1167        assert!(
1168            result.expansions.len() <= 2,
1169            "should cap at 2, got {}",
1170            result.expansions.len()
1171        );
1172    }
1173
1174    // -- add / remove synonyms ----------------------------------------------
1175
1176    #[test]
1177    fn test_vec_add_synonym() {
1178        let mut exp = VectorQueryExpander::new(default_vec_config());
1179        exp.add_synonym("a", "b", vec![1.0]);
1180        assert_eq!(exp.synonym_count("a"), 1);
1181    }
1182
1183    #[test]
1184    fn test_vec_add_multiple_synonyms() {
1185        let mut exp = VectorQueryExpander::new(default_vec_config());
1186        exp.add_synonym("a", "b", vec![1.0]);
1187        exp.add_synonym("a", "c", vec![2.0]);
1188        assert_eq!(exp.synonym_count("a"), 2);
1189    }
1190
1191    #[test]
1192    fn test_vec_remove_synonym_exists() {
1193        let mut exp = VectorQueryExpander::new(default_vec_config());
1194        exp.add_synonym("a", "b", vec![1.0]);
1195        assert!(exp.remove_synonym("a", "b"));
1196        assert_eq!(exp.synonym_count("a"), 0);
1197    }
1198
1199    #[test]
1200    fn test_vec_remove_synonym_not_found() {
1201        let mut exp = VectorQueryExpander::new(default_vec_config());
1202        assert!(!exp.remove_synonym("a", "b"));
1203    }
1204
1205    #[test]
1206    fn test_vec_remove_synonym_partial() {
1207        let mut exp = VectorQueryExpander::new(default_vec_config());
1208        exp.add_synonym("a", "b", vec![1.0]);
1209        exp.add_synonym("a", "c", vec![2.0]);
1210        assert!(exp.remove_synonym("a", "b"));
1211        assert_eq!(exp.synonym_count("a"), 1);
1212    }
1213
1214    // -- empty query --------------------------------------------------------
1215
1216    #[test]
1217    fn test_vec_empty_query_term() {
1218        let mut exp = VectorQueryExpander::new(default_vec_config());
1219        let qv = vec_of(&[1.0, 0.0]);
1220        let result = exp.expand_query("", &qv);
1221        assert!(result.expansions.is_empty());
1222    }
1223
1224    // -- clear_synonyms -----------------------------------------------------
1225
1226    #[test]
1227    fn test_vec_clear_synonyms() {
1228        let mut exp = VectorQueryExpander::new(default_vec_config());
1229        exp.add_synonym("a", "b", vec![1.0]);
1230        exp.add_synonym("c", "d", vec![2.0]);
1231        exp.clear_synonyms();
1232        assert_eq!(exp.synonym_count("a"), 0);
1233        assert_eq!(exp.synonym_count("c"), 0);
1234    }
1235
1236    // -- stats --------------------------------------------------------------
1237
1238    #[test]
1239    fn test_vec_stats_initial() {
1240        let exp = VectorQueryExpander::new(default_vec_config());
1241        let s = exp.stats();
1242        assert_eq!(s.total_terms, 0);
1243        assert_eq!(s.total_synonyms, 0);
1244        assert_eq!(s.expansions_performed, 0);
1245    }
1246
1247    #[test]
1248    fn test_vec_stats_after_operations() {
1249        let mut exp = VectorQueryExpander::new(default_vec_config());
1250        exp.add_synonym("a", "b", vec![1.0]);
1251        exp.add_synonym("a", "c", vec![2.0]);
1252        exp.add_synonym("x", "y", vec![3.0]);
1253        let s = exp.stats();
1254        assert_eq!(s.total_terms, 2);
1255        assert_eq!(s.total_synonyms, 3);
1256    }
1257
1258    #[test]
1259    fn test_vec_stats_expansions_performed() {
1260        let mut exp = VectorQueryExpander::new(default_vec_config());
1261        let qv = vec_of(&[1.0]);
1262        exp.expand_query("a", &qv);
1263        exp.expand_query("b", &qv);
1264        exp.expand_query("c", &qv);
1265        assert_eq!(exp.stats().expansions_performed, 3);
1266    }
1267
1268    // -- multiple terms with overlapping synonyms ---------------------------
1269
1270    #[test]
1271    fn test_vec_overlapping_synonyms_different_terms() {
1272        let cfg = VectorExpanderConfig {
1273            similarity_threshold: 0.0,
1274            ..default_vec_config()
1275        };
1276        let mut exp = VectorQueryExpander::new(cfg);
1277        // "shared_syn" is a synonym for both "a" and "b"
1278        exp.add_synonym("a", "shared_syn", vec_of(&[1.0, 0.0]));
1279        exp.add_synonym("b", "shared_syn", vec_of(&[0.0, 1.0]));
1280
1281        let r_a = exp.expand_query("a", &vec_of(&[1.0, 0.0]));
1282        let r_b = exp.expand_query("b", &vec_of(&[0.0, 1.0]));
1283        assert_eq!(r_a.expansions.len(), 1);
1284        assert_eq!(r_b.expansions.len(), 1);
1285        assert_eq!(r_a.expansions[0].term, "shared_syn");
1286        assert_eq!(r_b.expansions[0].term, "shared_syn");
1287    }
1288
1289    // -- combine_vectors standalone -----------------------------------------
1290
1291    #[test]
1292    fn test_vec_combine_vectors_no_expansions() {
1293        let orig = vec_of(&[3.0, 4.0]);
1294        let combined = VectorQueryExpander::combine_vectors(&orig, &[]);
1295        let norm: f64 = combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1296        assert!((norm - 1.0).abs() < 1e-9);
1297        // Should point in same direction as original
1298        assert!(combined[0] > 0.0);
1299        assert!(combined[1] > 0.0);
1300    }
1301
1302    #[test]
1303    fn test_vec_combine_vectors_weighted() {
1304        let orig = vec_of(&[1.0, 0.0]);
1305        let exp_vec = vec_of(&[0.0, 1.0]);
1306        let combined = VectorQueryExpander::combine_vectors(&orig, &[(exp_vec, 1.0)]);
1307        // With equal weight: (1+0)/2, (0+1)/2 = (0.5, 0.5), normalised
1308        let expected = 1.0 / 2.0_f64.sqrt();
1309        assert!(
1310            (combined[0] - expected).abs() < 1e-9,
1311            "expected {expected}, got {}",
1312            combined[0]
1313        );
1314        assert!(
1315            (combined[1] - expected).abs() < 1e-9,
1316            "expected {expected}, got {}",
1317            combined[1]
1318        );
1319    }
1320
1321    // -- synonym_count for unknown term -------------------------------------
1322
1323    #[test]
1324    fn test_vec_synonym_count_unknown_term() {
1325        let exp = VectorQueryExpander::new(default_vec_config());
1326        assert_eq!(exp.synonym_count("nonexistent"), 0);
1327    }
1328}