1use std::collections::HashMap;
16
17#[derive(Debug, Clone, PartialEq)]
21pub enum SummarizerError {
22 InsufficientSentences {
24 min: usize,
26 got: usize,
28 },
29 EmptyText,
31 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#[derive(Debug, Clone, PartialEq)]
53pub enum SummarizationMethod {
54 TfIdf {
56 top_n: usize,
58 },
59 TextRank {
61 top_n: usize,
63 damping: f64,
65 max_iter: u32,
67 },
68 Lead {
70 n_sentences: usize,
72 },
73 Hybrid {
75 top_n: usize,
77 tfidf_weight: f64,
79 textrank_weight: f64,
81 },
82}
83
84impl SummarizationMethod {
85 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#[derive(Debug, Clone)]
109pub struct SummarizerConfig {
110 pub method: SummarizationMethod,
112 pub min_sentence_length: usize,
114 pub max_sentence_length: usize,
116 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
131fn 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#[derive(Debug, Clone)]
147pub struct SentenceScore {
148 pub sentence_index: usize,
150 pub text: String,
152 pub score: f64,
154 pub method_scores: HashMap<String, f64>,
156}
157
158#[derive(Debug, Clone)]
160pub struct TextSummaryResult {
161 pub original_sentence_count: usize,
163 pub summary_sentences: Vec<SentenceScore>,
165 pub compression_ratio: f64,
167 pub method: String,
169}
170
171#[derive(Debug, Clone)]
173pub struct TextSummarizerStats {
174 pub documents_in_corpus: u32,
176 pub vocabulary_size: usize,
178 pub avg_sentences_per_doc: f64,
180}
181
182#[derive(Debug, Clone)]
200pub struct TextSummarizer {
201 pub config: SummarizerConfig,
203 pub document_frequencies: HashMap<String, u32>,
205 pub total_documents: u32,
207 summarize_calls: u32,
209 total_sentences_seen: u64,
211}
212
213impl TextSummarizer {
214 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 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 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; Ok(TextSummaryResult {
302 original_sentence_count: n,
303 summary_sentences: scored,
304 compression_ratio,
305 method: method_name,
306 })
307 }
308
309 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 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 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 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 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 i += 1;
371 while i < len && chars[i].is_whitespace() {
372 i += 1;
373 }
374 continue;
375 }
376 }
377 i += 1;
378 }
379
380 let remaining = current.trim().to_string();
382 if !remaining.is_empty() {
383 sentences.push(remaining);
384 }
385
386 sentences
387 }
388
389 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 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 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 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 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 let combined_df = local_df + corpus_df;
459 let combined_n = n_docs + corpus_n;
460
461 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 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 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 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 for (i, row) in transition.iter_mut().enumerate() {
521 row[i] = 0.0;
522 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 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 let incoming: f64 = (0..n).map(|i| transition[i][j] * scores[i]).sum();
543 new_scores[j] += damping * incoming;
544 }
545
546 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 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 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 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 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 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 let tr_scores = Self::textrank_scores(&matrix, 0.85, 100);
701
702 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 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 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); 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 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#[cfg(test)]
793mod tests {
794 use crate::text_summarizer::{
795 SummarizationMethod, SummarizerConfig, SummarizerError, TextSummarizer,
796 };
797 use std::collections::HashMap;
798
799 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 #[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 #[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 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 #[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 #[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 assert!(rare_score >= common_score);
997 }
998
999 #[test]
1002 fn test_textrank_scores_uniform_matrix() {
1003 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 for (a, b) in scores_100.iter().zip(scores_1000.iter()) {
1039 assert!((a - b).abs() < 1e-4);
1040 }
1041 }
1042
1043 #[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 #[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 #[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 #[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 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 #[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 #[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 let mut s = tfidf_summarizer(2);
1297 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 #[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 #[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 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 #[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 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 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 assert!(stats.avg_sentences_per_doc > 0.0);
1426 }
1427}