Skip to main content

ipfrs_semantic/document_summarizer/
mod.rs

1//! Full-featured extractive and abstractive-style document summarization.
2//!
3//! [`DocumentSummarizer`] implements five summarization strategies driven by TF-IDF,
4//! position bias, sentence length heuristics, and optional embedding centrality:
5//!
6//! * **Extractive** — score every sentence and return the top-k in original order.
7//! * **Keyphrase** — extract the most significant 2–4-word n-gram keyphrases.
8//! * **Headline** — return the single most important sentence, truncated.
9//! * **Abstractive** — concatenate top-3 sentences with transition words stripped,
10//!   trimmed to a target word count.
11//! * **Hierarchical** — cluster sentences and pick one representative per cluster.
12
13pub mod ds_types;
14pub use ds_types::{
15    DocumentChunk, SentenceScore, SummarizerConfig, SummarizerError, SummarizerStats,
16    SummaryResult, SummaryStyle,
17};
18
19use std::collections::HashMap;
20
21// ── xorshift PRNG (tests) ─────────────────────────────────────────────────────
22
23/// Minimal xorshift64 PRNG; used in tests to avoid the `rand` crate.
24#[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
34// ── Helpers ───────────────────────────────────────────────────────────────────
35
36pub(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
53/// Transition phrases removed during abstractive summarization.
54const 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
84/// Tokenize `text` into lowercase alphanumeric tokens.
85pub 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
92/// Split `text` into sentences on `'. '`, `'! '`, `'? '`, and `'\n\n'` boundaries.
93pub 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        // Double newline paragraph break.
105        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            // Skip additional newlines.
112            while i + 1 < len && chars[i + 1] == '\n' {
113                i += 1;
114            }
115            i += 1;
116            continue;
117        }
118
119        // Sentence-ending punctuation followed by a space or end-of-string.
120        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                // Skip trailing space.
129                if i + 1 < len && chars[i + 1] == ' ' {
130                    i += 1;
131                }
132            }
133        }
134
135        i += 1;
136    }
137
138    // Flush any remaining text.
139    let remainder = current.trim().to_string();
140    if !remainder.is_empty() {
141        sentences.push(remainder);
142    }
143
144    sentences
145}
146
147/// Compute TF-IDF for `term` given the tokens of its document and the full corpus.
148pub 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
162/// Cosine similarity between two f64 slices; returns 0.0 on dimension mismatch or zero norm.
163pub 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
176/// Compute mean cosine similarity of embedding `i` to all other embeddings.
177fn 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
190/// Position score: sentences at the start and end of the document score higher.
191fn 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; // 0.0 … 1.0
199                                                 // U-shaped: 1 at edges, cos²(π·rel/2) falls then rises — use parabola-like:
200                                                 // score = 1 - 4*(rel - 0.5)^2 gives 0 at edges, 1 at centre; invert:
201    let centrality = 4.0 * (rel - 0.5).powi(2); // 1 at edges, 0 at centre
202    centrality * position_bias
203}
204
205/// Length score: prefer sentences of "ideal" length (~100–200 chars).
206fn length_score(sentence: &str) -> f64 {
207    let len = sentence.len() as f64;
208    if len <= 0.0 {
209        return 0.0;
210    }
211    // Gaussian-ish peak around 150 chars.
212    let ideal = 150.0_f64;
213    let sigma = 80.0_f64;
214    (-(len - ideal).powi(2) / (2.0 * sigma.powi(2))).exp()
215}
216
217/// Remove leading transition words from a sentence.
218fn 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; // +1 for the delimiter
224                let stripped = sentence[skip..].trim_start_matches([',', ' ', ';']);
225                if !stripped.is_empty() {
226                    // Safety: `stripped` is a subslice of `sentence`
227                    let offset = stripped.as_ptr() as usize - sentence.as_ptr() as usize;
228                    return &sentence[offset..];
229                }
230            }
231        }
232    }
233    sentence
234}
235
236// ── DocumentSummarizer ────────────────────────────────────────────────────────
237
238/// Production-quality document summarizer supporting five summarization strategies.
239pub struct DocumentSummarizer {
240    config: SummarizerConfig,
241    stats: SummarizerStats,
242}
243
244impl DocumentSummarizer {
245    /// Create a new summarizer with the supplied configuration.
246    pub fn new(config: SummarizerConfig) -> Self {
247        Self {
248            config,
249            stats: SummarizerStats::default(),
250        }
251    }
252
253    /// Create a summarizer with a default extractive (3-sentence) configuration.
254    pub fn with_defaults() -> Self {
255        Self::new(SummarizerConfig::default())
256    }
257
258    /// Return a reference to the accumulated statistics.
259    pub fn stats(&self) -> &SummarizerStats {
260        &self.stats
261    }
262
263    // ── Public API ────────────────────────────────────────────────────────────
264
265    /// Summarize `text` using the configured strategy.
266    ///
267    /// `embeddings`, when provided, must contain one vector per sentence in document
268    /// order and must all share the same dimension.
269    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        // Validate embeddings dimensions.
279        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        // Filter by configured length bounds.
298        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        // Align embeddings to filtered sentences if provided.
308        // We keep a mapping: filtered_index → original_index for embedding look-up.
309        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        // Update stats (incremental mean).
361        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    /// Score a single sentence.
373    ///
374    /// `corpus` holds the tokenized form of every sentence in the document (used for IDF).
375    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        // TF-IDF: average over non-stop content terms.
386        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        // Embedding centrality is computed externally and injected via summarize_extractive.
405        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    /// Extract the top-`n` n-gram keyphrases from `text`.
419    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        // Build 2-4 word n-gram candidates from token windows that start and end on
424        // non-stop words.
425        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                // Skip if first or last token is a stop word or very short.
433                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        // Score each phrase: count * avg_tfidf of its tokens.
446        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        // De-duplicate by prefix/suffix containment.
469        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    /// Split `text` into overlapping chunks of approximately `chunk_size` characters.
483    ///
484    /// Chunks overlap by 10% of `chunk_size` to preserve context across boundaries.
485    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        // Detect section titles (lines ending with ':' or short ALL-CAPS lines).
501        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    /// Compute a quality score in \[0, 1\] as the fraction of `original`'s top keyphrases
536    /// that appear (as substrings) in `summary`.
537    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    // ── Private strategy implementations ─────────────────────────────────────
551
552    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                // Inject embedding centrality when available.
567                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                            // Recompute final score with centrality contribution.
573                            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        // Sort descending by final_score, break ties by original index (ascending).
602        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        // Restore original document order.
612        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        // Truncate cleanly at a word boundary.
689        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        // Sort descending, take top 3, then restore original order.
726        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        // Strip transition words from each sentence.
735        let cleaned: Vec<String> = scores
736            .iter()
737            .map(|s| strip_transitions(&s.sentence).to_string())
738            .collect();
739
740        // Concatenate and trim to target_words.
741        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
822// ── Private utilities ─────────────────────────────────────────────────────────
823
824/// Build a map from character position to section title for the given text.
825fn 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; // +1 for '\n'
836    }
837    map
838}
839
840/// Truncate `text` to at most `max_chars` characters at a word boundary.
841fn 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    // Walk back to find the last space.
847    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
856/// k-means-lite clustering: assign each sentence to the nearest centroid (by cosine),
857/// then pick the sentence with the highest `final_score` in each cluster as representative.
858fn 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    // Seed centroids: evenly spaced sentence indices.
871    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        // Assign.
880        let mut changed = false;
881        for (i, emb) in embeddings.iter().enumerate() {
882            let best = (0..k)
883                .map(|c| (c, cosine_similarity(emb, &centroids[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        // Update centroids.
896        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    // Pick best sentence per cluster (highest final_score).
920    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    // Return in original document order.
934    result.sort_by_key(|(idx, _)| *idx);
935    result.into_iter().map(|(_, s)| s).collect()
936}
937
938/// Positional clustering fallback: divide sentences into k equal-sized buckets
939/// and pick the highest-scoring sentence from each bucket.
940fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use std::env::temp_dir;
980
981    // ── Helpers ───────────────────────────────────────────────────────────────
982
983    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    // ── xorshift64 ────────────────────────────────────────────────────────────
1022
1023    #[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    // ── tokenize ──────────────────────────────────────────────────────────────
1040
1041    #[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    // ── split_sentences ───────────────────────────────────────────────────────
1067
1068    #[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    // ── tf_idf ────────────────────────────────────────────────────────────────
1092
1093    #[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        // "great" appears in only one doc; "machine" in both → great should have higher idf.
1111        assert!(score_rare > score_common);
1112    }
1113
1114    // ── cosine_similarity ─────────────────────────────────────────────────────
1115
1116    #[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    // ── SummarizerError ───────────────────────────────────────────────────────
1145
1146    #[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]]; // dim mismatch
1176        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    // ── SummaryStyle::Extractive ──────────────────────────────────────────────
1215
1216    #[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    // ── SummaryStyle::Keyphrase ───────────────────────────────────────────────
1280
1281    #[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    // ── SummaryStyle::Headline ────────────────────────────────────────────────
1317
1318    #[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    // ── SummaryStyle::Abstractive ─────────────────────────────────────────────
1370
1371    #[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    // ── SummaryStyle::Hierarchical ────────────────────────────────────────────
1407
1408    #[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    // ── score_sentence ────────────────────────────────────────────────────────
1461
1462    #[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        // position_bias 1.0: both edges score high, middle scores low.
1486        // first.position_score should be >= middle.position_score
1487        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    // ── extract_keyphrases ────────────────────────────────────────────────────
1506
1507    #[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    // ── chunk_document ────────────────────────────────────────────────────────
1542
1543    #[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        // All chunk indices are assigned.
1550        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        // Verify temp_dir is accessible (represents test isolation policy).
1588        let tmp = temp_dir();
1589        assert!(tmp.exists());
1590    }
1591
1592    // ── quality_score ─────────────────────────────────────────────────────────
1593
1594    #[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    // ── SummaryResult fields ──────────────────────────────────────────────────
1631
1632    #[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    // ── embedding centrality ──────────────────────────────────────────────────
1666
1667    #[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        // A sentence whose embedding is very similar to others should get a higher
1683        // final_score when use_embeddings = true.
1684        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        // Two very different sentences with embeddings pointing in same direction.
1692        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    // ── SummarizerStats ───────────────────────────────────────────────────────
1703
1704    #[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    // ── SummarizerConfig ──────────────────────────────────────────────────────
1745
1746    #[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        // Neither "machine" nor "learning" should start or end a keyphrase.
1765        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    // ── DocumentChunk ─────────────────────────────────────────────────────────
1775
1776    #[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    // ── Miscellaneous / edge cases ────────────────────────────────────────────
1804
1805    #[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}