Skip to main content

ipfrs_semantic/
query_expansion.rs

1//! # Query Expansion Engine
2//!
3//! Enriches search queries with synonyms, related terms, and contextual expansions
4//! to improve recall in semantic search pipelines.
5//!
6//! ## Overview
7//!
8//! The [`QueryExpansionEngine`] accepts a raw query string, tokenises it, and for
9//! each token looks up a [`SynonymEntry`] registry and a context-term map.  It
10//! returns a [`QeExpandedQuery`] containing the original query, a deduplicated /
11//! weighted set of [`QeExpansionTerm`]s, and an optional pre-computed query
12//! embedding.
13//!
14//! ## Example
15//!
16//! ```rust
17//! use ipfrs_semantic::query_expansion::{
18//!     ExpansionConfig, QueryExpansionEngine, SynonymEntry,
19//! };
20//!
21//! let config = ExpansionConfig::default();
22//! let mut engine = QueryExpansionEngine::new(config);
23//!
24//! engine.add_synonym_entry(SynonymEntry {
25//!     term: "car".to_string(),
26//!     synonyms: vec!["automobile".to_string(), "vehicle".to_string()],
27//!     hypernyms: vec!["transport".to_string()],
28//!     hyponyms: vec!["sedan".to_string(), "suv".to_string()],
29//! });
30//!
31//! let expanded = engine.expand_query("fast car", None);
32//! assert!(!expanded.terms.is_empty());
33//! ```
34
35use std::collections::HashMap;
36
37// ──────────────────────────────────────────────────────────────────────────────
38// Core data types
39// ──────────────────────────────────────────────────────────────────────────────
40
41/// The origin of an expansion term.
42#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub enum ExpansionSource {
44    /// Direct synonym of the original token.
45    Synonym,
46    /// A more general / broader term (hypernym).
47    Hypernym,
48    /// A more specific / narrower term (hyponym).
49    Hyponym,
50    /// A contextually related term from the context map.
51    RelatedTerm,
52    /// Contextual expansion (also used for the original tokens themselves).
53    ContextualExpansion,
54}
55
56impl ExpansionSource {
57    /// Human-readable name used in statistics maps.
58    pub fn as_str(&self) -> &'static str {
59        match self {
60            Self::Synonym => "Synonym",
61            Self::Hypernym => "Hypernym",
62            Self::Hyponym => "Hyponym",
63            Self::RelatedTerm => "RelatedTerm",
64            Self::ContextualExpansion => "ContextualExpansion",
65        }
66    }
67}
68
69/// A single term in an expanded query, annotated with its weight and origin.
70#[derive(Clone, Debug)]
71pub struct QeExpansionTerm {
72    /// The term string (always lowercased).
73    pub term: String,
74    /// Relevance weight in `(0.0, 1.0]` for expansion terms, or `>1.0` for
75    /// boosted original tokens.
76    pub weight: f64,
77    /// Where this term came from.
78    pub source: ExpansionSource,
79}
80
81/// A lexical entry stored in the synonym database.
82#[derive(Clone, Debug)]
83pub struct SynonymEntry {
84    /// Canonical term (will be lowercased when stored).
85    pub term: String,
86    /// Direct synonyms (e.g., "car" → ["automobile", "vehicle"]).
87    pub synonyms: Vec<String>,
88    /// More general / broader terms (hypernyms).
89    pub hypernyms: Vec<String>,
90    /// More specific / narrower terms (hyponyms).
91    pub hyponyms: Vec<String>,
92}
93
94/// Tuning knobs for [`QueryExpansionEngine`].
95#[derive(Clone, Debug)]
96pub struct ExpansionConfig {
97    /// Maximum number of expansion terms added **per original token**.
98    pub max_expansions_per_term: usize,
99    /// Minimum weight an expansion must have to be kept.
100    pub min_weight: f64,
101    /// Whether to add synonyms.
102    pub include_synonyms: bool,
103    /// Whether to add hypernyms.
104    pub include_hypernyms: bool,
105    /// Whether to add hyponyms.
106    pub include_hyponyms: bool,
107    /// Whether to add context-map related terms.
108    pub include_related: bool,
109    /// Boost factor applied to the original query tokens (weight = this value).
110    pub boost_exact_match: f64,
111}
112
113impl Default for ExpansionConfig {
114    fn default() -> Self {
115        Self {
116            max_expansions_per_term: 5,
117            min_weight: 0.3,
118            include_synonyms: true,
119            include_hypernyms: true,
120            include_hyponyms: true,
121            include_related: true,
122            boost_exact_match: 2.0,
123        }
124    }
125}
126
127/// The result of expanding a query string.
128#[derive(Clone, Debug)]
129pub struct QeExpandedQuery {
130    /// The raw, unmodified query string.
131    pub original: String,
132    /// All expansion terms (including the originals, boosted).
133    pub terms: Vec<QeExpansionTerm>,
134    /// An optional pre-computed embedding for the full query.
135    pub query_embedding: Option<Vec<f64>>,
136}
137
138/// Summary statistics about how much a query was expanded.
139#[derive(Clone, Debug)]
140pub struct ExpansionStats {
141    /// Number of unique tokens in the original query.
142    pub original_term_count: usize,
143    /// Total number of terms in the expanded query (including originals).
144    pub expanded_term_count: usize,
145    /// `expanded_term_count / original_term_count` (or `0.0` if there are no
146    /// original terms).
147    pub expansion_ratio: f64,
148    /// Number of terms contributed by each [`ExpansionSource`] variant.
149    pub sources: HashMap<String, usize>,
150}
151
152// ──────────────────────────────────────────────────────────────────────────────
153// Engine
154// ──────────────────────────────────────────────────────────────────────────────
155
156/// Production-grade query expansion engine.
157///
158/// # Thread Safety
159///
160/// The engine holds no shared state — callers that need concurrent access should
161/// wrap it in a `Mutex` / `RwLock` (write lock only for `expand_query` because
162/// it mutates the counters).
163pub struct QueryExpansionEngine {
164    /// User-supplied configuration.
165    pub config: ExpansionConfig,
166    /// Synonym / hypernym / hyponym registry, keyed by lowercased term.
167    pub synonym_db: HashMap<String, SynonymEntry>,
168    /// Context-term map: trigger word → list of related terms.
169    pub context_terms: HashMap<String, Vec<String>>,
170    /// Total number of `expand_query` calls.
171    pub expansions_performed: u64,
172    /// Cumulative count of expansion terms added across all calls.
173    pub total_terms_added: u64,
174}
175
176impl QueryExpansionEngine {
177    // ── Construction ──────────────────────────────────────────────────────────
178
179    /// Create a new engine with the given configuration.
180    pub fn new(config: ExpansionConfig) -> Self {
181        Self {
182            config,
183            synonym_db: HashMap::new(),
184            context_terms: HashMap::new(),
185            expansions_performed: 0,
186            total_terms_added: 0,
187        }
188    }
189
190    // ── Registry mutations ─────────────────────────────────────────────────────
191
192    /// Insert or replace a [`SynonymEntry`] in the synonym database.
193    ///
194    /// The `entry.term` is lowercased before use as the map key so that
195    /// lookups are case-insensitive.
196    pub fn add_synonym_entry(&mut self, entry: SynonymEntry) {
197        let key = entry.term.to_lowercase();
198        self.synonym_db.insert(key, entry);
199    }
200
201    /// Register a list of related terms that fire when `trigger` appears in a
202    /// query.
203    ///
204    /// If an entry for `trigger` already exists the new terms are **appended**
205    /// (deduplication is left to the caller).
206    pub fn add_context_terms(&mut self, trigger: String, related: Vec<String>) {
207        let key = trigger.to_lowercase();
208        self.context_terms.entry(key).or_default().extend(related);
209    }
210
211    // ── Per-token expansion ───────────────────────────────────────────────────
212
213    /// Return expansion terms for a single `term`, respecting the current
214    /// [`ExpansionConfig`].
215    ///
216    /// The returned list is sorted by weight descending and capped at
217    /// `config.max_expansions_per_term`.  Terms below `config.min_weight` are
218    /// excluded.
219    pub fn expand_term(&self, term: &str) -> Vec<QeExpansionTerm> {
220        let lower = term.to_lowercase();
221        let mut candidates: Vec<QeExpansionTerm> = Vec::new();
222
223        // ── Synonym-database lookup ──────────────────────────────────────────
224        if let Some(entry) = self.synonym_db.get(&lower) {
225            if self.config.include_synonyms {
226                for s in &entry.synonyms {
227                    candidates.push(QeExpansionTerm {
228                        term: s.to_lowercase(),
229                        weight: 0.9,
230                        source: ExpansionSource::Synonym,
231                    });
232                }
233            }
234
235            if self.config.include_hypernyms {
236                for h in &entry.hypernyms {
237                    candidates.push(QeExpansionTerm {
238                        term: h.to_lowercase(),
239                        weight: 0.7,
240                        source: ExpansionSource::Hypernym,
241                    });
242                }
243            }
244
245            if self.config.include_hyponyms {
246                for h in &entry.hyponyms {
247                    candidates.push(QeExpansionTerm {
248                        term: h.to_lowercase(),
249                        weight: 0.6,
250                        source: ExpansionSource::Hyponym,
251                    });
252                }
253            }
254        }
255
256        // ── Context-term lookup ──────────────────────────────────────────────
257        if self.config.include_related {
258            if let Some(related) = self.context_terms.get(&lower) {
259                for r in related {
260                    candidates.push(QeExpansionTerm {
261                        term: r.to_lowercase(),
262                        weight: 0.5,
263                        source: ExpansionSource::RelatedTerm,
264                    });
265                }
266            }
267        }
268
269        // ── Filter, sort, cap ────────────────────────────────────────────────
270        candidates.retain(|t| t.weight >= self.config.min_weight);
271        candidates.sort_by(|a, b| {
272            b.weight
273                .partial_cmp(&a.weight)
274                .unwrap_or(std::cmp::Ordering::Equal)
275        });
276        candidates.truncate(self.config.max_expansions_per_term);
277        candidates
278    }
279
280    // ── Full query expansion ──────────────────────────────────────────────────
281
282    /// Tokenise `query`, expand every token, and return a [`QeExpandedQuery`].
283    ///
284    /// Tokenisation splits on ASCII whitespace and the punctuation characters
285    /// `.`, `,`, `!`, `?`, `;`, `:`.  Empty tokens are discarded.
286    ///
287    /// The `embedding` argument, if provided, is stored verbatim in the result
288    /// (e.g. a pre-computed dense vector for the full query string).
289    pub fn expand_query(&mut self, query: &str, embedding: Option<Vec<f64>>) -> QeExpandedQuery {
290        // ── Tokenise ─────────────────────────────────────────────────────────
291        let tokens: Vec<String> = query
292            .split(|c: char| {
293                c.is_ascii_whitespace() || matches!(c, '.' | ',' | '!' | '?' | ';' | ':')
294            })
295            .filter(|s| !s.is_empty())
296            .map(|s| s.to_lowercase())
297            .collect();
298
299        // Unique tokens (preserve first-occurrence order).
300        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
301        let unique_tokens: Vec<String> = tokens
302            .into_iter()
303            .filter(|t| seen.insert(t.clone()))
304            .collect();
305
306        // ── Collect all terms ─────────────────────────────────────────────────
307        // term → best weight seen so far
308        let mut term_map: HashMap<String, QeExpansionTerm> = HashMap::new();
309
310        // 1. Original tokens (boosted)
311        for tok in &unique_tokens {
312            let candidate = QeExpansionTerm {
313                term: tok.clone(),
314                weight: self.config.boost_exact_match,
315                source: ExpansionSource::ContextualExpansion,
316            };
317            Self::upsert_term(&mut term_map, candidate);
318        }
319
320        // 2. Expanded terms
321        for tok in &unique_tokens {
322            for exp in self.expand_term(tok) {
323                Self::upsert_term(&mut term_map, exp);
324            }
325        }
326
327        // ── Build final sorted term list ──────────────────────────────────────
328        let mut terms: Vec<QeExpansionTerm> = term_map.into_values().collect();
329        terms.sort_by(|a, b| {
330            b.weight
331                .partial_cmp(&a.weight)
332                .unwrap_or(std::cmp::Ordering::Equal)
333        });
334
335        // ── Update counters ───────────────────────────────────────────────────
336        let expansion_count = terms
337            .iter()
338            .filter(|t| !matches!(t.source, ExpansionSource::ContextualExpansion))
339            .count() as u64;
340
341        self.expansions_performed += 1;
342        self.total_terms_added += expansion_count;
343
344        QeExpandedQuery {
345            original: query.to_string(),
346            terms,
347            query_embedding: embedding,
348        }
349    }
350
351    // ── Post-expansion utilities ──────────────────────────────────────────────
352
353    /// Build a space-separated search string from all terms whose weight is at
354    /// or above `config.min_weight`.
355    ///
356    /// The original tokens (source = `ContextualExpansion`) are placed first,
357    /// followed by expansion terms, both groups sorted by weight descending.
358    pub fn build_search_string(&self, expanded: &QeExpandedQuery) -> String {
359        let min_w = self.config.min_weight;
360
361        let mut originals: Vec<&QeExpansionTerm> = expanded
362            .terms
363            .iter()
364            .filter(|t| {
365                t.weight >= min_w && matches!(t.source, ExpansionSource::ContextualExpansion)
366            })
367            .collect();
368
369        let mut expansions: Vec<&QeExpansionTerm> = expanded
370            .terms
371            .iter()
372            .filter(|t| {
373                t.weight >= min_w && !matches!(t.source, ExpansionSource::ContextualExpansion)
374            })
375            .collect();
376
377        originals.sort_by(|a, b| {
378            b.weight
379                .partial_cmp(&a.weight)
380                .unwrap_or(std::cmp::Ordering::Equal)
381        });
382        expansions.sort_by(|a, b| {
383            b.weight
384                .partial_cmp(&a.weight)
385                .unwrap_or(std::cmp::Ordering::Equal)
386        });
387
388        originals
389            .iter()
390            .chain(expansions.iter())
391            .map(|t| t.term.as_str())
392            .collect::<Vec<_>>()
393            .join(" ")
394    }
395
396    /// Return `(term, weight)` pairs sorted by weight descending.
397    pub fn weighted_terms<'a>(&self, expanded: &'a QeExpandedQuery) -> Vec<(&'a str, f64)> {
398        let mut pairs: Vec<(&str, f64)> = expanded
399            .terms
400            .iter()
401            .map(|t| (t.term.as_str(), t.weight))
402            .collect();
403        pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
404        pairs
405    }
406
407    /// Compute expansion statistics for a [`QeExpandedQuery`].
408    pub fn coverage_stats(&self, expanded: &QeExpandedQuery) -> ExpansionStats {
409        // "Original" tokens are those with source == ContextualExpansion and
410        // weight == boost_exact_match.  We count unique such terms.
411        let original_term_count = expanded
412            .terms
413            .iter()
414            .filter(|t| matches!(t.source, ExpansionSource::ContextualExpansion))
415            .count();
416
417        let expanded_term_count = expanded.terms.len();
418
419        let expansion_ratio = if original_term_count == 0 {
420            0.0
421        } else {
422            expanded_term_count as f64 / original_term_count as f64
423        };
424
425        let mut sources: HashMap<String, usize> = HashMap::new();
426        for t in &expanded.terms {
427            *sources.entry(t.source.as_str().to_string()).or_insert(0) += 1;
428        }
429
430        ExpansionStats {
431            original_term_count,
432            expanded_term_count,
433            expansion_ratio,
434            sources,
435        }
436    }
437
438    /// Return `(expansions_performed, total_terms_added)`.
439    pub fn engine_stats(&self) -> (u64, u64) {
440        (self.expansions_performed, self.total_terms_added)
441    }
442
443    // ── Private helpers ────────────────────────────────────────────────────────
444
445    /// Insert `candidate` into `map`, keeping the highest weight when a term
446    /// already exists.
447    fn upsert_term(map: &mut HashMap<String, QeExpansionTerm>, candidate: QeExpansionTerm) {
448        map.entry(candidate.term.clone())
449            .and_modify(|existing| {
450                if candidate.weight > existing.weight {
451                    *existing = candidate.clone();
452                }
453            })
454            .or_insert(candidate);
455    }
456}
457
458// ──────────────────────────────────────────────────────────────────────────────
459// Tests
460// ──────────────────────────────────────────────────────────────────────────────
461
462#[cfg(test)]
463mod tests {
464    use super::{
465        ExpansionConfig, ExpansionSource, QeExpandedQuery, QueryExpansionEngine, SynonymEntry,
466    };
467    use std::collections::HashMap;
468
469    // ── Helpers ────────────────────────────────────────────────────────────────
470
471    fn car_entry() -> SynonymEntry {
472        SynonymEntry {
473            term: "car".to_string(),
474            synonyms: vec!["automobile".to_string(), "vehicle".to_string()],
475            hypernyms: vec!["transport".to_string()],
476            hyponyms: vec!["sedan".to_string(), "suv".to_string()],
477        }
478    }
479
480    fn engine_with_car() -> QueryExpansionEngine {
481        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
482        e.add_synonym_entry(car_entry());
483        e
484    }
485
486    fn count_source(expanded: &QeExpandedQuery, src: &ExpansionSource) -> usize {
487        expanded.terms.iter().filter(|t| &t.source == src).count()
488    }
489
490    // ── 1. Construction ────────────────────────────────────────────────────────
491
492    #[test]
493    fn test_new_engine_starts_empty() {
494        let e = QueryExpansionEngine::new(ExpansionConfig::default());
495        assert!(e.synonym_db.is_empty());
496        assert!(e.context_terms.is_empty());
497        assert_eq!(e.expansions_performed, 0);
498        assert_eq!(e.total_terms_added, 0);
499    }
500
501    #[test]
502    fn test_default_config_values() {
503        let cfg = ExpansionConfig::default();
504        assert_eq!(cfg.max_expansions_per_term, 5);
505        assert!((cfg.min_weight - 0.3).abs() < f64::EPSILON);
506        assert!(cfg.include_synonyms);
507        assert!(cfg.include_hypernyms);
508        assert!(cfg.include_hyponyms);
509        assert!(cfg.include_related);
510        assert!((cfg.boost_exact_match - 2.0).abs() < f64::EPSILON);
511    }
512
513    // ── 2. add_synonym_entry ───────────────────────────────────────────────────
514
515    #[test]
516    fn test_add_synonym_entry_lowercases_key() {
517        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
518        e.add_synonym_entry(SynonymEntry {
519            term: "CAR".to_string(),
520            synonyms: vec!["auto".to_string()],
521            hypernyms: vec![],
522            hyponyms: vec![],
523        });
524        assert!(e.synonym_db.contains_key("car"));
525        assert!(!e.synonym_db.contains_key("CAR"));
526    }
527
528    #[test]
529    fn test_add_synonym_entry_replaces_existing() {
530        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
531        e.add_synonym_entry(SynonymEntry {
532            term: "car".to_string(),
533            synonyms: vec!["auto".to_string()],
534            hypernyms: vec![],
535            hyponyms: vec![],
536        });
537        e.add_synonym_entry(SynonymEntry {
538            term: "car".to_string(),
539            synonyms: vec!["vehicle".to_string()],
540            hypernyms: vec![],
541            hyponyms: vec![],
542        });
543        let entry = e.synonym_db.get("car").expect("entry missing");
544        assert_eq!(entry.synonyms, vec!["vehicle"]);
545    }
546
547    // ── 3. add_context_terms ───────────────────────────────────────────────────
548
549    #[test]
550    fn test_add_context_terms_appends() {
551        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
552        e.add_context_terms("rust".to_string(), vec!["memory".to_string()]);
553        e.add_context_terms("rust".to_string(), vec!["safety".to_string()]);
554        let terms = e.context_terms.get("rust").expect("missing");
555        assert_eq!(terms.len(), 2);
556    }
557
558    #[test]
559    fn test_add_context_terms_lowercases_trigger() {
560        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
561        e.add_context_terms("RUST".to_string(), vec!["memory".to_string()]);
562        assert!(e.context_terms.contains_key("rust"));
563    }
564
565    // ── 4. expand_term ────────────────────────────────────────────────────────
566
567    #[test]
568    fn test_expand_term_unknown_returns_empty() {
569        let e = engine_with_car();
570        assert!(e.expand_term("bicycle").is_empty());
571    }
572
573    #[test]
574    fn test_expand_term_synonyms() {
575        let e = engine_with_car();
576        let terms = e.expand_term("car");
577        let syns: Vec<_> = terms
578            .iter()
579            .filter(|t| t.source == ExpansionSource::Synonym)
580            .collect();
581        assert!(!syns.is_empty(), "expected at least one synonym");
582        assert!(syns.iter().all(|t| (t.weight - 0.9).abs() < f64::EPSILON));
583    }
584
585    #[test]
586    fn test_expand_term_hypernyms() {
587        let e = engine_with_car();
588        let terms = e.expand_term("car");
589        let hypers: Vec<_> = terms
590            .iter()
591            .filter(|t| t.source == ExpansionSource::Hypernym)
592            .collect();
593        assert!(!hypers.is_empty());
594        assert!(hypers.iter().all(|t| (t.weight - 0.7).abs() < f64::EPSILON));
595    }
596
597    #[test]
598    fn test_expand_term_hyponyms() {
599        let e = engine_with_car();
600        let terms = e.expand_term("car");
601        let hypos: Vec<_> = terms
602            .iter()
603            .filter(|t| t.source == ExpansionSource::Hyponym)
604            .collect();
605        assert!(!hypos.is_empty());
606        assert!(hypos.iter().all(|t| (t.weight - 0.6).abs() < f64::EPSILON));
607    }
608
609    #[test]
610    fn test_expand_term_case_insensitive() {
611        let e = engine_with_car();
612        let lower = e.expand_term("car");
613        let upper = e.expand_term("CAR");
614        assert_eq!(lower.len(), upper.len());
615    }
616
617    #[test]
618    fn test_expand_term_respects_max_expansions() {
619        let cfg = ExpansionConfig {
620            max_expansions_per_term: 2,
621            ..Default::default()
622        };
623        let mut e = QueryExpansionEngine::new(cfg);
624        e.add_synonym_entry(car_entry());
625        let terms = e.expand_term("car");
626        assert!(terms.len() <= 2);
627    }
628
629    #[test]
630    fn test_expand_term_sorted_desc() {
631        let e = engine_with_car();
632        let terms = e.expand_term("car");
633        for pair in terms.windows(2) {
634            assert!(pair[0].weight >= pair[1].weight);
635        }
636    }
637
638    #[test]
639    fn test_expand_term_min_weight_filter() {
640        let cfg = ExpansionConfig {
641            min_weight: 0.8,
642            ..Default::default()
643        };
644        let mut e = QueryExpansionEngine::new(cfg);
645        e.add_synonym_entry(car_entry());
646        // Only synonyms (0.9) should survive; hypernyms (0.7) and hyponyms (0.6)
647        // and related terms (0.5) should be filtered.
648        let terms = e.expand_term("car");
649        assert!(terms.iter().all(|t| t.weight >= 0.8));
650    }
651
652    #[test]
653    fn test_expand_term_context_related() {
654        // Use a larger cap so the related term (weight 0.5) is not crowded out
655        // by the 5 synonym/hypernym/hyponym terms from car_entry().
656        let cfg = ExpansionConfig {
657            max_expansions_per_term: 10,
658            ..Default::default()
659        };
660        let mut e = QueryExpansionEngine::new(cfg);
661        e.add_synonym_entry(car_entry());
662        e.add_context_terms("car".to_string(), vec!["driving".to_string()]);
663        let terms = e.expand_term("car");
664        let related: Vec<_> = terms
665            .iter()
666            .filter(|t| t.source == ExpansionSource::RelatedTerm)
667            .collect();
668        assert!(
669            !related.is_empty(),
670            "expected related terms, got: {terms:?}"
671        );
672        assert!(related
673            .iter()
674            .all(|t| (t.weight - 0.5).abs() < f64::EPSILON));
675    }
676
677    #[test]
678    fn test_expand_term_include_synonyms_false() {
679        let cfg = ExpansionConfig {
680            include_synonyms: false,
681            ..Default::default()
682        };
683        let mut e = QueryExpansionEngine::new(cfg);
684        e.add_synonym_entry(car_entry());
685        let terms = e.expand_term("car");
686        assert!(terms.iter().all(|t| t.source != ExpansionSource::Synonym));
687    }
688
689    #[test]
690    fn test_expand_term_include_hypernyms_false() {
691        let cfg = ExpansionConfig {
692            include_hypernyms: false,
693            ..Default::default()
694        };
695        let mut e = QueryExpansionEngine::new(cfg);
696        e.add_synonym_entry(car_entry());
697        let terms = e.expand_term("car");
698        assert!(terms.iter().all(|t| t.source != ExpansionSource::Hypernym));
699    }
700
701    #[test]
702    fn test_expand_term_include_hyponyms_false() {
703        let cfg = ExpansionConfig {
704            include_hyponyms: false,
705            ..Default::default()
706        };
707        let mut e = QueryExpansionEngine::new(cfg);
708        e.add_synonym_entry(car_entry());
709        let terms = e.expand_term("car");
710        assert!(terms.iter().all(|t| t.source != ExpansionSource::Hyponym));
711    }
712
713    #[test]
714    fn test_expand_term_include_related_false() {
715        let cfg = ExpansionConfig {
716            include_related: false,
717            ..Default::default()
718        };
719        let mut e = QueryExpansionEngine::new(cfg);
720        e.add_synonym_entry(car_entry());
721        e.add_context_terms("car".to_string(), vec!["road".to_string()]);
722        let terms = e.expand_term("car");
723        assert!(terms
724            .iter()
725            .all(|t| t.source != ExpansionSource::RelatedTerm));
726    }
727
728    // ── 5. expand_query ───────────────────────────────────────────────────────
729
730    #[test]
731    fn test_expand_query_empty_string() {
732        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
733        let eq = e.expand_query("", None);
734        assert_eq!(eq.original, "");
735        assert!(eq.terms.is_empty());
736    }
737
738    #[test]
739    fn test_expand_query_original_preserved() {
740        let mut e = engine_with_car();
741        let eq = e.expand_query("fast car", None);
742        assert_eq!(eq.original, "fast car");
743    }
744
745    #[test]
746    fn test_expand_query_original_tokens_boosted() {
747        let mut e = engine_with_car();
748        let eq = e.expand_query("car", None);
749        let original_term = eq
750            .terms
751            .iter()
752            .find(|t| t.term == "car" && t.source == ExpansionSource::ContextualExpansion);
753        assert!(original_term.is_some());
754        let w = original_term.map(|t| t.weight).unwrap_or(0.0);
755        assert!((w - 2.0).abs() < f64::EPSILON);
756    }
757
758    #[test]
759    fn test_expand_query_no_duplicates() {
760        let mut e = engine_with_car();
761        let eq = e.expand_query("car car car", None);
762        let terms: Vec<_> = eq.terms.iter().map(|t| &t.term).collect();
763        let unique: std::collections::HashSet<_> = terms.iter().collect();
764        assert_eq!(terms.len(), unique.len(), "duplicate terms found");
765    }
766
767    #[test]
768    fn test_expand_query_punctuation_tokenisation() {
769        let mut e = engine_with_car();
770        // Punctuation separators should produce the same tokens as spaces.
771        let with_punct = e.expand_query("fast,car!speed", None);
772        let terms_punct: std::collections::HashSet<_> =
773            with_punct.terms.iter().map(|t| t.term.clone()).collect();
774        assert!(terms_punct.contains("car"));
775        assert!(terms_punct.contains("fast"));
776        assert!(terms_punct.contains("speed"));
777    }
778
779    #[test]
780    fn test_expand_query_with_embedding() {
781        let mut e = engine_with_car();
782        let emb = vec![0.1, 0.2, 0.3];
783        let eq = e.expand_query("car", Some(emb.clone()));
784        assert_eq!(eq.query_embedding, Some(emb));
785    }
786
787    #[test]
788    fn test_expand_query_no_embedding() {
789        let mut e = engine_with_car();
790        let eq = e.expand_query("car", None);
791        assert!(eq.query_embedding.is_none());
792    }
793
794    #[test]
795    fn test_expand_query_terms_sorted_desc() {
796        let mut e = engine_with_car();
797        let eq = e.expand_query("car", None);
798        for pair in eq.terms.windows(2) {
799            assert!(
800                pair[0].weight >= pair[1].weight,
801                "terms not sorted: {} < {}",
802                pair[0].weight,
803                pair[1].weight
804            );
805        }
806    }
807
808    #[test]
809    fn test_expand_query_has_synonyms() {
810        let mut e = engine_with_car();
811        let eq = e.expand_query("car", None);
812        assert!(count_source(&eq, &ExpansionSource::Synonym) > 0);
813    }
814
815    #[test]
816    fn test_expand_query_has_hypernyms() {
817        let mut e = engine_with_car();
818        let eq = e.expand_query("car", None);
819        assert!(count_source(&eq, &ExpansionSource::Hypernym) > 0);
820    }
821
822    #[test]
823    fn test_expand_query_has_hyponyms() {
824        let mut e = engine_with_car();
825        let eq = e.expand_query("car", None);
826        assert!(count_source(&eq, &ExpansionSource::Hyponym) > 0);
827    }
828
829    #[test]
830    fn test_expand_query_counters_increment() {
831        let mut e = engine_with_car();
832        e.expand_query("car", None);
833        e.expand_query("car", None);
834        assert_eq!(e.expansions_performed, 2);
835        assert!(e.total_terms_added > 0);
836    }
837
838    #[test]
839    fn test_expand_query_multi_token() {
840        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
841        e.add_synonym_entry(SynonymEntry {
842            term: "fast".to_string(),
843            synonyms: vec!["quick".to_string()],
844            hypernyms: vec![],
845            hyponyms: vec![],
846        });
847        e.add_synonym_entry(car_entry());
848        let eq = e.expand_query("fast car", None);
849        let term_strs: Vec<_> = eq.terms.iter().map(|t| t.term.as_str()).collect();
850        assert!(term_strs.contains(&"quick"));
851        assert!(term_strs.contains(&"automobile"));
852    }
853
854    // ── 6. build_search_string ────────────────────────────────────────────────
855
856    #[test]
857    fn test_build_search_string_contains_original() {
858        let mut e = engine_with_car();
859        let eq = e.expand_query("car", None);
860        let s = e.build_search_string(&eq);
861        assert!(s.contains("car"), "search string missing original: {s}");
862    }
863
864    #[test]
865    fn test_build_search_string_no_duplicates() {
866        let mut e = engine_with_car();
867        let eq = e.expand_query("car", None);
868        let s = e.build_search_string(&eq);
869        let words: Vec<_> = s.split_whitespace().collect();
870        let unique: std::collections::HashSet<_> = words.iter().collect();
871        assert_eq!(
872            words.len(),
873            unique.len(),
874            "duplicates in search string: {s}"
875        );
876    }
877
878    #[test]
879    fn test_build_search_string_original_first() {
880        let mut e = engine_with_car();
881        let eq = e.expand_query("car", None);
882        let s = e.build_search_string(&eq);
883        // "car" is the original token so it must appear before any expansion.
884        let words: Vec<_> = s.split_whitespace().collect();
885        let car_pos = words.iter().position(|w| *w == "car");
886        assert_eq!(car_pos, Some(0), "original not first in: {s}");
887    }
888
889    #[test]
890    fn test_build_search_string_respects_min_weight() {
891        let cfg = ExpansionConfig {
892            min_weight: 0.95,
893            boost_exact_match: 2.0,
894            ..Default::default()
895        };
896        let mut e = QueryExpansionEngine::new(cfg);
897        e.add_synonym_entry(car_entry());
898        let eq = e.expand_query("car", None);
899        let s = e.build_search_string(&eq);
900        // Only the boosted original ("car", weight=2.0) clears 0.95.
901        // synonyms (0.9) are below threshold → not included.
902        assert!(
903            !s.contains("automobile"),
904            "automobile should be filtered: {s}"
905        );
906    }
907
908    // ── 7. weighted_terms ─────────────────────────────────────────────────────
909
910    #[test]
911    fn test_weighted_terms_sorted_desc() {
912        let mut e = engine_with_car();
913        let eq = e.expand_query("car", None);
914        let pairs = e.weighted_terms(&eq);
915        for w in pairs.windows(2) {
916            assert!(w[0].1 >= w[1].1);
917        }
918    }
919
920    #[test]
921    fn test_weighted_terms_count_matches_terms() {
922        let mut e = engine_with_car();
923        let eq = e.expand_query("car", None);
924        assert_eq!(e.weighted_terms(&eq).len(), eq.terms.len());
925    }
926
927    // ── 8. coverage_stats ─────────────────────────────────────────────────────
928
929    #[test]
930    fn test_coverage_stats_empty_query() {
931        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
932        let eq = e.expand_query("", None);
933        let stats = e.coverage_stats(&eq);
934        assert_eq!(stats.original_term_count, 0);
935        assert_eq!(stats.expanded_term_count, 0);
936        assert!((stats.expansion_ratio - 0.0).abs() < f64::EPSILON);
937    }
938
939    #[test]
940    fn test_coverage_stats_ratio() {
941        let mut e = engine_with_car();
942        let eq = e.expand_query("car", None);
943        let stats = e.coverage_stats(&eq);
944        assert_eq!(stats.original_term_count, 1);
945        assert!(stats.expanded_term_count >= 1);
946        assert!((stats.expansion_ratio - stats.expanded_term_count as f64).abs() < f64::EPSILON);
947    }
948
949    #[test]
950    fn test_coverage_stats_sources_populated() {
951        let mut e = engine_with_car();
952        let eq = e.expand_query("car", None);
953        let stats = e.coverage_stats(&eq);
954        assert!(!stats.sources.is_empty());
955        assert!(stats.sources.contains_key("ContextualExpansion"));
956    }
957
958    #[test]
959    fn test_coverage_stats_source_counts_sum_to_total() {
960        let mut e = engine_with_car();
961        let eq = e.expand_query("car", None);
962        let stats = e.coverage_stats(&eq);
963        let sum: usize = stats.sources.values().sum();
964        assert_eq!(sum, stats.expanded_term_count);
965    }
966
967    // ── 9. engine_stats ───────────────────────────────────────────────────────
968
969    #[test]
970    fn test_engine_stats_initial_zero() {
971        let e = QueryExpansionEngine::new(ExpansionConfig::default());
972        assert_eq!(e.engine_stats(), (0, 0));
973    }
974
975    #[test]
976    fn test_engine_stats_after_expansion() {
977        let mut e = engine_with_car();
978        e.expand_query("car", None);
979        let (performed, added) = e.engine_stats();
980        assert_eq!(performed, 1);
981        assert!(added > 0);
982    }
983
984    #[test]
985    fn test_engine_stats_accumulate() {
986        let mut e = engine_with_car();
987        e.expand_query("car", None);
988        e.expand_query("car", None);
989        let (performed, _) = e.engine_stats();
990        assert_eq!(performed, 2);
991    }
992
993    // ── 10. Edge cases ─────────────────────────────────────────────────────────
994
995    #[test]
996    fn test_expand_query_all_punctuation() {
997        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
998        let eq = e.expand_query("...,,,!!!", None);
999        assert!(eq.terms.is_empty());
1000    }
1001
1002    #[test]
1003    fn test_deduplication_keeps_highest_weight() {
1004        // "automobile" might appear as a synonym with weight 0.9 and also as a
1005        // separate context term with weight 0.5.  The engine should keep 0.9.
1006        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
1007        e.add_synonym_entry(SynonymEntry {
1008            term: "car".to_string(),
1009            synonyms: vec!["automobile".to_string()],
1010            hypernyms: vec![],
1011            hyponyms: vec![],
1012        });
1013        e.add_context_terms("car".to_string(), vec!["automobile".to_string()]);
1014        let eq = e.expand_query("car", None);
1015        let auto_terms: Vec<_> = eq.terms.iter().filter(|t| t.term == "automobile").collect();
1016        assert_eq!(auto_terms.len(), 1, "duplicate automobile terms");
1017        assert!((auto_terms[0].weight - 0.9).abs() < f64::EPSILON);
1018    }
1019
1020    #[test]
1021    fn test_expand_term_output_lowercased() {
1022        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
1023        e.add_synonym_entry(SynonymEntry {
1024            term: "car".to_string(),
1025            synonyms: vec!["Automobile".to_string()],
1026            hypernyms: vec![],
1027            hyponyms: vec![],
1028        });
1029        let terms = e.expand_term("car");
1030        assert!(terms.iter().all(|t| t.term == t.term.to_lowercase()));
1031    }
1032
1033    #[test]
1034    fn test_multiple_entries_independent() {
1035        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
1036        e.add_synonym_entry(SynonymEntry {
1037            term: "car".to_string(),
1038            synonyms: vec!["vehicle".to_string()],
1039            hypernyms: vec![],
1040            hyponyms: vec![],
1041        });
1042        e.add_synonym_entry(SynonymEntry {
1043            term: "dog".to_string(),
1044            synonyms: vec!["canine".to_string()],
1045            hypernyms: vec![],
1046            hyponyms: vec![],
1047        });
1048        let car_terms = e.expand_term("car");
1049        let dog_terms = e.expand_term("dog");
1050        assert!(car_terms.iter().any(|t| t.term == "vehicle"));
1051        assert!(dog_terms.iter().any(|t| t.term == "canine"));
1052        // No cross-contamination
1053        assert!(!car_terms.iter().any(|t| t.term == "canine"));
1054        assert!(!dog_terms.iter().any(|t| t.term == "vehicle"));
1055    }
1056
1057    #[test]
1058    fn test_expansion_stats_multi_token() {
1059        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
1060        e.add_synonym_entry(SynonymEntry {
1061            term: "fast".to_string(),
1062            synonyms: vec!["quick".to_string()],
1063            hypernyms: vec![],
1064            hyponyms: vec![],
1065        });
1066        e.add_synonym_entry(car_entry());
1067        let eq = e.expand_query("fast car", None);
1068        let stats = e.coverage_stats(&eq);
1069        assert_eq!(stats.original_term_count, 2, "two original tokens");
1070        assert!(stats.expanded_term_count > 2);
1071        // sources should have "Synonym" from both entries
1072        assert!(stats.sources.get("Synonym").copied().unwrap_or(0) >= 3);
1073    }
1074
1075    #[test]
1076    fn test_expand_query_colon_semicolon_separators() {
1077        let mut e = engine_with_car();
1078        let eq = e.expand_query("keyword:car;fast", None);
1079        let term_set: std::collections::HashSet<_> =
1080            eq.terms.iter().map(|t| t.term.as_str()).collect();
1081        assert!(term_set.contains("keyword"));
1082        assert!(term_set.contains("car"));
1083        assert!(term_set.contains("fast"));
1084    }
1085
1086    #[test]
1087    fn test_weighted_terms_references_are_valid() {
1088        let mut e = engine_with_car();
1089        let eq = e.expand_query("car", None);
1090        let pairs = e.weighted_terms(&eq);
1091        // All term references must be non-empty strings.
1092        for (term, weight) in &pairs {
1093            assert!(!term.is_empty());
1094            assert!(*weight > 0.0);
1095        }
1096    }
1097
1098    #[test]
1099    fn test_large_synonym_list_capped() {
1100        let cfg = ExpansionConfig {
1101            max_expansions_per_term: 3,
1102            ..Default::default()
1103        };
1104        let mut e = QueryExpansionEngine::new(cfg);
1105        e.add_synonym_entry(SynonymEntry {
1106            term: "thing".to_string(),
1107            synonyms: vec![
1108                "a".to_string(),
1109                "b".to_string(),
1110                "c".to_string(),
1111                "d".to_string(),
1112                "e".to_string(),
1113            ],
1114            hypernyms: vec!["object".to_string()],
1115            hyponyms: vec!["widget".to_string()],
1116        });
1117        let terms = e.expand_term("thing");
1118        assert_eq!(terms.len(), 3);
1119    }
1120
1121    #[test]
1122    fn test_expansion_source_as_str_variants() {
1123        // Ensure every variant produces a non-empty string, avoiding future regressions.
1124        let sources = [
1125            ExpansionSource::Synonym,
1126            ExpansionSource::Hypernym,
1127            ExpansionSource::Hyponym,
1128            ExpansionSource::RelatedTerm,
1129            ExpansionSource::ContextualExpansion,
1130        ];
1131        let names: Vec<_> = sources.iter().map(|s| s.as_str()).collect();
1132        let unique: std::collections::HashSet<_> = names.iter().collect();
1133        assert_eq!(names.len(), unique.len(), "duplicate source names");
1134        assert!(names.iter().all(|n| !n.is_empty()));
1135    }
1136
1137    #[test]
1138    fn test_build_search_string_empty_query() {
1139        let mut e = QueryExpansionEngine::new(ExpansionConfig::default());
1140        let eq = e.expand_query("", None);
1141        let s = e.build_search_string(&eq);
1142        assert!(s.is_empty(), "expected empty string, got: {s}");
1143    }
1144
1145    #[test]
1146    fn test_coverage_stats_sources_no_extra_keys() {
1147        let valid_keys = [
1148            "Synonym",
1149            "Hypernym",
1150            "Hyponym",
1151            "RelatedTerm",
1152            "ContextualExpansion",
1153        ];
1154        let mut e = engine_with_car();
1155        let eq = e.expand_query("car", None);
1156        let stats = e.coverage_stats(&eq);
1157        for key in stats.sources.keys() {
1158            assert!(
1159                valid_keys.contains(&key.as_str()),
1160                "unexpected source key: {key}"
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn test_engine_stats_type_returns_tuple() {
1167        let e = QueryExpansionEngine::new(ExpansionConfig::default());
1168        let (a, b): (u64, u64) = e.engine_stats();
1169        // Just verifying the return type destructures correctly.
1170        let _ = a + b;
1171    }
1172
1173    // Verify that HashMap used internally is std
1174    #[test]
1175    fn test_expansion_stats_sources_is_hashmap() {
1176        let stats = super::ExpansionStats {
1177            original_term_count: 1,
1178            expanded_term_count: 3,
1179            expansion_ratio: 3.0,
1180            sources: HashMap::from([("Synonym".to_string(), 2_usize)]),
1181        };
1182        assert_eq!(*stats.sources.get("Synonym").unwrap_or(&0), 2);
1183    }
1184}