Skip to main content

context_forge/analysis/
extraction.rs

1use std::collections::HashSet;
2use std::hash::{Hash, Hasher};
3
4const ABBREVIATIONS: &[&str] = &[
5    "e.g.", "i.e.", "etc.", "vs.", "cf.", "approx.", "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.",
6    "Jr.", "Fig.", "Eq.", "Vol.", "No.",
7];
8
9/// Configuration for passage extraction around high-recurrence terms.
10#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct ExtractionConfig {
13    /// Number of surrounding sentences to include on each side.
14    pub context_window: usize,
15    /// Whether to deduplicate extracted passages by content hash.
16    pub dedup_enabled: bool,
17    /// Maximum number of sentences allowed in a single extracted passage.
18    ///
19    /// If a merged range exceeds this value, it is split into sentence-boundary
20    /// chunks of at most this size. A value of `0` is treated as a per-sentence
21    /// cap (each sentence becomes its own chunk).
22    ///
23    /// After splitting, each chunk is re-evaluated and chunks that do not
24    /// contain any matching high-recurrence term are discarded. As a result,
25    /// when `context_window` is larger than `max_passage_sentences`, some
26    /// context-only sentences may be dropped and the effective surrounding
27    /// context in the returned passages can be smaller than the original
28    /// merged window.
29    pub max_passage_sentences: usize,
30}
31
32impl Default for ExtractionConfig {
33    fn default() -> Self {
34        Self {
35            context_window: 1,
36            dedup_enabled: true,
37            max_passage_sentences: 6,
38        }
39    }
40}
41
42/// Input entry with pre-filtered content.
43#[derive(Debug, Clone)]
44pub struct ExtractionEntry {
45    /// Source entry identifier.
46    pub entry_id: String,
47    /// Pre-filtered text content for extraction.
48    pub content: String,
49}
50
51/// One extracted passage and its metadata.
52#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct ExtractedPassage {
55    /// Extracted passage text.
56    pub text: String,
57    /// Entry ID this passage came from.
58    pub source_entry_id: String,
59    /// High-recurrence terms present in this passage.
60    ///
61    /// For passages produced by splitting an over-long merged range, terms are
62    /// re-evaluated: only terms whose lowercased form appears in the chunk's
63    /// sentences are included, not all terms from the original merged window.
64    pub triggering_terms: Vec<String>,
65    /// Content hash for within-session deduplication only.
66    /// Computed with [`std::collections::hash_map::DefaultHasher`],
67    /// which is NOT stable across Rust versions. Do not persist.
68    pub content_hash: String,
69}
70
71#[derive(Debug, Clone)]
72struct WindowRange {
73    start: usize,
74    end: usize,
75    terms: Vec<String>,
76}
77
78/// Extract passages by finding sentence windows around high-recurrence term matches.
79#[must_use]
80#[allow(
81    clippy::implicit_hasher,
82    reason = "HashSet with default hasher is acceptable for deterministic local dedup"
83)]
84pub fn extract_passages(
85    entries: &[ExtractionEntry],
86    high_recurrence_terms: &[String],
87    config: &ExtractionConfig,
88) -> Vec<ExtractedPassage> {
89    if entries.is_empty() || high_recurrence_terms.is_empty() {
90        return Vec::new();
91    }
92
93    let normalized_terms: Vec<(String, String)> = high_recurrence_terms
94        .iter()
95        .cloned()
96        .map(|term| {
97            let lower = term.to_lowercase();
98            (term, lower)
99        })
100        .collect();
101
102    let mut passages: Vec<ExtractedPassage> = Vec::new();
103
104    for entry in entries {
105        let sentences = split_into_sentences(&entry.content);
106        if sentences.is_empty() {
107            continue;
108        }
109
110        let merged_ranges =
111            collect_merged_ranges(&sentences, &normalized_terms, config.context_window);
112        let ranges = split_ranges_by_max_passage_sentences(
113            &sentences,
114            &normalized_terms,
115            merged_ranges,
116            config.max_passage_sentences,
117        );
118        for range in ranges {
119            let passage_text = sentences[range.start..=range.end].join("\n");
120            let trimmed_text = passage_text.trim().to_string();
121            if trimmed_text.is_empty() {
122                continue;
123            }
124
125            let content_hash = hash_text(&trimmed_text);
126            passages.push(ExtractedPassage {
127                text: trimmed_text,
128                source_entry_id: entry.entry_id.clone(),
129                triggering_terms: range.terms,
130                content_hash,
131            });
132        }
133    }
134
135    if config.dedup_enabled {
136        let mut seen_hashes: HashSet<String> = HashSet::new();
137        passages.retain(|passage| seen_hashes.insert(passage.content_hash.clone()));
138    }
139
140    passages
141}
142
143fn split_into_sentences(content: &str) -> Vec<String> {
144    let mut sentences = Vec::new();
145
146    for line in content.split('\n') {
147        let trimmed_line = line.trim();
148        if trimmed_line.is_empty() {
149            continue;
150        }
151
152        sentences.extend(split_line_into_sentences(trimmed_line));
153    }
154
155    sentences
156}
157
158fn split_line_into_sentences(line: &str) -> Vec<String> {
159    let mut sentences = Vec::new();
160    let chars: Vec<(usize, char)> = line.char_indices().collect();
161    let mut start = 0_usize;
162
163    for (index, (byte_index, ch)) in chars.iter().copied().enumerate() {
164        if !matches!(ch, '.' | '!' | '?') {
165            continue;
166        }
167
168        let prev_is_dot = index > 0 && chars[index - 1].1 == '.';
169        if ch == '.' && prev_is_dot {
170            continue;
171        }
172
173        let punctuation_end = byte_index + ch.len_utf8();
174        let is_end_of_line = index + 1 == chars.len();
175
176        if ch == '.' && ends_with_known_abbreviation(&line[..punctuation_end]) {
177            continue;
178        }
179
180        let should_split = is_end_of_line || {
181            let mut lookahead = index + 1;
182            let mut saw_whitespace = false;
183
184            while lookahead < chars.len() && chars[lookahead].1.is_whitespace() {
185                saw_whitespace = true;
186                lookahead += 1;
187            }
188
189            saw_whitespace && lookahead < chars.len() && chars[lookahead].1.is_uppercase()
190        };
191
192        if should_split {
193            let sentence = line[start..punctuation_end].trim();
194            if !sentence.is_empty() {
195                sentences.push(sentence.to_string());
196            }
197            start = punctuation_end;
198        }
199    }
200
201    if start < line.len() {
202        let tail = line[start..].trim();
203        if !tail.is_empty() {
204            sentences.push(tail.to_string());
205        }
206    }
207
208    sentences
209}
210
211fn ends_with_known_abbreviation(text: &str) -> bool {
212    let text_lower = text.to_lowercase();
213    ABBREVIATIONS
214        .iter()
215        .any(|abbreviation| text_lower.ends_with(&abbreviation.to_lowercase()))
216}
217
218/// Collect sentence windows around term matches and merge overlapping or adjacent ranges.
219///
220/// Adjacent windows (those separated by a single sentence gap) are also merged
221/// to avoid producing back-to-back passages with a one-sentence gap between them.
222/// This means the effective context may be slightly wider than `context_window`
223/// when matches are close together.
224///
225/// Term matching is substring-based: the term `"context forge"` will also match
226/// `"context forged"`. Word-boundary matching is not implemented.
227fn collect_merged_ranges(
228    sentences: &[String],
229    normalized_terms: &[(String, String)],
230    context_window: usize,
231) -> Vec<WindowRange> {
232    let mut raw_ranges: Vec<WindowRange> = Vec::new();
233
234    for (sentence_index, sentence) in sentences.iter().enumerate() {
235        let sentence_lower = sentence.to_lowercase();
236        let mut matched_terms: Vec<String> = normalized_terms
237            .iter()
238            .filter_map(|(original, lower)| {
239                if sentence_lower.contains(lower) {
240                    Some(original.clone())
241                } else {
242                    None
243                }
244            })
245            .collect();
246
247        if matched_terms.is_empty() {
248            continue;
249        }
250
251        matched_terms.sort();
252        matched_terms.dedup();
253
254        let start = sentence_index.saturating_sub(context_window);
255        let end = usize::min(
256            sentences.len().saturating_sub(1),
257            sentence_index + context_window,
258        );
259        raw_ranges.push(WindowRange {
260            start,
261            end,
262            terms: matched_terms,
263        });
264    }
265
266    if raw_ranges.is_empty() {
267        return raw_ranges;
268    }
269
270    raw_ranges.sort_by(|left, right| {
271        left.start
272            .cmp(&right.start)
273            .then_with(|| left.end.cmp(&right.end))
274    });
275
276    let mut merged: Vec<WindowRange> = Vec::new();
277    for range in raw_ranges {
278        if let Some(last) = merged.last_mut() {
279            if range.start <= last.end + 1 {
280                last.end = usize::max(last.end, range.end);
281                for term in range.terms {
282                    if !last.terms.contains(&term) {
283                        last.terms.push(term);
284                    }
285                }
286                last.terms.sort();
287                continue;
288            }
289        }
290
291        merged.push(range);
292    }
293
294    merged
295}
296
297fn split_ranges_by_max_passage_sentences(
298    sentences: &[String],
299    normalized_terms: &[(String, String)],
300    ranges: Vec<WindowRange>,
301    max_passage_sentences: usize,
302) -> Vec<WindowRange> {
303    if ranges.is_empty() {
304        return ranges;
305    }
306
307    let chunk_size = max_passage_sentences.max(1);
308
309    let mut split_ranges: Vec<WindowRange> = Vec::new();
310
311    for range in ranges {
312        let span_len = range.end.saturating_sub(range.start) + 1;
313        if span_len <= chunk_size {
314            split_ranges.push(range);
315            continue;
316        }
317
318        let mut chunk_start = range.start;
319        while chunk_start <= range.end {
320            let chunk_end = usize::min(
321                range.end,
322                chunk_start.saturating_add(chunk_size).saturating_sub(1),
323            );
324
325            let terms = collect_terms_in_range(sentences, normalized_terms, chunk_start, chunk_end);
326            if !terms.is_empty() {
327                split_ranges.push(WindowRange {
328                    start: chunk_start,
329                    end: chunk_end,
330                    terms,
331                });
332            }
333
334            chunk_start = chunk_end.saturating_add(1);
335        }
336    }
337
338    split_ranges
339}
340
341fn collect_terms_in_range(
342    sentences: &[String],
343    normalized_terms: &[(String, String)],
344    start: usize,
345    end: usize,
346) -> Vec<String> {
347    debug_assert!(
348        start < sentences.len(),
349        "chunk_start out of bounds: {start} >= {}",
350        sentences.len()
351    );
352    debug_assert!(
353        end < sentences.len(),
354        "chunk_end out of bounds: {end} >= {}",
355        sentences.len()
356    );
357    debug_assert!(
358        start <= end,
359        "invalid chunk range: start ({start}) > end ({end})"
360    );
361
362    // Pre-compute lowercased sentences once, then filter terms against the slice.
363    let lower_sentences: Vec<String> = sentences[start..=end]
364        .iter()
365        .map(|sentence| sentence.to_lowercase())
366        .collect();
367
368    let mut matched_terms: Vec<String> = normalized_terms
369        .iter()
370        .filter_map(|(original, lower)| {
371            let has_match = lower_sentences
372                .iter()
373                .any(|sentence| sentence.contains(lower));
374            if has_match {
375                Some(original.clone())
376            } else {
377                None
378            }
379        })
380        .collect();
381
382    matched_terms.sort();
383    matched_terms.dedup();
384    matched_terms
385}
386
387fn hash_text(text: &str) -> String {
388    let mut hasher = std::collections::hash_map::DefaultHasher::new();
389    text.hash(&mut hasher);
390    format!("{:016x}", hasher.finish())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::{extract_passages, split_into_sentences, ExtractionConfig, ExtractionEntry};
396
397    fn single_entry(content: &str) -> Vec<ExtractionEntry> {
398        vec![ExtractionEntry {
399            entry_id: "entry-1".to_string(),
400            content: content.to_string(),
401        }]
402    }
403
404    fn default_terms(term: &str) -> Vec<String> {
405        vec![term.to_string()]
406    }
407
408    #[test]
409    fn extract_empty_content() {
410        let entries = single_entry("");
411        let config = ExtractionConfig::default();
412
413        let passages = extract_passages(&entries, &default_terms("context"), &config);
414        assert!(passages.is_empty());
415    }
416
417    #[test]
418    fn extract_no_matching_terms() {
419        let entries = single_entry("Alpha sentence. Beta sentence.");
420        let config = ExtractionConfig::default();
421
422        let passages = extract_passages(&entries, &default_terms("context"), &config);
423        assert!(passages.is_empty());
424    }
425
426    #[test]
427    fn extract_single_sentence_entry() {
428        let entries = single_entry("Context appears here.");
429        let config = ExtractionConfig::default();
430
431        let passages = extract_passages(&entries, &default_terms("context"), &config);
432        assert_eq!(passages.len(), 1);
433        assert_eq!(passages[0].text, "Context appears here.");
434    }
435
436    #[test]
437    fn extract_term_at_start() {
438        let entries = single_entry("Context starts here. Second sentence. Third sentence.");
439        let config = ExtractionConfig::default();
440
441        let passages = extract_passages(&entries, &default_terms("context"), &config);
442        assert_eq!(passages.len(), 1);
443        assert_eq!(passages[0].text, "Context starts here.\nSecond sentence.");
444    }
445
446    #[test]
447    fn extract_term_at_end() {
448        let entries = single_entry("First sentence. Context ends here.");
449        let config = ExtractionConfig::default();
450
451        let passages = extract_passages(&entries, &default_terms("context"), &config);
452        assert_eq!(passages.len(), 1);
453        assert_eq!(passages[0].text, "First sentence.\nContext ends here.");
454    }
455
456    #[test]
457    fn extract_window_zero() {
458        let entries = single_entry("First sentence. Context middle. Last sentence.");
459        let config = ExtractionConfig {
460            context_window: 0,
461            dedup_enabled: true,
462            max_passage_sentences: 6,
463        };
464
465        let passages = extract_passages(&entries, &default_terms("context"), &config);
466        assert_eq!(passages.len(), 1);
467        assert_eq!(passages[0].text, "Context middle.");
468    }
469
470    #[test]
471    fn extract_overlapping_windows_merge() {
472        let entries = single_entry("One. Alpha context. Three. Beta context. Five. Six.");
473        let terms = vec!["alpha".to_string(), "beta".to_string()];
474        let config = ExtractionConfig::default();
475
476        let passages = extract_passages(&entries, &terms, &config);
477        assert_eq!(passages.len(), 1);
478        assert_eq!(
479            passages[0].text,
480            "One.\nAlpha context.\nThree.\nBeta context.\nFive."
481        );
482    }
483
484    #[test]
485    fn extract_dedup_across_entries() {
486        let entries = vec![
487            ExtractionEntry {
488                entry_id: "entry-1".to_string(),
489                content: "Context is stable.".to_string(),
490            },
491            ExtractionEntry {
492                entry_id: "entry-2".to_string(),
493                content: "Context is stable.".to_string(),
494            },
495        ];
496        let config = ExtractionConfig::default();
497
498        let passages = extract_passages(&entries, &default_terms("context"), &config);
499        assert_eq!(passages.len(), 1);
500        assert_eq!(passages[0].source_entry_id, "entry-1");
501    }
502
503    #[test]
504    fn extract_multiple_terms_one_sentence() {
505        let entries = single_entry("Context forge appears in one sentence.");
506        let terms = vec!["context".to_string(), "forge".to_string()];
507        let config = ExtractionConfig {
508            context_window: 0,
509            dedup_enabled: true,
510            max_passage_sentences: 6,
511        };
512
513        let passages = extract_passages(&entries, &terms, &config);
514        assert_eq!(passages.len(), 1);
515        assert_eq!(
516            passages[0].triggering_terms,
517            vec!["context".to_string(), "forge".to_string()]
518        );
519    }
520
521    #[test]
522    fn extract_newline_separated_messages() {
523        let sentences = split_into_sentences("line one\nline two\nline three");
524        assert_eq!(sentences.len(), 3);
525    }
526
527    #[test]
528    fn extract_punctuation_within_line() {
529        let sentences = split_into_sentences("First sentence. Second sentence. Third.");
530        assert_eq!(sentences.len(), 3);
531    }
532
533    #[test]
534    fn split_exclamation_mark_terminates_sentence() {
535        let sentences = split_into_sentences("It works! Context is next.");
536        assert_eq!(sentences.len(), 2);
537        assert_eq!(sentences[0], "It works!");
538        assert_eq!(sentences[1], "Context is next.");
539    }
540
541    #[test]
542    fn split_question_mark_terminates_sentence() {
543        let sentences = split_into_sentences("Is this correct? Context says yes.");
544        assert_eq!(sentences.len(), 2);
545        assert_eq!(sentences[0], "Is this correct?");
546        assert_eq!(sentences[1], "Context says yes.");
547    }
548
549    #[test]
550    fn split_abbreviation_not_split() {
551        let sentences = split_into_sentences("Use e.g. This method works.");
552        assert_eq!(sentences.len(), 1);
553    }
554
555    #[test]
556    fn split_title_abbreviation_not_split() {
557        let sentences = split_into_sentences("See Dr. Smith for details.");
558        assert_eq!(sentences.len(), 1);
559    }
560
561    #[test]
562    fn split_ellipsis_not_split() {
563        let sentences = split_into_sentences("Well... Maybe next time.");
564        assert_eq!(sentences.len(), 1);
565    }
566
567    #[test]
568    fn extract_mixed_boundaries() {
569        let sentences = split_into_sentences("Line with two sentences. Second here.\nNew line.");
570        assert_eq!(sentences.len(), 3);
571    }
572
573    #[test]
574    fn extract_empty_lines_are_boundaries() {
575        let sentences = split_into_sentences("Sentence one.\n\nSentence two.");
576        assert_eq!(sentences.len(), 2);
577    }
578
579    #[test]
580    fn extract_ngram_term_matching() {
581        let entries = single_entry("We are validating context forge behavior.");
582        let terms = vec!["context forge".to_string()];
583        let config = ExtractionConfig {
584            context_window: 0,
585            dedup_enabled: true,
586            max_passage_sentences: 6,
587        };
588
589        let passages = extract_passages(&entries, &terms, &config);
590        assert_eq!(passages.len(), 1);
591    }
592
593    #[test]
594    fn extract_substring_term_matches_derived_words() {
595        let entries = single_entry("The code was context forged overnight.");
596        let terms = vec!["context forge".to_string()];
597        let config = ExtractionConfig {
598            context_window: 0,
599            dedup_enabled: true,
600            max_passage_sentences: 6,
601        };
602
603        let passages = extract_passages(&entries, &terms, &config);
604        assert_eq!(
605            passages.len(),
606            1,
607            "substring match should find derived words"
608        );
609    }
610
611    #[test]
612    fn extract_adjacent_windows_merge() {
613        let entries = vec![ExtractionEntry {
614            entry_id: "entry-1".to_string(),
615            content: "Alpha context. Two. Three. Beta context. Five.".to_string(),
616        }];
617        let terms = vec!["alpha".to_string(), "beta".to_string()];
618        let config = ExtractionConfig::default();
619
620        let passages = extract_passages(&entries, &terms, &config);
621        assert_eq!(passages.len(), 1, "adjacent windows should merge");
622        assert!(passages[0].text.contains("Two."));
623        assert!(passages[0].text.contains("Three."));
624    }
625
626    #[test]
627    fn extract_case_insensitive_matching() {
628        let entries = single_entry("The Context is important.");
629        let config = ExtractionConfig {
630            context_window: 0,
631            dedup_enabled: true,
632            max_passage_sentences: 6,
633        };
634
635        let passages = extract_passages(&entries, &default_terms("context"), &config);
636        assert_eq!(passages.len(), 1);
637    }
638
639    #[test]
640    fn extract_dedup_disabled() {
641        let entries = vec![
642            ExtractionEntry {
643                entry_id: "entry-1".to_string(),
644                content: "Context is stable.".to_string(),
645            },
646            ExtractionEntry {
647                entry_id: "entry-2".to_string(),
648                content: "Context is stable.".to_string(),
649            },
650        ];
651        let config = ExtractionConfig {
652            context_window: 1,
653            dedup_enabled: false,
654            max_passage_sentences: 6,
655        };
656
657        let passages = extract_passages(&entries, &default_terms("context"), &config);
658        assert_eq!(passages.len(), 2);
659    }
660
661    #[test]
662    fn extract_version_numbers_not_split() {
663        let sentences = split_into_sentences("Using v0.3.1 in production.");
664        assert_eq!(sentences.len(), 1);
665        assert_eq!(sentences[0], "Using v0.3.1 in production.");
666    }
667
668    #[test]
669    fn extract_file_extensions_not_split() {
670        let sentences = split_into_sentences("Edit file.rs for changes.");
671        assert_eq!(sentences.len(), 1);
672        assert_eq!(sentences[0], "Edit file.rs for changes.");
673    }
674
675    #[test]
676    fn extract_over_cap_splits_into_multiple_passages() {
677        let entries = single_entry(
678            "Context one. Context two. Context three. Context four. Context five. Context six. Context seven. Context eight. Context nine. Context ten.",
679        );
680        let config = ExtractionConfig {
681            context_window: 0,
682            dedup_enabled: true,
683            max_passage_sentences: 6,
684        };
685
686        let passages = extract_passages(&entries, &default_terms("context"), &config);
687        assert_eq!(passages.len(), 2);
688        assert_eq!(passages[0].text.lines().count(), 6);
689        assert_eq!(passages[1].text.lines().count(), 4);
690    }
691
692    #[test]
693    fn extract_cap_zero_emits_single_sentence_passages() {
694        let entries = single_entry("Context one. Context two. Context three.");
695        let config = ExtractionConfig {
696            context_window: 1,
697            dedup_enabled: true,
698            max_passage_sentences: 0,
699        };
700
701        let passages = extract_passages(&entries, &default_terms("context"), &config);
702        assert_eq!(passages.len(), 3);
703        assert_eq!(passages[0].text, "Context one.");
704        assert_eq!(passages[1].text, "Context two.");
705        assert_eq!(passages[2].text, "Context three.");
706    }
707
708    #[test]
709    fn extract_split_re_evaluates_triggering_terms_per_chunk() {
710        let entries = single_entry(
711            "One. Alpha signal. Three. Four. Five. Six. Seven. Eight. Nine. Beta signal. Eleven. Twelve.",
712        );
713        let terms = vec!["alpha".to_string(), "beta".to_string()];
714        let config = ExtractionConfig {
715            context_window: 5,
716            dedup_enabled: true,
717            max_passage_sentences: 6,
718        };
719
720        let passages = extract_passages(&entries, &terms, &config);
721        assert_eq!(passages.len(), 2);
722        assert_eq!(passages[0].triggering_terms, vec!["alpha".to_string()]);
723        assert_eq!(passages[1].triggering_terms, vec!["beta".to_string()]);
724    }
725
726    #[test]
727    fn extract_split_discards_chunks_without_matching_terms() {
728        let entries = single_entry(
729            "Alpha signal. Two. Three. Four. Five. Six. Seven. Eight. Nine. Ten. Eleven. Twelve. Thirteen. Fourteen. Fifteen. Sixteen. Seventeen. Beta signal.",
730        );
731        let terms = vec!["alpha".to_string(), "beta".to_string()];
732        let config = ExtractionConfig {
733            context_window: 8,
734            dedup_enabled: true,
735            max_passage_sentences: 6,
736        };
737
738        let passages = extract_passages(&entries, &terms, &config);
739        assert_eq!(passages.len(), 2);
740        assert_eq!(passages[0].triggering_terms, vec!["alpha".to_string()]);
741        assert_eq!(passages[1].triggering_terms, vec!["beta".to_string()]);
742    }
743}