Skip to main content

seyk_core/
segment.rs

1//! Splitting resources into independently searchable segments.
2//!
3//! Text is split along its own structure (Markdown headings, blank-line
4//! paragraphs) into chunks of a few hundred tokens. Every segment keeps the
5//! exact byte range it came from, so a hit always maps back to the source.
6
7use std::ops::Range;
8
9use serde::{Deserialize, Serialize};
10
11/// Bump when segmentation output changes for the same input and config.
12const TEXT_ALGORITHM_VERSION: u32 = 2;
13
14/// How a text resource should be parsed.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum TextFormat {
18    Markdown,
19    Plain,
20    Code,
21}
22
23/// Size limits for text segments, in UTF-8 bytes (characters, for ASCII text).
24///
25/// Defaults are chosen for retrieval quality, not to fill the 8K context.
26/// A vector that averages several ideas scores lower for a query matching only
27/// one of them, so segments follow the author's own units: every paragraph of
28/// at least `min_chars` is its own segment. Shorter fragments (labels, one-line
29/// notes) are merged with their neighbours up to `target_chars`.
30///
31/// Gemma's tokenizer averages roughly four characters per token on English
32/// prose: 60 characters is a short sentence, the 1,000-character target is
33/// about 250 tokens and the 1,800-character maximum about 450.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct TextSegmenterConfig {
36    /// Paragraphs at least this long are never merged with each other.
37    pub min_chars: usize,
38    /// Stop merging short fragments once a chunk reaches this size.
39    pub target_chars: usize,
40    /// Longer paragraphs are split at sentences. A merged chunk can exceed this
41    /// slightly, since headings and the gaps between paragraphs are not counted.
42    pub max_chars: usize,
43    /// When a long paragraph is cut at a sentence, its last sentence (if no
44    /// longer than this) is repeated at the start of the next piece. There is
45    /// no overlap across paragraph breaks the author chose.
46    pub overlap_chars: usize,
47}
48
49impl Default for TextSegmenterConfig {
50    fn default() -> Self {
51        Self {
52            min_chars: 60,
53            target_chars: 1000,
54            max_chars: 1800,
55            overlap_chars: 300,
56        }
57    }
58}
59
60/// A searchable slice of a text resource.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct TextSegment {
63    /// Byte range into the source. `&source[byte_range]` is exactly `text`.
64    pub byte_range: Range<usize>,
65    /// 1-based, inclusive line numbers.
66    pub line_start: u64,
67    pub line_end: u64,
68    /// Markdown heading path, for example `Part One > The Kitchen`.
69    pub heading: Option<String>,
70    pub text: String,
71}
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct TextSegmenter {
75    pub config: TextSegmenterConfig,
76}
77
78#[derive(Debug, Clone)]
79struct Block {
80    range: Range<usize>,
81    is_heading: bool,
82    heading_path: Option<String>,
83}
84
85/// The chunk being built: one contiguous source span, gaps included.
86struct Pending {
87    range: Range<usize>,
88    /// Heading path of the last block added.
89    heading: Option<String>,
90    /// Bytes of non-heading text, so 0 means the chunk is only headings.
91    body_len: usize,
92}
93
94impl TextSegmenter {
95    pub fn new(config: TextSegmenterConfig) -> Self {
96        Self { config }
97    }
98
99    /// Identifies the algorithm and its parameters. Stored with cached vectors.
100    pub fn version(&self) -> String {
101        let c = &self.config;
102        format!(
103            "text-v{TEXT_ALGORITHM_VERSION}:n{}:t{}:m{}:o{}",
104            c.min_chars, c.target_chars, c.max_chars, c.overlap_chars
105        )
106    }
107
108    pub fn segment(&self, source: &str, format: TextFormat) -> Vec<TextSegment> {
109        let blocks = match format {
110            TextFormat::Markdown => markdown_blocks(source),
111            TextFormat::Plain | TextFormat::Code => paragraph_blocks(source),
112        };
113        let blocks = self.split_oversized(source, blocks);
114        let lines = LineIndex::new(source);
115
116        let mut out = Vec::new();
117        let mut current: Option<Pending> = None;
118
119        for block in blocks {
120            let len = block.range.len();
121            // Every non-heading block is nonempty, so no body means only headings.
122            let body_len = current.as_ref().map_or(0, |c| c.body_len);
123            let only_headings = body_len == 0;
124            let min = self.config.min_chars;
125
126            // A heading always starts a new chunk; a heading-only chunk merges forward.
127            let new_section = block.is_heading && !only_headings;
128            // Two substantial paragraphs are separate ideas.
129            let both_substantial = !block.is_heading && body_len >= min && len >= min;
130            let too_big = !only_headings && body_len + len > self.config.target_chars;
131            if new_section || both_substantial || too_big {
132                self.flush(source, &lines, current.take(), &mut out);
133            }
134            let added = if block.is_heading { 0 } else { len };
135            match &mut current {
136                Some(c) => {
137                    c.range.end = block.range.end;
138                    c.heading = block.heading_path;
139                    c.body_len += added;
140                }
141                None => {
142                    current = Some(Pending {
143                        range: block.range,
144                        heading: block.heading_path,
145                        body_len: added,
146                    })
147                }
148            }
149        }
150        self.flush(source, &lines, current, &mut out);
151        out
152    }
153
154    fn flush(
155        &self,
156        source: &str,
157        lines: &LineIndex,
158        pending: Option<Pending>,
159        out: &mut Vec<TextSegment>,
160    ) {
161        let Some(Pending {
162            range: Range { start, end },
163            heading,
164            ..
165        }) = pending
166        else {
167            return;
168        };
169        let text = &source[start..end];
170        if text.trim().is_empty() {
171            return;
172        }
173        out.push(TextSegment {
174            byte_range: start..end,
175            line_start: lines.line_of(start),
176            line_end: lines.line_of(end.saturating_sub(1).max(start)),
177            heading,
178            text: text.to_string(),
179        });
180    }
181
182    /// Split any block longer than `max_chars` at sentence boundaries.
183    fn split_oversized(&self, source: &str, blocks: Vec<Block>) -> Vec<Block> {
184        let max = self.config.max_chars;
185        let mut out = Vec::with_capacity(blocks.len());
186        for block in blocks {
187            if block.range.len() <= max {
188                out.push(block);
189                continue;
190            }
191            let mut start = block.range.start;
192            while block.range.end - start > max {
193                // Keep whitespace skipping inside this block, with absolute offsets.
194                let (piece_end, next) = self.split_point(&source[..block.range.end], start);
195                out.push(trimmed_block(source, start..piece_end, &block));
196                start = next;
197            }
198            if start < block.range.end {
199                out.push(trimmed_block(source, start..block.range.end, &block));
200            }
201        }
202        out.retain(|b| !b.range.is_empty());
203        out
204    }
205}
206
207impl TextSegmenter {
208    /// Where the piece starting at `start` ends, and where the next one begins.
209    ///
210    /// Budgets are bytes, so every offset is snapped to a char boundary, and the
211    /// next start is always past `start`. A budget too small for even one char
212    /// still takes that whole char, so the caller always makes progress.
213    fn split_point(&self, source: &str, start: usize) -> (usize, usize) {
214        let mut window_end = floor_char_boundary(source, start + self.config.max_chars);
215        if window_end == start {
216            window_end += source[start..].chars().next().map_or(1, char::len_utf8);
217        }
218        let window = &source[start..window_end];
219        // Prefer a sentence end, then any whitespace, past the halfway point.
220        let half = window.len() / 2;
221        let cut = rfind_sentence_end(window, half)
222            .or_else(|| {
223                window
224                    .char_indices()
225                    .rev()
226                    .take_while(|&(i, _)| i >= half)
227                    .find(|&(_, c)| c.is_whitespace())
228                    .map(|(i, c)| i + c.len_utf8())
229            })
230            .unwrap_or(window.len());
231        let piece_end = start + cut;
232        // Repeat the piece's last sentence so a thought cut in half is still
233        // whole in one of the two pieces. The piece is trimmed, so its own final
234        // punctuation is never followed by whitespace and cannot match.
235        let piece = source[start..piece_end].trim_end();
236        let next = rfind_sentence_end(piece, 0)
237            .map(|i| skip_whitespace(source, start + i))
238            .filter(|&s| s > start && piece_end - s <= self.config.overlap_chars)
239            .unwrap_or_else(|| skip_whitespace(source, piece_end));
240        (piece_end, next)
241    }
242}
243
244fn trimmed_block(source: &str, range: Range<usize>, like: &Block) -> Block {
245    Block {
246        range: trim_range(source, range),
247        is_heading: false,
248        heading_path: like.heading_path.clone(),
249    }
250}
251
252/// Offset just past the last ASCII sentence end at or after `min`. ASCII, so
253/// always a char boundary.
254fn rfind_sentence_end(window: &str, min: usize) -> Option<usize> {
255    let bytes = window.as_bytes();
256    (min..bytes.len().saturating_sub(1))
257        .rev()
258        .find(|&i| {
259            matches!(bytes[i], b'.' | b'!' | b'?' | b'\n') && bytes[i + 1].is_ascii_whitespace()
260        })
261        .map(|i| i + 1)
262}
263
264fn floor_char_boundary(s: &str, mut i: usize) -> usize {
265    i = i.min(s.len());
266    while !s.is_char_boundary(i) {
267        i -= 1;
268    }
269    i
270}
271
272fn skip_whitespace(s: &str, mut i: usize) -> usize {
273    while i < s.len() {
274        let c = s[i..].chars().next().unwrap();
275        if !c.is_whitespace() {
276            break;
277        }
278        i += c.len_utf8();
279    }
280    i
281}
282
283fn trim_range(s: &str, r: Range<usize>) -> Range<usize> {
284    let slice = &s[r.clone()];
285    let lead = slice.len() - slice.trim_start().len();
286    let trail = slice.len() - slice.trim_end().len();
287    if lead == slice.len() {
288        return r.start..r.start;
289    }
290    (r.start + lead)..(r.end - trail)
291}
292
293/// Lines with their byte range, excluding the newline.
294fn lines_with_ranges(source: &str) -> impl Iterator<Item = (Range<usize>, &str)> {
295    let mut pos = 0;
296    source.split_inclusive('\n').map(move |raw| {
297        let start = pos;
298        pos += raw.len();
299        let line = raw.trim_end_matches(['\n', '\r']);
300        (start..start + line.len(), line)
301    })
302}
303
304/// Blank-line separated paragraphs.
305fn paragraph_blocks(source: &str) -> Vec<Block> {
306    let mut blocks = Vec::new();
307    let mut para: Option<Range<usize>> = None;
308    for (range, line) in lines_with_ranges(source) {
309        if line.trim().is_empty() {
310            if let Some(p) = para.take() {
311                blocks.push(Block {
312                    range: p,
313                    is_heading: false,
314                    heading_path: None,
315                });
316            }
317        } else {
318            para = Some(match para {
319                Some(p) => p.start..range.end,
320                None => range,
321            });
322        }
323    }
324    if let Some(p) = para {
325        blocks.push(Block {
326            range: p,
327            is_heading: false,
328            heading_path: None,
329        });
330    }
331    for b in &mut blocks {
332        b.range = trim_range(source, b.range.clone());
333    }
334    blocks
335}
336
337/// Markdown paragraphs, ATX headings and fenced code blocks.
338fn markdown_blocks(source: &str) -> Vec<Block> {
339    let mut blocks = Vec::new();
340    let mut headings: Vec<(usize, String)> = Vec::new();
341    let mut para: Option<Range<usize>> = None;
342    let mut fence: Option<(String, Range<usize>)> = None;
343
344    let path = |headings: &[(usize, String)]| {
345        (!headings.is_empty()).then(|| {
346            headings
347                .iter()
348                .map(|(_, t)| t.as_str())
349                .collect::<Vec<_>>()
350                .join(" > ")
351        })
352    };
353
354    for (range, line) in lines_with_ranges(source) {
355        let trimmed = line.trim_start();
356
357        if let Some((marker, frange)) = &mut fence {
358            frange.end = range.end;
359            if trimmed.starts_with(marker.as_str()) {
360                blocks.push(Block {
361                    range: frange.clone(),
362                    is_heading: false,
363                    heading_path: path(&headings),
364                });
365                fence = None;
366            }
367            continue;
368        }
369
370        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
371            if let Some(p) = para.take() {
372                blocks.push(Block {
373                    range: p,
374                    is_heading: false,
375                    heading_path: path(&headings),
376                });
377            }
378            fence = Some((trimmed[..3].to_string(), range));
379            continue;
380        }
381
382        if let Some((level, title)) = atx_heading(line) {
383            if let Some(p) = para.take() {
384                blocks.push(Block {
385                    range: p,
386                    is_heading: false,
387                    heading_path: path(&headings),
388                });
389            }
390            headings.retain(|(l, _)| *l < level);
391            headings.push((level, title));
392            blocks.push(Block {
393                range,
394                is_heading: true,
395                heading_path: path(&headings),
396            });
397            continue;
398        }
399
400        if line.trim().is_empty() {
401            if let Some(p) = para.take() {
402                blocks.push(Block {
403                    range: p,
404                    is_heading: false,
405                    heading_path: path(&headings),
406                });
407            }
408        } else {
409            para = Some(match para {
410                Some(p) => p.start..range.end,
411                None => range,
412            });
413        }
414    }
415    if let Some((_, frange)) = fence {
416        blocks.push(Block {
417            range: frange,
418            is_heading: false,
419            heading_path: path(&headings),
420        });
421    }
422    if let Some(p) = para {
423        blocks.push(Block {
424            range: p,
425            is_heading: false,
426            heading_path: path(&headings),
427        });
428    }
429    for b in &mut blocks {
430        b.range = trim_range(source, b.range.clone());
431    }
432    blocks.retain(|b| !b.range.is_empty());
433    blocks
434}
435
436fn atx_heading(line: &str) -> Option<(usize, String)> {
437    let indent = line.len() - line.trim_start_matches(' ').len();
438    if indent > 3 {
439        return None;
440    }
441    let rest = &line[indent..];
442    let level = rest.bytes().take_while(|&b| b == b'#').count();
443    if !(1..=6).contains(&level) {
444        return None;
445    }
446    let after = &rest[level..];
447    if !after.is_empty() && !after.starts_with([' ', '\t']) {
448        return None;
449    }
450    let title = after.trim().trim_end_matches('#').trim().to_string();
451    Some((level, title))
452}
453
454/// Maps byte offsets to 1-based line numbers.
455pub(crate) struct LineIndex {
456    starts: Vec<usize>,
457}
458
459impl LineIndex {
460    pub(crate) fn new(source: &str) -> Self {
461        let mut starts = vec![0];
462        starts.extend(source.match_indices('\n').map(|(i, _)| i + 1));
463        Self { starts }
464    }
465
466    pub(crate) fn line_of(&self, byte: usize) -> u64 {
467        (self.starts.partition_point(|&s| s <= byte)) as u64
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn seg(src: &str, fmt: TextFormat) -> Vec<TextSegment> {
476        TextSegmenter::default().segment(src, fmt)
477    }
478
479    #[test]
480    fn locators_map_back_to_source() {
481        let src = "# Title\n\nFirst para line one.\nline two.\n\n## Sub\n\nSecond para.\n";
482        for s in seg(src, TextFormat::Markdown) {
483            assert_eq!(&src[s.byte_range.clone()], s.text);
484            let lines: Vec<&str> = src.lines().collect();
485            assert!(!lines[(s.line_start - 1) as usize].is_empty());
486            assert!(s.text.ends_with(lines[(s.line_end - 1) as usize].trim()));
487        }
488    }
489
490    #[test]
491    fn headings_stay_with_their_content() {
492        let src = "# Book\n\n## Garden\n\nShe waters the beds at dawn.\n\n## Soup\n\nTomato soup needs basil.\n";
493        let s = seg(src, TextFormat::Markdown);
494        assert_eq!(s.len(), 2);
495        assert!(
496            s[0].text.starts_with("# Book"),
497            "heading-only section merges forward"
498        );
499        assert!(s[0].text.contains("dawn"));
500        assert_eq!(s[0].heading.as_deref(), Some("Book > Garden"));
501        assert!(s[1].text.starts_with("## Soup"));
502        assert_eq!(s[1].heading.as_deref(), Some("Book > Soup"));
503        assert_eq!(s[1].line_start, 7);
504        assert_eq!(s[1].line_end, 9);
505    }
506
507    #[test]
508    fn boundaries_are_stable() {
509        let para = "This sentence is about memory and how it fades over years. ".repeat(8);
510        let src = format!("# A\n\n{para}\n\n{para}\n\n{para}\n\n# B\n\n{para}\n");
511        let a = seg(&src, TextFormat::Markdown);
512        let b = seg(&src, TextFormat::Markdown);
513        assert_eq!(a, b);
514        assert!(a.len() >= 3);
515        for s in &a {
516            assert!(
517                s.text.len() <= TextSegmenterConfig::default().max_chars,
518                "{}",
519                s.text.len()
520            );
521        }
522        // Editing section B must not move section A's boundaries.
523        let edited = src.replace("# B\n\n", "# B\n\nA new opening line.\n\n");
524        let c = seg(&edited, TextFormat::Markdown);
525        let a_only: Vec<_> = a
526            .iter()
527            .filter(|s| s.heading.as_deref() == Some("A"))
528            .collect();
529        let c_only: Vec<_> = c
530            .iter()
531            .filter(|s| s.heading.as_deref() == Some("A"))
532            .collect();
533        assert_eq!(a_only, c_only);
534    }
535
536    #[test]
537    fn long_paragraph_is_split_at_sentences() {
538        let src = "One sentence about grief as a weight in the chest. ".repeat(80);
539        let s = seg(&src, TextFormat::Plain);
540        assert!(s.len() > 1);
541        for piece in &s {
542            assert!(piece.text.len() <= 1800);
543            assert!(
544                piece.text.ends_with('.'),
545                "split at sentence end: {:?}",
546                &piece.text[piece.text.len() - 10..]
547            );
548            assert_eq!(&src[piece.byte_range.clone()], piece.text);
549        }
550    }
551
552    #[test]
553    fn substantial_paragraphs_stay_separate_and_fragments_merge() {
554        let src = "Grief: after the funeral her chest felt packed with wet sand.\n\n\
555                   Memory: she is certain the argument happened in winter, not summer.\n\n\
556                   TODO\n\n\
557                   Inheritance: a temper, a way of folding shirts, the silence at dinner.\n";
558        let s = seg(src, TextFormat::Plain);
559        let texts: Vec<&str> = s.iter().map(|x| x.text.as_str()).collect();
560        assert_eq!(texts.len(), 3, "{texts:#?}");
561        assert!(texts[0].starts_with("Grief"));
562        assert!(
563            texts[1].starts_with("Memory") && texts[1].ends_with("TODO"),
564            "fragment joins its neighbour"
565        );
566        assert!(texts[2].starts_with("Inheritance"));
567        assert_eq!((s[1].line_start, s[1].line_end), (3, 5));
568    }
569
570    #[test]
571    fn split_paragraphs_overlap_by_one_sentence() {
572        let src = (0..60)
573            .map(|i| format!("Sentence number {i} is about the long walk home."))
574            .collect::<Vec<_>>()
575            .join(" ");
576        let s = seg(&src, TextFormat::Plain);
577        assert!(s.len() > 1);
578        for pair in s.windows(2) {
579            let last_sentence = pair[0].text.rsplit(". ").next().unwrap();
580            assert!(
581                pair[1]
582                    .text
583                    .starts_with(last_sentence.trim_end_matches('.')),
584                "{:?}",
585                &pair[1].text[..40]
586            );
587            assert!(pair[1].byte_range.start < pair[0].byte_range.end);
588        }
589    }
590
591    #[test]
592    fn fenced_code_is_not_parsed_as_headings() {
593        let src = "# Real\n\n```\n# not a heading\n```\n";
594        let s = seg(src, TextFormat::Markdown);
595        assert_eq!(s.len(), 1);
596        assert_eq!(s[0].heading.as_deref(), Some("Real"));
597    }
598
599    #[test]
600    fn multibyte_text_is_safe() {
601        let src = "Ünïcödé — „Zitat“ ✓. ".repeat(200);
602        for s in seg(&src, TextFormat::Plain) {
603            assert_eq!(&src[s.byte_range.clone()], s.text);
604        }
605    }
606
607    /// Every piece is nonempty, maps back to its source slice, and the pieces
608    /// cover the text in order.
609    fn assert_well_formed(src: &str, segs: &[TextSegment]) {
610        assert!(!segs.is_empty() || src.trim().is_empty());
611        let mut last_start = None;
612        for s in segs {
613            assert!(!s.text.is_empty());
614            assert_eq!(&src[s.byte_range.clone()], s.text);
615            assert!(last_start < Some(s.byte_range.start), "pieces must advance");
616            last_start = Some(s.byte_range.start);
617        }
618    }
619
620    #[test]
621    fn long_runs_without_punctuation_split_on_char_boundaries() {
622        // 2, 3 and 4 byte chars with no sentence end or ASCII space to cut at;
623        // these used to panic by slicing inside a char.
624        for unit in ["é", "日本語", "─", "🙂"] {
625            let src = unit.repeat(3000 / unit.len() + 1);
626            assert_well_formed(&src, &seg(&src, TextFormat::Plain));
627        }
628        // Multibyte whitespace (NBSP, em space) as the only cut points.
629        for ws in ['\u{a0}', '\u{2003}'] {
630            let src = format!("wörd{ws}").repeat(800);
631            assert_well_formed(&src, &seg(&src, TextFormat::Plain));
632        }
633    }
634
635    #[test]
636    fn tiny_budgets_still_make_progress() {
637        let src = "Ab é 日. Cd ─ 🙂! Ef gh.";
638        for max_chars in 0..6 {
639            let s = TextSegmenter::new(TextSegmenterConfig {
640                min_chars: 0,
641                target_chars: max_chars,
642                max_chars,
643                overlap_chars: 2,
644            })
645            .segment(src, TextFormat::Plain);
646            assert_well_formed(src, &s);
647        }
648    }
649
650    #[test]
651    fn tiny_budget_does_not_skip_past_paragraph_end() {
652        let segmenter = TextSegmenter::new(TextSegmenterConfig {
653            min_chars: 0,
654            target_chars: 1,
655            max_chars: 1,
656            overlap_chars: 0,
657        });
658        let segments = segmenter.segment("é\n\nx", TextFormat::Plain);
659        assert_eq!(segments.len(), 2);
660        assert_eq!(segments[0].byte_range, 0..2);
661        assert_eq!(segments[0].text, "é");
662        assert_eq!(segments[1].byte_range, 4..5);
663        assert_eq!(segments[1].text, "x");
664        assert_eq!((segments[0].line_start, segments[1].line_start), (1, 3));
665    }
666
667    fn blocks_for(source: &str, format: TextFormat) -> Vec<Block> {
668        match format {
669            TextFormat::Markdown => markdown_blocks(source),
670            TextFormat::Plain | TextFormat::Code => paragraph_blocks(source),
671        }
672    }
673
674    fn assert_ranges_cover(source: &str, ranges: &[Range<usize>]) {
675        for range in ranges {
676            assert!(range.start < range.end);
677            assert!(range.end <= source.len());
678            assert!(source.is_char_boundary(range.start));
679            assert!(source.is_char_boundary(range.end));
680        }
681        for pair in ranges.windows(2) {
682            assert!(pair[0].start < pair[1].start, "starts must advance");
683            assert!(pair[0].end <= pair[1].end, "ends must not retreat");
684        }
685        // Whitespace may be trimmed or skipped; every other character must be
686        // represented, including when adjacent ranges deliberately overlap.
687        for (offset, ch) in source.char_indices() {
688            assert!(ch.is_whitespace() || ranges.iter().any(|range| range.contains(&offset)));
689        }
690    }
691
692    #[test]
693    fn tiny_budgets_stay_in_blocks_and_cover_multibyte_text() {
694        let sources = [
695            "é\n\nx",
696            "日\n\n🙂\n\né\t \n\n",
697            " \té\u{2003}\n\n日\u{a0}\r\n\r\n🙂 \t",
698            "Ab é 日. Cd ─ 🙂! Ef gh.\n\n é \n\n",
699            "a. b. c. d. é. 日. 🙂.\n\nx\n",
700            "# é\n\n日\n\n## 🙂\n\né\t\n",
701            "# 日\n\n```\né\n🙂\n```\n\n日\t\n",
702            "\n\t\u{2003}\n",
703            "",
704        ];
705        for source in sources {
706            for format in [TextFormat::Plain, TextFormat::Markdown] {
707                for max_chars in 0..=5 {
708                    for overlap_chars in [0, 2, 5] {
709                        let segmenter = TextSegmenter::new(TextSegmenterConfig {
710                            min_chars: 0,
711                            target_chars: max_chars,
712                            max_chars,
713                            overlap_chars,
714                        });
715                        // Inspect each original block independently: public
716                        // segments can intentionally merge headings forward.
717                        for block in blocks_for(source, format) {
718                            let pieces = segmenter.split_oversized(source, vec![block.clone()]);
719                            for piece in &pieces {
720                                assert!(piece.range.start >= block.range.start);
721                                assert!(piece.range.end <= block.range.end);
722                            }
723                            let relative_ranges: Vec<_> = pieces
724                                .iter()
725                                .map(|piece| {
726                                    piece.range.start - block.range.start
727                                        ..piece.range.end - block.range.start
728                                })
729                                .collect();
730                            assert_ranges_cover(&source[block.range], &relative_ranges);
731                        }
732                        let segments = segmenter.segment(source, format);
733                        assert_well_formed(source, &segments);
734                        let ranges: Vec<_> =
735                            segments.iter().map(|s| s.byte_range.clone()).collect();
736                        assert_ranges_cover(source, &ranges);
737                    }
738                }
739            }
740        }
741    }
742
743    // Frozen pre-fix split_oversized. The helpers and the public segment
744    // assembly are unchanged; comparing every resulting Block field proves
745    // identical inputs to that assembly, including heading and byte metadata.
746    fn old_split_oversized(
747        segmenter: &TextSegmenter,
748        source: &str,
749        blocks: Vec<Block>,
750    ) -> Vec<Block> {
751        let max = segmenter.config.max_chars;
752        let mut out = Vec::with_capacity(blocks.len());
753        for block in blocks {
754            if block.range.len() <= max {
755                out.push(block);
756                continue;
757            }
758            let mut start = block.range.start;
759            while block.range.end - start > max {
760                let (piece_end, next) = segmenter.split_point(source, start);
761                out.push(trimmed_block(source, start..piece_end, &block));
762                start = next;
763            }
764            if start < block.range.end {
765                out.push(trimmed_block(source, start..block.range.end, &block));
766            }
767        }
768        out.retain(|b| !b.range.is_empty());
769        out
770    }
771
772    #[test]
773    fn successful_splits_match_the_previous_algorithm() {
774        let paragraphs = [
775            "é",
776            "日",
777            "🙂",
778            "a. b. é! 日? 🙂.",
779            "# Héading",
780            "```\né\n```",
781        ];
782        let mut compared = 0;
783        let mut formerly_panicked = 0;
784        let signature = |blocks: Vec<Block>| {
785            blocks
786                .into_iter()
787                .map(|block| (block.range, block.is_heading, block.heading_path))
788                .collect::<Vec<_>>()
789        };
790        for first in paragraphs {
791            for second in paragraphs {
792                for separator in ["\n\n", "\r\n\r\n", "\n \t\n", "\n\u{2003}\n"] {
793                    let source = format!("{first}{separator}{second}\t \n\n");
794                    for format in [TextFormat::Plain, TextFormat::Markdown, TextFormat::Code] {
795                        for max_chars in [0, 1, 2, 3, 4, 5, 6, 12, 64, 1800] {
796                            for overlap_chars in [0, 2, 300] {
797                                let segmenter = TextSegmenter::new(TextSegmenterConfig {
798                                    max_chars,
799                                    overlap_chars,
800                                    ..TextSegmenterConfig::default()
801                                });
802                                let blocks = blocks_for(&source, format);
803                                let new =
804                                    signature(segmenter.split_oversized(&source, blocks.clone()));
805                                match std::panic::catch_unwind(|| {
806                                    old_split_oversized(&segmenter, &source, blocks)
807                                }) {
808                                    Ok(old) => {
809                                        assert_eq!(
810                                            new,
811                                            signature(old),
812                                            "source={source:?}, format={format:?}, config={:?}",
813                                            segmenter.config
814                                        );
815                                        compared += 1;
816                                    }
817                                    Err(_) => formerly_panicked += 1,
818                                }
819                            }
820                        }
821                    }
822                }
823            }
824        }
825        assert!(compared > 0);
826        assert!(formerly_panicked > 0, "the table must exercise the old bug");
827        assert_eq!(compared + formerly_panicked, 12_960);
828        println!("{compared} identical successful splits; {formerly_panicked} former panics fixed");
829    }
830
831    #[test]
832    fn crlf_line_numbers() {
833        let src = "\r\na\r\n\r\nb\r\n";
834        let s = seg(src, TextFormat::Plain);
835        assert_eq!(s.len(), 1, "small paragraphs merge");
836        assert_eq!((s[0].line_start, s[0].line_end), (2, 4));
837        assert_eq!(s[0].text, "a\r\n\r\nb");
838    }
839}