Skip to main content

ipfrs_semantic/
concept_extractor.rs

1//! # Concept and Keyword Extraction
2//!
3//! Provides TF-IDF-based concept and keyword extraction from text, supporting
4//! n-gram phrases, named entity detection, technical term identification, and
5//! corpus-level IDF scoring for multi-document analysis.
6//!
7//! ## Overview
8//!
9//! - **TF-IDF scoring** with configurable smoothing
10//! - **N-gram phrase extraction** (bigrams, trigrams, etc.)
11//! - **Entity detection** (capitalized / all-caps terms)
12//! - **Technical term detection** (camelCase, snake_case)
13//! - **Stop-word filtering** with configurable word list
14//! - **Multi-document corpus statistics** for accurate IDF
15
16use std::collections::HashMap;
17
18// ---------------------------------------------------------------------------
19// ConceptType
20// ---------------------------------------------------------------------------
21
22/// Classifies the semantic role of an extracted concept.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ConceptType {
25    /// A plain keyword term.
26    Keyword,
27    /// A multi-word phrase (n-gram, n ≥ 2).
28    Phrase,
29    /// A named entity — starts with an upper-case letter or is fully capitalised.
30    Entity,
31    /// A technical identifier — camelCase or snake_case.
32    Technical,
33}
34
35// ---------------------------------------------------------------------------
36// Concept
37// ---------------------------------------------------------------------------
38
39/// A single extracted concept with scoring metadata.
40#[derive(Debug, Clone)]
41pub struct Concept {
42    /// Normalised surface form of the concept.
43    pub term: String,
44    /// Combined TF-IDF score.
45    pub score: f64,
46    /// Raw count of the term in the current document.
47    pub frequency: u32,
48    /// Number of corpus documents that contain this term.
49    pub doc_frequency: u32,
50    /// Semantic category of the concept.
51    pub concept_type: ConceptType,
52}
53
54// ---------------------------------------------------------------------------
55// ExtractorConfig
56// ---------------------------------------------------------------------------
57
58/// Configuration for [`ConceptExtractor`].
59#[derive(Debug, Clone)]
60pub struct ExtractorConfig {
61    /// Maximum number of concepts returned per document.
62    pub max_concepts: usize,
63    /// Minimum raw term frequency required for inclusion.
64    pub min_term_frequency: u32,
65    /// Minimum character length for a single token to be kept.
66    pub min_term_length: usize,
67    /// Maximum n for n-gram phrase extraction (1 = unigrams only).
68    pub max_ngram: usize,
69    /// Stop words to filter before scoring.
70    pub stop_words: Vec<String>,
71    /// Additive smoothing constant applied to IDF calculation.
72    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// ---------------------------------------------------------------------------
89// ExtractorStats
90// ---------------------------------------------------------------------------
91
92/// Cumulative statistics collected by [`ConceptExtractor`].
93#[derive(Debug, Clone, Default)]
94pub struct ExtractorStats {
95    /// Total number of documents processed since construction.
96    pub documents_processed: u64,
97    /// Total concepts extracted across all documents.
98    pub total_concepts_extracted: u64,
99    /// Rolling average of concepts extracted per document.
100    pub avg_concepts_per_doc: f64,
101}
102
103// ---------------------------------------------------------------------------
104// ConceptExtractor
105// ---------------------------------------------------------------------------
106
107/// Extracts concepts and keywords from text using TF-IDF and frequency analysis.
108///
109/// The extractor maintains corpus-level document-frequency statistics so that
110/// IDF weights improve as more documents are processed.
111///
112/// # Example
113///
114/// ```rust
115/// use ipfrs_semantic::concept_extractor::{ConceptExtractor, ExtractorConfig};
116///
117/// let config = ExtractorConfig::default();
118/// let mut extractor = ConceptExtractor::new(config);
119///
120/// let concepts = extractor.extract("The quick brown fox jumps over the lazy dog.");
121/// for c in &concepts {
122///     println!("{:?}: score={:.4}", c.term, c.score);
123/// }
124/// ```
125pub struct ConceptExtractor {
126    config: ExtractorConfig,
127    /// Per-term document frequency across all previously seen documents.
128    corpus_stats: HashMap<String, u32>,
129    /// Total documents ingested (including the current one during `extract`).
130    doc_count: usize,
131    stats: ExtractorStats,
132}
133
134impl ConceptExtractor {
135    /// Creates a new extractor with the given configuration.
136    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    // ------------------------------------------------------------------
146    // Public API
147    // ------------------------------------------------------------------
148
149    /// Extracts and returns the top concepts from `text`, updating internal
150    /// corpus statistics so subsequent calls benefit from improved IDF weights.
151    pub fn extract(&mut self, text: &str) -> Vec<Concept> {
152        // 1. Tokenize preserving original casing for entity/technical detection.
153        let raw_tokens: Vec<String> = Self::tokenize_raw(text);
154        // 2. Lower-cased tokens for TF / stop-word / IDF lookups.
155        let lc_tokens: Vec<String> = raw_tokens.iter().map(|t| t.to_lowercase()).collect();
156
157        // 3. Update corpus with this document's unique terms (unigrams only for IDF).
158        self.update_corpus(&lc_tokens);
159        self.doc_count += 1;
160
161        // 4. Compute unigram TF.
162        let tf_map = Self::compute_tf(&lc_tokens);
163
164        // 5. Build candidate concepts across all n-gram sizes.
165        let mut concepts: Vec<Concept> = Vec::new();
166        let doc_len = lc_tokens.len().max(1);
167
168        // Unigrams.
169        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        // N-grams (n = 2 .. max_ngram).
192        for n in 2..=self.config.max_ngram {
193            let ngrams = Self::extract_ngrams(&lc_tokens, n);
194            // TF for n-grams.
195            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                // Filter n-grams that are entirely stop words.
204                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                // Use doc_frequency of the full phrase from corpus (may be 0 if
211                // unseen — treat as 1 for IDF stability).
212                let doc_frequency = self.corpus_stats.get(ngram).copied().unwrap_or(1);
213                // For phrase concept type, check if any raw variant is entity/technical.
214                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        // 6. Sort by score descending, deduplicate by term, take top N.
231        let result = Self::top_concepts(&mut concepts, self.config.max_concepts);
232
233        // 7. Update statistics.
234        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    /// Tokenizes `text` into lower-cased tokens, splitting on whitespace and
244    /// ASCII punctuation, dropping tokens shorter than `min_term_length`.
245    ///
246    /// This is the public lower-cased variant used for TF/IDF computation.
247    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    /// Computes term frequency (raw count) for each token in `tokens`.
255    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    /// Returns the TF-IDF score for a term given its raw frequency `tf` and
264    /// the document length `doc_len`.
265    ///
266    /// Uses augmented TF (0.5 + 0.5 × tf / max_tf) to prevent bias towards
267    /// long documents, combined with smoothed IDF.
268    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        // Augmented TF normalisation.
271        let tf_norm = 0.5 + 0.5 * (tf as f64 / max_tf);
272        // Smoothed IDF: log((N + smooth) / (df + smooth)) + 1
273        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    /// Extracts all n-grams of size `n` from `tokens`, joining tokens with a
281    /// single space to form the phrase string.
282    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    /// Classifies a term into its [`ConceptType`].
290    ///
291    /// - **Entity** — starts with an ASCII upper-case letter *or* every ASCII
292    ///   letter in the term is upper-case (acronym).
293    /// - **Technical** — contains `_` (snake_case) or an interior upper-case
294    ///   letter (camelCase).
295    /// - **Phrase** — contains a space (multi-word).
296    /// - **Keyword** — everything else.
297    pub fn detect_concept_type(term: &str) -> ConceptType {
298        if term.contains(' ') {
299            return ConceptType::Phrase;
300        }
301        // All-caps acronym (e.g. "API", "HTTP").
302        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        // Starts with upper-case → named entity.
307        if term
308            .chars()
309            .next()
310            .map(|c| c.is_ascii_uppercase())
311            .unwrap_or(false)
312        {
313            return ConceptType::Entity;
314        }
315        // snake_case.
316        if term.contains('_') {
317            return ConceptType::Technical;
318        }
319        // camelCase — interior uppercase letter.
320        let mut chars = term.chars();
321        // Skip the first character.
322        let _ = chars.next();
323        if chars.any(|c| c.is_ascii_uppercase()) {
324            return ConceptType::Technical;
325        }
326        ConceptType::Keyword
327    }
328
329    /// Returns `true` if `word` appears in the configured stop-word list
330    /// (case-insensitive comparison).
331    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    /// Updates corpus document-frequency statistics for the unique terms found
337    /// in `tokens`.  Each unique term in the token slice counts as appearing
338    /// in one additional document.
339    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    /// Returns the top `n` concepts from `concepts` sorted by descending score.
349    /// Deduplicates by term (keeps the highest-scoring entry).
350    pub fn top_concepts(concepts: &mut Vec<Concept>, n: usize) -> Vec<Concept> {
351        // Deduplicate: keep highest score per term.
352        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    /// Returns a reference to the cumulative extraction statistics.
370    pub fn stats(&self) -> &ExtractorStats {
371        &self.stats
372    }
373
374    // ------------------------------------------------------------------
375    // Private helpers
376    // ------------------------------------------------------------------
377
378    /// Tokenizes `text` preserving original casing for entity/technical detection.
379    fn tokenize_raw(text: &str) -> Vec<String> {
380        // Split on whitespace, then strip leading/trailing ASCII punctuation from
381        // each token (commas, periods, brackets, quotes, etc.).
382        text.split_whitespace()
383            .flat_map(|word| {
384                // Split further on common punctuation that may be embedded
385                // (e.g. "foo/bar", "key:value", "term(s)").
386                split_on_punctuation(word)
387            })
388            .filter(|t| !t.is_empty())
389            .collect()
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Module-level helpers (not pub — internal implementation detail)
395// ---------------------------------------------------------------------------
396
397/// Splits a word on embedded punctuation characters that are not part of
398/// identifiers (slash, colon, parentheses, brackets, etc.) while preserving
399/// underscores and hyphens because they are meaningful in technical terms.
400fn split_on_punctuation(word: &str) -> Vec<String> {
401    // Strip wrapping punctuation first.
402    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    // Split on characters that cannot appear in a meaningful token.
437    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
463/// Detects the concept type from the original-cased raw token list.
464/// Falls back to the lower-cased term when no match is found in raw tokens.
465fn detect_concept_type_from_raw(
466    lc_term: &str,
467    raw_tokens: &[String],
468    lc_tokens: &[String],
469) -> ConceptType {
470    // Try to find the original-cased version of the term.
471    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
485/// Determines the concept type of a multi-word phrase from its raw (original-cased)
486/// surface form.  If any token looks like an entity or technical term, the whole
487/// phrase gets that classification; otherwise it is [`ConceptType::Phrase`].
488fn detect_phrase_type(raw_phrase: &str) -> ConceptType {
489    // If the phrase contains a space it is by definition a Phrase, but we still
490    // check whether it qualifies as an Entity (e.g. "New York") or Technical
491    // (e.g. "get_item size").
492    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
510/// Returns a curated English stop-word list.
511fn 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// ---------------------------------------------------------------------------
530// Tests
531// ---------------------------------------------------------------------------
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn make_extractor() -> ConceptExtractor {
538        ConceptExtractor::new(ExtractorConfig::default())
539    }
540
541    // -----------------------------------------------------------------------
542    // Tokenization
543    // -----------------------------------------------------------------------
544
545    #[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        // Punctuation characters must not appear as standalone tokens.
560        assert!(!tokens.iter().any(|t| t == "," || t == ";" || t == "."));
561    }
562
563    #[test]
564    fn test_tokenize_short_tokens_included() {
565        // Default min_term_length = 3; "a" and "is" should be filtered in
566        // concept extraction but tokenize itself does not filter length.
567        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    // -----------------------------------------------------------------------
591    // TF computation
592    // -----------------------------------------------------------------------
593
594    #[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    // -----------------------------------------------------------------------
620    // TF-IDF scoring
621    // -----------------------------------------------------------------------
622
623    #[test]
624    fn test_tfidf_increases_with_frequency() {
625        let mut extractor = make_extractor();
626        // Give the corpus some content so IDF is non-trivial.
627        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        // "common" appears in all 10 docs; "rare" appears in only 1.
641        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        // Term not in corpus — df defaults to 0.
660        let score = extractor.compute_tfidf("unknown", 1, 10);
661        assert!(score > 0.0, "Score must be positive even for unknown terms");
662    }
663
664    // -----------------------------------------------------------------------
665    // N-gram extraction
666    // -----------------------------------------------------------------------
667
668    #[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    // -----------------------------------------------------------------------
713    // Entity detection
714    // -----------------------------------------------------------------------
715
716    #[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    // -----------------------------------------------------------------------
745    // Technical term detection
746    // -----------------------------------------------------------------------
747
748    #[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    // -----------------------------------------------------------------------
789    // Stop word filtering
790    // -----------------------------------------------------------------------
791
792    #[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    // -----------------------------------------------------------------------
815    // Minimum frequency filter
816    // -----------------------------------------------------------------------
817
818    #[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        // "algorithm" appears once; "network" appears twice.
827        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    // -----------------------------------------------------------------------
840    // max_concepts limit
841    // -----------------------------------------------------------------------
842
843    #[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    // -----------------------------------------------------------------------
858    // Corpus IDF updates
859    // -----------------------------------------------------------------------
860
861    #[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        // Same doc: repeated token should not double-count.
872        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    // -----------------------------------------------------------------------
886    // Multi-document IDF
887    // -----------------------------------------------------------------------
888
889    #[test]
890    fn test_multi_document_idf() {
891        let mut extractor = make_extractor();
892        // Doc 1: "vector search"
893        extractor.extract("vector search database system architecture");
894        // Doc 2: "vector embedding"
895        extractor.extract("vector embedding representation learning");
896        // "vector" appears in 2 docs → lower IDF than "embedding" (1 doc).
897        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    // -----------------------------------------------------------------------
908    // Empty text
909    // -----------------------------------------------------------------------
910
911    #[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    // -----------------------------------------------------------------------
929    // Stats tracking
930    // -----------------------------------------------------------------------
931
932    #[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    // -----------------------------------------------------------------------
969    // Concept type classification
970    // -----------------------------------------------------------------------
971
972    #[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        // At least one of the language names should be classified as Entity.
986        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        // snake_case and camelCase identifiers in technical prose.
996        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        // Build a duplicate list and check dedup logic.
1060        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}