1pub mod ds_types;
14pub use ds_types::{
15 DocumentChunk, SentenceScore, SummarizerConfig, SummarizerError, SummarizerStats,
16 SummaryResult, SummaryStyle,
17};
18
19use std::collections::HashMap;
20
21#[allow(dead_code)]
25pub fn xorshift64(state: &mut u64) -> u64 {
26 let mut x = *state;
27 x ^= x << 13;
28 x ^= x >> 7;
29 x ^= x << 17;
30 *state = x;
31 x
32}
33
34pub(crate) fn default_stop_words() -> Vec<String> {
37 [
38 "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
39 "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do",
40 "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "that",
41 "which", "this", "these", "those", "it", "its", "we", "our", "they", "their", "he", "she",
42 "his", "her", "you", "your", "i", "my", "me", "us", "not", "no", "if", "as", "so", "then",
43 "than", "also", "just", "about", "after", "before", "between", "into", "through", "during",
44 "up", "down", "out", "off", "over", "under", "again", "further", "once", "very", "too",
45 "more", "most", "other", "some", "such", "both", "each", "few", "own", "same", "only",
46 "even", "when", "where", "how", "all", "while", "here", "there",
47 ]
48 .iter()
49 .map(|w| w.to_string())
50 .collect()
51}
52
53const TRANSITION_WORDS: &[&str] = &[
55 "however",
56 "furthermore",
57 "moreover",
58 "additionally",
59 "nevertheless",
60 "therefore",
61 "thus",
62 "hence",
63 "consequently",
64 "meanwhile",
65 "subsequently",
66 "nonetheless",
67 "accordingly",
68 "conversely",
69 "alternatively",
70 "similarly",
71 "specifically",
72 "particularly",
73 "generally",
74 "essentially",
75 "basically",
76 "obviously",
77 "clearly",
78 "certainly",
79 "indeed",
80 "actually",
81 "importantly",
82];
83
84pub fn tokenize(text: &str) -> Vec<String> {
86 text.split(|c: char| !c.is_alphanumeric())
87 .filter(|w| !w.is_empty())
88 .map(|w| w.to_lowercase())
89 .collect()
90}
91
92pub fn split_sentences(text: &str) -> Vec<String> {
94 let mut sentences: Vec<String> = Vec::new();
95 let mut current = String::new();
96 let chars: Vec<char> = text.chars().collect();
97 let len = chars.len();
98 let mut i = 0;
99
100 while i < len {
101 let ch = chars[i];
102 current.push(ch);
103
104 if ch == '\n' && i + 1 < len && chars[i + 1] == '\n' {
106 let trimmed = current.trim().to_string();
107 if !trimmed.is_empty() {
108 sentences.push(trimmed);
109 }
110 current.clear();
111 while i + 1 < len && chars[i + 1] == '\n' {
113 i += 1;
114 }
115 i += 1;
116 continue;
117 }
118
119 if matches!(ch, '.' | '!' | '?') {
121 let next_is_space_or_end = i + 1 >= len || chars[i + 1] == ' ' || chars[i + 1] == '\n';
122 if next_is_space_or_end {
123 let trimmed = current.trim().to_string();
124 if !trimmed.is_empty() {
125 sentences.push(trimmed);
126 }
127 current.clear();
128 if i + 1 < len && chars[i + 1] == ' ' {
130 i += 1;
131 }
132 }
133 }
134
135 i += 1;
136 }
137
138 let remainder = current.trim().to_string();
140 if !remainder.is_empty() {
141 sentences.push(remainder);
142 }
143
144 sentences
145}
146
147pub fn tf_idf(term: &str, doc_tokens: &[String], all_docs: &[Vec<String>]) -> f64 {
149 if doc_tokens.is_empty() || all_docs.is_empty() {
150 return 0.0;
151 }
152 let tf =
153 doc_tokens.iter().filter(|t| t.as_str() == term).count() as f64 / doc_tokens.len() as f64;
154 let df = all_docs
155 .iter()
156 .filter(|d| d.iter().any(|t| t.as_str() == term))
157 .count();
158 let idf = ((all_docs.len() as f64 + 1.0) / (df as f64 + 1.0)).ln();
159 tf * idf
160}
161
162pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
164 if a.is_empty() || a.len() != b.len() {
165 return 0.0;
166 }
167 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
168 let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
169 let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
170 if norm_a == 0.0 || norm_b == 0.0 {
171 return 0.0;
172 }
173 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
174}
175
176fn embedding_centrality_score(i: usize, embeddings: &[Vec<f64>]) -> f64 {
178 if embeddings.len() <= 1 {
179 return 0.0;
180 }
181 let sum: f64 = embeddings
182 .iter()
183 .enumerate()
184 .filter(|(j, _)| *j != i)
185 .map(|(_, other)| cosine_similarity(&embeddings[i], other))
186 .sum();
187 sum / (embeddings.len() - 1) as f64
188}
189
190fn position_score(index: usize, total: usize, position_bias: f64) -> f64 {
192 if total == 0 {
193 return 0.0;
194 }
195 if total == 1 {
196 return 1.0 * position_bias;
197 }
198 let rel = index as f64 / (total - 1) as f64; let centrality = 4.0 * (rel - 0.5).powi(2); centrality * position_bias
203}
204
205fn length_score(sentence: &str) -> f64 {
207 let len = sentence.len() as f64;
208 if len <= 0.0 {
209 return 0.0;
210 }
211 let ideal = 150.0_f64;
213 let sigma = 80.0_f64;
214 (-(len - ideal).powi(2) / (2.0 * sigma.powi(2))).exp()
215}
216
217fn strip_transitions(sentence: &str) -> &str {
219 let lower = sentence.to_lowercase();
220 for tw in TRANSITION_WORDS {
221 if let Some(rest) = lower.strip_prefix(tw) {
222 if rest.starts_with([',', ' ', ';']) {
223 let skip = tw.len() + 1; let stripped = sentence[skip..].trim_start_matches([',', ' ', ';']);
225 if !stripped.is_empty() {
226 let offset = stripped.as_ptr() as usize - sentence.as_ptr() as usize;
228 return &sentence[offset..];
229 }
230 }
231 }
232 }
233 sentence
234}
235
236pub struct DocumentSummarizer {
240 config: SummarizerConfig,
241 stats: SummarizerStats,
242}
243
244impl DocumentSummarizer {
245 pub fn new(config: SummarizerConfig) -> Self {
247 Self {
248 config,
249 stats: SummarizerStats::default(),
250 }
251 }
252
253 pub fn with_defaults() -> Self {
255 Self::new(SummarizerConfig::default())
256 }
257
258 pub fn stats(&self) -> &SummarizerStats {
260 &self.stats
261 }
262
263 pub fn summarize(
270 &mut self,
271 text: &str,
272 embeddings: Option<Vec<Vec<f64>>>,
273 ) -> Result<SummaryResult, SummarizerError> {
274 if text.trim().is_empty() {
275 return Err(SummarizerError::EmptyDocument);
276 }
277
278 if let Some(ref embs) = embeddings {
280 if let Some(first) = embs.first() {
281 let dim = first.len();
282 for (idx, e) in embs.iter().enumerate().skip(1) {
283 if e.len() != dim {
284 return Err(SummarizerError::EmbeddingDimensionMismatch {
285 expected: dim,
286 got: e.len(),
287 });
288 }
289 let _ = idx;
290 }
291 }
292 }
293
294 let original_length = text.len();
295 let sentences_raw = split_sentences(text);
296
297 let sentences: Vec<String> = sentences_raw
299 .iter()
300 .filter(|s| {
301 s.len() >= self.config.min_sentence_length
302 && s.len() <= self.config.max_sentence_length
303 })
304 .cloned()
305 .collect();
306
307 let filtered_indices: Vec<usize> = sentences_raw
310 .iter()
311 .enumerate()
312 .filter(|(_, s)| {
313 s.len() >= self.config.min_sentence_length
314 && s.len() <= self.config.max_sentence_length
315 })
316 .map(|(i, _)| i)
317 .collect();
318
319 let filtered_embeddings: Option<Vec<Vec<f64>>> = embeddings.as_ref().map(|embs| {
320 filtered_indices
321 .iter()
322 .filter_map(|&i| embs.get(i).cloned())
323 .collect()
324 });
325
326 let result = match &self.config.style.clone() {
327 SummaryStyle::Extractive { num_sentences } => self.summarize_extractive(
328 text,
329 &sentences,
330 filtered_embeddings.as_deref(),
331 *num_sentences,
332 original_length,
333 )?,
334 SummaryStyle::Keyphrase { num_phrases } => {
335 self.summarize_keyphrase(text, *num_phrases, original_length)?
336 }
337 SummaryStyle::Headline { max_chars } => self.summarize_headline(
338 text,
339 &sentences,
340 filtered_embeddings.as_deref(),
341 *max_chars,
342 original_length,
343 )?,
344 SummaryStyle::Abstractive { target_words } => self.summarize_abstractive(
345 text,
346 &sentences,
347 filtered_embeddings.as_deref(),
348 *target_words,
349 original_length,
350 )?,
351 SummaryStyle::Hierarchical { levels } => self.summarize_hierarchical(
352 text,
353 &sentences,
354 filtered_embeddings.as_deref(),
355 *levels,
356 original_length,
357 )?,
358 };
359
360 self.stats.documents_processed += 1;
362 let n = self.stats.documents_processed as f64;
363 let tokens = tokenize(text).len() as u64;
364 self.stats.total_tokens_processed += tokens;
365 self.stats.avg_compression_ratio +=
366 (result.compression_ratio - self.stats.avg_compression_ratio) / n;
367 self.stats.avg_quality_score += (result.quality_score - self.stats.avg_quality_score) / n;
368
369 Ok(result)
370 }
371
372 pub fn score_sentence(
376 &self,
377 sentence: &str,
378 index: usize,
379 total: usize,
380 corpus: &[Vec<String>],
381 ) -> SentenceScore {
382 let tokens = tokenize(sentence);
383 let stop = &self.config.stop_words;
384
385 let content_tokens: Vec<&String> = tokens
387 .iter()
388 .filter(|t| !stop.contains(t) && t.len() > 1)
389 .collect();
390
391 let tfidf_score = if content_tokens.is_empty() || corpus.is_empty() {
392 0.0
393 } else {
394 let sum: f64 = content_tokens
395 .iter()
396 .map(|t| tf_idf(t, &tokens, corpus))
397 .sum();
398 sum / content_tokens.len() as f64
399 };
400
401 let pos_score = position_score(index, total, self.config.position_bias);
402 let len_score = length_score(sentence);
403
404 let final_score = tfidf_score * 0.5 + pos_score * 0.25 + len_score * 0.25;
406
407 SentenceScore {
408 sentence: sentence.to_string(),
409 index,
410 tf_idf_score: tfidf_score,
411 position_score: pos_score,
412 length_score: len_score,
413 embedding_centrality: 0.0,
414 final_score,
415 }
416 }
417
418 pub fn extract_keyphrases(&self, text: &str, n: usize) -> Vec<String> {
420 let tokens = tokenize(text);
421 let stop = &self.config.stop_words;
422
423 let mut phrase_counts: HashMap<String, usize> = HashMap::new();
426 for window_size in 2usize..=4 {
427 if tokens.len() < window_size {
428 continue;
429 }
430 for i in 0..=(tokens.len() - window_size) {
431 let window = &tokens[i..i + window_size];
432 if stop.contains(&window[0])
434 || stop.contains(&window[window_size - 1])
435 || window[0].len() <= 1
436 || window[window_size - 1].len() <= 1
437 {
438 continue;
439 }
440 let phrase = window.join(" ");
441 *phrase_counts.entry(phrase).or_insert(0) += 1;
442 }
443 }
444
445 let all_tokens_vec = vec![tokens.clone()];
447 let mut scored: Vec<(String, f64)> = phrase_counts
448 .into_iter()
449 .map(|(phrase, count)| {
450 let phrase_tokens = tokenize(&phrase);
451 let avg_tfidf: f64 = if phrase_tokens.is_empty() {
452 0.0
453 } else {
454 phrase_tokens
455 .iter()
456 .filter(|t| !stop.contains(t))
457 .map(|t| tf_idf(t, &tokens, &all_tokens_vec))
458 .sum::<f64>()
459 / phrase_tokens.len() as f64
460 };
461 (phrase, count as f64 * avg_tfidf)
462 })
463 .collect();
464
465 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
466 scored.truncate(n);
467
468 let mut result: Vec<String> = Vec::new();
470 for (phrase, _) in scored {
471 let dominated = result.iter().any(|existing: &String| {
472 existing.contains(phrase.as_str()) || phrase.contains(existing.as_str())
473 });
474 if !dominated {
475 result.push(phrase);
476 }
477 }
478 result.truncate(n);
479 result
480 }
481
482 pub fn chunk_document(&self, text: &str, chunk_size: usize) -> Vec<DocumentChunk> {
486 if text.is_empty() || chunk_size == 0 {
487 return Vec::new();
488 }
489
490 let overlap = (chunk_size / 10).max(1);
491 let step = if chunk_size > overlap {
492 chunk_size - overlap
493 } else {
494 1
495 };
496
497 let chars: Vec<char> = text.chars().collect();
498 let total = chars.len();
499
500 let section_map = build_section_map(text);
502
503 let mut chunks: Vec<DocumentChunk> = Vec::new();
504 let mut start = 0_usize;
505 let mut chunk_index = 0_usize;
506
507 while start < total {
508 let end = (start + chunk_size).min(total);
509 let chunk_text: String = chars[start..end].iter().collect();
510 let trimmed = chunk_text.trim().to_string();
511 if !trimmed.is_empty() {
512 let section_title = section_map
513 .iter()
514 .filter(|(pos, _)| *pos <= start)
515 .max_by_key(|(pos, _)| *pos)
516 .map(|(_, title)| title.clone());
517
518 chunks.push(DocumentChunk {
519 text: trimmed,
520 embedding: None,
521 section_title,
522 chunk_index,
523 });
524 chunk_index += 1;
525 }
526 if end >= total {
527 break;
528 }
529 start += step;
530 }
531
532 chunks
533 }
534
535 pub fn quality_score(&self, original: &str, summary: &str) -> f64 {
538 let keyphrases = self.extract_keyphrases(original, 20);
539 if keyphrases.is_empty() {
540 return 0.0;
541 }
542 let summary_lower = summary.to_lowercase();
543 let covered = keyphrases
544 .iter()
545 .filter(|kp| summary_lower.contains(kp.as_str()))
546 .count();
547 (covered as f64 / keyphrases.len() as f64).clamp(0.0, 1.0)
548 }
549
550 fn score_sentences_with_embeddings(
553 &self,
554 sentences: &[String],
555 embeddings: Option<&[Vec<f64>]>,
556 corpus: &[Vec<String>],
557 ) -> Vec<SentenceScore> {
558 let total = sentences.len();
559
560 sentences
561 .iter()
562 .enumerate()
563 .map(|(i, sent)| {
564 let mut score = self.score_sentence(sent, i, total, corpus);
565
566 if self.config.use_embeddings {
568 if let Some(embs) = embeddings {
569 if embs.len() == sentences.len() {
570 let centrality = embedding_centrality_score(i, embs);
571 score.embedding_centrality = centrality;
572 score.final_score = score.tf_idf_score * 0.4
574 + score.position_score * 0.2
575 + score.length_score * 0.2
576 + centrality * 0.2;
577 }
578 }
579 }
580
581 score
582 })
583 .collect()
584 }
585
586 fn summarize_extractive(
587 &self,
588 original_text: &str,
589 sentences: &[String],
590 embeddings: Option<&[Vec<f64>]>,
591 num_sentences: usize,
592 original_length: usize,
593 ) -> Result<SummaryResult, SummarizerError> {
594 if sentences.is_empty() {
595 return Err(SummarizerError::InsufficientSentences { needed: 1, got: 0 });
596 }
597
598 let corpus: Vec<Vec<String>> = sentences.iter().map(|s| tokenize(s)).collect();
599 let mut scores = self.score_sentences_with_embeddings(sentences, embeddings, &corpus);
600
601 scores.sort_by(|a, b| {
603 b.final_score
604 .partial_cmp(&a.final_score)
605 .unwrap_or(std::cmp::Ordering::Equal)
606 .then_with(|| a.index.cmp(&b.index))
607 });
608
609 let take = num_sentences.min(scores.len());
610 let mut top: Vec<&SentenceScore> = scores.iter().take(take).collect();
611 top.sort_by_key(|s| s.index);
613
614 let selected: Vec<String> = top.iter().map(|s| s.sentence.clone()).collect();
615 let summary_text = selected.join(" ");
616 let summary_length = summary_text.len();
617 let compression_ratio = if original_length == 0 {
618 0.0
619 } else {
620 summary_length as f64 / original_length as f64
621 };
622 let keyphrases = self.extract_keyphrases(original_text, 10);
623 let quality = self.quality_score(original_text, &summary_text);
624
625 Ok(SummaryResult {
626 original_length,
627 summary_length,
628 compression_ratio,
629 sentences: selected,
630 keyphrases,
631 style: SummaryStyle::Extractive { num_sentences },
632 quality_score: quality,
633 })
634 }
635
636 fn summarize_keyphrase(
637 &self,
638 text: &str,
639 num_phrases: usize,
640 original_length: usize,
641 ) -> Result<SummaryResult, SummarizerError> {
642 let keyphrases = self.extract_keyphrases(text, num_phrases);
643 let summary_text = keyphrases.join(", ");
644 let summary_length = summary_text.len();
645 let compression_ratio = if original_length == 0 {
646 0.0
647 } else {
648 summary_length as f64 / original_length as f64
649 };
650 let quality = self.quality_score(text, &summary_text);
651
652 Ok(SummaryResult {
653 original_length,
654 summary_length,
655 compression_ratio,
656 sentences: keyphrases.clone(),
657 keyphrases,
658 style: SummaryStyle::Keyphrase { num_phrases },
659 quality_score: quality,
660 })
661 }
662
663 fn summarize_headline(
664 &self,
665 original_text: &str,
666 sentences: &[String],
667 embeddings: Option<&[Vec<f64>]>,
668 max_chars: usize,
669 original_length: usize,
670 ) -> Result<SummaryResult, SummarizerError> {
671 if sentences.is_empty() {
672 return Err(SummarizerError::InsufficientSentences { needed: 1, got: 0 });
673 }
674
675 let corpus: Vec<Vec<String>> = sentences.iter().map(|s| tokenize(s)).collect();
676 let scores = self.score_sentences_with_embeddings(sentences, embeddings, &corpus);
677
678 let best = scores
679 .iter()
680 .max_by(|a, b| {
681 a.final_score
682 .partial_cmp(&b.final_score)
683 .unwrap_or(std::cmp::Ordering::Equal)
684 })
685 .map(|s| s.sentence.as_str())
686 .unwrap_or("");
687
688 let headline = truncate_at_word(best, max_chars);
690 let summary_length = headline.len();
691 let compression_ratio = if original_length == 0 {
692 0.0
693 } else {
694 summary_length as f64 / original_length as f64
695 };
696 let keyphrases = self.extract_keyphrases(original_text, 5);
697 let quality = self.quality_score(original_text, &headline);
698
699 Ok(SummaryResult {
700 original_length,
701 summary_length,
702 compression_ratio,
703 sentences: vec![headline],
704 keyphrases,
705 style: SummaryStyle::Headline { max_chars },
706 quality_score: quality,
707 })
708 }
709
710 fn summarize_abstractive(
711 &self,
712 original_text: &str,
713 sentences: &[String],
714 embeddings: Option<&[Vec<f64>]>,
715 target_words: usize,
716 original_length: usize,
717 ) -> Result<SummaryResult, SummarizerError> {
718 if sentences.is_empty() {
719 return Err(SummarizerError::InsufficientSentences { needed: 1, got: 0 });
720 }
721
722 let corpus: Vec<Vec<String>> = sentences.iter().map(|s| tokenize(s)).collect();
723 let mut scores = self.score_sentences_with_embeddings(sentences, embeddings, &corpus);
724
725 scores.sort_by(|a, b| {
727 b.final_score
728 .partial_cmp(&a.final_score)
729 .unwrap_or(std::cmp::Ordering::Equal)
730 });
731 scores.truncate(3);
732 scores.sort_by_key(|s| s.index);
733
734 let cleaned: Vec<String> = scores
736 .iter()
737 .map(|s| strip_transitions(&s.sentence).to_string())
738 .collect();
739
740 let joined = cleaned.join(" ");
742 let words: Vec<&str> = joined.split_whitespace().collect();
743 let trimmed_words = if target_words > 0 && words.len() > target_words {
744 words[..target_words].join(" ")
745 } else {
746 joined.clone()
747 };
748
749 let summary_length = trimmed_words.len();
750 let compression_ratio = if original_length == 0 {
751 0.0
752 } else {
753 summary_length as f64 / original_length as f64
754 };
755 let keyphrases = self.extract_keyphrases(original_text, 8);
756 let quality = self.quality_score(original_text, &trimmed_words);
757
758 Ok(SummaryResult {
759 original_length,
760 summary_length,
761 compression_ratio,
762 sentences: vec![trimmed_words],
763 keyphrases,
764 style: SummaryStyle::Abstractive { target_words },
765 quality_score: quality,
766 })
767 }
768
769 fn summarize_hierarchical(
770 &self,
771 original_text: &str,
772 sentences: &[String],
773 embeddings: Option<&[Vec<f64>]>,
774 levels: usize,
775 original_length: usize,
776 ) -> Result<SummaryResult, SummarizerError> {
777 if sentences.is_empty() {
778 return Err(SummarizerError::InsufficientSentences { needed: 1, got: 0 });
779 }
780 if levels == 0 {
781 return Err(SummarizerError::ConfigurationError(
782 "levels must be >= 1".into(),
783 ));
784 }
785
786 let k = levels.min(sentences.len());
787 let corpus: Vec<Vec<String>> = sentences.iter().map(|s| tokenize(s)).collect();
788 let scores = self.score_sentences_with_embeddings(sentences, embeddings, &corpus);
789
790 let selected: Vec<String> = if let Some(embs) = embeddings {
791 if embs.len() == sentences.len() {
792 cluster_representative_sentences(sentences, embs, k, &scores)
793 } else {
794 positional_cluster_representatives(sentences, k, &scores)
795 }
796 } else {
797 positional_cluster_representatives(sentences, k, &scores)
798 };
799
800 let summary_text = selected.join(" ");
801 let summary_length = summary_text.len();
802 let compression_ratio = if original_length == 0 {
803 0.0
804 } else {
805 summary_length as f64 / original_length as f64
806 };
807 let keyphrases = self.extract_keyphrases(original_text, 8);
808 let quality = self.quality_score(original_text, &summary_text);
809
810 Ok(SummaryResult {
811 original_length,
812 summary_length,
813 compression_ratio,
814 sentences: selected,
815 keyphrases,
816 style: SummaryStyle::Hierarchical { levels },
817 quality_score: quality,
818 })
819 }
820}
821
822fn build_section_map(text: &str) -> Vec<(usize, String)> {
826 let mut map = Vec::new();
827 let mut pos = 0_usize;
828 for line in text.lines() {
829 let trimmed = line.trim();
830 let is_title = (!trimmed.is_empty() && trimmed.len() <= 80)
831 && (trimmed.ends_with(':') || trimmed == trimmed.to_uppercase() && trimmed.len() >= 3);
832 if is_title {
833 map.push((pos, trimmed.trim_end_matches(':').to_string()));
834 }
835 pos += line.len() + 1; }
837 map
838}
839
840fn truncate_at_word(text: &str, max_chars: usize) -> String {
842 if text.len() <= max_chars {
843 return text.to_string();
844 }
845 let truncated = &text[..max_chars];
846 if let Some(pos) = truncated.rfind(' ') {
848 truncated[..pos]
849 .trim_end_matches(|c: char| !c.is_alphanumeric())
850 .to_string()
851 } else {
852 truncated.to_string()
853 }
854}
855
856fn cluster_representative_sentences(
859 sentences: &[String],
860 embeddings: &[Vec<f64>],
861 k: usize,
862 scores: &[SentenceScore],
863) -> Vec<String> {
864 let n = sentences.len();
865 if n == 0 || k == 0 {
866 return Vec::new();
867 }
868 let k = k.min(n);
869
870 let step = n / k;
872 let mut centroids: Vec<Vec<f64>> = (0..k)
873 .map(|i| embeddings[(i * step).min(n - 1)].clone())
874 .collect();
875
876 let mut assignments = vec![0usize; n];
877
878 for _iter in 0..10 {
879 let mut changed = false;
881 for (i, emb) in embeddings.iter().enumerate() {
882 let best = (0..k)
883 .map(|c| (c, cosine_similarity(emb, ¢roids[c])))
884 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
885 .map(|(c, _)| c)
886 .unwrap_or(0);
887 if assignments[i] != best {
888 assignments[i] = best;
889 changed = true;
890 }
891 }
892 if !changed {
893 break;
894 }
895 for (c, centroid_slot) in centroids.iter_mut().enumerate().take(k) {
897 let members: Vec<&Vec<f64>> = (0..n)
898 .filter(|&i| assignments[i] == c)
899 .map(|i| &embeddings[i])
900 .collect();
901 if members.is_empty() {
902 continue;
903 }
904 let dim = members[0].len();
905 let mut centroid = vec![0.0_f64; dim];
906 for m in &members {
907 for (d, v) in m.iter().enumerate() {
908 centroid[d] += v;
909 }
910 }
911 let cnt = members.len() as f64;
912 for v in &mut centroid {
913 *v /= cnt;
914 }
915 *centroid_slot = centroid;
916 }
917 }
918
919 let mut result = Vec::new();
921 for c in 0..k {
922 let best_idx = (0..n).filter(|&i| assignments[i] == c).max_by(|&a, &b| {
923 scores[a]
924 .final_score
925 .partial_cmp(&scores[b].final_score)
926 .unwrap_or(std::cmp::Ordering::Equal)
927 });
928 if let Some(idx) = best_idx {
929 result.push((idx, sentences[idx].clone()));
930 }
931 }
932
933 result.sort_by_key(|(idx, _)| *idx);
935 result.into_iter().map(|(_, s)| s).collect()
936}
937
938fn positional_cluster_representatives(
941 sentences: &[String],
942 k: usize,
943 scores: &[SentenceScore],
944) -> Vec<String> {
945 let n = sentences.len();
946 if n == 0 || k == 0 {
947 return Vec::new();
948 }
949 let k = k.min(n);
950 let bucket_size = n.div_ceil(k);
951
952 let mut result: Vec<(usize, String)> = Vec::new();
953 for b in 0..k {
954 let start = b * bucket_size;
955 let end = ((b + 1) * bucket_size).min(n);
956 if start >= n {
957 break;
958 }
959 let best_idx = (start..end).max_by(|&a, &b_idx| {
960 scores[a]
961 .final_score
962 .partial_cmp(&scores[b_idx].final_score)
963 .unwrap_or(std::cmp::Ordering::Equal)
964 });
965 if let Some(idx) = best_idx {
966 result.push((idx, sentences[idx].clone()));
967 }
968 }
969
970 result.sort_by_key(|(idx, _)| *idx);
971 result.into_iter().map(|(_, s)| s).collect()
972}
973
974#[cfg(test)]
977mod tests {
978 use super::*;
979 use std::env::temp_dir;
980
981 fn default_summarizer() -> DocumentSummarizer {
984 DocumentSummarizer::with_defaults()
985 }
986
987 fn make_config(style: SummaryStyle) -> SummarizerConfig {
988 SummarizerConfig {
989 style,
990 ..SummarizerConfig::default()
991 }
992 }
993
994 fn long_text() -> &'static str {
995 "The quick brown fox jumps over the lazy dog. \
996 Machine learning is a subset of artificial intelligence that enables computers to learn. \
997 Natural language processing allows machines to understand human language effectively. \
998 Deep learning models are inspired by the structure of the human brain's neural networks. \
999 Data science combines statistics, programming, and domain knowledge to extract insights. \
1000 Reinforcement learning trains agents to make decisions by rewarding correct behaviour. \
1001 Transformer architectures revolutionized natural language processing tasks significantly. \
1002 Embeddings represent words and sentences as dense vectors in a high-dimensional space. \
1003 Semantic search retrieves documents based on meaning rather than exact keyword matching. \
1004 The field of computer vision enables machines to interpret and understand visual data."
1005 }
1006
1007 fn make_embeddings(n: usize, dim: usize, seed: u64) -> Vec<Vec<f64>> {
1008 let mut state = seed;
1009 (0..n)
1010 .map(|_| {
1011 (0..dim)
1012 .map(|_| {
1013 let x = xorshift64(&mut state);
1014 (x as f64 / u64::MAX as f64) * 2.0 - 1.0
1015 })
1016 .collect()
1017 })
1018 .collect()
1019 }
1020
1021 #[test]
1024 fn xorshift64_changes_state() {
1025 let mut s = 12345u64;
1026 let a = xorshift64(&mut s);
1027 let b = xorshift64(&mut s);
1028 assert_ne!(a, b);
1029 assert_ne!(s, 12345);
1030 }
1031
1032 #[test]
1033 fn xorshift64_deterministic() {
1034 let mut s1 = 9999u64;
1035 let mut s2 = 9999u64;
1036 assert_eq!(xorshift64(&mut s1), xorshift64(&mut s2));
1037 }
1038
1039 #[test]
1042 fn tokenize_basic() {
1043 let tokens = tokenize("Hello, World!");
1044 assert!(tokens.contains(&"hello".to_string()));
1045 assert!(tokens.contains(&"world".to_string()));
1046 assert_eq!(tokens.len(), 2);
1047 }
1048
1049 #[test]
1050 fn tokenize_empty() {
1051 assert!(tokenize("").is_empty());
1052 }
1053
1054 #[test]
1055 fn tokenize_lowercase() {
1056 let tokens = tokenize("UPPER lower MiXeD");
1057 assert!(tokens.iter().all(|t| t == &t.to_lowercase()));
1058 }
1059
1060 #[test]
1061 fn tokenize_strips_punctuation() {
1062 let tokens = tokenize("Hello... world!?");
1063 assert_eq!(tokens.len(), 2);
1064 }
1065
1066 #[test]
1069 fn split_sentences_basic() {
1070 let sents = split_sentences("Hello world. How are you? I am fine!");
1071 assert_eq!(sents.len(), 3);
1072 }
1073
1074 #[test]
1075 fn split_sentences_empty() {
1076 assert!(split_sentences("").is_empty());
1077 }
1078
1079 #[test]
1080 fn split_sentences_double_newline() {
1081 let sents = split_sentences("First paragraph.\n\nSecond paragraph.");
1082 assert_eq!(sents.len(), 2);
1083 }
1084
1085 #[test]
1086 fn split_sentences_no_terminal_punct() {
1087 let sents = split_sentences("A sentence without a period");
1088 assert_eq!(sents.len(), 1);
1089 }
1090
1091 #[test]
1094 fn tf_idf_zero_on_empty_doc() {
1095 assert_eq!(tf_idf("word", &[], &[vec!["word".into()]]), 0.0);
1096 }
1097
1098 #[test]
1099 fn tf_idf_zero_on_empty_corpus() {
1100 assert_eq!(tf_idf("word", &["word".into()], &[]), 0.0);
1101 }
1102
1103 #[test]
1104 fn tf_idf_rare_term_scores_higher() {
1105 let doc_a = tokenize("machine learning is great");
1106 let doc_b = tokenize("machine learning for everyone and everyone");
1107 let all = vec![doc_a.clone(), doc_b.clone()];
1108 let score_rare = tf_idf("great", &doc_a, &all);
1109 let score_common = tf_idf("machine", &doc_a, &all);
1110 assert!(score_rare > score_common);
1112 }
1113
1114 #[test]
1117 fn cosine_identical() {
1118 let v = vec![1.0, 2.0, 3.0];
1119 let s = cosine_similarity(&v, &v);
1120 assert!((s - 1.0).abs() < 1e-9);
1121 }
1122
1123 #[test]
1124 fn cosine_orthogonal() {
1125 let s = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]);
1126 assert!(s.abs() < 1e-9);
1127 }
1128
1129 #[test]
1130 fn cosine_empty_returns_zero() {
1131 assert_eq!(cosine_similarity(&[], &[1.0]), 0.0);
1132 }
1133
1134 #[test]
1135 fn cosine_dim_mismatch_returns_zero() {
1136 assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0]), 0.0);
1137 }
1138
1139 #[test]
1140 fn cosine_zero_norm_returns_zero() {
1141 assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), 0.0);
1142 }
1143
1144 #[test]
1147 fn error_empty_document() {
1148 let mut s = default_summarizer();
1149 let err = s
1150 .summarize(" ", None)
1151 .expect_err("test: whitespace-only document should return EmptyDocument error");
1152 assert!(matches!(err, SummarizerError::EmptyDocument));
1153 }
1154
1155 #[test]
1156 fn error_empty_string() {
1157 let mut s = default_summarizer();
1158 assert!(matches!(
1159 s.summarize("", None)
1160 .expect_err("test: empty string should return EmptyDocument error"),
1161 SummarizerError::EmptyDocument
1162 ));
1163 }
1164
1165 #[test]
1166 fn error_embedding_dimension_mismatch() {
1167 let cfg = SummarizerConfig {
1168 style: SummaryStyle::Extractive { num_sentences: 2 },
1169 use_embeddings: true,
1170 min_sentence_length: 1,
1171 ..SummarizerConfig::default()
1172 };
1173 let mut s = DocumentSummarizer::new(cfg);
1174 let text = "First sentence here. Second sentence here.";
1175 let embs = vec![vec![1.0_f64, 0.0], vec![1.0_f64, 0.0, 0.5]]; let err = s.summarize(text, Some(embs)).expect_err(
1177 "test: embedding dimension mismatch should return EmbeddingDimensionMismatch error",
1178 );
1179 assert!(matches!(
1180 err,
1181 SummarizerError::EmbeddingDimensionMismatch { .. }
1182 ));
1183 }
1184
1185 #[test]
1186 fn error_display_empty_document() {
1187 let e = SummarizerError::EmptyDocument;
1188 assert!(!format!("{e}").is_empty());
1189 }
1190
1191 #[test]
1192 fn error_display_insufficient_sentences() {
1193 let e = SummarizerError::InsufficientSentences { needed: 3, got: 1 };
1194 let msg = format!("{e}");
1195 assert!(msg.contains('3') || msg.contains('1'));
1196 }
1197
1198 #[test]
1199 fn error_display_embedding_mismatch() {
1200 let e = SummarizerError::EmbeddingDimensionMismatch {
1201 expected: 4,
1202 got: 2,
1203 };
1204 let msg = format!("{e}");
1205 assert!(msg.contains('4') || msg.contains('2'));
1206 }
1207
1208 #[test]
1209 fn error_display_config() {
1210 let e = SummarizerError::ConfigurationError("bad param".into());
1211 assert!(format!("{e}").contains("bad param"));
1212 }
1213
1214 #[test]
1217 fn extractive_returns_requested_sentence_count() {
1218 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1219 let mut s = DocumentSummarizer::new(cfg);
1220 let result = s
1221 .summarize(long_text(), None)
1222 .expect("test: extractive summarize should succeed");
1223 assert_eq!(result.sentences.len(), 3);
1224 }
1225
1226 #[test]
1227 fn extractive_does_not_exceed_available_sentences() {
1228 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 100 });
1229 let mut s = DocumentSummarizer::new(cfg);
1230 let result = s
1231 .summarize(long_text(), None)
1232 .expect("test: extractive summarize with high count should succeed");
1233 assert!(!result.sentences.is_empty());
1234 let raw_count = split_sentences(long_text()).len();
1235 assert!(result.sentences.len() <= raw_count);
1236 }
1237
1238 #[test]
1239 fn extractive_style_recorded_in_result() {
1240 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 2 });
1241 let mut s = DocumentSummarizer::new(cfg);
1242 let result = s
1243 .summarize(long_text(), None)
1244 .expect("test: extractive summarize should succeed");
1245 assert!(matches!(
1246 result.style,
1247 SummaryStyle::Extractive { num_sentences: 2 }
1248 ));
1249 }
1250
1251 #[test]
1252 fn extractive_compression_ratio_in_range() {
1253 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1254 let mut s = DocumentSummarizer::new(cfg);
1255 let result = s
1256 .summarize(long_text(), None)
1257 .expect("test: extractive summarize should succeed");
1258 assert!(result.compression_ratio > 0.0);
1259 assert!(result.compression_ratio <= 1.0);
1260 }
1261
1262 #[test]
1263 fn extractive_with_embeddings() {
1264 let sents = split_sentences(long_text());
1265 let embs = make_embeddings(sents.len(), 16, 42);
1266 let cfg = SummarizerConfig {
1267 style: SummaryStyle::Extractive { num_sentences: 3 },
1268 use_embeddings: true,
1269 min_sentence_length: 1,
1270 ..SummarizerConfig::default()
1271 };
1272 let mut s = DocumentSummarizer::new(cfg);
1273 let result = s
1274 .summarize(long_text(), Some(embs))
1275 .expect("test: extractive summarize with embeddings should succeed");
1276 assert_eq!(result.sentences.len(), 3);
1277 }
1278
1279 #[test]
1282 fn keyphrase_returns_requested_phrase_count() {
1283 let cfg = make_config(SummaryStyle::Keyphrase { num_phrases: 5 });
1284 let mut s = DocumentSummarizer::new(cfg);
1285 let result = s
1286 .summarize(long_text(), None)
1287 .expect("test: keyphrase summarize should succeed");
1288 assert!(result.sentences.len() <= 5);
1289 }
1290
1291 #[test]
1292 fn keyphrase_style_recorded() {
1293 let cfg = make_config(SummaryStyle::Keyphrase { num_phrases: 3 });
1294 let mut s = DocumentSummarizer::new(cfg);
1295 let result = s
1296 .summarize(long_text(), None)
1297 .expect("test: keyphrase summarize should succeed");
1298 assert!(matches!(
1299 result.style,
1300 SummaryStyle::Keyphrase { num_phrases: 3 }
1301 ));
1302 }
1303
1304 #[test]
1305 fn keyphrase_phrases_are_nonempty() {
1306 let cfg = make_config(SummaryStyle::Keyphrase { num_phrases: 5 });
1307 let mut s = DocumentSummarizer::new(cfg);
1308 let result = s
1309 .summarize(long_text(), None)
1310 .expect("test: keyphrase summarize should succeed");
1311 for phrase in &result.sentences {
1312 assert!(!phrase.is_empty());
1313 }
1314 }
1315
1316 #[test]
1319 fn headline_respects_max_chars() {
1320 let cfg = make_config(SummaryStyle::Headline { max_chars: 50 });
1321 let mut s = DocumentSummarizer::new(cfg);
1322 let result = s
1323 .summarize(long_text(), None)
1324 .expect("test: headline summarize should succeed");
1325 assert_eq!(result.sentences.len(), 1);
1326 assert!(result.sentences[0].len() <= 50);
1327 }
1328
1329 #[test]
1330 fn headline_style_recorded() {
1331 let cfg = make_config(SummaryStyle::Headline { max_chars: 80 });
1332 let mut s = DocumentSummarizer::new(cfg);
1333 let result = s
1334 .summarize(long_text(), None)
1335 .expect("test: headline summarize should succeed");
1336 assert!(matches!(
1337 result.style,
1338 SummaryStyle::Headline { max_chars: 80 }
1339 ));
1340 }
1341
1342 #[test]
1343 fn headline_is_nonempty() {
1344 let cfg = make_config(SummaryStyle::Headline { max_chars: 100 });
1345 let mut s = DocumentSummarizer::new(cfg);
1346 let result = s
1347 .summarize(long_text(), None)
1348 .expect("test: headline summarize should succeed");
1349 assert!(!result.sentences[0].is_empty());
1350 }
1351
1352 #[test]
1353 fn headline_with_embeddings() {
1354 let sents = split_sentences(long_text());
1355 let embs = make_embeddings(sents.len(), 8, 7);
1356 let cfg = SummarizerConfig {
1357 style: SummaryStyle::Headline { max_chars: 60 },
1358 use_embeddings: true,
1359 min_sentence_length: 1,
1360 ..SummarizerConfig::default()
1361 };
1362 let mut s = DocumentSummarizer::new(cfg);
1363 let result = s
1364 .summarize(long_text(), Some(embs))
1365 .expect("test: headline summarize with embeddings should succeed");
1366 assert!(result.sentences[0].len() <= 60);
1367 }
1368
1369 #[test]
1372 fn abstractive_respects_target_words() {
1373 let cfg = make_config(SummaryStyle::Abstractive { target_words: 20 });
1374 let mut s = DocumentSummarizer::new(cfg);
1375 let result = s
1376 .summarize(long_text(), None)
1377 .expect("test: abstractive summarize should succeed");
1378 assert_eq!(result.sentences.len(), 1);
1379 let word_count = result.sentences[0].split_whitespace().count();
1380 assert!(word_count <= 20);
1381 }
1382
1383 #[test]
1384 fn abstractive_style_recorded() {
1385 let cfg = make_config(SummaryStyle::Abstractive { target_words: 30 });
1386 let mut s = DocumentSummarizer::new(cfg);
1387 let result = s
1388 .summarize(long_text(), None)
1389 .expect("test: abstractive summarize should succeed");
1390 assert!(matches!(
1391 result.style,
1392 SummaryStyle::Abstractive { target_words: 30 }
1393 ));
1394 }
1395
1396 #[test]
1397 fn abstractive_output_nonempty() {
1398 let cfg = make_config(SummaryStyle::Abstractive { target_words: 50 });
1399 let mut s = DocumentSummarizer::new(cfg);
1400 let result = s
1401 .summarize(long_text(), None)
1402 .expect("test: abstractive summarize should succeed");
1403 assert!(!result.sentences[0].is_empty());
1404 }
1405
1406 #[test]
1409 fn hierarchical_levels_sentences() {
1410 let cfg = make_config(SummaryStyle::Hierarchical { levels: 3 });
1411 let mut s = DocumentSummarizer::new(cfg);
1412 let result = s
1413 .summarize(long_text(), None)
1414 .expect("test: hierarchical summarize should succeed");
1415 assert!(result.sentences.len() <= 3);
1416 assert!(!result.sentences.is_empty());
1417 }
1418
1419 #[test]
1420 fn hierarchical_style_recorded() {
1421 let cfg = make_config(SummaryStyle::Hierarchical { levels: 2 });
1422 let mut s = DocumentSummarizer::new(cfg);
1423 let result = s
1424 .summarize(long_text(), None)
1425 .expect("test: hierarchical summarize should succeed");
1426 assert!(matches!(
1427 result.style,
1428 SummaryStyle::Hierarchical { levels: 2 }
1429 ));
1430 }
1431
1432 #[test]
1433 fn hierarchical_with_embeddings() {
1434 let sents = split_sentences(long_text());
1435 let embs = make_embeddings(sents.len(), 16, 123);
1436 let cfg = SummarizerConfig {
1437 style: SummaryStyle::Hierarchical { levels: 4 },
1438 use_embeddings: true,
1439 min_sentence_length: 1,
1440 ..SummarizerConfig::default()
1441 };
1442 let mut s = DocumentSummarizer::new(cfg);
1443 let result = s
1444 .summarize(long_text(), Some(embs))
1445 .expect("test: hierarchical summarize with embeddings should succeed");
1446 assert!(!result.sentences.is_empty());
1447 assert!(result.sentences.len() <= 4);
1448 }
1449
1450 #[test]
1451 fn hierarchical_levels_zero_errors() {
1452 let cfg = make_config(SummaryStyle::Hierarchical { levels: 0 });
1453 let mut s = DocumentSummarizer::new(cfg);
1454 let err = s
1455 .summarize(long_text(), None)
1456 .expect_err("test: hierarchical with levels=0 should return ConfigurationError");
1457 assert!(matches!(err, SummarizerError::ConfigurationError(_)));
1458 }
1459
1460 #[test]
1463 fn score_sentence_returns_struct() {
1464 let s = default_summarizer();
1465 let corpus = vec![
1466 tokenize("hello world test sentence"),
1467 tokenize("another sentence here"),
1468 ];
1469 let score = s.score_sentence("hello world test sentence", 0, 5, &corpus);
1470 assert_eq!(score.index, 0);
1471 assert_eq!(score.sentence, "hello world test sentence");
1472 assert!(score.final_score >= 0.0);
1473 }
1474
1475 #[test]
1476 fn score_sentence_position_zero_is_higher() {
1477 let cfg = SummarizerConfig {
1478 position_bias: 1.0,
1479 ..SummarizerConfig::default()
1480 };
1481 let s = DocumentSummarizer::new(cfg);
1482 let corpus = vec![tokenize("test"); 5];
1483 let first = s.score_sentence("test first sentence", 0, 5, &corpus);
1484 let middle = s.score_sentence("test middle sentence", 2, 5, &corpus);
1485 assert!(first.position_score >= middle.position_score);
1488 }
1489
1490 #[test]
1491 fn score_sentence_empty_corpus() {
1492 let s = default_summarizer();
1493 let score = s.score_sentence("some sentence", 0, 1, &[]);
1494 assert_eq!(score.tf_idf_score, 0.0);
1495 }
1496
1497 #[test]
1498 fn score_sentence_length_score_range() {
1499 let s = default_summarizer();
1500 let corpus = vec![tokenize("hello world")];
1501 let score = s.score_sentence("hello world", 0, 1, &corpus);
1502 assert!((0.0..=1.0).contains(&score.length_score));
1503 }
1504
1505 #[test]
1508 fn extract_keyphrases_count_limit() {
1509 let s = default_summarizer();
1510 let phrases = s.extract_keyphrases(long_text(), 5);
1511 assert!(phrases.len() <= 5);
1512 }
1513
1514 #[test]
1515 fn extract_keyphrases_nonempty_on_rich_text() {
1516 let s = default_summarizer();
1517 let phrases = s.extract_keyphrases(long_text(), 10);
1518 assert!(!phrases.is_empty());
1519 }
1520
1521 #[test]
1522 fn extract_keyphrases_all_nonempty() {
1523 let s = default_summarizer();
1524 for phrase in s.extract_keyphrases(long_text(), 8) {
1525 assert!(!phrase.is_empty());
1526 }
1527 }
1528
1529 #[test]
1530 fn extract_keyphrases_zero_on_empty() {
1531 let s = default_summarizer();
1532 assert!(s.extract_keyphrases("", 5).is_empty());
1533 }
1534
1535 #[test]
1536 fn extract_keyphrases_n_zero_returns_empty() {
1537 let s = default_summarizer();
1538 assert!(s.extract_keyphrases(long_text(), 0).is_empty());
1539 }
1540
1541 #[test]
1544 fn chunk_document_covers_all_content() {
1545 let s = default_summarizer();
1546 let text = long_text();
1547 let chunks = s.chunk_document(text, 100);
1548 assert!(!chunks.is_empty());
1549 for (i, c) in chunks.iter().enumerate() {
1551 assert_eq!(c.chunk_index, i);
1552 }
1553 }
1554
1555 #[test]
1556 fn chunk_document_empty_text() {
1557 let s = default_summarizer();
1558 assert!(s.chunk_document("", 100).is_empty());
1559 }
1560
1561 #[test]
1562 fn chunk_document_zero_size() {
1563 let s = default_summarizer();
1564 assert!(s.chunk_document(long_text(), 0).is_empty());
1565 }
1566
1567 #[test]
1568 fn chunk_document_chunk_size_covers_full_text() {
1569 let s = default_summarizer();
1570 let text = "short text";
1571 let chunks = s.chunk_document(text, 1000);
1572 assert_eq!(chunks.len(), 1);
1573 assert_eq!(chunks[0].chunk_index, 0);
1574 }
1575
1576 #[test]
1577 fn chunk_document_embeddings_none_by_default() {
1578 let s = default_summarizer();
1579 let chunks = s.chunk_document(long_text(), 200);
1580 for c in &chunks {
1581 assert!(c.embedding.is_none());
1582 }
1583 }
1584
1585 #[test]
1586 fn chunk_document_uses_temp_dir_conceptually() {
1587 let tmp = temp_dir();
1589 assert!(tmp.exists());
1590 }
1591
1592 #[test]
1595 fn quality_score_identical_text_is_high() {
1596 let s = default_summarizer();
1597 let qs = s.quality_score(long_text(), long_text());
1598 assert!(
1599 qs > 0.5,
1600 "quality score of identical texts should be > 0.5, got {qs}"
1601 );
1602 }
1603
1604 #[test]
1605 fn quality_score_empty_summary_is_zero() {
1606 let s = default_summarizer();
1607 let qs = s.quality_score(long_text(), "");
1608 assert_eq!(qs, 0.0);
1609 }
1610
1611 #[test]
1612 fn quality_score_in_range() {
1613 let s = default_summarizer();
1614 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1615 let mut ds = DocumentSummarizer::new(cfg);
1616 let result = ds
1617 .summarize(long_text(), None)
1618 .expect("test: extractive summarize for quality_score test should succeed");
1619 let summary = result.sentences.join(" ");
1620 let qs = s.quality_score(long_text(), &summary);
1621 assert!((0.0..=1.0).contains(&qs));
1622 }
1623
1624 #[test]
1625 fn quality_score_empty_original_is_zero() {
1626 let s = default_summarizer();
1627 assert_eq!(s.quality_score("", "some summary"), 0.0);
1628 }
1629
1630 #[test]
1633 fn summary_result_original_length_correct() {
1634 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 2 });
1635 let mut s = DocumentSummarizer::new(cfg);
1636 let text = long_text();
1637 let result = s
1638 .summarize(text, None)
1639 .expect("test: extractive summarize should succeed");
1640 assert_eq!(result.original_length, text.len());
1641 }
1642
1643 #[test]
1644 fn summary_result_compression_ratio_formula() {
1645 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 2 });
1646 let mut s = DocumentSummarizer::new(cfg);
1647 let text = long_text();
1648 let result = s
1649 .summarize(text, None)
1650 .expect("test: extractive summarize should succeed");
1651 let expected = result.summary_length as f64 / result.original_length as f64;
1652 assert!((result.compression_ratio - expected).abs() < 1e-9);
1653 }
1654
1655 #[test]
1656 fn summary_result_keyphrases_nonempty() {
1657 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1658 let mut s = DocumentSummarizer::new(cfg);
1659 let result = s
1660 .summarize(long_text(), None)
1661 .expect("test: extractive summarize should succeed");
1662 assert!(!result.keyphrases.is_empty());
1663 }
1664
1665 #[test]
1668 fn embedding_centrality_single_emb_returns_zero() {
1669 let embs = vec![vec![1.0, 0.0]];
1670 assert_eq!(embedding_centrality_score(0, &embs), 0.0);
1671 }
1672
1673 #[test]
1674 fn embedding_centrality_identical_embs() {
1675 let embs = vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]];
1676 let score = embedding_centrality_score(0, &embs);
1677 assert!((score - 1.0).abs() < 1e-9);
1678 }
1679
1680 #[test]
1681 fn embedding_centrality_affects_score() {
1682 let cfg = SummarizerConfig {
1685 style: SummaryStyle::Extractive { num_sentences: 1 },
1686 use_embeddings: true,
1687 min_sentence_length: 1,
1688 ..SummarizerConfig::default()
1689 };
1690 let mut s = DocumentSummarizer::new(cfg);
1691 let text =
1693 "Machine learning enables computers to learn patterns from data automatically.\n\n\
1694 Natural language processing is a field of artificial intelligence research.";
1695 let embs = vec![vec![1.0_f64, 0.0], vec![1.0_f64, 0.0]];
1696 let result = s
1697 .summarize(text, Some(embs))
1698 .expect("test: extractive summarize with central embeddings should succeed");
1699 assert_eq!(result.sentences.len(), 1);
1700 }
1701
1702 #[test]
1705 fn stats_initial_default() {
1706 let s = default_summarizer();
1707 let st = s.stats();
1708 assert_eq!(st.documents_processed, 0);
1709 assert_eq!(st.total_tokens_processed, 0);
1710 }
1711
1712 #[test]
1713 fn stats_increments_after_summarize() {
1714 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 2 });
1715 let mut s = DocumentSummarizer::new(cfg);
1716 s.summarize(long_text(), None)
1717 .expect("test: summarize for stats increment should succeed");
1718 assert_eq!(s.stats().documents_processed, 1);
1719 assert!(s.stats().total_tokens_processed > 0);
1720 }
1721
1722 #[test]
1723 fn stats_compression_ratio_running_avg() {
1724 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1725 let mut s = DocumentSummarizer::new(cfg);
1726 s.summarize(long_text(), None)
1727 .expect("test: first summarize for running avg should succeed");
1728 s.summarize(long_text(), None)
1729 .expect("test: second summarize for running avg should succeed");
1730 let st = s.stats();
1731 assert_eq!(st.documents_processed, 2);
1732 assert!((0.0..=1.0).contains(&st.avg_compression_ratio));
1733 }
1734
1735 #[test]
1736 fn stats_quality_score_running_avg() {
1737 let cfg = make_config(SummaryStyle::Extractive { num_sentences: 3 });
1738 let mut s = DocumentSummarizer::new(cfg);
1739 s.summarize(long_text(), None)
1740 .expect("test: summarize for quality score avg should succeed");
1741 assert!((0.0..=1.0).contains(&s.stats().avg_quality_score));
1742 }
1743
1744 #[test]
1747 fn config_default_style_is_extractive_3() {
1748 let cfg = SummarizerConfig::default();
1749 assert!(matches!(
1750 cfg.style,
1751 SummaryStyle::Extractive { num_sentences: 3 }
1752 ));
1753 }
1754
1755 #[test]
1756 fn config_custom_stop_words() {
1757 let cfg = SummarizerConfig {
1758 stop_words: vec!["machine".to_string(), "learning".to_string()],
1759 style: SummaryStyle::Keyphrase { num_phrases: 5 },
1760 ..SummarizerConfig::default()
1761 };
1762 let s = DocumentSummarizer::new(cfg);
1763 let phrases = s.extract_keyphrases(long_text(), 5);
1764 for phrase in &phrases {
1766 let words: Vec<&str> = phrase.split_whitespace().collect();
1767 if let Some(first) = words.first() {
1768 assert_ne!(*first, "machine");
1769 assert_ne!(*first, "learning");
1770 }
1771 }
1772 }
1773
1774 #[test]
1777 fn document_chunk_fields_accessible() {
1778 let chunk = DocumentChunk {
1779 text: "sample text".to_string(),
1780 embedding: Some(vec![1.0, 2.0]),
1781 section_title: Some("Introduction".to_string()),
1782 chunk_index: 0,
1783 };
1784 assert_eq!(chunk.text, "sample text");
1785 assert_eq!(chunk.chunk_index, 0);
1786 assert!(chunk.embedding.is_some());
1787 assert!(chunk.section_title.is_some());
1788 }
1789
1790 #[test]
1791 fn document_chunk_no_embedding_no_title() {
1792 let chunk = DocumentChunk {
1793 text: "plain text".to_string(),
1794 embedding: None,
1795 section_title: None,
1796 chunk_index: 5,
1797 };
1798 assert!(chunk.embedding.is_none());
1799 assert!(chunk.section_title.is_none());
1800 assert_eq!(chunk.chunk_index, 5);
1801 }
1802
1803 #[test]
1806 fn single_sentence_document_extractive() {
1807 let cfg = SummarizerConfig {
1808 style: SummaryStyle::Extractive { num_sentences: 3 },
1809 min_sentence_length: 1,
1810 ..SummarizerConfig::default()
1811 };
1812 let mut s = DocumentSummarizer::new(cfg);
1813 let result = s
1814 .summarize("A single sentence document.", None)
1815 .expect("test: single-sentence extractive summarize should succeed");
1816 assert_eq!(result.sentences.len(), 1);
1817 }
1818
1819 #[test]
1820 fn headline_large_max_chars_returns_full_best_sentence() {
1821 let cfg = make_config(SummaryStyle::Headline { max_chars: 10000 });
1822 let mut s = DocumentSummarizer::new(cfg);
1823 let result = s
1824 .summarize(long_text(), None)
1825 .expect("test: headline with large max_chars should succeed");
1826 assert_eq!(result.sentences.len(), 1);
1827 assert!(!result.sentences[0].is_empty());
1828 }
1829
1830 #[test]
1831 fn abstractive_unlimited_words_returns_all_top3() {
1832 let cfg = make_config(SummaryStyle::Abstractive {
1833 target_words: 10000,
1834 });
1835 let mut s = DocumentSummarizer::new(cfg);
1836 let result = s
1837 .summarize(long_text(), None)
1838 .expect("test: abstractive with unlimited words should succeed");
1839 assert!(!result.sentences[0].is_empty());
1840 }
1841
1842 #[test]
1843 fn summarize_multiple_styles_sequential() {
1844 let text = long_text();
1845 let styles = vec![
1846 SummaryStyle::Extractive { num_sentences: 2 },
1847 SummaryStyle::Keyphrase { num_phrases: 4 },
1848 SummaryStyle::Headline { max_chars: 60 },
1849 SummaryStyle::Abstractive { target_words: 25 },
1850 SummaryStyle::Hierarchical { levels: 3 },
1851 ];
1852 for style in styles {
1853 let cfg = make_config(style);
1854 let mut s = DocumentSummarizer::new(cfg);
1855 let result = s
1856 .summarize(text, None)
1857 .expect("test: each summarization style should succeed on long_text");
1858 assert!(!result.sentences.is_empty());
1859 }
1860 }
1861}