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
365            .lock()
366            .map(|pool| pool.len())
367            .unwrap_or(0)
368    }
369}
370
371// Note: Default implementation is not provided for BatchTokenizer because initialization
372// can fail (dictionary loading, thread pool setup, etc.). Use BatchTokenizer::new() explicitly instead.
373
374/// 병렬 스트리밍 프로세서
375///
376/// 대용량 파일을 청크로 나누어 병렬 처리합니다.
377pub struct ParallelStreamProcessor {
378    /// 배치 토크나이저
379    batch: BatchTokenizer,
380
381    /// 청크 크기
382    chunk_size: usize,
383}
384
385impl ParallelStreamProcessor {
386    /// 기본 청크 크기 (16KB)
387    pub const DEFAULT_CHUNK_SIZE: usize = 16384;
388
389    /// 새 병렬 스트리밍 프로세서 생성
390    ///
391    /// # Errors
392    ///
393    /// 배치 토크나이저 초기화 실패 시
394    pub fn new() -> Result<Self> {
395        Ok(Self {
396            batch: BatchTokenizer::new()?,
397            chunk_size: Self::DEFAULT_CHUNK_SIZE,
398        })
399    }
400
401    /// 청크 크기 설정
402    #[must_use]
403    pub const fn with_chunk_size(mut self, size: usize) -> Self {
404        self.chunk_size = size;
405        self
406    }
407
408    /// 대용량 파일 처리
409    ///
410    /// # Arguments
411    ///
412    /// * `path` - 파일 경로
413    ///
414    /// # Returns
415    ///
416    /// 모든 토큰 목록
417    ///
418    /// # Errors
419    ///
420    /// 파일 읽기 실패 시
421    pub fn process_large_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Token>> {
422        let content = std::fs::read_to_string(path)
423            .map_err(|e| crate::Error::Analysis(format!("Failed to read file: {e}")))?;
424
425        Ok(self.batch.tokenize_chunked(&content, self.chunk_size))
426    }
427
428    /// 여러 대용량 파일 병렬 처리
429    ///
430    /// # Arguments
431    ///
432    /// * `paths` - 파일 경로 목록
433    ///
434    /// # Returns
435    ///
436    /// 각 파일의 토큰 목록
437    ///
438    /// # Errors
439    ///
440    /// 파일 읽기 실패 시
441    pub fn process_files<P: AsRef<Path> + Sync>(&self, paths: &[P]) -> Result<Vec<Vec<Token>>> {
442        self.batch.tokenize_files(paths)
443    }
444}
445
446// Note: Default implementation is not provided for ParallelStreamProcessor because initialization
447// can fail (dictionary loading, thread pool setup, etc.). Use ParallelStreamProcessor::new() explicitly instead.
448
449/// 대용량 파일 스트리밍 프로세서
450///
451/// 파일을 청크 단위로 읽으면서 토큰화를 수행합니다.
452/// 메모리 효율적인 처리를 위해 전체 파일을 메모리에 로드하지 않습니다.
453pub struct LargeFileProcessor {
454    /// 배치 토크나이저
455    batch: BatchTokenizer,
456
457    /// 버퍼 크기 (바이트)
458    buffer_size: usize,
459
460    /// 진행률 콜백 (Send + Sync for parallel processing)
461    progress_callback: Option<Box<dyn Fn(LargeFileProgress) + Send + Sync>>,
462}
463
464/// 대용량 파일 처리 진행 상황
465#[derive(Debug, Clone)]
466pub struct LargeFileProgress {
467    /// 처리된 바이트 수
468    pub bytes_processed: usize,
469    /// 파일 총 크기
470    pub total_bytes: usize,
471    /// 생성된 토큰 수
472    pub tokens_generated: usize,
473}
474
475impl LargeFileProgress {
476    /// 진행률 퍼센트 계산
477    #[must_use]
478    #[allow(clippy::cast_precision_loss)]
479    pub fn percent(&self) -> f64 {
480        if self.total_bytes == 0 {
481            100.0
482        } else {
483            (self.bytes_processed as f64 / self.total_bytes as f64) * 100.0
484        }
485    }
486}
487
488impl LargeFileProcessor {
489    /// 기본 버퍼 크기 (64KB)
490    pub const DEFAULT_BUFFER_SIZE: usize = 65536;
491
492    /// 새 대용량 파일 프로세서 생성
493    ///
494    /// # Errors
495    ///
496    /// 배치 토크나이저 초기화 실패 시
497    pub fn new() -> Result<Self> {
498        Ok(Self {
499            batch: BatchTokenizer::new()?,
500            buffer_size: Self::DEFAULT_BUFFER_SIZE,
501            progress_callback: None,
502        })
503    }
504
505    /// 버퍼 크기 설정
506    #[must_use]
507    pub const fn with_buffer_size(mut self, size: usize) -> Self {
508        self.buffer_size = size;
509        self
510    }
511
512    /// 진행률 콜백 설정
513    #[must_use]
514    pub fn with_progress_callback<F>(mut self, callback: F) -> Self
515    where
516        F: Fn(LargeFileProgress) + Send + Sync + 'static,
517    {
518        self.progress_callback = Some(Box::new(callback));
519        self
520    }
521
522    /// 대용량 파일 스트리밍 처리
523    ///
524    /// 파일을 청크 단위로 읽어 토큰화합니다.
525    /// 문장 경계를 고려하여 분할합니다.
526    ///
527    /// # Arguments
528    ///
529    /// * `path` - 파일 경로
530    ///
531    /// # Returns
532    ///
533    /// 토큰 목록
534    ///
535    /// # Errors
536    ///
537    /// 파일 읽기 실패 시
538    pub fn process_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Token>> {
539        use std::io::{BufRead, BufReader};
540
541        let file = std::fs::File::open(path.as_ref())
542            .map_err(|e| crate::Error::Analysis(format!("Failed to open file: {e}")))?;
543
544        let metadata = file
545            .metadata()
546            .map_err(|e| crate::Error::Analysis(format!("Failed to read metadata: {e}")))?;
547
548        #[allow(clippy::cast_possible_truncation)]
549        let total_bytes = metadata.len() as usize;
550        let reader = BufReader::with_capacity(self.buffer_size, file);
551
552        let mut all_tokens = Vec::new();
553        let mut bytes_processed = 0;
554        let mut pending_text = String::new();
555        let sentence_delimiters = ['.', '!', '?', '。', '.', '\n'];
556
557        for line in reader.lines() {
558            let line = line
559                .map_err(|e| crate::Error::Analysis(format!("Failed to read line: {e}")))?;
560
561            bytes_processed += line.len() + 1; // +1 for newline
562            pending_text.push_str(&line);
563            pending_text.push('\n');
564
565            // 충분한 텍스트가 모이면 처리
566            if pending_text.len() >= self.buffer_size {
567                // 마지막 문장 경계 찾기
568                if let Some(pos) = pending_text
569                    .char_indices()
570                    .rev()
571                    .find(|(_, c)| sentence_delimiters.contains(c))
572                    .map(|(i, _)| i)
573                {
574                    let to_process = pending_text[..=pos].to_string();
575                    let remaining = pending_text[pos + 1..].to_string();
576
577                    let tokens = self.batch.tokenize_single(&to_process);
578                    all_tokens.extend(tokens);
579
580                    pending_text = remaining;
581                }
582            }
583
584            // 진행률 보고
585            if let Some(ref callback) = self.progress_callback {
586                callback(LargeFileProgress {
587                    bytes_processed,
588                    total_bytes,
589                    tokens_generated: all_tokens.len(),
590                });
591            }
592        }
593
594        // 남은 텍스트 처리
595        if !pending_text.is_empty() {
596            let tokens = self.batch.tokenize_single(&pending_text);
597            all_tokens.extend(tokens);
598        }
599
600        // 최종 진행률 보고
601        if let Some(ref callback) = self.progress_callback {
602            callback(LargeFileProgress {
603                bytes_processed: total_bytes,
604                total_bytes,
605                tokens_generated: all_tokens.len(),
606            });
607        }
608
609        Ok(all_tokens)
610    }
611
612    /// 여러 대용량 파일 병렬 처리
613    ///
614    /// # Errors
615    ///
616    /// 파일 읽기 실패 시
617    pub fn process_files<P: AsRef<Path> + Sync>(&self, paths: &[P]) -> Result<Vec<Vec<Token>>> {
618        paths
619            .par_iter()
620            .map(|path| self.process_file(path))
621            .collect()
622    }
623}
624
625// Note: Default implementation is not provided for LargeFileProcessor because initialization
626// can fail (dictionary loading, etc.). Use LargeFileProcessor::new() explicitly instead.
627
628#[cfg(test)]
629#[allow(clippy::expect_used)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn test_batch_tokenizer_creation() {
635        let batch = BatchTokenizer::new();
636        assert!(batch.is_ok());
637    }
638
639    #[test]
640    fn test_default_pool_size() {
641        let size = BatchTokenizer::default_pool_size();
642        assert!(size > 0);
643    }
644
645    #[test]
646    fn test_tokenize_batch() {
647        let batch = BatchTokenizer::new().expect("should create");
648        let texts = vec!["안녕하세요", "감사합니다"];
649
650        let results = batch.tokenize_batch(&texts);
651
652        assert_eq!(results.len(), 2);
653        assert!(!results[0].is_empty());
654        assert!(!results[1].is_empty());
655    }
656
657    #[test]
658    fn test_tokenize_batch_owned() {
659        let batch = BatchTokenizer::new().expect("should create");
660        let texts = vec!["안녕하세요".to_string(), "감사합니다".to_string()];
661
662        let results = batch.tokenize_batch_owned(&texts);
663
664        assert_eq!(results.len(), 2);
665    }
666
667    #[test]
668    fn test_tokenize_chunked() {
669        let batch = BatchTokenizer::new().expect("should create");
670        let text = "안녕하세요 감사합니다 좋은 하루 되세요";
671
672        let tokens = batch.tokenize_chunked(text, 10);
673
674        // mini-dict 환경에서는 토큰이 없을 수 있으므로 패닉 없이 완료되면 성공
675        let _ = tokens.len();
676    }
677
678    #[test]
679    fn test_split_into_chunks() {
680        let text = "안녕하세요 감사합니다";
681
682        let chunks = BatchTokenizer::split_into_chunks(text, 5);
683
684        assert!(chunks.len() > 1);
685    }
686
687    #[test]
688    fn test_pool_size() {
689        let batch = BatchTokenizer::new().expect("should create");
690        assert_eq!(batch.pool_size(), BatchTokenizer::default_pool_size());
691    }
692
693    #[test]
694    fn test_available_tokenizers() {
695        let batch = BatchTokenizer::new().expect("should create");
696        let available = batch.available_tokenizers();
697        assert_eq!(available, batch.pool_size());
698    }
699
700    #[test]
701    fn test_with_pool_size() {
702        let batch = BatchTokenizer::with_pool_size(4).expect("should create");
703        assert_eq!(batch.pool_size(), 4);
704    }
705
706    #[test]
707    fn test_parallel_stream_processor_creation() {
708        let processor = ParallelStreamProcessor::new();
709        assert!(processor.is_ok());
710    }
711
712    #[test]
713    fn test_with_chunk_size() {
714        let processor = ParallelStreamProcessor::new()
715            .expect("should create")
716            .with_chunk_size(8192);
717
718        assert_eq!(processor.chunk_size, 8192);
719    }
720
721    #[test]
722    fn test_empty_batch() {
723        let batch = BatchTokenizer::new().expect("should create");
724        let texts: Vec<&str> = vec![];
725
726        let results = batch.tokenize_batch(&texts);
727
728        assert!(results.is_empty());
729    }
730
731    #[test]
732    fn test_single_item_batch() {
733        let batch = BatchTokenizer::new().expect("should create");
734        let texts = vec!["안녕하세요"];
735
736        let results = batch.tokenize_batch(&texts);
737
738        assert_eq!(results.len(), 1);
739        assert!(!results[0].is_empty());
740    }
741
742    #[test]
743    fn test_large_batch() {
744        let batch = BatchTokenizer::new().expect("should create");
745        let texts: Vec<&str> = (0..100).map(|_| "안녕하세요").collect();
746
747        let results = batch.tokenize_batch(&texts);
748
749        assert_eq!(results.len(), 100);
750    }
751
752    #[test]
753    fn test_smart_chunking_respects_sentence_boundary() {
754        // 구분자가 많은 텍스트
755        let text = "안녕. 감사. 좋아. 행복. 건강.";
756
757        // 문장 구분자에서 분할되어야 함
758        let chunks = BatchTokenizer::split_into_chunks(text, 6);
759
760        // 여러 청크로 분할되어야 함
761        assert!(chunks.len() > 1, "Should split into multiple chunks");
762
763        // 대부분의 청크가 구분자로 끝나야 함 (마지막 청크 제외)
764        let delimiter_ending: Vec<_> = chunks[..chunks.len().saturating_sub(1)]
765            .iter()
766            .filter(|chunk| {
767                let trimmed = chunk.trim();
768                trimmed.ends_with('.') || trimmed.ends_with(' ')
769            })
770            .collect();
771
772        // 적어도 일부 청크는 구분자에서 분할되어야 함
773        assert!(
774            !delimiter_ending.is_empty() || chunks.len() <= 2,
775            "At least some chunks should end with delimiters"
776        );
777    }
778
779    #[test]
780    fn test_smart_chunking_with_spaces() {
781        let text = "안녕하세요 감사합니다 좋은 하루 되세요";
782
783        let chunks = BatchTokenizer::split_into_chunks_smart(text, 8, &[' ']);
784
785        // 공백에서 분할되어야 함
786        for chunk in &chunks {
787            assert!(!chunk.is_empty());
788        }
789    }
790
791    #[test]
792    fn test_split_with_overlap() {
793        let text = "안녕하세요감사합니다좋은하루되세요";
794
795        let chunks = BatchTokenizer::split_with_overlap(text, 5, 2);
796
797        assert!(chunks.len() > 1);
798
799        // 오버랩 확인: 이전 청크의 끝과 다음 청크의 시작이 겹쳐야 함
800        if chunks.len() >= 2 {
801            let first_end: String = chunks[0].chars().rev().take(2).collect::<String>();
802            let first_end: String = first_end.chars().rev().collect();
803            let second_start: String = chunks[1].chars().take(2).collect();
804            assert_eq!(
805                first_end, second_start,
806                "Overlap should match: {} vs {}",
807                first_end, second_start
808            );
809        }
810    }
811
812    #[test]
813    fn test_split_with_overlap_empty_text() {
814        let chunks = BatchTokenizer::split_with_overlap("", 5, 2);
815        assert!(chunks.is_empty());
816    }
817
818    #[test]
819    fn test_split_with_overlap_large_overlap() {
820        let text = "안녕하세요";
821
822        // 오버랩이 청크 크기의 절반을 넘으면 제한됨
823        let chunks = BatchTokenizer::split_with_overlap(text, 4, 10);
824
825        assert!(!chunks.is_empty());
826    }
827
828    #[test]
829    fn test_smart_chunking_empty_text() {
830        let chunks = BatchTokenizer::split_into_chunks("", 5);
831        assert!(chunks.is_empty());
832    }
833
834    #[test]
835    fn test_smart_chunking_no_delimiter() {
836        // 구분자 없는 텍스트
837        let text = "안녕하세요감사합니다";
838
839        let chunks = BatchTokenizer::split_into_chunks(text, 4);
840
841        // 분할은 되어야 함
842        assert!(chunks.len() >= 1);
843    }
844
845    #[test]
846    fn test_large_file_processor_creation() {
847        let processor = LargeFileProcessor::new();
848        assert!(processor.is_ok());
849    }
850
851    #[test]
852    fn test_large_file_processor_with_buffer_size() {
853        let processor = LargeFileProcessor::new()
854            .expect("should create")
855            .with_buffer_size(32768);
856
857        assert_eq!(processor.buffer_size, 32768);
858    }
859
860    #[test]
861    fn test_large_file_progress_percent() {
862        let progress = LargeFileProgress {
863            bytes_processed: 50,
864            total_bytes: 100,
865            tokens_generated: 10,
866        };
867
868        assert!((progress.percent() - 50.0).abs() < 0.001);
869    }
870
871    #[test]
872    fn test_large_file_progress_percent_zero_total() {
873        let progress = LargeFileProgress {
874            bytes_processed: 50,
875            total_bytes: 0,
876            tokens_generated: 10,
877        };
878
879        assert!((progress.percent() - 100.0).abs() < 0.001);
880    }
881
882    #[test]
883    fn test_large_file_processor_with_callback() {
884        use std::sync::atomic::{AtomicUsize, Ordering};
885        use std::sync::Arc;
886
887        let callback_count = Arc::new(AtomicUsize::new(0));
888        let callback_count_clone = Arc::clone(&callback_count);
889
890        let _processor = LargeFileProcessor::new()
891            .expect("should create")
892            .with_progress_callback(move |_progress| {
893                callback_count_clone.fetch_add(1, Ordering::SeqCst);
894            });
895
896        // 콜백이 설정되었는지 확인 (실제 호출은 파일 처리 시)
897        assert!(callback_count.load(Ordering::SeqCst) == 0);
898    }
899}