Skip to main content

mecab_ko_core/
batch.rs

1//! # Batch Processing Module
2//!
3//! Rayon 기반 병렬 배치 처리
4//!
5//! ## 주요 기능
6//!
7//! - 병렬 배치 토큰화
8//! - Work-stealing 스케줄링
9//! - CPU 코어 활용 최적화
10//!
11//! ## Example
12//!
13//! ```rust,no_run
14//! use mecab_ko_core::batch::BatchTokenizer;
15//!
16//! let mut batch = BatchTokenizer::new().unwrap();
17//! let texts = vec!["안녕하세요", "감사합니다", "좋은 하루 되세요"];
18//! let results = batch.tokenize_batch(&texts);
19//!
20//! for (text, tokens) in texts.iter().zip(results.iter()) {
21//!     println!("{}: {} tokens", text, tokens.len());
22//! }
23//! ```
24
25use std::path::Path;
26use std::sync::{Arc, Mutex};
27
28use rayon::prelude::*;
29
30use crate::tokenizer::{Token, Tokenizer};
31use crate::Result;
32
33/// 배치 토크나이저
34///
35/// Rayon을 사용하여 여러 텍스트를 병렬로 처리합니다.
36/// 내부적으로 토크나이저 풀을 관리하여 각 스레드가 독립적으로 작업합니다.
37pub struct BatchTokenizer {
38    /// 토크나이저 풀
39    tokenizer_pool: Arc<Mutex<Vec<Tokenizer>>>,
40
41    /// 풀 크기
42    pool_size: usize,
43}
44
45impl BatchTokenizer {
46    /// 기본 풀 크기 (CPU 코어 수)
47    #[must_use]
48    pub fn default_pool_size() -> usize {
49        rayon::current_num_threads()
50    }
51
52    /// 새 배치 토크나이저 생성
53    ///
54    /// CPU 코어 수만큼 토크나이저를 미리 생성합니다.
55    ///
56    /// # Errors
57    ///
58    /// 토크나이저 초기화 실패 시
59    pub fn new() -> Result<Self> {
60        Self::with_pool_size(Self::default_pool_size())
61    }
62
63    /// 풀 크기 지정하여 생성
64    ///
65    /// # Arguments
66    ///
67    /// * `pool_size` - 토크나이저 풀 크기
68    ///
69    /// # Errors
70    ///
71    /// 토크나이저 초기화 실패 시
72    pub fn with_pool_size(pool_size: usize) -> Result<Self> {
73        let mut tokenizers = Vec::with_capacity(pool_size);
74
75        for _ in 0..pool_size {
76            tokenizers.push(Tokenizer::new()?);
77        }
78
79        Ok(Self {
80            tokenizer_pool: Arc::new(Mutex::new(tokenizers)),
81            pool_size,
82        })
83    }
84
85    /// 사전 경로 지정하여 생성
86    ///
87    /// # Arguments
88    ///
89    /// * `dict_path` - 사전 디렉토리 경로
90    /// * `pool_size` - 토크나이저 풀 크기
91    ///
92    /// # Errors
93    ///
94    /// 토크나이저 초기화 실패 시
95    pub fn with_dict<P: AsRef<Path>>(dict_path: P, pool_size: usize) -> Result<Self> {
96        let mut tokenizers = Vec::with_capacity(pool_size);
97
98        for _ in 0..pool_size {
99            tokenizers.push(Tokenizer::with_dict(dict_path.as_ref())?);
100        }
101
102        Ok(Self {
103            tokenizer_pool: Arc::new(Mutex::new(tokenizers)),
104            pool_size,
105        })
106    }
107
108    /// 배치 토큰화
109    ///
110    /// 여러 텍스트를 병렬로 처리합니다.
111    ///
112    /// # Arguments
113    ///
114    /// * `texts` - 텍스트 목록
115    ///
116    /// # Returns
117    ///
118    /// 각 텍스트의 토큰 목록
119    ///
120    /// # Example
121    ///
122    /// ```rust,no_run
123    /// use mecab_ko_core::batch::BatchTokenizer;
124    ///
125    /// let batch = BatchTokenizer::new().unwrap();
126    /// let texts = vec!["안녕하세요", "감사합니다"];
127    /// let results = batch.tokenize_batch(&texts);
128    /// ```
129    #[must_use]
130    pub fn tokenize_batch(&self, texts: &[&str]) -> Vec<Vec<Token>> {
131        texts
132            .par_iter()
133            .map(|text| self.tokenize_single(text))
134            .collect()
135    }
136
137    /// 배치 토큰화 (소유된 문자열)
138    ///
139    /// # Arguments
140    ///
141    /// * `texts` - 텍스트 목록
142    ///
143    /// # Returns
144    ///
145    /// 각 텍스트의 토큰 목록
146    #[must_use]
147    pub fn tokenize_batch_owned(&self, texts: &[String]) -> Vec<Vec<Token>> {
148        texts
149            .par_iter()
150            .map(|text| self.tokenize_single(text))
151            .collect()
152    }
153
154    /// 단일 텍스트 토큰화
155    ///
156    /// 풀에서 토크나이저를 가져와 사용합니다.
157    fn tokenize_single(&self, text: &str) -> Vec<Token> {
158        // 풀에서 토크나이저 가져오기
159        let Ok(mut pool) = self.tokenizer_pool.lock() else {
160            return Vec::new(); // Lock poisoned, return empty result
161        };
162
163        if let Some(mut tokenizer) = pool.pop() {
164            // 풀 락 해제
165            drop(pool);
166
167            // 토큰화 수행
168            let tokens = tokenizer.tokenize(text);
169
170            // 토크나이저 반환
171            if let Ok(mut pool) = self.tokenizer_pool.lock() {
172                pool.push(tokenizer);
173            }
174            // If lock fails here, tokenizer is dropped but processing succeeded
175
176            tokens
177        } else {
178            // 풀이 비어있으면 임시 토크나이저 생성 (fallback)
179            drop(pool);
180            Tokenizer::new()
181                .map(|mut tok| tok.tokenize(text))
182                .unwrap_or_default()
183        }
184    }
185
186    /// 파일 목록 배치 처리
187    ///
188    /// # Arguments
189    ///
190    /// * `paths` - 파일 경로 목록
191    ///
192    /// # Returns
193    ///
194    /// 각 파일의 토큰 목록
195    ///
196    /// # Errors
197    ///
198    /// 파일 읽기 실패 시
199    pub fn tokenize_files<P: AsRef<Path> + Sync>(&self, paths: &[P]) -> Result<Vec<Vec<Token>>> {
200        paths
201            .par_iter()
202            .map(|path| {
203                let content = std::fs::read_to_string(path)
204                    .map_err(|e| crate::Error::Analysis(format!("Failed to read file: {e}")))?;
205                Ok(self.tokenize_single(&content))
206            })
207            .collect()
208    }
209
210    /// 청크 단위 병렬 처리
211    ///
212    /// 대용량 텍스트를 청크로 나누어 병렬 처리합니다.
213    ///
214    /// # Arguments
215    ///
216    /// * `text` - 입력 텍스트
217    /// * `chunk_size` - 청크 크기 (문자 단위)
218    ///
219    /// # Returns
220    ///
221    /// 모든 토큰 목록
222    #[must_use]
223    pub fn tokenize_chunked(&self, text: &str, chunk_size: usize) -> Vec<Token> {
224        let chunks = Self::split_into_chunks(text, chunk_size);
225
226        let results: Vec<Vec<Token>> = chunks
227            .par_iter()
228            .map(|chunk| self.tokenize_single(chunk))
229            .collect();
230
231        // 결과 병합
232        results.into_iter().flatten().collect()
233    }
234
235    /// 텍스트를 청크로 분할 (단어 경계 존중)
236    ///
237    /// 문장 구분자나 공백에서 분할하여 단어 중간에서 끊기지 않도록 합니다.
238    fn split_into_chunks(text: &str, chunk_size: usize) -> Vec<String> {
239        Self::split_into_chunks_smart(text, chunk_size, &['.', '!', '?', '。', '.', '\n', ' '])
240    }
241
242    /// 스마트 청크 분할 (구분자 지정 가능)
243    ///
244    /// # Arguments
245    ///
246    /// * `text` - 분할할 텍스트
247    /// * `chunk_size` - 목표 청크 크기 (문자 단위)
248    /// * `delimiters` - 분할 가능한 구분자 목록
249    fn split_into_chunks_smart(text: &str, chunk_size: usize, delimiters: &[char]) -> Vec<String> {
250        if text.is_empty() {
251            return Vec::new();
252        }
253
254        let mut chunks = Vec::new();
255        let mut current_start = 0;
256        let chars: Vec<(usize, char)> = text.char_indices().collect();
257
258        while current_start < chars.len() {
259            // 목표 끝점 계산
260            let target_end = (current_start + chunk_size).min(chars.len());
261
262            if target_end >= chars.len() {
263                // 남은 텍스트 전체를 마지막 청크로
264                let byte_start = chars[current_start].0;
265                chunks.push(text[byte_start..].to_string());
266                break;
267            }
268
269            // 목표 끝점에서 뒤로 탐색하여 분할점 찾기
270            let mut split_pos = target_end;
271            let mut found_delimiter = false;
272
273            // 뒤로 탐색 (최대 청크 크기의 25%까지)
274            let min_pos = current_start + (chunk_size * 3 / 4).max(1);
275            while split_pos > min_pos {
276                if delimiters.contains(&chars[split_pos - 1].1) {
277                    found_delimiter = true;
278                    break;
279                }
280                split_pos -= 1;
281            }
282
283            // 구분자를 못 찾으면 앞으로 탐색 (최대 청크 크기의 25%까지)
284            if !found_delimiter {
285                split_pos = target_end;
286                let max_pos = (target_end + chunk_size / 4).min(chars.len());
287                while split_pos < max_pos {
288                    if delimiters.contains(&chars[split_pos - 1].1) {
289                        found_delimiter = true;
290                        break;
291                    }
292                    split_pos += 1;
293                }
294            }
295
296            // 여전히 못 찾으면 그냥 목표 끝점에서 분할
297            if !found_delimiter {
298                split_pos = target_end;
299            }
300
301            // 청크 추출
302            let byte_start = chars[current_start].0;
303            let byte_end = if split_pos < chars.len() {
304                chars[split_pos].0
305            } else {
306                text.len()
307            };
308
309            let chunk = text[byte_start..byte_end].to_string();
310            if !chunk.is_empty() {
311                chunks.push(chunk);
312            }
313
314            current_start = split_pos;
315        }
316
317        chunks
318    }
319
320    /// 오버랩 있는 청크 분할
321    ///
322    /// 컨텍스트 보존을 위해 청크 간 오버랩을 추가합니다.
323    ///
324    /// # Arguments
325    ///
326    /// * `text` - 분할할 텍스트
327    /// * `chunk_size` - 청크 크기
328    /// * `overlap` - 오버랩 크기 (문자 단위)
329    #[must_use]
330    pub fn split_with_overlap(text: &str, chunk_size: usize, overlap: usize) -> Vec<String> {
331        if text.is_empty() || chunk_size == 0 {
332            return Vec::new();
333        }
334
335        let overlap = overlap.min(chunk_size / 2); // 오버랩은 청크의 절반을 넘지 않음
336        let chars: Vec<char> = text.chars().collect();
337        let mut chunks = Vec::new();
338        let mut pos = 0;
339
340        while pos < chars.len() {
341            let end = (pos + chunk_size).min(chars.len());
342            let chunk: String = chars[pos..end].iter().collect();
343            chunks.push(chunk);
344
345            if end >= chars.len() {
346                break;
347            }
348
349            pos = end.saturating_sub(overlap);
350        }
351
352        chunks
353    }
354
355    /// 풀 크기 조회
356    #[must_use]
357    pub const fn pool_size(&self) -> usize {
358        self.pool_size
359    }
360
361    /// 현재 사용 가능한 토크나이저 수
362    #[must_use]
363    pub fn available_tokenizers(&self) -> usize {
364        self.tokenizer_pool.lock().map_or(0, |pool| pool.len())
365    }
366}
367
368// Note: Default implementation is not provided for BatchTokenizer because initialization
369// can fail (dictionary loading, thread pool setup, etc.). Use BatchTokenizer::new() explicitly instead.
370
371/// 병렬 스트리밍 프로세서
372///
373/// 대용량 파일을 청크로 나누어 병렬 처리합니다.
374pub struct ParallelStreamProcessor {
375    /// 배치 토크나이저
376    batch: BatchTokenizer,
377
378    /// 청크 크기
379    chunk_size: usize,
380}
381
382impl ParallelStreamProcessor {
383    /// 기본 청크 크기 (16KB)
384    pub const DEFAULT_CHUNK_SIZE: usize = 16384;
385
386    /// 새 병렬 스트리밍 프로세서 생성
387    ///
388    /// # Errors
389    ///
390    /// 배치 토크나이저 초기화 실패 시
391    pub fn new() -> Result<Self> {
392        Ok(Self {
393            batch: BatchTokenizer::new()?,
394            chunk_size: Self::DEFAULT_CHUNK_SIZE,
395        })
396    }
397
398    /// 청크 크기 설정
399    #[must_use]
400    pub const fn with_chunk_size(mut self, size: usize) -> Self {
401        self.chunk_size = size;
402        self
403    }
404
405    /// 대용량 파일 처리
406    ///
407    /// # Arguments
408    ///
409    /// * `path` - 파일 경로
410    ///
411    /// # Returns
412    ///
413    /// 모든 토큰 목록
414    ///
415    /// # Errors
416    ///
417    /// 파일 읽기 실패 시
418    pub fn process_large_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Token>> {
419        let content = std::fs::read_to_string(path)
420            .map_err(|e| crate::Error::Analysis(format!("Failed to read file: {e}")))?;
421
422        Ok(self.batch.tokenize_chunked(&content, self.chunk_size))
423    }
424
425    /// 여러 대용량 파일 병렬 처리
426    ///
427    /// # Arguments
428    ///
429    /// * `paths` - 파일 경로 목록
430    ///
431    /// # Returns
432    ///
433    /// 각 파일의 토큰 목록
434    ///
435    /// # Errors
436    ///
437    /// 파일 읽기 실패 시
438    pub fn process_files<P: AsRef<Path> + Sync>(&self, paths: &[P]) -> Result<Vec<Vec<Token>>> {
439        self.batch.tokenize_files(paths)
440    }
441}
442
443// Note: Default implementation is not provided for ParallelStreamProcessor because initialization
444// can fail (dictionary loading, thread pool setup, etc.). Use ParallelStreamProcessor::new() explicitly instead.
445
446/// 대용량 파일 스트리밍 프로세서
447///
448/// 파일을 청크 단위로 읽으면서 토큰화를 수행합니다.
449/// 메모리 효율적인 처리를 위해 전체 파일을 메모리에 로드하지 않습니다.
450pub struct LargeFileProcessor {
451    /// 배치 토크나이저
452    batch: BatchTokenizer,
453
454    /// 버퍼 크기 (바이트)
455    buffer_size: usize,
456
457    /// 진행률 콜백 (Send + Sync for parallel processing)
458    progress_callback: Option<Box<dyn Fn(LargeFileProgress) + Send + Sync>>,
459}
460
461/// 대용량 파일 처리 진행 상황
462#[derive(Debug, Clone)]
463pub struct LargeFileProgress {
464    /// 처리된 바이트 수
465    pub bytes_processed: usize,
466    /// 파일 총 크기
467    pub total_bytes: usize,
468    /// 생성된 토큰 수
469    pub tokens_generated: usize,
470}
471
472impl LargeFileProgress {
473    /// 진행률 퍼센트 계산
474    #[must_use]
475    #[allow(clippy::cast_precision_loss)]
476    pub fn percent(&self) -> f64 {
477        if self.total_bytes == 0 {
478            100.0
479        } else {
480            (self.bytes_processed as f64 / self.total_bytes as f64) * 100.0
481        }
482    }
483}
484
485impl LargeFileProcessor {
486    /// 기본 버퍼 크기 (64KB)
487    pub const DEFAULT_BUFFER_SIZE: usize = 65536;
488
489    /// 새 대용량 파일 프로세서 생성
490    ///
491    /// # Errors
492    ///
493    /// 배치 토크나이저 초기화 실패 시
494    pub fn new() -> Result<Self> {
495        Ok(Self {
496            batch: BatchTokenizer::new()?,
497            buffer_size: Self::DEFAULT_BUFFER_SIZE,
498            progress_callback: None,
499        })
500    }
501
502    /// 버퍼 크기 설정
503    #[must_use]
504    pub const fn with_buffer_size(mut self, size: usize) -> Self {
505        self.buffer_size = size;
506        self
507    }
508
509    /// 진행률 콜백 설정
510    #[must_use]
511    pub fn with_progress_callback<F>(mut self, callback: F) -> Self
512    where
513        F: Fn(LargeFileProgress) + Send + Sync + 'static,
514    {
515        self.progress_callback = Some(Box::new(callback));
516        self
517    }
518
519    /// 대용량 파일 스트리밍 처리
520    ///
521    /// 파일을 청크 단위로 읽어 토큰화합니다.
522    /// 문장 경계를 고려하여 분할합니다.
523    ///
524    /// # Arguments
525    ///
526    /// * `path` - 파일 경로
527    ///
528    /// # Returns
529    ///
530    /// 토큰 목록
531    ///
532    /// # Errors
533    ///
534    /// 파일 읽기 실패 시
535    pub fn process_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Token>> {
536        use std::io::{BufRead, BufReader};
537
538        let file = std::fs::File::open(path.as_ref())
539            .map_err(|e| crate::Error::Analysis(format!("Failed to open file: {e}")))?;
540
541        let metadata = file
542            .metadata()
543            .map_err(|e| crate::Error::Analysis(format!("Failed to read metadata: {e}")))?;
544
545        #[allow(clippy::cast_possible_truncation)]
546        let total_bytes = metadata.len() as usize;
547        let reader = BufReader::with_capacity(self.buffer_size, file);
548
549        let mut all_tokens = Vec::new();
550        let mut bytes_processed = 0;
551        let mut pending_text = String::new();
552        let sentence_delimiters = ['.', '!', '?', '。', '.', '\n'];
553
554        for line in reader.lines() {
555            let line =
556                line.map_err(|e| crate::Error::Analysis(format!("Failed to read line: {e}")))?;
557
558            bytes_processed += line.len() + 1; // +1 for newline
559            pending_text.push_str(&line);
560            pending_text.push('\n');
561
562            // 충분한 텍스트가 모이면 처리
563            if pending_text.len() >= self.buffer_size {
564                // 마지막 문장 경계 찾기
565                if let Some(pos) = pending_text
566                    .char_indices()
567                    .rev()
568                    .find(|(_, c)| sentence_delimiters.contains(c))
569                    .map(|(i, _)| i)
570                {
571                    let to_process = pending_text[..=pos].to_string();
572                    let remaining = pending_text[pos + 1..].to_string();
573
574                    let tokens = self.batch.tokenize_single(&to_process);
575                    all_tokens.extend(tokens);
576
577                    pending_text = remaining;
578                }
579            }
580
581            // 진행률 보고
582            if let Some(ref callback) = self.progress_callback {
583                callback(LargeFileProgress {
584                    bytes_processed,
585                    total_bytes,
586                    tokens_generated: all_tokens.len(),
587                });
588            }
589        }
590
591        // 남은 텍스트 처리
592        if !pending_text.is_empty() {
593            let tokens = self.batch.tokenize_single(&pending_text);
594            all_tokens.extend(tokens);
595        }
596
597        // 최종 진행률 보고
598        if let Some(ref callback) = self.progress_callback {
599            callback(LargeFileProgress {
600                bytes_processed: total_bytes,
601                total_bytes,
602                tokens_generated: all_tokens.len(),
603            });
604        }
605
606        Ok(all_tokens)
607    }
608
609    /// 여러 대용량 파일 병렬 처리
610    ///
611    /// # Errors
612    ///
613    /// 파일 읽기 실패 시
614    pub fn process_files<P: AsRef<Path> + Sync>(&self, paths: &[P]) -> Result<Vec<Vec<Token>>> {
615        paths
616            .par_iter()
617            .map(|path| self.process_file(path))
618            .collect()
619    }
620}
621
622// Note: Default implementation is not provided for LargeFileProcessor because initialization
623// can fail (dictionary loading, etc.). Use LargeFileProcessor::new() explicitly instead.
624
625#[cfg(test)]
626#[allow(clippy::expect_used)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn test_batch_tokenizer_creation() {
632        let batch = BatchTokenizer::new();
633        assert!(batch.is_ok());
634    }
635
636    #[test]
637    fn test_default_pool_size() {
638        let size = BatchTokenizer::default_pool_size();
639        assert!(size > 0);
640    }
641
642    #[test]
643    fn test_tokenize_batch() {
644        let batch = BatchTokenizer::new().expect("should create");
645        let texts = vec!["안녕하세요", "감사합니다"];
646
647        let results = batch.tokenize_batch(&texts);
648
649        assert_eq!(results.len(), 2);
650        assert!(!results[0].is_empty());
651        assert!(!results[1].is_empty());
652    }
653
654    #[test]
655    fn test_tokenize_batch_owned() {
656        let batch = BatchTokenizer::new().expect("should create");
657        let texts = vec!["안녕하세요".to_string(), "감사합니다".to_string()];
658
659        let results = batch.tokenize_batch_owned(&texts);
660
661        assert_eq!(results.len(), 2);
662    }
663
664    #[test]
665    fn test_tokenize_chunked() {
666        let batch = BatchTokenizer::new().expect("should create");
667        let text = "안녕하세요 감사합니다 좋은 하루 되세요";
668
669        let tokens = batch.tokenize_chunked(text, 10);
670
671        // mini-dict 환경에서는 토큰이 없을 수 있으므로 패닉 없이 완료되면 성공
672        let _ = tokens.len();
673    }
674
675    #[test]
676    fn test_split_into_chunks() {
677        let text = "안녕하세요 감사합니다";
678
679        let chunks = BatchTokenizer::split_into_chunks(text, 5);
680
681        assert!(chunks.len() > 1);
682    }
683
684    #[test]
685    fn test_pool_size() {
686        let batch = BatchTokenizer::new().expect("should create");
687        assert_eq!(batch.pool_size(), BatchTokenizer::default_pool_size());
688    }
689
690    #[test]
691    fn test_available_tokenizers() {
692        let batch = BatchTokenizer::new().expect("should create");
693        let available = batch.available_tokenizers();
694        assert_eq!(available, batch.pool_size());
695    }
696
697    #[test]
698    fn test_with_pool_size() {
699        let batch = BatchTokenizer::with_pool_size(4).expect("should create");
700        assert_eq!(batch.pool_size(), 4);
701    }
702
703    #[test]
704    fn test_parallel_stream_processor_creation() {
705        let processor = ParallelStreamProcessor::new();
706        assert!(processor.is_ok());
707    }
708
709    #[test]
710    fn test_with_chunk_size() {
711        let processor = ParallelStreamProcessor::new()
712            .expect("should create")
713            .with_chunk_size(8192);
714
715        assert_eq!(processor.chunk_size, 8192);
716    }
717
718    #[test]
719    fn test_empty_batch() {
720        let batch = BatchTokenizer::new().expect("should create");
721        let texts: Vec<&str> = vec![];
722
723        let results = batch.tokenize_batch(&texts);
724
725        assert!(results.is_empty());
726    }
727
728    #[test]
729    fn test_single_item_batch() {
730        let batch = BatchTokenizer::new().expect("should create");
731        let texts = vec!["안녕하세요"];
732
733        let results = batch.tokenize_batch(&texts);
734
735        assert_eq!(results.len(), 1);
736        assert!(!results[0].is_empty());
737    }
738
739    #[test]
740    fn test_large_batch() {
741        let batch = BatchTokenizer::new().expect("should create");
742        let texts: Vec<&str> = (0..100).map(|_| "안녕하세요").collect();
743
744        let results = batch.tokenize_batch(&texts);
745
746        assert_eq!(results.len(), 100);
747    }
748
749    #[test]
750    fn test_smart_chunking_respects_sentence_boundary() {
751        // 구분자가 많은 텍스트
752        let text = "안녕. 감사. 좋아. 행복. 건강.";
753
754        // 문장 구분자에서 분할되어야 함
755        let chunks = BatchTokenizer::split_into_chunks(text, 6);
756
757        // 여러 청크로 분할되어야 함
758        assert!(chunks.len() > 1, "Should split into multiple chunks");
759
760        // 대부분의 청크가 구분자로 끝나야 함 (마지막 청크 제외)
761        let has_delimiter_ending = chunks[..chunks.len().saturating_sub(1)]
762            .iter()
763            .any(|chunk| {
764                let trimmed = chunk.trim();
765                trimmed.ends_with('.') || trimmed.ends_with(' ')
766            });
767
768        // 적어도 일부 청크는 구분자에서 분할되어야 함
769        assert!(
770            has_delimiter_ending || chunks.len() <= 2,
771            "At least some chunks should end with delimiters"
772        );
773    }
774
775    #[test]
776    fn test_smart_chunking_with_spaces() {
777        let text = "안녕하세요 감사합니다 좋은 하루 되세요";
778
779        let chunks = BatchTokenizer::split_into_chunks_smart(text, 8, &[' ']);
780
781        // 공백에서 분할되어야 함
782        for chunk in &chunks {
783            assert!(!chunk.is_empty());
784        }
785    }
786
787    #[test]
788    fn test_split_with_overlap() {
789        let text = "안녕하세요감사합니다좋은하루되세요";
790
791        let chunks = BatchTokenizer::split_with_overlap(text, 5, 2);
792
793        assert!(chunks.len() > 1);
794
795        // 오버랩 확인: 이전 청크의 끝과 다음 청크의 시작이 겹쳐야 함
796        if chunks.len() >= 2 {
797            let first_end: String = chunks[0].chars().rev().take(2).collect::<String>();
798            let first_end: String = first_end.chars().rev().collect();
799            let second_start: String = chunks[1].chars().take(2).collect();
800            assert_eq!(
801                first_end, second_start,
802                "Overlap should match: {first_end} vs {second_start}"
803            );
804        }
805    }
806
807    #[test]
808    fn test_split_with_overlap_empty_text() {
809        let chunks = BatchTokenizer::split_with_overlap("", 5, 2);
810        assert!(chunks.is_empty());
811    }
812
813    #[test]
814    fn test_split_with_overlap_large_overlap() {
815        let text = "안녕하세요";
816
817        // 오버랩이 청크 크기의 절반을 넘으면 제한됨
818        let chunks = BatchTokenizer::split_with_overlap(text, 4, 10);
819
820        assert!(!chunks.is_empty());
821    }
822
823    #[test]
824    fn test_smart_chunking_empty_text() {
825        let chunks = BatchTokenizer::split_into_chunks("", 5);
826        assert!(chunks.is_empty());
827    }
828
829    #[test]
830    fn test_smart_chunking_no_delimiter() {
831        // 구분자 없는 텍스트
832        let text = "안녕하세요감사합니다";
833
834        let chunks = BatchTokenizer::split_into_chunks(text, 4);
835
836        // 분할은 되어야 함
837        assert!(!chunks.is_empty());
838    }
839
840    #[test]
841    fn test_large_file_processor_creation() {
842        let processor = LargeFileProcessor::new();
843        assert!(processor.is_ok());
844    }
845
846    #[test]
847    fn test_large_file_processor_with_buffer_size() {
848        let processor = LargeFileProcessor::new()
849            .expect("should create")
850            .with_buffer_size(32768);
851
852        assert_eq!(processor.buffer_size, 32768);
853    }
854
855    #[test]
856    fn test_large_file_progress_percent() {
857        let progress = LargeFileProgress {
858            bytes_processed: 50,
859            total_bytes: 100,
860            tokens_generated: 10,
861        };
862
863        assert!((progress.percent() - 50.0).abs() < 0.001);
864    }
865
866    #[test]
867    fn test_large_file_progress_percent_zero_total() {
868        let progress = LargeFileProgress {
869            bytes_processed: 50,
870            total_bytes: 0,
871            tokens_generated: 10,
872        };
873
874        assert!((progress.percent() - 100.0).abs() < 0.001);
875    }
876
877    #[test]
878    fn test_large_file_processor_with_callback() {
879        use std::sync::atomic::{AtomicUsize, Ordering};
880        use std::sync::Arc;
881
882        let callback_count = Arc::new(AtomicUsize::new(0));
883        let callback_count_clone = Arc::clone(&callback_count);
884
885        let _processor = LargeFileProcessor::new()
886            .expect("should create")
887            .with_progress_callback(move |_progress| {
888                callback_count_clone.fetch_add(1, Ordering::SeqCst);
889            });
890
891        // 콜백이 설정되었는지 확인 (실제 호출은 파일 처리 시)
892        assert!(callback_count.load(Ordering::SeqCst) == 0);
893    }
894}