Skip to main content

oxidize_pdf/ai/
chunking.rs

1//! Document chunking for RAG (Retrieval Augmented Generation)
2//!
3//! This module provides functionality to split PDF documents into smaller chunks
4//! suitable for processing with Large Language Models (LLMs). LLMs have token limits,
5//! so long documents need to be split into manageable pieces while preserving context.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use oxidize_pdf::ai::DocumentChunker;
11//! use oxidize_pdf::parser::{PdfReader, PdfDocument};
12//!
13//! # fn main() -> oxidize_pdf::Result<()> {
14//! let reader = PdfReader::open("large_document.pdf")?;
15//! let pdf_doc = PdfDocument::new(reader);
16//! let text_pages = pdf_doc.extract_text()?;
17//!
18//! let chunker = DocumentChunker::new(512, 50);  // 512 tokens, 50 overlap
19//! let page_texts: Vec<(usize, String)> = text_pages.iter()
20//!     .enumerate()
21//!     .map(|(idx, page)| (idx + 1, page.text.clone()))
22//!     .collect();
23//! let chunks = chunker.chunk_text_with_pages(&page_texts)?;
24//!
25//! println!("Created {} chunks", chunks.len());
26//! for chunk in &chunks {
27//!     println!("Chunk {}: {} tokens", chunk.id, chunk.tokens);
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::{Document, Result};
34use std::collections::HashMap;
35
36/// A chunk of a PDF document suitable for LLM processing
37///
38/// Each chunk represents a portion of the document's text with associated metadata
39/// that helps maintain context during retrieval and generation.
40#[derive(Debug, Clone)]
41pub struct DocumentChunk {
42    /// Unique identifier for this chunk (e.g., "chunk_0", "chunk_1")
43    pub id: String,
44
45    /// The text content of this chunk
46    pub content: String,
47
48    /// Estimated number of tokens in this chunk
49    pub tokens: usize,
50
51    /// Page numbers where this chunk's content appears (1-indexed)
52    pub page_numbers: Vec<usize>,
53
54    /// Index of this chunk in the sequence (0-indexed)
55    pub chunk_index: usize,
56
57    /// Additional metadata for this chunk
58    pub metadata: ChunkMetadata,
59}
60
61/// A language detected for a chunk or aggregated over a document.
62///
63/// `code` is the ISO 639-3 code (e.g. `"eng"`, `"spa"`, `"cmn"`). The
64/// underlying detector (`whatlang`) is an internal implementation detail and is
65/// not part of this public API.
66#[derive(Debug, Clone, PartialEq)]
67pub struct DetectedLanguage {
68    /// ISO 639-3 language code.
69    pub code: String,
70    /// Detector confidence in `[0.0, 1.0]`.
71    pub confidence: f32,
72    /// Whether the detector considers this detection reliable.
73    pub reliable: bool,
74}
75
76/// Detect the language of `text` using `whatlang`. Returns `None` only when
77/// `whatlang` cannot produce any detection (e.g. empty input). Detections are
78/// surfaced as-is, including unreliable ones — callers decide whether to trust a
79/// result using the `reliable` flag (and `confidence`). Unreliable detections on
80/// short or ambiguous text carry effectively-random codes, so consumers should
81/// gate routing on `reliable`.
82#[cfg(feature = "language-detection")]
83fn detect_chunk_language(text: &str) -> Option<DetectedLanguage> {
84    whatlang::detect(text).map(|info| DetectedLanguage {
85        code: info.lang().code().to_string(),
86        confidence: info.confidence() as f32,
87        reliable: info.is_reliable(),
88    })
89}
90
91/// No-op when the `language-detection` feature is disabled.
92#[cfg(not(feature = "language-detection"))]
93fn detect_chunk_language(_text: &str) -> Option<DetectedLanguage> {
94    None
95}
96
97/// Metadata for a document chunk
98#[derive(Debug, Clone, Default)]
99pub struct ChunkMetadata {
100    /// Position information about where this chunk appears in the document
101    pub position: ChunkPosition,
102
103    /// Confidence score for text extraction quality (0.0-1.0)
104    /// 1.0 = high confidence, 0.0 = low confidence
105    pub confidence: f32,
106
107    /// Whether this chunk respects sentence boundaries
108    pub sentence_boundary_respected: bool,
109
110    /// Detected language for this chunk, if language detection ran
111    /// (`DocumentChunker::with_language_detection(true)` + the
112    /// `language-detection` feature). `None` otherwise.
113    pub language: Option<DetectedLanguage>,
114}
115
116/// Position information for a chunk within the document
117#[derive(Debug, Clone, Default)]
118pub struct ChunkPosition {
119    /// Character offset where this chunk starts in the full document text
120    pub start_char: usize,
121
122    /// Character offset where this chunk ends in the full document text
123    pub end_char: usize,
124
125    /// First page number where this chunk appears (1-indexed)
126    pub first_page: usize,
127
128    /// Last page number where this chunk appears (1-indexed)
129    pub last_page: usize,
130}
131
132/// Configurable document chunker for splitting PDFs into LLM-friendly pieces
133///
134/// The chunker uses a simple fixed-size strategy with overlap to ensure context
135/// is preserved between consecutive chunks.
136///
137/// # Example
138///
139/// ```no_run
140/// use oxidize_pdf::ai::DocumentChunker;
141///
142/// // Create a chunker with 512 token chunks and 50 token overlap
143/// let chunker = DocumentChunker::new(512, 50);
144/// ```
145#[derive(Debug, Clone)]
146pub struct DocumentChunker {
147    /// Target size for each chunk in tokens
148    chunk_size: usize,
149
150    /// Number of tokens to overlap between consecutive chunks
151    overlap: usize,
152
153    /// Whether to run per-chunk language detection
154    detect_language: bool,
155}
156
157impl DocumentChunker {
158    /// Create a new document chunker with specified chunk size and overlap
159    ///
160    /// # Arguments
161    ///
162    /// * `chunk_size` - Target number of tokens per chunk (typical: 256-1024)
163    /// * `overlap` - Number of tokens to overlap between chunks (typical: 10-100)
164    ///
165    /// # Example
166    ///
167    /// ```
168    /// use oxidize_pdf::ai::DocumentChunker;
169    ///
170    /// // For GPT-3.5/4 with 4K context, use smaller chunks
171    /// let chunker = DocumentChunker::new(512, 50);
172    ///
173    /// // For Claude with 100K context, you can use larger chunks
174    /// let chunker_large = DocumentChunker::new(2048, 200);
175    /// ```
176    pub fn new(chunk_size: usize, overlap: usize) -> Self {
177        Self {
178            chunk_size,
179            overlap,
180            detect_language: false,
181        }
182    }
183
184    /// Create a default chunker with sensible defaults for most LLMs
185    ///
186    /// Uses 512 token chunks with 50 token overlap, which works well with
187    /// GPT-3.5, GPT-4, and similar models.
188    pub fn default() -> Self {
189        Self::new(512, 50)
190    }
191
192    /// Enable per-chunk language detection.
193    ///
194    /// Requires the `language-detection` feature; without it this flag is a
195    /// no-op and `ChunkMetadata::language` stays `None`. Disabled by default.
196    pub fn with_language_detection(mut self, enabled: bool) -> Self {
197        self.detect_language = enabled;
198        self
199    }
200
201    /// Dominant language across chunks that already carry a detected language,
202    /// weighted by chunk content length (chars). Returns `None` if no chunk has
203    /// a language.
204    ///
205    /// `confidence` is the length-weighted mean of the winning code's chunk
206    /// confidences; `reliable` is true if any contributing chunk for the winning
207    /// code was reliable.
208    pub fn document_language(chunks: &[DocumentChunk]) -> Option<DetectedLanguage> {
209        // Per-code accumulators: (total_weight, weighted_confidence_sum, any_reliable)
210        let mut acc: HashMap<String, (usize, f64, bool)> = HashMap::new();
211        for chunk in chunks {
212            if let Some(lang) = &chunk.metadata.language {
213                let weight = chunk.content.chars().count().max(1);
214                let entry = acc.entry(lang.code.clone()).or_insert((0, 0.0, false));
215                entry.0 += weight;
216                entry.1 += weight as f64 * lang.confidence as f64;
217                entry.2 |= lang.reliable;
218            }
219        }
220
221        // Winner = highest total weight; tie broken by code for determinism.
222        let (code, (total_weight, conf_sum, reliable)) = acc
223            .into_iter()
224            .max_by(|a, b| a.1 .0.cmp(&b.1 .0).then_with(|| b.0.cmp(&a.0)))?;
225
226        Some(DetectedLanguage {
227            code,
228            confidence: (conf_sum / total_weight as f64) as f32,
229            reliable,
230        })
231    }
232
233    /// Chunk a PDF document into pieces suitable for LLM processing
234    ///
235    /// # Arguments
236    ///
237    /// * `doc` - The PDF document to chunk
238    ///
239    /// # Returns
240    ///
241    /// A vector of `DocumentChunk` objects, each containing a portion of the document.
242    ///
243    /// # Example
244    ///
245    /// ```no_run
246    /// use oxidize_pdf::{Document, ai::DocumentChunker};
247    ///
248    /// # fn main() -> oxidize_pdf::Result<()> {
249    /// let doc = Document::new();
250    /// // Add pages to doc...
251    /// let chunker = DocumentChunker::new(512, 50);
252    /// let chunks = chunker.chunk_document(&doc)?;
253    ///
254    /// for chunk in chunks {
255    ///     println!("Processing chunk {}: {} tokens", chunk.id, chunk.tokens);
256    ///     // Send to LLM for processing...
257    /// }
258    /// # Ok(())
259    /// # }
260    /// ```
261    pub fn chunk_document(&self, doc: &Document) -> Result<Vec<DocumentChunk>> {
262        // Extract all text from the document
263        let full_text = doc.extract_text()?;
264
265        // Chunk the text
266        self.chunk_text(&full_text)
267    }
268
269    /// Chunk a text string into fixed-size pieces with overlap
270    ///
271    /// This is the core chunking algorithm that:
272    /// 1. Tokenizes the text (simple whitespace split)
273    /// 2. Creates chunks of `chunk_size` tokens
274    /// 3. Applies `overlap` tokens between consecutive chunks
275    /// 4. Respects sentence boundaries when possible
276    ///
277    /// # Arguments
278    ///
279    /// * `text` - The text to chunk
280    ///
281    /// # Returns
282    ///
283    /// A vector of `DocumentChunk` objects
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// use oxidize_pdf::ai::DocumentChunker;
289    ///
290    /// let chunker = DocumentChunker::new(10, 2);
291    /// let text = "This is some sample text that will be chunked into smaller pieces";
292    /// let chunks = chunker.chunk_text(text).unwrap();
293    /// println!("Created {} chunks", chunks.len());
294    /// ```
295    pub fn chunk_text(&self, text: &str) -> Result<Vec<DocumentChunk>> {
296        // For simple text chunking, we don't have page information
297        self.chunk_text_internal(text, &[], 0)
298    }
299
300    /// Chunk text with page information for accurate page tracking
301    ///
302    /// # Arguments
303    ///
304    /// * `page_texts` - Vector of (page_number, text) tuples (1-indexed page numbers)
305    ///
306    /// # Returns
307    ///
308    /// A vector of `DocumentChunk` objects with page tracking
309    pub fn chunk_text_with_pages(
310        &self,
311        page_texts: &[(usize, String)],
312    ) -> Result<Vec<DocumentChunk>> {
313        // Combine all page texts with page markers
314        let mut full_text = String::new();
315        let mut page_boundaries = vec![0]; // Character positions where pages start
316
317        for (_page_num, text) in page_texts {
318            if !full_text.is_empty() {
319                full_text.push_str("\n\n"); // Page separator
320            }
321            full_text.push_str(text);
322            page_boundaries.push(full_text.len());
323        }
324
325        let page_numbers: Vec<usize> = page_texts.iter().map(|(num, _)| *num).collect();
326
327        // `page_texts` may be empty (e.g. a PDF from which no text extracted);
328        // fall back to page 0 rather than indexing into an empty vec.
329        let first_page = page_numbers.first().copied().unwrap_or(0);
330        self.chunk_text_internal(&full_text, &page_boundaries, first_page)
331    }
332
333    /// Internal chunking implementation with page tracking
334    fn chunk_text_internal(
335        &self,
336        text: &str,
337        page_boundaries: &[usize],
338        first_page: usize,
339    ) -> Result<Vec<DocumentChunk>> {
340        if text.is_empty() {
341            return Ok(Vec::new());
342        }
343
344        // Tokenize: simple whitespace split for now
345        // Enhancement: Use proper tokenizer (tiktoken) for accurate token counts
346        // Priority: MEDIUM - Current whitespace split provides estimates
347        // Accurate tokenization would require tiktoken-rs external dependency
348        // Target: v1.7.0 for LLM integration improvements
349        let tokens: Vec<&str> = text.split_whitespace().collect();
350
351        if tokens.is_empty() {
352            return Ok(Vec::new());
353        }
354
355        // Normalize degenerate configuration without changing the public
356        // constructor: a chunk must hold at least one token, and the overlap must
357        // leave room for `start` to advance by at least one token between chunks.
358        let chunk_size = self.chunk_size.max(1);
359        let overlap = self.overlap.min(chunk_size - 1);
360
361        let mut chunks = Vec::new();
362        let mut start = 0;
363        let mut chunk_idx = 0;
364        let mut char_offset = 0;
365
366        while start < tokens.len() {
367            // Calculate end position for this chunk
368            let mut end = (start + chunk_size).min(tokens.len());
369
370            // Try to respect sentence boundaries
371            let sentence_boundary_respected = if end < tokens.len() && end > start {
372                // Look for sentence endings in the last few tokens, but never
373                // before this chunk's own start: backtracking past `start` would
374                // collapse `end` to <= start, panicking on tokens[start..end] and
375                // stalling forward progress (#308).
376                let window_start = end.saturating_sub(10).max(start + 1);
377                let search_window = (window_start..end).rev();
378                let mut found_boundary = false;
379
380                for i in search_window {
381                    let token = tokens[i];
382                    if token.ends_with('.') || token.ends_with('!') || token.ends_with('?') {
383                        end = i + 1; // Include the sentence-ending token
384                        found_boundary = true;
385                        break;
386                    }
387                }
388                found_boundary
389            } else {
390                false
391            };
392
393            // Extract chunk tokens
394            let chunk_tokens = &tokens[start..end];
395
396            // Join tokens back into text
397            let content = chunk_tokens.join(" ");
398
399            // Detect language for this chunk (no-op unless enabled + feature on)
400            let language = if self.detect_language {
401                detect_chunk_language(&content)
402            } else {
403                None
404            };
405
406            // Calculate character positions
407            let start_char = char_offset;
408            let end_char = char_offset + content.len();
409            char_offset = end_char;
410
411            // Determine page numbers for this chunk
412            let (page_nums, first_pg, last_pg) = if page_boundaries.is_empty() {
413                (Vec::new(), 0, 0)
414            } else {
415                let mut pages = Vec::new();
416                let mut first = first_page;
417                let mut last = first_page;
418
419                for (idx, &boundary) in page_boundaries.iter().enumerate().skip(1) {
420                    if start_char < boundary && end_char > page_boundaries[idx - 1] {
421                        let page_num = first_page + idx - 1;
422                        pages.push(page_num);
423                        if pages.len() == 1 {
424                            first = page_num;
425                        }
426                        last = page_num;
427                    }
428                }
429
430                if pages.is_empty() {
431                    // Chunk is beyond all tracked pages
432                    pages.push(first_page);
433                    first = first_page;
434                    last = first_page;
435                }
436
437                (pages, first, last)
438            };
439
440            // Create chunk
441            let chunk = DocumentChunk {
442                id: format!("chunk_{}", chunk_idx),
443                content,
444                tokens: chunk_tokens.len(),
445                page_numbers: page_nums.clone(),
446                chunk_index: chunk_idx,
447                metadata: ChunkMetadata {
448                    position: ChunkPosition {
449                        start_char,
450                        end_char,
451                        first_page: first_pg,
452                        last_page: last_pg,
453                    },
454                    confidence: 1.0, // Default high confidence for text-based chunking
455                    sentence_boundary_respected,
456                    language,
457                },
458            };
459
460            chunks.push(chunk);
461            chunk_idx += 1;
462
463            // Move start position with overlap
464            if end < tokens.len() {
465                // Apply overlap, but guarantee strict forward progress regardless
466                // of how far sentence-boundary backtracking pulled `end` back or
467                // how `overlap` compares to the chunk size (#308). `end > start`
468                // always holds here, so falling back to `start = end` advances.
469                let next_start = end.saturating_sub(overlap);
470                start = if next_start > start { next_start } else { end };
471            } else {
472                // Reached the end
473                break;
474            }
475        }
476
477        Ok(chunks)
478    }
479
480    /// Estimate the number of tokens in a text string
481    ///
482    /// Uses a simple approximation: 1 token ≈ 0.75 words (or ~1.33 tokens per word).
483    /// This is reasonably accurate for English text with GPT models.
484    ///
485    /// # Arguments
486    ///
487    /// * `text` - The text to estimate tokens for
488    ///
489    /// # Returns
490    ///
491    /// Estimated number of tokens
492    ///
493    /// # Note
494    ///
495    /// This is an approximation. For exact token counts, integrate with
496    /// a proper tokenizer like tiktoken.
497    pub fn estimate_tokens(text: &str) -> usize {
498        // Simple approximation: count words
499        // 1 token ≈ 0.75 words for English text
500        let words = text.split_whitespace().count();
501        ((words as f32) * 1.33) as usize
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_basic_chunking() {
511        let chunker = DocumentChunker::new(10, 2);
512
513        // Create text with exactly 25 words
514        let text = (0..25)
515            .map(|i| format!("word{}", i))
516            .collect::<Vec<_>>()
517            .join(" ");
518
519        let chunks = chunker.chunk_text(&text).unwrap();
520
521        // Should create 3 chunks:
522        // Chunk 0: words 0-9 (10 tokens)
523        // Chunk 1: words 8-17 (10 tokens, overlap of 2)
524        // Chunk 2: words 16-24 (9 tokens, overlap of 2)
525        assert_eq!(chunks.len(), 3, "Should create 3 chunks");
526
527        // Check first chunk
528        assert_eq!(chunks[0].tokens, 10);
529        assert_eq!(chunks[0].chunk_index, 0);
530        assert_eq!(chunks[0].id, "chunk_0");
531        assert_eq!(chunks[0].metadata.position.start_char, 0);
532
533        // Check second chunk
534        assert_eq!(chunks[1].tokens, 10);
535        assert_eq!(chunks[1].chunk_index, 1);
536
537        // Check third chunk
538        assert_eq!(chunks[2].tokens, 9);
539        assert_eq!(chunks[2].chunk_index, 2);
540    }
541
542    #[test]
543    fn test_overlap_preserves_context() {
544        let chunker = DocumentChunker::new(5, 2);
545
546        // Text: "a b c d e f g h i j"
547        let text = "a b c d e f g h i j";
548
549        let chunks = chunker.chunk_text(&text).unwrap();
550
551        // Chunk 0: a b c d e (positions 0-4)
552        // Chunk 1: d e f g h (positions 3-7, overlap of 2: d e)
553        // Chunk 2: g h i j (positions 6-9, overlap of 2: g h)
554
555        // Check overlap between chunk 0 and 1
556        let chunk0_end = chunks[0]
557            .content
558            .split_whitespace()
559            .rev()
560            .take(2)
561            .collect::<Vec<_>>();
562        let chunk1_start = chunks[1]
563            .content
564            .split_whitespace()
565            .take(2)
566            .collect::<Vec<_>>();
567
568        assert_eq!(chunk0_end, vec!["e", "d"]);
569        assert_eq!(chunk1_start, vec!["d", "e"]);
570    }
571
572    #[test]
573    fn test_empty_text() {
574        let chunker = DocumentChunker::new(10, 2);
575        let chunks = chunker.chunk_text("").unwrap();
576        assert_eq!(chunks.len(), 0);
577    }
578
579    #[test]
580    fn test_text_smaller_than_chunk_size() {
581        let chunker = DocumentChunker::new(100, 10);
582        let text = "just a few words";
583
584        let chunks = chunker.chunk_text(&text).unwrap();
585
586        assert_eq!(chunks.len(), 1);
587        assert_eq!(chunks[0].tokens, 4);
588    }
589
590    #[test]
591    fn test_token_estimation() {
592        // "hello world" = 2 words ≈ 2.66 tokens
593        let tokens = DocumentChunker::estimate_tokens("hello world");
594        assert!(
595            tokens >= 2 && tokens <= 3,
596            "Expected ~2-3 tokens, got {}",
597            tokens
598        );
599
600        // Empty text
601        assert_eq!(DocumentChunker::estimate_tokens(""), 0);
602
603        // Longer text: 100 words ≈ 133 tokens
604        let long_text = (0..100)
605            .map(|i| format!("word{}", i))
606            .collect::<Vec<_>>()
607            .join(" ");
608        let tokens_long = DocumentChunker::estimate_tokens(&long_text);
609        assert!(
610            tokens_long >= 120 && tokens_long <= 140,
611            "Expected ~133 tokens, got {}",
612            tokens_long
613        );
614    }
615
616    #[test]
617    fn test_chunk_ids_are_unique() {
618        let chunker = DocumentChunker::new(5, 1);
619        let text = (0..20)
620            .map(|i| format!("word{}", i))
621            .collect::<Vec<_>>()
622            .join(" ");
623
624        let chunks = chunker.chunk_text(&text).unwrap();
625
626        let ids: Vec<String> = chunks.iter().map(|c| c.id.clone()).collect();
627        let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
628
629        assert_eq!(
630            ids.len(),
631            unique_ids.len(),
632            "All chunk IDs should be unique"
633        );
634    }
635
636    #[test]
637    fn test_sentence_boundary_detection() {
638        let chunker = DocumentChunker::new(10, 2);
639
640        let text = "This is the first sentence. This is the second sentence. This is the third sentence. And here is a fourth one.";
641
642        let chunks = chunker.chunk_text(&text).unwrap();
643
644        // At least some chunks should respect sentence boundaries
645        let has_boundary_respect = chunks
646            .iter()
647            .any(|c| c.metadata.sentence_boundary_respected);
648        assert!(
649            has_boundary_respect,
650            "At least some chunks should respect sentence boundaries"
651        );
652
653        // Check that sentences aren't broken in the middle (chunks should end with punctuation or be the last chunk)
654        for (i, chunk) in chunks.iter().enumerate() {
655            if i < chunks.len() - 1 && chunk.metadata.sentence_boundary_respected {
656                assert!(
657                    chunk.content.ends_with('.')
658                        || chunk.content.ends_with('!')
659                        || chunk.content.ends_with('?'),
660                    "Chunk {} should end with sentence punctuation",
661                    i
662                );
663            }
664        }
665    }
666
667    #[test]
668    fn test_page_tracking() {
669        let chunker = DocumentChunker::new(10, 2);
670
671        let page_texts = vec![
672            (1, "This is page one content.".to_string()),
673            (2, "This is page two content.".to_string()),
674            (3, "This is page three content.".to_string()),
675        ];
676
677        let chunks = chunker.chunk_text_with_pages(&page_texts).unwrap();
678
679        // All chunks should have page information
680        for chunk in &chunks {
681            assert!(
682                !chunk.page_numbers.is_empty(),
683                "Chunk should have page numbers"
684            );
685            assert!(
686                chunk.metadata.position.first_page > 0,
687                "First page should be > 0"
688            );
689            assert!(
690                chunk.metadata.position.last_page > 0,
691                "Last page should be > 0"
692            );
693        }
694
695        // First chunk should start at page 1
696        assert_eq!(
697            chunks[0].metadata.position.first_page, 1,
698            "First chunk should start at page 1"
699        );
700    }
701
702    #[test]
703    fn test_metadata_position_tracking() {
704        let chunker = DocumentChunker::new(5, 1);
705
706        let text = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10";
707
708        let chunks = chunker.chunk_text(&text).unwrap();
709
710        // Check that positions are sequential and non-overlapping in character space
711        for i in 0..chunks.len() - 1 {
712            assert!(
713                chunks[i].metadata.position.end_char
714                    <= chunks[i + 1].metadata.position.start_char + 10,
715                "Chunks should have reasonable character positions"
716            );
717        }
718
719        // First chunk should start at position 0
720        assert_eq!(chunks[0].metadata.position.start_char, 0);
721
722        // Each chunk should have a meaningful character range
723        for chunk in &chunks {
724            assert!(
725                chunk.metadata.position.end_char > chunk.metadata.position.start_char,
726                "End char should be greater than start char"
727            );
728        }
729    }
730
731    #[test]
732    fn test_confidence_scores() {
733        let chunker = DocumentChunker::new(10, 2);
734
735        let text = "This is a test document with multiple sentences.";
736
737        let chunks = chunker.chunk_text(&text).unwrap();
738
739        // All chunks should have confidence scores
740        for chunk in &chunks {
741            assert!(
742                chunk.metadata.confidence >= 0.0 && chunk.metadata.confidence <= 1.0,
743                "Confidence should be between 0.0 and 1.0"
744            );
745        }
746    }
747
748    #[test]
749    fn test_performance_100_pages() {
750        use std::time::Instant;
751
752        let chunker = DocumentChunker::new(512, 50);
753
754        // Generate 100 pages with ~200 words each (typical PDF page)
755        let page_texts: Vec<(usize, String)> = (1..=100)
756            .map(|page_num| {
757                let words: Vec<String> = (0..200).map(|i| format!("word{}", i)).collect();
758                (page_num, words.join(" "))
759            })
760            .collect();
761
762        let start = Instant::now();
763        let chunks = chunker.chunk_text_with_pages(&page_texts).unwrap();
764        let duration = start.elapsed();
765
766        tracing::debug!("Chunked 100 pages in {:?}", duration);
767        tracing::debug!("Created {} chunks", chunks.len());
768
769        // Target: < 500ms for 100 pages (relaxed for debug builds)
770        // In release mode this should be well under 100ms
771        assert!(
772            duration.as_millis() < 500,
773            "Chunking 100 pages took {:?}, should be < 500ms",
774            duration
775        );
776    }
777
778    /// Run `chunk_text` on a worker thread so a reintroduced infinite loop fails
779    /// the test with a timeout instead of hanging the whole test runner.
780    fn chunk_text_bounded(chunker: DocumentChunker, text: &str) -> Vec<DocumentChunk> {
781        use std::sync::mpsc;
782        use std::time::Duration;
783
784        let (tx, rx) = mpsc::channel();
785        let owned = text.to_string();
786        std::thread::spawn(move || {
787            let result = chunker.chunk_text(&owned);
788            // Ignore send errors: the receiver may have already timed out.
789            let _ = tx.send(result);
790        });
791
792        match rx.recv_timeout(Duration::from_secs(10)) {
793            Ok(result) => result.expect("chunk_text returned an error"),
794            Err(_) => panic!("chunk_text did not terminate within 10s (infinite loop, #308)"),
795        }
796    }
797
798    /// Reconstruct the set of source tokens covered by the chunks. Every input
799    /// token must appear in at least one chunk (coverage), proving the loop both
800    /// terminated and did not silently drop content.
801    fn assert_full_token_coverage(text: &str, chunks: &[DocumentChunk]) {
802        let mut covered = std::collections::HashSet::new();
803        for chunk in chunks {
804            for tok in chunk.content.split_whitespace() {
805                covered.insert(tok.to_string());
806            }
807        }
808        for tok in text.split_whitespace() {
809            assert!(
810                covered.contains(tok),
811                "token {:?} from the source is missing from all chunks",
812                tok
813            );
814        }
815    }
816
817    #[test]
818    fn test_no_infinite_loop_when_sentence_boundary_at_chunk_start() {
819        // Exact reproduction from issue #308: chunk_size=10, overlap=2, and the
820        // only sentence-ending token in the first window is at index 0 ("Hi.").
821        // Pre-fix, sentence-boundary backtracking set end=1, start=0 stayed put,
822        // and the loop never advanced.
823        let chunker = DocumentChunker::new(10, 2);
824        let text = "Hi. word word word word word word word word word word word word";
825
826        let chunks = chunk_text_bounded(chunker, text);
827
828        assert!(!chunks.is_empty(), "must produce at least one chunk");
829        assert_full_token_coverage(text, &chunks);
830        // Chunk indices must be strictly sequential (no repeats from a stalled loop).
831        for (i, chunk) in chunks.iter().enumerate() {
832            assert_eq!(chunk.chunk_index, i, "chunk indices must be sequential");
833        }
834    }
835
836    #[test]
837    fn test_no_infinite_loop_when_overlap_meets_or_exceeds_chunk_size() {
838        // overlap >= chunk_size leaves no room for the overlap to advance `start`.
839        // The loop must still make forward progress via the strict-advance guard.
840        let text = (0..30)
841            .map(|i| format!("word{}", i))
842            .collect::<Vec<_>>()
843            .join(" ");
844
845        for (chunk_size, overlap) in [(3usize, 5usize), (4, 4), (1, 10)] {
846            let chunker = DocumentChunker::new(chunk_size, overlap);
847            let chunks = chunk_text_bounded(chunker, &text);
848            assert!(
849                !chunks.is_empty(),
850                "chunk_size={chunk_size}, overlap={overlap}: must produce chunks"
851            );
852            assert_full_token_coverage(&text, &chunks);
853        }
854    }
855
856    #[test]
857    fn test_no_panic_when_chunk_size_below_search_window() {
858        // chunk_size < 10 means the raw sentence-boundary search window
859        // (end-10 .. end) can reach below `start`. The window lower bound must be
860        // clamped so `end` never collapses to <= start (would panic on the slice
861        // tokens[start..end]).
862        let text =
863            "first. second third fourth. fifth sixth seventh eighth. ninth tenth eleventh twelfth";
864        let chunker = DocumentChunker::new(4, 1);
865
866        let chunks = chunk_text_bounded(chunker, text);
867
868        assert!(!chunks.is_empty());
869        assert_full_token_coverage(text, &chunks);
870        // No empty chunks: every chunk holds at least one token.
871        for chunk in &chunks {
872            assert!(chunk.tokens >= 1, "chunk {} is empty", chunk.chunk_index);
873        }
874    }
875
876    #[test]
877    fn test_zero_chunk_size_terminates_with_coverage() {
878        // Degenerate chunk_size=0 must not loop forever; it is normalized to a
879        // minimum of one token per chunk.
880        let text = "alpha beta gamma delta epsilon";
881        let chunker = DocumentChunker::new(0, 0);
882
883        let chunks = chunk_text_bounded(chunker, text);
884
885        assert!(!chunks.is_empty());
886        assert_full_token_coverage(text, &chunks);
887    }
888
889    #[test]
890    fn test_sentence_boundary_still_respected_after_loop_fix() {
891        // Regression guard: the loop fix must not disable the sentence-boundary
892        // feature. With a period mid-window, the first chunk should end at the
893        // sentence boundary, not at the raw chunk_size cut.
894        let chunker = DocumentChunker::new(10, 2);
895        let text = "one two three four five. six seven eight nine ten eleven twelve thirteen";
896
897        let chunks = chunk_text_bounded(chunker, text);
898
899        assert!(chunks[0].metadata.sentence_boundary_respected);
900        assert!(
901            chunks[0].content.ends_with("five."),
902            "first chunk should end at the sentence boundary, got: {:?}",
903            chunks[0].content
904        );
905        assert_full_token_coverage(text, &chunks);
906    }
907}