1use std::collections::HashMap;
36
37#[derive(Clone, Debug, PartialEq, Eq, Hash)]
43pub enum ExpansionSource {
44 Synonym,
46 Hypernym,
48 Hyponym,
50 RelatedTerm,
52 ContextualExpansion,
54}
55
56impl ExpansionSource {
57 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#[derive(Clone, Debug)]
71pub struct QeExpansionTerm {
72 pub term: String,
74 pub weight: f64,
77 pub source: ExpansionSource,
79}
80
81#[derive(Clone, Debug)]
83pub struct SynonymEntry {
84 pub term: String,
86 pub synonyms: Vec<String>,
88 pub hypernyms: Vec<String>,
90 pub hyponyms: Vec<String>,
92}
93
94#[derive(Clone, Debug)]
96pub struct ExpansionConfig {
97 pub max_expansions_per_term: usize,
99 pub min_weight: f64,
101 pub include_synonyms: bool,
103 pub include_hypernyms: bool,
105 pub include_hyponyms: bool,
107 pub include_related: bool,
109 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#[derive(Clone, Debug)]
129pub struct QeExpandedQuery {
130 pub original: String,
132 pub terms: Vec<QeExpansionTerm>,
134 pub query_embedding: Option<Vec<f64>>,
136}
137
138#[derive(Clone, Debug)]
140pub struct ExpansionStats {
141 pub original_term_count: usize,
143 pub expanded_term_count: usize,
145 pub expansion_ratio: f64,
148 pub sources: HashMap<String, usize>,
150}
151
152pub struct QueryExpansionEngine {
164 pub config: ExpansionConfig,
166 pub synonym_db: HashMap<String, SynonymEntry>,
168 pub context_terms: HashMap<String, Vec<String>>,
170 pub expansions_performed: u64,
172 pub total_terms_added: u64,
174}
175
176impl QueryExpansionEngine {
177 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 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 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 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 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 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 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 pub fn expand_query(&mut self, query: &str, embedding: Option<Vec<f64>>) -> QeExpandedQuery {
290 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 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 let mut term_map: HashMap<String, QeExpansionTerm> = HashMap::new();
309
310 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 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 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 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 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 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 pub fn coverage_stats(&self, expanded: &QeExpandedQuery) -> ExpansionStats {
409 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 pub fn engine_stats(&self) -> (u64, u64) {
440 (self.expansions_performed, self.total_terms_added)
441 }
442
443 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#[cfg(test)]
463mod tests {
464 use super::{
465 ExpansionConfig, ExpansionSource, QeExpandedQuery, QueryExpansionEngine, SynonymEntry,
466 };
467 use std::collections::HashMap;
468
469 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 #[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 #[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 #[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 #[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 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 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 #[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 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 #[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 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 assert!(
903 !s.contains("automobile"),
904 "automobile should be filtered: {s}"
905 );
906 }
907
908 #[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 #[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 #[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 #[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 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 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 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 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 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 let _ = a + b;
1171 }
1172
1173 #[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}