Skip to main content

ipfrs_semantic/
text_summarizer.rs

1//! Extractive text summarization using TF-IDF sentence scoring and TextRank graph-based ranking.
2//!
3//! [`TextSummarizer`] implements multiple summarization strategies:
4//!
5//! * **TF-IDF** — score each sentence by the sum of TF-IDF weights of its content words and
6//!   return the top-N sentences in original order.
7//! * **TextRank** — build a sentence similarity graph (cosine similarity of TF-IDF vectors) and
8//!   run PageRank-style iteration; return top-N sentences in original order.
9//! * **Lead** — baseline; return the first N sentences unchanged.
10//! * **Hybrid** — weighted combination of TF-IDF and TextRank scores.
11//!
12//! The summarizer also maintains an optional external corpus (via [`TextSummarizer::add_to_corpus`])
13//! to improve IDF estimates across multiple documents.
14
15use std::collections::HashMap;
16
17// ── Error ─────────────────────────────────────────────────────────────────────
18
19/// Errors that can be returned by [`TextSummarizer`].
20#[derive(Debug, Clone, PartialEq)]
21pub enum SummarizerError {
22    /// The text contained fewer sentences than the algorithm requires.
23    InsufficientSentences {
24        /// Minimum number required.
25        min: usize,
26        /// Actual number found.
27        got: usize,
28    },
29    /// The input string was empty or contained only whitespace.
30    EmptyText,
31    /// A configuration parameter was invalid.
32    InvalidConfig(String),
33}
34
35impl std::fmt::Display for SummarizerError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::InsufficientSentences { min, got } => {
39                write!(f, "insufficient sentences: need at least {min}, got {got}")
40            }
41            Self::EmptyText => write!(f, "input text is empty"),
42            Self::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
43        }
44    }
45}
46
47impl std::error::Error for SummarizerError {}
48
49// ── Summarization method ──────────────────────────────────────────────────────
50
51/// Which summarization algorithm [`TextSummarizer`] should use.
52#[derive(Debug, Clone, PartialEq)]
53pub enum SummarizationMethod {
54    /// Score sentences by sum of TF-IDF weights; return top `top_n` in original order.
55    TfIdf {
56        /// Number of sentences to include in the summary.
57        top_n: usize,
58    },
59    /// PageRank-style sentence graph ranking; return top `top_n` in original order.
60    TextRank {
61        /// Number of sentences to include in the summary.
62        top_n: usize,
63        /// Damping factor (typically 0.85).
64        damping: f64,
65        /// Maximum number of PageRank iterations.
66        max_iter: u32,
67    },
68    /// Return the first `n_sentences` sentences (baseline).
69    Lead {
70        /// Number of leading sentences to return.
71        n_sentences: usize,
72    },
73    /// Weighted combination of TF-IDF and TextRank scores.
74    Hybrid {
75        /// Number of sentences to include in the summary.
76        top_n: usize,
77        /// Weight applied to the TF-IDF score component (should sum to 1.0 with `textrank_weight`).
78        tfidf_weight: f64,
79        /// Weight applied to the TextRank score component.
80        textrank_weight: f64,
81    },
82}
83
84impl SummarizationMethod {
85    /// Returns the maximum number of sentences this method can return, or `None` for Lead.
86    fn top_n(&self) -> Option<usize> {
87        match self {
88            Self::TfIdf { top_n } => Some(*top_n),
89            Self::TextRank { top_n, .. } => Some(*top_n),
90            Self::Lead { n_sentences } => Some(*n_sentences),
91            Self::Hybrid { top_n, .. } => Some(*top_n),
92        }
93    }
94
95    fn name(&self) -> &'static str {
96        match self {
97            Self::TfIdf { .. } => "tfidf",
98            Self::TextRank { .. } => "textrank",
99            Self::Lead { .. } => "lead",
100            Self::Hybrid { .. } => "hybrid",
101        }
102    }
103}
104
105// ── Config ────────────────────────────────────────────────────────────────────
106
107/// Configuration for [`TextSummarizer`].
108#[derive(Debug, Clone)]
109pub struct SummarizerConfig {
110    /// Which algorithm to use.
111    pub method: SummarizationMethod,
112    /// Minimum character length for a sentence to be considered (inclusive).
113    pub min_sentence_length: usize,
114    /// Maximum character length for a sentence to be considered (inclusive).
115    pub max_sentence_length: usize,
116    /// Words to ignore during tokenization.
117    pub stop_words: Vec<String>,
118}
119
120impl Default for SummarizerConfig {
121    fn default() -> Self {
122        Self {
123            method: SummarizationMethod::TfIdf { top_n: 3 },
124            min_sentence_length: 10,
125            max_sentence_length: 1000,
126            stop_words: default_stop_words(),
127        }
128    }
129}
130
131/// Returns the default English stop-word list.
132fn default_stop_words() -> Vec<String> {
133    [
134        "the", "a", "an", "is", "it", "in", "on", "at", "to", "of", "and", "or", "but", "for",
135        "with", "this", "that", "are", "was", "were", "be", "been", "have", "has", "had", "do",
136        "does", "did", "will", "would", "could", "should",
137    ]
138    .iter()
139    .map(|s| s.to_string())
140    .collect()
141}
142
143// ── Output types ──────────────────────────────────────────────────────────────
144
145/// A sentence together with its relevance scores.
146#[derive(Debug, Clone)]
147pub struct SentenceScore {
148    /// Zero-based index of this sentence in the original text.
149    pub sentence_index: usize,
150    /// Raw text of the sentence.
151    pub text: String,
152    /// Final composite score used for ranking.
153    pub score: f64,
154    /// Per-method component scores (e.g. `"tfidf"`, `"textrank"`).
155    pub method_scores: HashMap<String, f64>,
156}
157
158/// The result of a summarization run.
159#[derive(Debug, Clone)]
160pub struct TextSummaryResult {
161    /// Number of sentences in the source text (after filtering by length).
162    pub original_sentence_count: usize,
163    /// Selected sentences, ordered as they appear in the source text.
164    pub summary_sentences: Vec<SentenceScore>,
165    /// Fraction of original sentences retained (0.0–1.0).
166    pub compression_ratio: f64,
167    /// Name of the method used (e.g. `"tfidf"`, `"textrank"`, `"lead"`, `"hybrid"`).
168    pub method: String,
169}
170
171/// Usage statistics exposed by [`TextSummarizer::stats`].
172#[derive(Debug, Clone)]
173pub struct TextSummarizerStats {
174    /// Number of documents added to the corpus via [`TextSummarizer::add_to_corpus`].
175    pub documents_in_corpus: u32,
176    /// Number of distinct terms in the IDF vocabulary.
177    pub vocabulary_size: usize,
178    /// Average number of sentences per document seen (across `summarize` calls).
179    pub avg_sentences_per_doc: f64,
180}
181
182// ── Core summarizer ───────────────────────────────────────────────────────────
183
184/// Extractive text summarizer combining TF-IDF and TextRank.
185///
186/// ```
187/// use ipfrs_semantic::text_summarizer::{TextSummarizer, SummarizerConfig, SummarizationMethod};
188///
189/// let config = SummarizerConfig {
190///     method: SummarizationMethod::TfIdf { top_n: 2 },
191///     ..SummarizerConfig::default()
192/// };
193/// let mut summarizer = TextSummarizer::new(config);
194/// let result = summarizer.summarize(
195///     "The sky is blue. The ocean is also blue. Grass is green. Mountains are tall."
196/// ).unwrap();
197/// assert_eq!(result.summary_sentences.len(), 2);
198/// ```
199#[derive(Debug, Clone)]
200pub struct TextSummarizer {
201    /// Active configuration.
202    pub config: SummarizerConfig,
203    /// Corpus-level document frequencies: term → count of documents containing that term.
204    pub document_frequencies: HashMap<String, u32>,
205    /// Total number of documents added to the corpus.
206    pub total_documents: u32,
207    /// Number of `summarize` calls made.
208    summarize_calls: u32,
209    /// Total sentences seen across all `summarize` calls.
210    total_sentences_seen: u64,
211}
212
213impl TextSummarizer {
214    /// Create a new summarizer with the given configuration.
215    pub fn new(config: SummarizerConfig) -> Self {
216        Self {
217            config,
218            document_frequencies: HashMap::new(),
219            total_documents: 0,
220            summarize_calls: 0,
221            total_sentences_seen: 0,
222        }
223    }
224
225    // ── Public API ────────────────────────────────────────────────────────────
226
227    /// Summarize `text` using the configured method.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`SummarizerError::EmptyText`] when `text` is blank, or
232    /// [`SummarizerError::InsufficientSentences`] when fewer sentences are
233    /// found than the algorithm requires.
234    pub fn summarize(&mut self, text: &str) -> Result<TextSummaryResult, SummarizerError> {
235        if text.trim().is_empty() {
236            return Err(SummarizerError::EmptyText);
237        }
238
239        self.validate_config()?;
240
241        let sentences = self.split_sentences(text);
242        let sentences = self.filter_by_length(sentences);
243        let n = sentences.len();
244
245        self.summarize_calls += 1;
246        self.total_sentences_seen += n as u64;
247
248        let top_n = self.config.method.top_n().unwrap_or(n);
249
250        if n == 0 {
251            return Err(SummarizerError::InsufficientSentences { min: 1, got: 0 });
252        }
253
254        // Tokenize every sentence once
255        let tokens_per_sentence: Vec<Vec<String>> = sentences
256            .iter()
257            .map(|s| self.tokenize_sentence(s))
258            .collect();
259
260        let method_name = self.config.method.name().to_string();
261
262        let scored = match &self.config.method.clone() {
263            SummarizationMethod::TfIdf { top_n } => {
264                self.score_tfidf(&sentences, &tokens_per_sentence, *top_n)?
265            }
266            SummarizationMethod::TextRank {
267                top_n,
268                damping,
269                max_iter,
270            } => self.score_textrank(
271                &sentences,
272                &tokens_per_sentence,
273                *top_n,
274                *damping,
275                *max_iter,
276            )?,
277            SummarizationMethod::Lead { n_sentences } => {
278                self.score_lead(&sentences, *n_sentences)?
279            }
280            SummarizationMethod::Hybrid {
281                top_n,
282                tfidf_weight,
283                textrank_weight,
284            } => self.score_hybrid(
285                &sentences,
286                &tokens_per_sentence,
287                *top_n,
288                *tfidf_weight,
289                *textrank_weight,
290            )?,
291        };
292
293        let compression_ratio = if n == 0 {
294            0.0
295        } else {
296            scored.len() as f64 / n as f64
297        };
298
299        let _ = top_n; // already consumed
300
301        Ok(TextSummaryResult {
302            original_sentence_count: n,
303            summary_sentences: scored,
304            compression_ratio,
305            method: method_name,
306        })
307    }
308
309    /// Add `text` to the external IDF corpus.
310    ///
311    /// Each sentence in `text` is treated as a document for IDF purposes.
312    /// Calling this before `summarize` improves IDF estimates, especially for
313    /// domain-specific vocabulary.
314    pub fn add_to_corpus(&mut self, text: &str) {
315        let sentences = self.split_sentences(text);
316        for sentence in &sentences {
317            let tokens = self.tokenize_sentence(sentence);
318            // Collect unique tokens per sentence (each sentence = one document)
319            let mut seen = std::collections::HashSet::new();
320            for token in tokens {
321                if seen.insert(token.clone()) {
322                    *self.document_frequencies.entry(token).or_insert(0) += 1;
323                }
324            }
325            self.total_documents += 1;
326        }
327    }
328
329    /// Return usage statistics.
330    pub fn stats(&self) -> TextSummarizerStats {
331        let avg_sentences_per_doc = if self.summarize_calls == 0 {
332            0.0
333        } else {
334            self.total_sentences_seen as f64 / self.summarize_calls as f64
335        };
336        TextSummarizerStats {
337            documents_in_corpus: self.total_documents,
338            vocabulary_size: self.document_frequencies.len(),
339            avg_sentences_per_doc,
340        }
341    }
342
343    // ── Sentence splitting & tokenization ─────────────────────────────────────
344
345    /// Split `text` into sentences on `.`, `!`, or `?` followed by whitespace or end of string.
346    pub fn split_sentences(&self, text: &str) -> Vec<String> {
347        let mut sentences = Vec::new();
348        let mut current = String::new();
349
350        let chars: Vec<char> = text.chars().collect();
351        let len = chars.len();
352        let mut i = 0;
353
354        while i < len {
355            let ch = chars[i];
356            current.push(ch);
357
358            if matches!(ch, '.' | '!' | '?') {
359                // Look ahead: next char must be whitespace, or we are at end
360                let at_end = i + 1 >= len;
361                let next_is_space = chars.get(i + 1).map(|c| c.is_whitespace()).unwrap_or(false);
362
363                if at_end || next_is_space {
364                    let trimmed = current.trim().to_string();
365                    if !trimmed.is_empty() {
366                        sentences.push(trimmed);
367                    }
368                    current = String::new();
369                    // Skip leading whitespace for the next sentence
370                    i += 1;
371                    while i < len && chars[i].is_whitespace() {
372                        i += 1;
373                    }
374                    continue;
375                }
376            }
377            i += 1;
378        }
379
380        // Any remaining text that did not end with a terminator
381        let remaining = current.trim().to_string();
382        if !remaining.is_empty() {
383            sentences.push(remaining);
384        }
385
386        sentences
387    }
388
389    /// Tokenize a sentence: lowercase, keep alphanumeric chars, drop stop words.
390    pub fn tokenize_sentence(&self, sentence: &str) -> Vec<String> {
391        let stop_words: std::collections::HashSet<&str> =
392            self.config.stop_words.iter().map(|s| s.as_str()).collect();
393
394        sentence
395            .split_whitespace()
396            .flat_map(|word| {
397                // Keep only alphanumeric characters within each word
398                let cleaned: String = word
399                    .chars()
400                    .filter(|c| c.is_alphanumeric())
401                    .collect::<String>()
402                    .to_lowercase();
403                if cleaned.is_empty() {
404                    None
405                } else {
406                    Some(cleaned)
407                }
408            })
409            .filter(|token| !stop_words.contains(token.as_str()))
410            .collect()
411    }
412
413    // ── TF-IDF helpers ────────────────────────────────────────────────────────
414
415    /// Compute the TF-IDF vector for one sentence relative to the given sentence corpus.
416    ///
417    /// IDF is smoothed: `ln((1 + N) / (1 + df)) + 1` where N is the number of sentences
418    /// used as corpus. If the summarizer has an external corpus its frequencies are
419    /// blended in (additive).
420    pub fn tfidf_vector(
421        &self,
422        tokens: &[String],
423        all_sentences_tokens: &[Vec<String>],
424    ) -> HashMap<String, f64> {
425        if tokens.is_empty() {
426            return HashMap::new();
427        }
428
429        let n_docs = all_sentences_tokens.len() as f64;
430
431        // Term frequency within this sentence
432        let mut tf: HashMap<&str, f64> = HashMap::new();
433        for token in tokens {
434            *tf.entry(token.as_str()).or_insert(0.0) += 1.0;
435        }
436        let token_count = tokens.len() as f64;
437
438        // Document frequency per term from the local corpus
439        let mut df_local: HashMap<&str, u32> = HashMap::new();
440        for sent_tokens in all_sentences_tokens {
441            let mut seen = std::collections::HashSet::new();
442            for token in sent_tokens {
443                if seen.insert(token.as_str()) {
444                    *df_local.entry(token.as_str()).or_insert(0) += 1;
445                }
446            }
447        }
448
449        let mut result = HashMap::new();
450        for (term, &raw_tf) in &tf {
451            let normalized_tf = raw_tf / token_count;
452
453            let local_df = *df_local.get(term).unwrap_or(&0) as f64;
454            let corpus_df = self.document_frequencies.get(*term).copied().unwrap_or(0) as f64;
455            let corpus_n = self.total_documents as f64;
456
457            // Blend local + external corpus
458            let combined_df = local_df + corpus_df;
459            let combined_n = n_docs + corpus_n;
460
461            // Smoothed IDF
462            let idf = ((1.0 + combined_n) / (1.0 + combined_df)).ln() + 1.0;
463
464            result.insert(term.to_string(), normalized_tf * idf);
465        }
466        result
467    }
468
469    /// Cosine similarity between two TF-IDF vectors.
470    ///
471    /// Returns `0.0` when either vector is empty or has zero magnitude.
472    pub fn cosine_similarity(a: &HashMap<String, f64>, b: &HashMap<String, f64>) -> f64 {
473        if a.is_empty() || b.is_empty() {
474            return 0.0;
475        }
476
477        let dot: f64 = a
478            .iter()
479            .filter_map(|(k, va)| b.get(k).map(|vb| va * vb))
480            .sum();
481
482        let norm_a: f64 = a.values().map(|v| v * v).sum::<f64>().sqrt();
483        let norm_b: f64 = b.values().map(|v| v * v).sum::<f64>().sqrt();
484
485        if norm_a == 0.0 || norm_b == 0.0 {
486            0.0
487        } else {
488            dot / (norm_a * norm_b)
489        }
490    }
491
492    /// Run PageRank-style iteration on the sentence similarity matrix.
493    ///
494    /// Initialises scores uniformly and iterates until the maximum per-node delta
495    /// is less than `1e-6` or `max_iter` is reached.
496    pub fn textrank_scores(
497        similarity_matrix: &[Vec<f64>],
498        damping: f64,
499        max_iter: u32,
500    ) -> Vec<f64> {
501        let n = similarity_matrix.len();
502        if n == 0 {
503            return Vec::new();
504        }
505
506        // Normalise each row so that outgoing weights sum to 1
507        let mut transition: Vec<Vec<f64>> = similarity_matrix
508            .iter()
509            .map(|row| {
510                let total: f64 = row.iter().sum();
511                if total == 0.0 {
512                    vec![1.0 / n as f64; n]
513                } else {
514                    row.iter().map(|v| v / total).collect()
515                }
516            })
517            .collect();
518
519        // Zero out self-loops
520        for (i, row) in transition.iter_mut().enumerate() {
521            row[i] = 0.0;
522            // Re-normalise after zeroing self-loop
523            let total: f64 = row.iter().sum();
524            if total > 0.0 {
525                for v in row.iter_mut() {
526                    *v /= total;
527                }
528            } else {
529                // Dangling node: distribute uniformly
530                for v in row.iter_mut() {
531                    *v = 1.0 / n as f64;
532                }
533            }
534        }
535
536        let mut scores = vec![1.0 / n as f64; n];
537
538        for _ in 0..max_iter {
539            let mut new_scores = vec![(1.0 - damping) / n as f64; n];
540            for j in 0..n {
541                // Sum contributions from all nodes pointing to j
542                let incoming: f64 = (0..n).map(|i| transition[i][j] * scores[i]).sum();
543                new_scores[j] += damping * incoming;
544            }
545
546            // Check convergence
547            let max_delta = scores
548                .iter()
549                .zip(new_scores.iter())
550                .map(|(a, b)| (a - b).abs())
551                .fold(0.0_f64, f64::max);
552
553            scores = new_scores;
554            if max_delta < 1e-6 {
555                break;
556            }
557        }
558
559        scores
560    }
561
562    // ── Private helpers ───────────────────────────────────────────────────────
563
564    fn validate_config(&self) -> Result<(), SummarizerError> {
565        if self.config.min_sentence_length > self.config.max_sentence_length {
566            return Err(SummarizerError::InvalidConfig(format!(
567                "min_sentence_length ({}) must not exceed max_sentence_length ({})",
568                self.config.min_sentence_length, self.config.max_sentence_length
569            )));
570        }
571        match &self.config.method {
572            SummarizationMethod::TextRank { damping, .. } if *damping <= 0.0 || *damping >= 1.0 => {
573                return Err(SummarizerError::InvalidConfig(format!(
574                    "TextRank damping must be in (0, 1), got {damping}"
575                )));
576            }
577            SummarizationMethod::Hybrid {
578                tfidf_weight,
579                textrank_weight,
580                ..
581            } if *tfidf_weight < 0.0 || *textrank_weight < 0.0 => {
582                return Err(SummarizerError::InvalidConfig(
583                    "Hybrid weights must be non-negative".to_string(),
584                ));
585            }
586            _ => {}
587        }
588        Ok(())
589    }
590
591    fn filter_by_length(&self, sentences: Vec<String>) -> Vec<String> {
592        sentences
593            .into_iter()
594            .filter(|s| {
595                s.len() >= self.config.min_sentence_length
596                    && s.len() <= self.config.max_sentence_length
597            })
598            .collect()
599    }
600
601    /// Build TF-IDF vectors for all sentences.
602    fn build_tfidf_vectors(
603        &self,
604        tokens_per_sentence: &[Vec<String>],
605    ) -> Vec<HashMap<String, f64>> {
606        tokens_per_sentence
607            .iter()
608            .map(|tokens| self.tfidf_vector(tokens, tokens_per_sentence))
609            .collect()
610    }
611
612    /// Score each sentence by the sum of its TF-IDF weights.
613    fn tfidf_sentence_scores(&self, tokens_per_sentence: &[Vec<String>]) -> Vec<f64> {
614        let vectors = self.build_tfidf_vectors(tokens_per_sentence);
615        vectors.iter().map(|v| v.values().sum::<f64>()).collect()
616    }
617
618    fn score_tfidf(
619        &self,
620        sentences: &[String],
621        tokens_per_sentence: &[Vec<String>],
622        top_n: usize,
623    ) -> Result<Vec<SentenceScore>, SummarizerError> {
624        let raw_scores = self.tfidf_sentence_scores(tokens_per_sentence);
625        let scored = self.top_n_in_order(sentences, &raw_scores, top_n, "tfidf");
626        Ok(scored)
627    }
628
629    fn score_textrank(
630        &self,
631        sentences: &[String],
632        tokens_per_sentence: &[Vec<String>],
633        top_n: usize,
634        damping: f64,
635        max_iter: u32,
636    ) -> Result<Vec<SentenceScore>, SummarizerError> {
637        let n = sentences.len();
638        let vectors = self.build_tfidf_vectors(tokens_per_sentence);
639
640        // Build N×N similarity matrix
641        let mut matrix = vec![vec![0.0_f64; n]; n];
642        for i in 0..n {
643            for j in 0..n {
644                if i != j {
645                    matrix[i][j] = Self::cosine_similarity(&vectors[i], &vectors[j]);
646                }
647            }
648        }
649
650        let tr_scores = Self::textrank_scores(&matrix, damping, max_iter);
651        let scored = self.top_n_in_order(sentences, &tr_scores, top_n, "textrank");
652        Ok(scored)
653    }
654
655    fn score_lead(
656        &self,
657        sentences: &[String],
658        n_sentences: usize,
659    ) -> Result<Vec<SentenceScore>, SummarizerError> {
660        let take = n_sentences.min(sentences.len());
661        let result = sentences[..take]
662            .iter()
663            .enumerate()
664            .map(|(i, text)| {
665                let mut method_scores = HashMap::new();
666                method_scores.insert("lead".to_string(), 1.0);
667                SentenceScore {
668                    sentence_index: i,
669                    text: text.clone(),
670                    score: 1.0,
671                    method_scores,
672                }
673            })
674            .collect();
675        Ok(result)
676    }
677
678    fn score_hybrid(
679        &self,
680        sentences: &[String],
681        tokens_per_sentence: &[Vec<String>],
682        top_n: usize,
683        tfidf_weight: f64,
684        textrank_weight: f64,
685    ) -> Result<Vec<SentenceScore>, SummarizerError> {
686        let n = sentences.len();
687        let tfidf_scores = self.tfidf_sentence_scores(tokens_per_sentence);
688
689        // TextRank component
690        let vectors = self.build_tfidf_vectors(tokens_per_sentence);
691        let mut matrix = vec![vec![0.0_f64; n]; n];
692        for i in 0..n {
693            for j in 0..n {
694                if i != j {
695                    matrix[i][j] = Self::cosine_similarity(&vectors[i], &vectors[j]);
696                }
697            }
698        }
699        // Use default TextRank params for hybrid
700        let tr_scores = Self::textrank_scores(&matrix, 0.85, 100);
701
702        // Normalise each component to [0, 1] before blending
703        let norm_tfidf = Self::normalise(&tfidf_scores);
704        let norm_tr = Self::normalise(&tr_scores);
705
706        let total_weight = tfidf_weight + textrank_weight;
707        let combined: Vec<f64> = norm_tfidf
708            .iter()
709            .zip(norm_tr.iter())
710            .map(|(tf, tr)| {
711                if total_weight == 0.0 {
712                    0.0
713                } else {
714                    (tfidf_weight * tf + textrank_weight * tr) / total_weight
715                }
716            })
717            .collect();
718
719        // Build scored output keeping per-method details
720        let top_n_capped = top_n.min(n);
721        let mut indexed: Vec<(usize, f64)> = combined.iter().copied().enumerate().collect();
722        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
723        indexed.truncate(top_n_capped);
724        indexed.sort_by_key(|&(i, _)| i);
725
726        let result = indexed
727            .into_iter()
728            .map(|(i, score)| {
729                let mut method_scores = HashMap::new();
730                method_scores.insert("tfidf".to_string(), norm_tfidf[i]);
731                method_scores.insert("textrank".to_string(), norm_tr[i]);
732                SentenceScore {
733                    sentence_index: i,
734                    text: sentences[i].clone(),
735                    score,
736                    method_scores,
737                }
738            })
739            .collect();
740
741        Ok(result)
742    }
743
744    /// Select up to `top_n` highest-scoring sentence indices and return them in original order.
745    fn top_n_in_order(
746        &self,
747        sentences: &[String],
748        scores: &[f64],
749        top_n: usize,
750        method_name: &str,
751    ) -> Vec<SentenceScore> {
752        let n = sentences.len();
753        let take = top_n.min(n);
754
755        let mut indexed: Vec<(usize, f64)> = scores.iter().copied().enumerate().collect();
756        indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
757        indexed.truncate(take);
758        indexed.sort_by_key(|&(i, _)| i); // restore original order
759
760        indexed
761            .into_iter()
762            .map(|(i, score)| {
763                let mut method_scores = HashMap::new();
764                method_scores.insert(method_name.to_string(), score);
765                SentenceScore {
766                    sentence_index: i,
767                    text: sentences[i].clone(),
768                    score,
769                    method_scores,
770                }
771            })
772            .collect()
773    }
774
775    /// Linearly normalise `values` to [0, 1].  All-equal inputs map to 0.0.
776    fn normalise(values: &[f64]) -> Vec<f64> {
777        if values.is_empty() {
778            return Vec::new();
779        }
780        let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
781        let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
782        let range = max - min;
783        if range == 0.0 {
784            return vec![0.0; values.len()];
785        }
786        values.iter().map(|v| (v - min) / range).collect()
787    }
788}
789
790// ── Tests ─────────────────────────────────────────────────────────────────────
791
792#[cfg(test)]
793mod tests {
794    use crate::text_summarizer::{
795        SummarizationMethod, SummarizerConfig, SummarizerError, TextSummarizer,
796    };
797    use std::collections::HashMap;
798
799    // Helper: build a summarizer with TF-IDF top_n
800    fn tfidf_summarizer(top_n: usize) -> TextSummarizer {
801        TextSummarizer::new(SummarizerConfig {
802            method: SummarizationMethod::TfIdf { top_n },
803            ..SummarizerConfig::default()
804        })
805    }
806
807    fn textrank_summarizer(top_n: usize) -> TextSummarizer {
808        TextSummarizer::new(SummarizerConfig {
809            method: SummarizationMethod::TextRank {
810                top_n,
811                damping: 0.85,
812                max_iter: 100,
813            },
814            ..SummarizerConfig::default()
815        })
816    }
817
818    fn lead_summarizer(n: usize) -> TextSummarizer {
819        TextSummarizer::new(SummarizerConfig {
820            method: SummarizationMethod::Lead { n_sentences: n },
821            ..SummarizerConfig::default()
822        })
823    }
824
825    fn hybrid_summarizer(top_n: usize, tw: f64, rw: f64) -> TextSummarizer {
826        TextSummarizer::new(SummarizerConfig {
827            method: SummarizationMethod::Hybrid {
828                top_n,
829                tfidf_weight: tw,
830                textrank_weight: rw,
831            },
832            ..SummarizerConfig::default()
833        })
834    }
835
836    const SAMPLE: &str = "The quick brown fox jumps over the lazy dog. \
837         Artificial intelligence is transforming many industries. \
838         Machine learning enables computers to learn from data. \
839         The weather today is sunny and warm. \
840         Deep learning models require large amounts of training data.";
841
842    // ── 1. split_sentences ────────────────────────────────────────────────────
843
844    #[test]
845    fn test_split_sentences_basic() {
846        let s = TextSummarizer::new(SummarizerConfig::default());
847        let sents = s.split_sentences("Hello world. Foo bar! Baz qux?");
848        assert_eq!(sents.len(), 3);
849    }
850
851    #[test]
852    fn test_split_sentences_empty_string() {
853        let s = TextSummarizer::new(SummarizerConfig::default());
854        let sents = s.split_sentences("");
855        assert!(sents.is_empty());
856    }
857
858    #[test]
859    fn test_split_sentences_no_terminator() {
860        let s = TextSummarizer::new(SummarizerConfig::default());
861        let sents = s.split_sentences("No terminator here");
862        assert_eq!(sents.len(), 1);
863        assert_eq!(sents[0], "No terminator here");
864    }
865
866    #[test]
867    fn test_split_sentences_multiple_spaces() {
868        let s = TextSummarizer::new(SummarizerConfig::default());
869        let sents = s.split_sentences("Hello.   World.");
870        assert_eq!(sents.len(), 2);
871    }
872
873    #[test]
874    fn test_split_sentences_trims_whitespace() {
875        let s = TextSummarizer::new(SummarizerConfig::default());
876        let sents = s.split_sentences("  Leading spaces.  Trailing spaces.  ");
877        assert!(sents.iter().all(|s| s == s.trim()));
878    }
879
880    // ── 2. tokenize_sentence ──────────────────────────────────────────────────
881
882    #[test]
883    fn test_tokenize_lowercases() {
884        let s = TextSummarizer::new(SummarizerConfig::default());
885        let tokens = s.tokenize_sentence("Hello WORLD");
886        assert!(tokens.contains(&"hello".to_string()));
887        assert!(tokens.contains(&"world".to_string()));
888    }
889
890    #[test]
891    fn test_tokenize_removes_stop_words() {
892        let s = TextSummarizer::new(SummarizerConfig::default());
893        let tokens = s.tokenize_sentence("the quick brown fox");
894        assert!(!tokens.contains(&"the".to_string()));
895        assert!(tokens.contains(&"quick".to_string()));
896    }
897
898    #[test]
899    fn test_tokenize_removes_punctuation() {
900        let s = TextSummarizer::new(SummarizerConfig::default());
901        let tokens = s.tokenize_sentence("Hello, world!");
902        // punctuation stripped from tokens
903        assert!(tokens
904            .iter()
905            .all(|t| t.chars().all(|c| c.is_alphanumeric())));
906    }
907
908    #[test]
909    fn test_tokenize_empty_sentence() {
910        let s = TextSummarizer::new(SummarizerConfig::default());
911        let tokens = s.tokenize_sentence("");
912        assert!(tokens.is_empty());
913    }
914
915    #[test]
916    fn test_tokenize_all_stop_words() {
917        let s = TextSummarizer::new(SummarizerConfig::default());
918        let tokens = s.tokenize_sentence("the a an is it in on at");
919        assert!(tokens.is_empty());
920    }
921
922    // ── 3. cosine_similarity ──────────────────────────────────────────────────
923
924    #[test]
925    fn test_cosine_identical_vectors() {
926        let mut v: HashMap<String, f64> = HashMap::new();
927        v.insert("foo".to_string(), 1.0);
928        v.insert("bar".to_string(), 2.0);
929        let sim = TextSummarizer::cosine_similarity(&v, &v);
930        assert!((sim - 1.0).abs() < 1e-9);
931    }
932
933    #[test]
934    fn test_cosine_orthogonal_vectors() {
935        let mut a: HashMap<String, f64> = HashMap::new();
936        a.insert("foo".to_string(), 1.0);
937        let mut b: HashMap<String, f64> = HashMap::new();
938        b.insert("bar".to_string(), 1.0);
939        let sim = TextSummarizer::cosine_similarity(&a, &b);
940        assert!(sim.abs() < 1e-9);
941    }
942
943    #[test]
944    fn test_cosine_empty_vector() {
945        let a: HashMap<String, f64> = HashMap::new();
946        let mut b: HashMap<String, f64> = HashMap::new();
947        b.insert("foo".to_string(), 1.0);
948        assert_eq!(TextSummarizer::cosine_similarity(&a, &b), 0.0);
949        assert_eq!(TextSummarizer::cosine_similarity(&b, &a), 0.0);
950    }
951
952    #[test]
953    fn test_cosine_partial_overlap() {
954        let mut a: HashMap<String, f64> = HashMap::new();
955        a.insert("foo".to_string(), 1.0);
956        a.insert("bar".to_string(), 1.0);
957        let mut b: HashMap<String, f64> = HashMap::new();
958        b.insert("foo".to_string(), 1.0);
959        b.insert("baz".to_string(), 1.0);
960        let sim = TextSummarizer::cosine_similarity(&a, &b);
961        assert!(sim > 0.0 && sim < 1.0);
962    }
963
964    // ── 4. tfidf_vector ───────────────────────────────────────────────────────
965
966    #[test]
967    fn test_tfidf_vector_non_empty() {
968        let s = TextSummarizer::new(SummarizerConfig::default());
969        let tokens = vec!["machine".to_string(), "learning".to_string()];
970        let corpus = vec![
971            tokens.clone(),
972            vec!["deep".to_string(), "learning".to_string()],
973        ];
974        let vec = s.tfidf_vector(&tokens, &corpus);
975        assert!(!vec.is_empty());
976    }
977
978    #[test]
979    fn test_tfidf_vector_empty_tokens() {
980        let s = TextSummarizer::new(SummarizerConfig::default());
981        let vec = s.tfidf_vector(&[], &[]);
982        assert!(vec.is_empty());
983    }
984
985    #[test]
986    fn test_tfidf_rare_term_higher_idf() {
987        let s = TextSummarizer::new(SummarizerConfig::default());
988        let rare = vec!["uniqueterm".to_string()];
989        let common = vec!["shared".to_string()];
990        let corpus = vec![rare.clone(), common.clone(), common.clone(), common.clone()];
991        let rare_vec = s.tfidf_vector(&rare, &corpus);
992        let common_vec = s.tfidf_vector(&common, &corpus);
993        let rare_score = rare_vec.values().sum::<f64>();
994        let common_score = common_vec.values().sum::<f64>();
995        // Rare term should have higher IDF, but tf is equal, so rare_score >= common_score
996        assert!(rare_score >= common_score);
997    }
998
999    // ── 5. textrank_scores ────────────────────────────────────────────────────
1000
1001    #[test]
1002    fn test_textrank_scores_uniform_matrix() {
1003        // All sentences equally similar to each other → uniform scores
1004        let n = 4;
1005        let sim = vec![vec![1.0; n]; n];
1006        let scores = TextSummarizer::textrank_scores(&sim, 0.85, 200);
1007        assert_eq!(scores.len(), n);
1008        let expected = 1.0 / n as f64;
1009        for &s in &scores {
1010            assert!((s - expected).abs() < 1e-3, "score {s} vs {expected}");
1011        }
1012    }
1013
1014    #[test]
1015    fn test_textrank_scores_empty_matrix() {
1016        let scores = TextSummarizer::textrank_scores(&[], 0.85, 100);
1017        assert!(scores.is_empty());
1018    }
1019
1020    #[test]
1021    fn test_textrank_scores_single_sentence() {
1022        let sim = vec![vec![0.0]];
1023        let scores = TextSummarizer::textrank_scores(&sim, 0.85, 100);
1024        assert_eq!(scores.len(), 1);
1025    }
1026
1027    #[test]
1028    fn test_textrank_scores_convergence() {
1029        let _n = 3;
1030        let sim = vec![
1031            vec![0.0, 0.8, 0.2],
1032            vec![0.8, 0.0, 0.5],
1033            vec![0.2, 0.5, 0.0],
1034        ];
1035        let scores_100 = TextSummarizer::textrank_scores(&sim, 0.85, 100);
1036        let scores_1000 = TextSummarizer::textrank_scores(&sim, 0.85, 1000);
1037        // Scores should be close after 100 iterations
1038        for (a, b) in scores_100.iter().zip(scores_1000.iter()) {
1039            assert!((a - b).abs() < 1e-4);
1040        }
1041    }
1042
1043    // ── 6. summarize — error cases ────────────────────────────────────────────
1044
1045    #[test]
1046    fn test_summarize_empty_text_error() {
1047        let mut s = tfidf_summarizer(2);
1048        let err = s
1049            .summarize("")
1050            .expect_err("test: empty string should return an error");
1051        assert_eq!(err, SummarizerError::EmptyText);
1052    }
1053
1054    #[test]
1055    fn test_summarize_whitespace_only_error() {
1056        let mut s = tfidf_summarizer(2);
1057        let err = s
1058            .summarize("   \n\t  ")
1059            .expect_err("test: whitespace-only string should return an error");
1060        assert_eq!(err, SummarizerError::EmptyText);
1061    }
1062
1063    #[test]
1064    fn test_summarize_invalid_config_length_bounds() {
1065        let cfg = SummarizerConfig {
1066            method: SummarizationMethod::TfIdf { top_n: 2 },
1067            min_sentence_length: 500,
1068            max_sentence_length: 10,
1069            stop_words: vec![],
1070        };
1071        let mut s = TextSummarizer::new(cfg);
1072        let err = s
1073            .summarize("Hello world. Foo bar.")
1074            .expect_err("test: invalid config (min > max sentence length) should return an error");
1075        matches!(err, SummarizerError::InvalidConfig(_));
1076    }
1077
1078    #[test]
1079    fn test_summarize_invalid_textrank_damping() {
1080        let cfg = SummarizerConfig {
1081            method: SummarizationMethod::TextRank {
1082                top_n: 2,
1083                damping: 1.5,
1084                max_iter: 100,
1085            },
1086            ..SummarizerConfig::default()
1087        };
1088        let mut s = TextSummarizer::new(cfg);
1089        let err = s
1090            .summarize(SAMPLE)
1091            .expect_err("test: invalid damping factor should return an error");
1092        matches!(err, SummarizerError::InvalidConfig(_));
1093    }
1094
1095    // ── 7. summarize — TF-IDF ─────────────────────────────────────────────────
1096
1097    #[test]
1098    fn test_tfidf_returns_correct_count() {
1099        let mut s = tfidf_summarizer(2);
1100        let result = s
1101            .summarize(SAMPLE)
1102            .expect("test: TF-IDF summarize on valid SAMPLE should succeed");
1103        assert_eq!(result.summary_sentences.len(), 2);
1104    }
1105
1106    #[test]
1107    fn test_tfidf_preserves_original_order() {
1108        let mut s = tfidf_summarizer(3);
1109        let result = s
1110            .summarize(SAMPLE)
1111            .expect("test: TF-IDF summarize on valid SAMPLE should succeed");
1112        let indices: Vec<usize> = result
1113            .summary_sentences
1114            .iter()
1115            .map(|ss| ss.sentence_index)
1116            .collect();
1117        let mut sorted = indices.clone();
1118        sorted.sort_unstable();
1119        assert_eq!(indices, sorted);
1120    }
1121
1122    #[test]
1123    fn test_tfidf_method_name() {
1124        let mut s = tfidf_summarizer(2);
1125        let result = s
1126            .summarize(SAMPLE)
1127            .expect("test: TF-IDF summarize on valid SAMPLE should succeed");
1128        assert_eq!(result.method, "tfidf");
1129    }
1130
1131    #[test]
1132    fn test_tfidf_compression_ratio() {
1133        let mut s = tfidf_summarizer(2);
1134        let result = s
1135            .summarize(SAMPLE)
1136            .expect("test: TF-IDF summarize on valid SAMPLE should succeed");
1137        assert!(result.compression_ratio > 0.0 && result.compression_ratio <= 1.0);
1138    }
1139
1140    #[test]
1141    fn test_tfidf_top_n_capped_at_sentence_count() {
1142        let mut s = tfidf_summarizer(100);
1143        let result = s
1144            .summarize("Only two sentences here. Second one follows.")
1145            .expect("test: TF-IDF summarize with top_n larger than sentence count should succeed");
1146        assert!(result.summary_sentences.len() <= result.original_sentence_count);
1147    }
1148
1149    // ── 8. summarize — TextRank ───────────────────────────────────────────────
1150
1151    #[test]
1152    fn test_textrank_returns_correct_count() {
1153        let mut s = textrank_summarizer(2);
1154        let result = s
1155            .summarize(SAMPLE)
1156            .expect("test: TextRank summarize on valid SAMPLE should succeed");
1157        assert_eq!(result.summary_sentences.len(), 2);
1158    }
1159
1160    #[test]
1161    fn test_textrank_preserves_original_order() {
1162        let mut s = textrank_summarizer(3);
1163        let result = s
1164            .summarize(SAMPLE)
1165            .expect("test: TextRank summarize on valid SAMPLE should succeed");
1166        let indices: Vec<usize> = result
1167            .summary_sentences
1168            .iter()
1169            .map(|ss| ss.sentence_index)
1170            .collect();
1171        let mut sorted = indices.clone();
1172        sorted.sort_unstable();
1173        assert_eq!(indices, sorted);
1174    }
1175
1176    #[test]
1177    fn test_textrank_method_name() {
1178        let mut s = textrank_summarizer(2);
1179        let result = s
1180            .summarize(SAMPLE)
1181            .expect("test: TextRank summarize on valid SAMPLE should succeed");
1182        assert_eq!(result.method, "textrank");
1183    }
1184
1185    #[test]
1186    fn test_textrank_scores_are_non_negative() {
1187        let mut s = textrank_summarizer(3);
1188        let result = s
1189            .summarize(SAMPLE)
1190            .expect("test: TextRank summarize on valid SAMPLE should succeed");
1191        for ss in &result.summary_sentences {
1192            assert!(ss.score >= 0.0);
1193        }
1194    }
1195
1196    // ── 9. summarize — Lead ───────────────────────────────────────────────────
1197
1198    #[test]
1199    fn test_lead_returns_first_n() {
1200        let mut s = lead_summarizer(2);
1201        let result = s
1202            .summarize(SAMPLE)
1203            .expect("test: Lead summarize on valid SAMPLE should succeed");
1204        assert_eq!(result.summary_sentences.len(), 2);
1205        assert_eq!(result.summary_sentences[0].sentence_index, 0);
1206        assert_eq!(result.summary_sentences[1].sentence_index, 1);
1207    }
1208
1209    #[test]
1210    fn test_lead_method_name() {
1211        let mut s = lead_summarizer(2);
1212        let result = s
1213            .summarize(SAMPLE)
1214            .expect("test: Lead summarize on valid SAMPLE should succeed");
1215        assert_eq!(result.method, "lead");
1216    }
1217
1218    #[test]
1219    fn test_lead_capped_at_available_sentences() {
1220        let mut s = lead_summarizer(100);
1221        // Sentences long enough to survive the min_sentence_length=10 filter
1222        let result = s
1223            .summarize("First sentence here. Second sentence here. Third sentence here.")
1224            .expect("test: Lead summarize with top_n larger than sentence count should succeed");
1225        assert!(result.summary_sentences.len() <= 3);
1226    }
1227
1228    // ── 10. summarize — Hybrid ────────────────────────────────────────────────
1229
1230    #[test]
1231    fn test_hybrid_returns_correct_count() {
1232        let mut s = hybrid_summarizer(2, 0.5, 0.5);
1233        let result = s
1234            .summarize(SAMPLE)
1235            .expect("test: Hybrid summarize on valid SAMPLE should succeed");
1236        assert_eq!(result.summary_sentences.len(), 2);
1237    }
1238
1239    #[test]
1240    fn test_hybrid_method_name() {
1241        let mut s = hybrid_summarizer(2, 0.5, 0.5);
1242        let result = s
1243            .summarize(SAMPLE)
1244            .expect("test: Hybrid summarize on valid SAMPLE should succeed");
1245        assert_eq!(result.method, "hybrid");
1246    }
1247
1248    #[test]
1249    fn test_hybrid_method_scores_contain_both_keys() {
1250        let mut s = hybrid_summarizer(2, 0.5, 0.5);
1251        let result = s
1252            .summarize(SAMPLE)
1253            .expect("test: summarize SAMPLE for hybrid method_scores keys");
1254        for ss in &result.summary_sentences {
1255            assert!(ss.method_scores.contains_key("tfidf"));
1256            assert!(ss.method_scores.contains_key("textrank"));
1257        }
1258    }
1259
1260    #[test]
1261    fn test_hybrid_preserves_original_order() {
1262        let mut s = hybrid_summarizer(3, 0.6, 0.4);
1263        let result = s
1264            .summarize(SAMPLE)
1265            .expect("test: summarize SAMPLE for hybrid sentence order");
1266        let indices: Vec<usize> = result
1267            .summary_sentences
1268            .iter()
1269            .map(|ss| ss.sentence_index)
1270            .collect();
1271        let mut sorted = indices.clone();
1272        sorted.sort_unstable();
1273        assert_eq!(indices, sorted);
1274    }
1275
1276    // ── 11. add_to_corpus ─────────────────────────────────────────────────────
1277
1278    #[test]
1279    fn test_add_to_corpus_increases_vocab() {
1280        let mut s = tfidf_summarizer(2);
1281        assert_eq!(s.document_frequencies.len(), 0);
1282        s.add_to_corpus("Machine learning is powerful. Deep learning too.");
1283        assert!(!s.document_frequencies.is_empty());
1284    }
1285
1286    #[test]
1287    fn test_add_to_corpus_increases_total_documents() {
1288        let mut s = tfidf_summarizer(2);
1289        s.add_to_corpus("First sentence. Second sentence.");
1290        assert!(s.total_documents >= 1);
1291    }
1292
1293    #[test]
1294    fn test_corpus_influences_idf() {
1295        // With a corpus, terms appearing in many corpus docs should have lower IDF
1296        let mut s = tfidf_summarizer(2);
1297        // Add "common" word many times
1298        for _ in 0..10 {
1299            s.add_to_corpus("common word appears everywhere.");
1300        }
1301        let tokens_common = vec!["common".to_string()];
1302        let tokens_rare = vec!["xyzrare".to_string()];
1303        let corpus_local = vec![tokens_common.clone(), tokens_rare.clone()];
1304        let v_common = s.tfidf_vector(&tokens_common, &corpus_local);
1305        let v_rare = s.tfidf_vector(&tokens_rare, &corpus_local);
1306        let score_common: f64 = v_common.values().sum();
1307        let score_rare: f64 = v_rare.values().sum();
1308        assert!(score_rare > score_common);
1309    }
1310
1311    // ── 12. stats ─────────────────────────────────────────────────────────────
1312
1313    #[test]
1314    fn test_stats_initial_state() {
1315        let s = tfidf_summarizer(2);
1316        let stats = s.stats();
1317        assert_eq!(stats.documents_in_corpus, 0);
1318        assert_eq!(stats.vocabulary_size, 0);
1319        assert_eq!(stats.avg_sentences_per_doc, 0.0);
1320    }
1321
1322    #[test]
1323    fn test_stats_after_summarize() {
1324        let mut s = tfidf_summarizer(2);
1325        s.summarize(SAMPLE)
1326            .expect("test: summarize SAMPLE to update stats");
1327        let stats = s.stats();
1328        assert!(stats.avg_sentences_per_doc > 0.0);
1329    }
1330
1331    #[test]
1332    fn test_stats_after_corpus() {
1333        let mut s = tfidf_summarizer(2);
1334        s.add_to_corpus(SAMPLE);
1335        let stats = s.stats();
1336        assert!(stats.vocabulary_size > 0);
1337        assert!(stats.documents_in_corpus > 0);
1338    }
1339
1340    // ── 13. SentenceScore fields ──────────────────────────────────────────────
1341
1342    #[test]
1343    fn test_sentence_score_text_matches_original() {
1344        let mut s = tfidf_summarizer(2);
1345        let result = s
1346            .summarize(SAMPLE)
1347            .expect("test: summarize SAMPLE to access summary sentences");
1348        let original_sentences = s.split_sentences(SAMPLE);
1349        for ss in &result.summary_sentences {
1350            let orig = &original_sentences[ss.sentence_index];
1351            // Text should match (modulo length filtering)
1352            assert_eq!(&ss.text, orig);
1353        }
1354    }
1355
1356    #[test]
1357    fn test_sentence_score_has_tfidf_method_score() {
1358        let mut s = tfidf_summarizer(2);
1359        let result = s
1360            .summarize(SAMPLE)
1361            .expect("test: summarize SAMPLE to check tfidf method score");
1362        for ss in &result.summary_sentences {
1363            assert!(ss.method_scores.contains_key("tfidf"));
1364        }
1365    }
1366
1367    // ── 14. edge cases ────────────────────────────────────────────────────────
1368
1369    #[test]
1370    fn test_single_sentence_tfidf() {
1371        let mut s = tfidf_summarizer(1);
1372        let result = s
1373            .summarize("Just one sentence here with content words.")
1374            .expect("test: summarize single sentence with tfidf");
1375        assert_eq!(result.summary_sentences.len(), 1);
1376    }
1377
1378    #[test]
1379    fn test_single_sentence_textrank() {
1380        let mut s = textrank_summarizer(1);
1381        let result = s
1382            .summarize("Just one sentence here with content words.")
1383            .expect("test: summarize single sentence with textrank");
1384        assert_eq!(result.summary_sentences.len(), 1);
1385    }
1386
1387    #[test]
1388    fn test_min_sentence_length_filter() {
1389        let cfg = SummarizerConfig {
1390            method: SummarizationMethod::TfIdf { top_n: 5 },
1391            min_sentence_length: 50,
1392            max_sentence_length: 1000,
1393            stop_words: vec![],
1394        };
1395        let mut s = TextSummarizer::new(cfg);
1396        // Short sentences should be filtered out
1397        let long =
1398            "This is a much longer sentence with plenty of content words to pass the filter.";
1399        let text = format!("Hi. Bye. {long}");
1400        let result = s
1401            .summarize(&text)
1402            .expect("test: summarize text with min_sentence_length filter");
1403        // Only the long sentence should survive
1404        assert!(result.original_sentence_count <= 1);
1405    }
1406
1407    #[test]
1408    fn test_compression_ratio_never_exceeds_one() {
1409        let mut s = tfidf_summarizer(10);
1410        let result = s
1411            .summarize(SAMPLE)
1412            .expect("test: summarize SAMPLE for compression ratio check");
1413        assert!(result.compression_ratio <= 1.0);
1414    }
1415
1416    #[test]
1417    fn test_summarize_increases_call_count() {
1418        let mut s = tfidf_summarizer(2);
1419        s.summarize(SAMPLE)
1420            .expect("test: first summarize call for stats check");
1421        s.summarize(SAMPLE)
1422            .expect("test: second summarize call for stats check");
1423        let stats = s.stats();
1424        // avg_sentences_per_doc should reflect 2 calls
1425        assert!(stats.avg_sentences_per_doc > 0.0);
1426    }
1427}