1use std::collections::HashMap;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ConceptType {
25 Keyword,
27 Phrase,
29 Entity,
31 Technical,
33}
34
35#[derive(Debug, Clone)]
41pub struct Concept {
42 pub term: String,
44 pub score: f64,
46 pub frequency: u32,
48 pub doc_frequency: u32,
50 pub concept_type: ConceptType,
52}
53
54#[derive(Debug, Clone)]
60pub struct ExtractorConfig {
61 pub max_concepts: usize,
63 pub min_term_frequency: u32,
65 pub min_term_length: usize,
67 pub max_ngram: usize,
69 pub stop_words: Vec<String>,
71 pub idf_smoothing: f64,
73}
74
75impl Default for ExtractorConfig {
76 fn default() -> Self {
77 Self {
78 max_concepts: 20,
79 min_term_frequency: 1,
80 min_term_length: 3,
81 max_ngram: 3,
82 stop_words: default_stop_words(),
83 idf_smoothing: 1.0,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Default)]
94pub struct ExtractorStats {
95 pub documents_processed: u64,
97 pub total_concepts_extracted: u64,
99 pub avg_concepts_per_doc: f64,
101}
102
103pub struct ConceptExtractor {
126 config: ExtractorConfig,
127 corpus_stats: HashMap<String, u32>,
129 doc_count: usize,
131 stats: ExtractorStats,
132}
133
134impl ConceptExtractor {
135 pub fn new(config: ExtractorConfig) -> Self {
137 Self {
138 config,
139 corpus_stats: HashMap::new(),
140 doc_count: 0,
141 stats: ExtractorStats::default(),
142 }
143 }
144
145 pub fn extract(&mut self, text: &str) -> Vec<Concept> {
152 let raw_tokens: Vec<String> = Self::tokenize_raw(text);
154 let lc_tokens: Vec<String> = raw_tokens.iter().map(|t| t.to_lowercase()).collect();
156
157 self.update_corpus(&lc_tokens);
159 self.doc_count += 1;
160
161 let tf_map = Self::compute_tf(&lc_tokens);
163
164 let mut concepts: Vec<Concept> = Vec::new();
166 let doc_len = lc_tokens.len().max(1);
167
168 for (term, &tf) in &tf_map {
170 if self.is_stop_word(term) {
171 continue;
172 }
173 if tf < self.config.min_term_frequency {
174 continue;
175 }
176 if term.len() < self.config.min_term_length {
177 continue;
178 }
179 let score = self.compute_tfidf(term, tf, doc_len);
180 let doc_frequency = self.corpus_stats.get(term).copied().unwrap_or(1);
181 let concept_type = detect_concept_type_from_raw(term, &raw_tokens, &lc_tokens);
182 concepts.push(Concept {
183 term: term.clone(),
184 score,
185 frequency: tf,
186 doc_frequency,
187 concept_type,
188 });
189 }
190
191 for n in 2..=self.config.max_ngram {
193 let ngrams = Self::extract_ngrams(&lc_tokens, n);
194 let ngram_tf = Self::compute_tf(&ngrams);
196 let raw_ngrams = Self::extract_ngrams(&raw_tokens, n);
197 let raw_ngram_tf = Self::compute_tf(&raw_ngrams);
198
199 for (ngram, &tf) in &ngram_tf {
200 if tf < self.config.min_term_frequency {
201 continue;
202 }
203 let parts: Vec<&str> = ngram.split(' ').collect();
205 let all_stop = parts.iter().all(|p| self.is_stop_word(p));
206 if all_stop {
207 continue;
208 }
209 let score = self.compute_tfidf(ngram, tf, doc_len);
210 let doc_frequency = self.corpus_stats.get(ngram).copied().unwrap_or(1);
213 let raw_phrase = raw_ngram_tf
215 .keys()
216 .find(|k| k.to_lowercase() == *ngram)
217 .cloned()
218 .unwrap_or_else(|| ngram.clone());
219 let concept_type = detect_phrase_type(&raw_phrase);
220 concepts.push(Concept {
221 term: ngram.clone(),
222 score,
223 frequency: tf,
224 doc_frequency,
225 concept_type,
226 });
227 }
228 }
229
230 let result = Self::top_concepts(&mut concepts, self.config.max_concepts);
232
233 let extracted = result.len() as u64;
235 self.stats.documents_processed += 1;
236 self.stats.total_concepts_extracted += extracted;
237 self.stats.avg_concepts_per_doc =
238 self.stats.total_concepts_extracted as f64 / self.stats.documents_processed as f64;
239
240 result
241 }
242
243 pub fn tokenize(text: &str) -> Vec<String> {
248 Self::tokenize_raw(text)
249 .into_iter()
250 .map(|t| t.to_lowercase())
251 .collect()
252 }
253
254 pub fn compute_tf(tokens: &[String]) -> HashMap<String, u32> {
256 let mut map: HashMap<String, u32> = HashMap::new();
257 for tok in tokens {
258 *map.entry(tok.clone()).or_insert(0) += 1;
259 }
260 map
261 }
262
263 pub fn compute_tfidf(&self, term: &str, tf: u32, doc_len: usize) -> f64 {
269 let max_tf = (doc_len as f64).max(1.0);
270 let tf_norm = 0.5 + 0.5 * (tf as f64 / max_tf);
272 let n = (self.doc_count as f64).max(1.0);
274 let df = self.corpus_stats.get(term).copied().unwrap_or(0) as f64;
275 let smooth = self.config.idf_smoothing;
276 let idf = ((n + smooth) / (df + smooth)).ln() + 1.0;
277 tf_norm * idf
278 }
279
280 pub fn extract_ngrams(tokens: &[String], n: usize) -> Vec<String> {
283 if n == 0 || tokens.len() < n {
284 return Vec::new();
285 }
286 tokens.windows(n).map(|w| w.join(" ")).collect()
287 }
288
289 pub fn detect_concept_type(term: &str) -> ConceptType {
298 if term.contains(' ') {
299 return ConceptType::Phrase;
300 }
301 let ascii_letters: Vec<char> = term.chars().filter(|c| c.is_ascii_alphabetic()).collect();
303 if !ascii_letters.is_empty() && ascii_letters.iter().all(|c| c.is_ascii_uppercase()) {
304 return ConceptType::Entity;
305 }
306 if term
308 .chars()
309 .next()
310 .map(|c| c.is_ascii_uppercase())
311 .unwrap_or(false)
312 {
313 return ConceptType::Entity;
314 }
315 if term.contains('_') {
317 return ConceptType::Technical;
318 }
319 let mut chars = term.chars();
321 let _ = chars.next();
323 if chars.any(|c| c.is_ascii_uppercase()) {
324 return ConceptType::Technical;
325 }
326 ConceptType::Keyword
327 }
328
329 pub fn is_stop_word(&self, word: &str) -> bool {
332 let lower = word.to_lowercase();
333 self.config.stop_words.iter().any(|sw| sw == &lower)
334 }
335
336 pub fn update_corpus(&mut self, tokens: &[String]) {
340 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
341 for tok in tokens {
342 if seen.insert(tok.as_str()) {
343 *self.corpus_stats.entry(tok.clone()).or_insert(0) += 1;
344 }
345 }
346 }
347
348 pub fn top_concepts(concepts: &mut Vec<Concept>, n: usize) -> Vec<Concept> {
351 let mut best: HashMap<String, Concept> = HashMap::new();
353 for c in concepts.drain(..) {
354 let entry = best.entry(c.term.clone()).or_insert_with(|| c.clone());
355 if c.score > entry.score {
356 *entry = c;
357 }
358 }
359 let mut sorted: Vec<Concept> = best.into_values().collect();
360 sorted.sort_by(|a, b| {
361 b.score
362 .partial_cmp(&a.score)
363 .unwrap_or(std::cmp::Ordering::Equal)
364 });
365 sorted.truncate(n);
366 sorted
367 }
368
369 pub fn stats(&self) -> &ExtractorStats {
371 &self.stats
372 }
373
374 fn tokenize_raw(text: &str) -> Vec<String> {
380 text.split_whitespace()
383 .flat_map(|word| {
384 split_on_punctuation(word)
387 })
388 .filter(|t| !t.is_empty())
389 .collect()
390 }
391}
392
393fn split_on_punctuation(word: &str) -> Vec<String> {
401 let trimmed = word.trim_matches(|c: char| {
403 matches!(
404 c,
405 '.' | ','
406 | '!'
407 | '?'
408 | ';'
409 | ':'
410 | '"'
411 | '\''
412 | '('
413 | ')'
414 | '['
415 | ']'
416 | '{'
417 | '}'
418 | '<'
419 | '>'
420 | '/'
421 | '\\'
422 | '|'
423 | '*'
424 | '&'
425 | '#'
426 | '@'
427 | '%'
428 | '^'
429 | '~'
430 | '`'
431 )
432 });
433 if trimmed.is_empty() {
434 return Vec::new();
435 }
436 let parts: Vec<String> = trimmed
438 .split(|c: char| {
439 matches!(
440 c,
441 '/' | '\\'
442 | '('
443 | ')'
444 | '['
445 | ']'
446 | '{'
447 | '}'
448 | '<'
449 | '>'
450 | '|'
451 | ';'
452 | ','
453 | '"'
454 | '`'
455 )
456 })
457 .map(str::to_owned)
458 .filter(|s| !s.is_empty())
459 .collect();
460 parts
461}
462
463fn detect_concept_type_from_raw(
466 lc_term: &str,
467 raw_tokens: &[String],
468 lc_tokens: &[String],
469) -> ConceptType {
470 let raw = lc_tokens
472 .iter()
473 .zip(raw_tokens.iter())
474 .find_map(|(lc, raw)| {
475 if lc == lc_term {
476 Some(raw.as_str())
477 } else {
478 None
479 }
480 })
481 .unwrap_or(lc_term);
482 ConceptExtractor::detect_concept_type(raw)
483}
484
485fn detect_phrase_type(raw_phrase: &str) -> ConceptType {
489 let mut has_entity = false;
493 let mut has_technical = false;
494 for part in raw_phrase.split(' ') {
495 match ConceptExtractor::detect_concept_type(part) {
496 ConceptType::Entity => has_entity = true,
497 ConceptType::Technical => has_technical = true,
498 _ => {}
499 }
500 }
501 if has_entity {
502 ConceptType::Entity
503 } else if has_technical {
504 ConceptType::Technical
505 } else {
506 ConceptType::Phrase
507 }
508}
509
510fn default_stop_words() -> Vec<String> {
512 [
513 "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
514 "from", "as", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
515 "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can",
516 "need", "dare", "ought", "used", "it", "its", "this", "that", "these", "those", "i", "me",
517 "my", "we", "our", "you", "your", "he", "she", "they", "them", "their", "his", "her",
518 "who", "which", "what", "when", "where", "why", "how", "all", "any", "both", "each", "few",
519 "more", "most", "other", "some", "such", "no", "not", "only", "own", "same", "than", "too",
520 "very", "just", "because", "if", "then", "so", "up", "out", "about", "into", "through",
521 "during", "before", "after", "above", "below", "between", "each", "every", "also", "get",
522 "got", "let",
523 ]
524 .iter()
525 .map(|s| s.to_string())
526 .collect()
527}
528
529#[cfg(test)]
534mod tests {
535 use super::*;
536
537 fn make_extractor() -> ConceptExtractor {
538 ConceptExtractor::new(ExtractorConfig::default())
539 }
540
541 #[test]
546 fn test_tokenize_basic() {
547 let tokens = ConceptExtractor::tokenize("Hello, world! This is a test.");
548 assert!(tokens.contains(&"hello".to_string()));
549 assert!(tokens.contains(&"world".to_string()));
550 assert!(tokens.contains(&"test".to_string()));
551 }
552
553 #[test]
554 fn test_tokenize_punctuation_stripped() {
555 let tokens = ConceptExtractor::tokenize("foo, bar; baz.");
556 assert!(tokens.contains(&"foo".to_string()));
557 assert!(tokens.contains(&"bar".to_string()));
558 assert!(tokens.contains(&"baz".to_string()));
559 assert!(!tokens.iter().any(|t| t == "," || t == ";" || t == "."));
561 }
562
563 #[test]
564 fn test_tokenize_short_tokens_included() {
565 let tokens = ConceptExtractor::tokenize("a cat");
568 assert!(tokens.contains(&"cat".to_string()));
569 }
570
571 #[test]
572 fn test_tokenize_embedded_slash() {
573 let tokens = ConceptExtractor::tokenize("foo/bar baz");
574 assert!(tokens.contains(&"foo".to_string()));
575 assert!(tokens.contains(&"bar".to_string()));
576 }
577
578 #[test]
579 fn test_tokenize_empty_string() {
580 let tokens = ConceptExtractor::tokenize("");
581 assert!(tokens.is_empty());
582 }
583
584 #[test]
585 fn test_tokenize_whitespace_only() {
586 let tokens = ConceptExtractor::tokenize(" \t\n ");
587 assert!(tokens.is_empty());
588 }
589
590 #[test]
595 fn test_compute_tf_basic() {
596 let tokens: Vec<String> = ["apple", "banana", "apple", "cherry"]
597 .iter()
598 .map(|s| s.to_string())
599 .collect();
600 let tf = ConceptExtractor::compute_tf(&tokens);
601 assert_eq!(tf.get("apple"), Some(&2));
602 assert_eq!(tf.get("banana"), Some(&1));
603 assert_eq!(tf.get("cherry"), Some(&1));
604 }
605
606 #[test]
607 fn test_compute_tf_empty() {
608 let tf = ConceptExtractor::compute_tf(&[]);
609 assert!(tf.is_empty());
610 }
611
612 #[test]
613 fn test_compute_tf_single_token() {
614 let tokens = vec!["rust".to_string()];
615 let tf = ConceptExtractor::compute_tf(&tokens);
616 assert_eq!(tf.get("rust"), Some(&1));
617 }
618
619 #[test]
624 fn test_tfidf_increases_with_frequency() {
625 let mut extractor = make_extractor();
626 extractor.update_corpus(&["rust".to_string(), "programming".to_string()]);
628 extractor.doc_count = 1;
629 let score_low = extractor.compute_tfidf("rust", 1, 100);
630 let score_high = extractor.compute_tfidf("rust", 10, 100);
631 assert!(
632 score_high > score_low,
633 "Higher TF should yield higher TF-IDF"
634 );
635 }
636
637 #[test]
638 fn test_tfidf_rare_term_scores_higher() {
639 let mut extractor = make_extractor();
640 for _ in 0..10 {
642 extractor.update_corpus(&["common".to_string()]);
643 extractor.doc_count += 1;
644 }
645 extractor.update_corpus(&["rare".to_string()]);
646 extractor.doc_count += 1;
647
648 let score_common = extractor.compute_tfidf("common", 3, 50);
649 let score_rare = extractor.compute_tfidf("rare", 3, 50);
650 assert!(
651 score_rare > score_common,
652 "Rare term (high IDF) should score higher than common term"
653 );
654 }
655
656 #[test]
657 fn test_tfidf_zero_frequency_term() {
658 let extractor = make_extractor();
659 let score = extractor.compute_tfidf("unknown", 1, 10);
661 assert!(score > 0.0, "Score must be positive even for unknown terms");
662 }
663
664 #[test]
669 fn test_extract_bigrams() {
670 let tokens: Vec<String> = ["machine", "learning", "model"]
671 .iter()
672 .map(|s| s.to_string())
673 .collect();
674 let bigrams = ConceptExtractor::extract_ngrams(&tokens, 2);
675 assert_eq!(bigrams, vec!["machine learning", "learning model"]);
676 }
677
678 #[test]
679 fn test_extract_trigrams() {
680 let tokens: Vec<String> = ["deep", "neural", "network", "architecture"]
681 .iter()
682 .map(|s| s.to_string())
683 .collect();
684 let trigrams = ConceptExtractor::extract_ngrams(&tokens, 3);
685 assert_eq!(
686 trigrams,
687 vec!["deep neural network", "neural network architecture"]
688 );
689 }
690
691 #[test]
692 fn test_extract_ngrams_too_short() {
693 let tokens: Vec<String> = ["only", "two"].iter().map(|s| s.to_string()).collect();
694 let trigrams = ConceptExtractor::extract_ngrams(&tokens, 3);
695 assert!(trigrams.is_empty());
696 }
697
698 #[test]
699 fn test_extract_ngrams_n_zero() {
700 let tokens: Vec<String> = ["hello", "world"].iter().map(|s| s.to_string()).collect();
701 let result = ConceptExtractor::extract_ngrams(&tokens, 0);
702 assert!(result.is_empty());
703 }
704
705 #[test]
706 fn test_extract_unigrams_as_ngrams() {
707 let tokens: Vec<String> = ["foo", "bar"].iter().map(|s| s.to_string()).collect();
708 let unigrams = ConceptExtractor::extract_ngrams(&tokens, 1);
709 assert_eq!(unigrams, vec!["foo", "bar"]);
710 }
711
712 #[test]
717 fn test_detect_entity_starts_uppercase() {
718 assert_eq!(
719 ConceptExtractor::detect_concept_type("London"),
720 ConceptType::Entity
721 );
722 }
723
724 #[test]
725 fn test_detect_entity_all_caps() {
726 assert_eq!(
727 ConceptExtractor::detect_concept_type("API"),
728 ConceptType::Entity
729 );
730 assert_eq!(
731 ConceptExtractor::detect_concept_type("HTTP"),
732 ConceptType::Entity
733 );
734 }
735
736 #[test]
737 fn test_detect_entity_single_capital() {
738 assert_eq!(
739 ConceptExtractor::detect_concept_type("Rust"),
740 ConceptType::Entity
741 );
742 }
743
744 #[test]
749 fn test_detect_technical_camel_case() {
750 assert_eq!(
751 ConceptExtractor::detect_concept_type("camelCase"),
752 ConceptType::Technical
753 );
754 assert_eq!(
755 ConceptExtractor::detect_concept_type("myVariable"),
756 ConceptType::Technical
757 );
758 }
759
760 #[test]
761 fn test_detect_technical_snake_case() {
762 assert_eq!(
763 ConceptExtractor::detect_concept_type("snake_case"),
764 ConceptType::Technical
765 );
766 assert_eq!(
767 ConceptExtractor::detect_concept_type("get_value"),
768 ConceptType::Technical
769 );
770 }
771
772 #[test]
773 fn test_detect_keyword_lowercase() {
774 assert_eq!(
775 ConceptExtractor::detect_concept_type("keyword"),
776 ConceptType::Keyword
777 );
778 }
779
780 #[test]
781 fn test_detect_phrase_with_space() {
782 assert_eq!(
783 ConceptExtractor::detect_concept_type("machine learning"),
784 ConceptType::Phrase
785 );
786 }
787
788 #[test]
793 fn test_stop_word_filtered() {
794 let extractor = make_extractor();
795 assert!(extractor.is_stop_word("the"));
796 assert!(extractor.is_stop_word("and"));
797 assert!(extractor.is_stop_word("is"));
798 }
799
800 #[test]
801 fn test_stop_word_case_insensitive() {
802 let extractor = make_extractor();
803 assert!(extractor.is_stop_word("The"));
804 assert!(extractor.is_stop_word("AND"));
805 }
806
807 #[test]
808 fn test_non_stop_word() {
809 let extractor = make_extractor();
810 assert!(!extractor.is_stop_word("algorithm"));
811 assert!(!extractor.is_stop_word("neural"));
812 }
813
814 #[test]
819 fn test_min_frequency_filter() {
820 let config = ExtractorConfig {
821 min_term_frequency: 2,
822 max_concepts: 100,
823 ..ExtractorConfig::default()
824 };
825 let mut extractor = ConceptExtractor::new(config);
826 let concepts = extractor.extract("neural network deep network algorithm");
828 let terms: Vec<&str> = concepts.iter().map(|c| c.term.as_str()).collect();
829 assert!(
830 terms.contains(&"network"),
831 "network (freq=2) should be included"
832 );
833 assert!(
834 !terms.contains(&"algorithm"),
835 "algorithm (freq=1) should be excluded"
836 );
837 }
838
839 #[test]
844 fn test_max_concepts_limit() {
845 let config = ExtractorConfig {
846 max_concepts: 3,
847 min_term_frequency: 1,
848 ..ExtractorConfig::default()
849 };
850 let mut extractor = ConceptExtractor::new(config);
851 let concepts = extractor.extract(
852 "machine learning artificial intelligence deep neural network computer vision",
853 );
854 assert!(concepts.len() <= 3, "Should not exceed max_concepts limit");
855 }
856
857 #[test]
862 fn test_update_corpus_increments_doc_freq() {
863 let mut extractor = make_extractor();
864 let tokens: Vec<String> = ["rust", "programming"]
865 .iter()
866 .map(|s| s.to_string())
867 .collect();
868 extractor.update_corpus(&tokens);
869 assert_eq!(extractor.corpus_stats.get("rust"), Some(&1));
870 assert_eq!(extractor.corpus_stats.get("programming"), Some(&1));
871 let tokens2: Vec<String> = ["rust", "rust", "memory"]
873 .iter()
874 .map(|s| s.to_string())
875 .collect();
876 extractor.update_corpus(&tokens2);
877 assert_eq!(
878 extractor.corpus_stats.get("rust"),
879 Some(&2),
880 "rust should appear in 2 documents"
881 );
882 assert_eq!(extractor.corpus_stats.get("memory"), Some(&1));
883 }
884
885 #[test]
890 fn test_multi_document_idf() {
891 let mut extractor = make_extractor();
892 extractor.extract("vector search database system architecture");
894 extractor.extract("vector embedding representation learning");
896 let idf_vector = extractor.corpus_stats.get("vector").copied().unwrap_or(0);
898 let idf_embedding = extractor
899 .corpus_stats
900 .get("embedding")
901 .copied()
902 .unwrap_or(0);
903 assert_eq!(idf_vector, 2, "vector should appear in 2 documents");
904 assert_eq!(idf_embedding, 1, "embedding should appear in 1 document");
905 }
906
907 #[test]
912 fn test_extract_empty_text() {
913 let mut extractor = make_extractor();
914 let concepts = extractor.extract("");
915 assert!(concepts.is_empty(), "Empty text should yield no concepts");
916 }
917
918 #[test]
919 fn test_extract_only_stop_words() {
920 let mut extractor = make_extractor();
921 let concepts = extractor.extract("the and or but is are was");
922 assert!(
923 concepts.is_empty(),
924 "Stop-word-only text should yield no concepts"
925 );
926 }
927
928 #[test]
933 fn test_stats_documents_processed() {
934 let mut extractor = make_extractor();
935 assert_eq!(extractor.stats().documents_processed, 0);
936 extractor.extract("semantic vector search");
937 assert_eq!(extractor.stats().documents_processed, 1);
938 extractor.extract("deep learning embeddings");
939 assert_eq!(extractor.stats().documents_processed, 2);
940 }
941
942 #[test]
943 fn test_stats_total_concepts_extracted() {
944 let mut extractor = make_extractor();
945 extractor.extract("neural network deep learning architecture");
946 let after_first = extractor.stats().total_concepts_extracted;
947 extractor.extract("vector database approximate search retrieval");
948 let after_second = extractor.stats().total_concepts_extracted;
949 assert!(
950 after_second >= after_first,
951 "Total concepts should be non-decreasing"
952 );
953 }
954
955 #[test]
956 fn test_stats_avg_concepts_per_doc() {
957 let mut extractor = make_extractor();
958 extractor.extract("machine learning model training pipeline");
959 extractor.extract("vector similarity search index retrieval");
960 let stats = extractor.stats();
961 assert!(
962 stats.avg_concepts_per_doc > 0.0,
963 "avg_concepts_per_doc should be positive after processing docs"
964 );
965 assert_eq!(stats.documents_processed, 2);
966 }
967
968 #[test]
973 fn test_extract_includes_entities() {
974 let mut extractor = ConceptExtractor::new(ExtractorConfig {
975 stop_words: Vec::new(),
976 min_term_length: 2,
977 ..ExtractorConfig::default()
978 });
979 let concepts = extractor.extract("Python Rust Go programming languages");
980 let entity_terms: Vec<&str> = concepts
981 .iter()
982 .filter(|c| c.concept_type == ConceptType::Entity)
983 .map(|c| c.term.as_str())
984 .collect();
985 assert!(
987 !entity_terms.is_empty(),
988 "Should detect capitalised language names as entities"
989 );
990 }
991
992 #[test]
993 fn test_extract_technical_terms() {
994 let mut extractor = make_extractor();
995 let concepts =
997 extractor.extract("the function get_embedding uses camelCase internally for indexing");
998 let technical: Vec<&str> = concepts
999 .iter()
1000 .filter(|c| c.concept_type == ConceptType::Technical)
1001 .map(|c| c.term.as_str())
1002 .collect();
1003 assert!(
1004 !technical.is_empty(),
1005 "Technical terms should be detected: {:?}",
1006 technical
1007 );
1008 }
1009
1010 #[test]
1011 fn test_phrase_extraction_bigram() {
1012 let mut extractor = ConceptExtractor::new(ExtractorConfig {
1013 max_ngram: 2,
1014 min_term_frequency: 1,
1015 max_concepts: 50,
1016 stop_words: Vec::new(),
1017 min_term_length: 2,
1018 idf_smoothing: 1.0,
1019 });
1020 let concepts = extractor.extract("machine learning machine learning algorithm");
1021 let phrase_terms: Vec<&str> = concepts
1022 .iter()
1023 .filter(|c| c.concept_type == ConceptType::Phrase)
1024 .map(|c| c.term.as_str())
1025 .collect();
1026 assert!(
1027 phrase_terms.contains(&"machine learning"),
1028 "Should extract 'machine learning' bigram, got: {:?}",
1029 phrase_terms
1030 );
1031 }
1032
1033 #[test]
1034 fn test_concept_score_positive() {
1035 let mut extractor = make_extractor();
1036 let concepts = extractor
1037 .extract("information retrieval semantic search vector embeddings neural network");
1038 for c in &concepts {
1039 assert!(c.score > 0.0, "All concepts must have positive scores");
1040 }
1041 }
1042
1043 #[test]
1044 fn test_concept_frequency_matches_occurrence() {
1045 let mut extractor = make_extractor();
1046 let concepts = extractor.extract("vector vector vector search search");
1047 let vector_c = concepts.iter().find(|c| c.term == "vector");
1048 let search_c = concepts.iter().find(|c| c.term == "search");
1049 if let Some(vc) = vector_c {
1050 assert_eq!(vc.frequency, 3, "vector should have frequency 3");
1051 }
1052 if let Some(sc) = search_c {
1053 assert_eq!(sc.frequency, 2, "search should have frequency 2");
1054 }
1055 }
1056
1057 #[test]
1058 fn test_top_concepts_deduplication() {
1059 let mut concepts = vec![
1061 Concept {
1062 term: "rust".to_string(),
1063 score: 0.5,
1064 frequency: 1,
1065 doc_frequency: 1,
1066 concept_type: ConceptType::Keyword,
1067 },
1068 Concept {
1069 term: "rust".to_string(),
1070 score: 0.9,
1071 frequency: 2,
1072 doc_frequency: 1,
1073 concept_type: ConceptType::Keyword,
1074 },
1075 ];
1076 let result = ConceptExtractor::top_concepts(&mut concepts, 10);
1077 assert_eq!(result.len(), 1, "Deduplication should collapse duplicates");
1078 assert!(
1079 (result[0].score - 0.9).abs() < 1e-9,
1080 "Should keep the higher-scoring entry"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_top_concepts_sorted_by_score() {
1086 let mut concepts = vec![
1087 Concept {
1088 term: "alpha".to_string(),
1089 score: 0.3,
1090 frequency: 1,
1091 doc_frequency: 1,
1092 concept_type: ConceptType::Keyword,
1093 },
1094 Concept {
1095 term: "beta".to_string(),
1096 score: 0.8,
1097 frequency: 2,
1098 doc_frequency: 1,
1099 concept_type: ConceptType::Keyword,
1100 },
1101 Concept {
1102 term: "gamma".to_string(),
1103 score: 0.5,
1104 frequency: 1,
1105 doc_frequency: 1,
1106 concept_type: ConceptType::Keyword,
1107 },
1108 ];
1109 let result = ConceptExtractor::top_concepts(&mut concepts, 10);
1110 assert_eq!(result[0].term, "beta");
1111 assert_eq!(result[1].term, "gamma");
1112 assert_eq!(result[2].term, "alpha");
1113 }
1114}