Skip to main content

ipfrs_semantic/
multilingual_normalizer.rs

1//! # Multilingual Text Normalizer
2//!
3//! Production-grade text normalization for multilingual content supporting:
4//! - Unicode normalization (combining character removal)
5//! - Script detection across Latin, Cyrillic, Arabic, CJK, and Devanagari scripts
6//! - Language hint detection from script analysis
7//! - Script-aware tokenization strategies
8//! - Configurable normalization pipelines
9
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14// ─────────────────────────────────────────────────────────────────────────────
15// Script enum
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// Unicode script classification for a run of characters.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Script {
21    /// Latin script (A-Z, a-z, extended Latin U+00C0-U+024F)
22    Latin,
23    /// Cyrillic script (U+0400-U+04FF)
24    Cyrillic,
25    /// Arabic script (U+0600-U+06FF)
26    Arabic,
27    /// CJK unified ideographs and CJK punctuation
28    CJK,
29    /// Devanagari script (U+0900-U+097F)
30    Devanagari,
31    /// Unknown / not classified
32    Unknown,
33}
34
35impl Script {
36    /// Human-readable name for display purposes.
37    pub fn name(&self) -> &'static str {
38        match self {
39            Script::Latin => "Latin",
40            Script::Cyrillic => "Cyrillic",
41            Script::Arabic => "Arabic",
42            Script::CJK => "CJK",
43            Script::Devanagari => "Devanagari",
44            Script::Unknown => "Unknown",
45        }
46    }
47}
48
49/// Classify a single Unicode code point into a [`Script`].
50fn char_script(c: char) -> Script {
51    let cp = c as u32;
52    match cp {
53        0x41..=0x7A | 0xC0..=0x24F => Script::Latin,
54        0x400..=0x4FF => Script::Cyrillic,
55        0x600..=0x6FF => Script::Arabic,
56        0x4E00..=0x9FFF | 0x3000..=0x303F => Script::CJK,
57        0x900..=0x97F => Script::Devanagari,
58        _ => Script::Unknown,
59    }
60}
61
62// ─────────────────────────────────────────────────────────────────────────────
63// LanguageHint enum
64// ─────────────────────────────────────────────────────────────────────────────
65
66/// High-level language hint inferred from script analysis.
67///
68/// This is a heuristic approximation — it should not be used as a
69/// production-quality language identifier for all use cases.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum LanguageHint {
72    English,
73    Russian,
74    Arabic,
75    Chinese,
76    Japanese,
77    Hindi,
78    Unknown,
79}
80
81impl LanguageHint {
82    /// BCP-47 language tag approximation.
83    pub fn bcp47(&self) -> &'static str {
84        match self {
85            LanguageHint::English => "en",
86            LanguageHint::Russian => "ru",
87            LanguageHint::Arabic => "ar",
88            LanguageHint::Chinese => "zh",
89            LanguageHint::Japanese => "ja",
90            LanguageHint::Hindi => "hi",
91            LanguageHint::Unknown => "und",
92        }
93    }
94}
95
96// ─────────────────────────────────────────────────────────────────────────────
97// NormalizationOptions
98// ─────────────────────────────────────────────────────────────────────────────
99
100/// Configuration flags that control the normalization pipeline.
101#[derive(Debug, Clone)]
102pub struct NormalizationOptions {
103    /// Convert all characters to lowercase.
104    pub lowercase: bool,
105    /// Remove combining diacritical marks (U+0300-U+036F).
106    pub strip_accents: bool,
107    /// Collapse multiple consecutive whitespace characters to a single space
108    /// and trim leading/trailing whitespace.
109    pub normalize_whitespace: bool,
110    /// Remove non-alphanumeric, non-space characters.
111    pub remove_punctuation: bool,
112    /// Truncate output to at most this many Unicode scalar values.
113    pub max_length: Option<usize>,
114    /// When `Some(script)`, retain only characters belonging to `script` plus
115    /// ASCII space (U+0020).
116    pub script_filter: Option<Script>,
117}
118
119impl Default for NormalizationOptions {
120    fn default() -> Self {
121        Self {
122            lowercase: true,
123            strip_accents: false,
124            normalize_whitespace: true,
125            remove_punctuation: false,
126            max_length: None,
127            script_filter: None,
128        }
129    }
130}
131
132// ─────────────────────────────────────────────────────────────────────────────
133// TokenizationStrategy
134// ─────────────────────────────────────────────────────────────────────────────
135
136/// Strategy that controls how normalized text is split into tokens.
137#[derive(Debug, Clone)]
138pub enum TokenizationStrategy {
139    /// Split on Unicode whitespace boundaries; empty tokens are discarded.
140    Whitespace,
141    /// Sliding window of `n` characters with a step size of 1.
142    CharacterNgram {
143        /// Width of each n-gram window.
144        n: usize,
145    },
146    /// Simplified BPE approximation: whitespace split, then tokens longer than
147    /// 10 characters are chunked into pieces of 5 characters each.
148    Subword {
149        /// Target vocabulary size (used for future extensions; currently
150        /// determines the chunk length = max(3, vocab_size / 1000)).
151        vocab_size: usize,
152    },
153    /// Contiguous runs of the same [`Script`] form individual tokens;
154    /// whitespace acts as an additional boundary.
155    ScriptAware,
156}
157
158// ─────────────────────────────────────────────────────────────────────────────
159// NormalizedText
160// ─────────────────────────────────────────────────────────────────────────────
161
162/// The output produced by [`MultilingualNormalizer::normalize`].
163#[derive(Debug, Clone)]
164pub struct NormalizedText {
165    /// Verbatim copy of the input string.
166    pub original: String,
167    /// Text after the normalization pipeline has been applied.
168    pub normalized: String,
169    /// Dominant script detected in `original`.
170    pub detected_script: Script,
171    /// Language inferred from the dominant script.
172    pub language_hint: LanguageHint,
173    /// Tokens produced by the configured [`TokenizationStrategy`].
174    pub tokens: Vec<String>,
175    /// Number of Unicode scalar values in `normalized`.
176    pub char_count: usize,
177    /// Number of tokens in `tokens`.
178    pub token_count: usize,
179}
180
181// ─────────────────────────────────────────────────────────────────────────────
182// NormalizerRunStats (internal atomic counters)
183// ─────────────────────────────────────────────────────────────────────────────
184
185/// Thread-safe, atomically updated runtime statistics.
186#[derive(Debug, Default)]
187struct NormalizerRunStats {
188    total_processed: AtomicU64,
189    total_chars_removed: AtomicU64,
190    total_tokens_generated: AtomicU64,
191}
192
193impl NormalizerRunStats {
194    fn record(&self, original_chars: u64, normalized_chars: u64, tokens: u64) {
195        self.total_processed.fetch_add(1, Ordering::Relaxed);
196        let removed = original_chars.saturating_sub(normalized_chars);
197        self.total_chars_removed
198            .fetch_add(removed, Ordering::Relaxed);
199        self.total_tokens_generated
200            .fetch_add(tokens, Ordering::Relaxed);
201    }
202}
203
204// ─────────────────────────────────────────────────────────────────────────────
205// NormalizerStats (public snapshot)
206// ─────────────────────────────────────────────────────────────────────────────
207
208/// A point-in-time snapshot of normalizer run statistics.
209#[derive(Debug, Clone)]
210pub struct NormalizerStats {
211    /// Total number of `normalize` calls that have completed.
212    pub total_processed: u64,
213    /// Cumulative number of characters removed by the normalization pipeline.
214    pub total_chars_removed: u64,
215    /// Cumulative number of tokens produced across all `normalize` calls.
216    pub total_tokens_generated: u64,
217    /// Ratio of removed characters to original characters, averaged over all
218    /// processed inputs.  Returns `0.0` when nothing has been processed yet.
219    pub avg_compression_ratio: f64,
220}
221
222// ─────────────────────────────────────────────────────────────────────────────
223// MultilingualNormalizer
224// ─────────────────────────────────────────────────────────────────────────────
225
226/// Production-grade multilingual text normalizer.
227///
228/// `MultilingualNormalizer` executes a configurable normalization pipeline on
229/// arbitrary Unicode text and tokenizes the result according to the chosen
230/// [`TokenizationStrategy`].  It is internally thread-safe via atomic counters
231/// wrapped in an [`Arc`], so a single instance may be shared across threads.
232///
233/// # Example
234///
235/// ```rust
236/// use ipfrs_semantic::multilingual_normalizer::{
237///     MultilingualNormalizer, NormalizationOptions, TokenizationStrategy,
238/// };
239///
240/// let opts  = NormalizationOptions { lowercase: true, ..Default::default() };
241/// let norm  = MultilingualNormalizer::new(opts, TokenizationStrategy::Whitespace);
242/// let result = norm.normalize("Hello World");
243/// assert_eq!(result.normalized, "hello world");
244/// assert_eq!(result.token_count, 2);
245/// ```
246pub struct MultilingualNormalizer {
247    /// Active normalization options.
248    pub options: NormalizationOptions,
249    /// Active tokenization strategy.
250    pub tokenization: TokenizationStrategy,
251    stats: Arc<NormalizerRunStats>,
252}
253
254impl std::fmt::Debug for MultilingualNormalizer {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.debug_struct("MultilingualNormalizer")
257            .field("options", &self.options)
258            .field("tokenization", &self.tokenization)
259            .finish()
260    }
261}
262
263impl MultilingualNormalizer {
264    // ── Construction ───────────────────────────────────────────────────────
265
266    /// Create a new normalizer with the given options and tokenization strategy.
267    pub fn new(options: NormalizationOptions, tokenization: TokenizationStrategy) -> Self {
268        Self {
269            options,
270            tokenization,
271            stats: Arc::new(NormalizerRunStats::default()),
272        }
273    }
274
275    /// Create a normalizer with default options and `Whitespace` tokenization.
276    pub fn with_defaults() -> Self {
277        Self::new(
278            NormalizationOptions::default(),
279            TokenizationStrategy::Whitespace,
280        )
281    }
282
283    // ── Public API ─────────────────────────────────────────────────────────
284
285    /// Normalize a single text string and tokenize it.
286    ///
287    /// The pipeline is:
288    /// 1. Detect dominant script.
289    /// 2. Derive a language hint from the script.
290    /// 3. Apply all enabled normalization options in order.
291    /// 4. Tokenize according to the configured strategy.
292    /// 5. Update internal statistics.
293    pub fn normalize(&self, text: &str) -> NormalizedText {
294        let original_chars = text.chars().count() as u64;
295
296        let detected_script = self.detect_script(text);
297        let language_hint = script_to_language_hint(detected_script);
298
299        let normalized = self.apply_options(text);
300        let normalized_chars = normalized.chars().count() as u64;
301        let char_count = normalized_chars as usize;
302
303        let tokens = self.tokenize(&normalized);
304        let token_count = tokens.len();
305
306        self.stats
307            .record(original_chars, normalized_chars, token_count as u64);
308
309        NormalizedText {
310            original: text.to_owned(),
311            normalized,
312            detected_script,
313            language_hint,
314            tokens,
315            char_count,
316            token_count,
317        }
318    }
319
320    /// Normalize a batch of text strings.
321    pub fn normalize_batch(&self, texts: &[&str]) -> Vec<NormalizedText> {
322        texts.iter().map(|t| self.normalize(t)).collect()
323    }
324
325    /// Detect the dominant script in `text` by counting characters per script.
326    ///
327    /// Returns [`Script::Unknown`] when the text is empty or when two scripts
328    /// tie for the highest count.
329    pub fn detect_script(&self, text: &str) -> Script {
330        detect_script_impl(text)
331    }
332
333    /// Derive a language hint from the dominant script in `text`.
334    pub fn detect_language(&self, text: &str) -> LanguageHint {
335        let script = self.detect_script(text);
336        script_to_language_hint(script)
337    }
338
339    /// Apply all enabled normalization options to `text` and return the result.
340    ///
341    /// Options are applied in this fixed order:
342    /// 1. `lowercase`
343    /// 2. `strip_accents`
344    /// 3. `normalize_whitespace`
345    /// 4. `remove_punctuation`
346    /// 5. `max_length`
347    /// 6. `script_filter`
348    pub fn apply_options(&self, text: &str) -> String {
349        let mut s: String = text.to_owned();
350
351        if self.options.lowercase {
352            s = s.to_lowercase();
353        }
354
355        if self.options.strip_accents {
356            s = strip_combining_chars(&s);
357        }
358
359        if self.options.normalize_whitespace {
360            s = normalize_whitespace_impl(&s);
361        }
362
363        if self.options.remove_punctuation {
364            s = remove_punctuation_impl(&s);
365        }
366
367        if let Some(max) = self.options.max_length {
368            if s.chars().count() > max {
369                s = s.chars().take(max).collect();
370            }
371        }
372
373        if let Some(ref script) = self.options.script_filter {
374            s = script_filter_impl(&s, *script);
375        }
376
377        s
378    }
379
380    /// Tokenize `text` according to the configured [`TokenizationStrategy`].
381    pub fn tokenize(&self, text: &str) -> Vec<String> {
382        tokenize_impl(text, &self.tokenization)
383    }
384
385    /// Remove Unicode combining diacritical marks (U+0300-U+036F) from `text`.
386    pub fn strip_combining_chars(&self, text: &str) -> String {
387        strip_combining_chars(text)
388    }
389
390    /// Return a point-in-time snapshot of accumulated statistics.
391    pub fn stats(&self) -> NormalizerStats {
392        let total_processed = self.stats.total_processed.load(Ordering::Relaxed);
393        let total_chars_removed = self.stats.total_chars_removed.load(Ordering::Relaxed);
394        let total_tokens_generated = self.stats.total_tokens_generated.load(Ordering::Relaxed);
395
396        // avg_compression_ratio = mean fraction of chars removed per document
397        let avg_compression_ratio = if total_processed == 0 {
398            0.0
399        } else {
400            total_chars_removed as f64 / total_processed as f64
401        };
402
403        NormalizerStats {
404            total_processed,
405            total_chars_removed,
406            total_tokens_generated,
407            avg_compression_ratio,
408        }
409    }
410}
411
412// ─────────────────────────────────────────────────────────────────────────────
413// Free-function helpers
414// ─────────────────────────────────────────────────────────────────────────────
415
416/// Pure function for script detection (also used by the public method).
417fn detect_script_impl(text: &str) -> Script {
418    let mut counts: HashMap<Script, usize> = HashMap::new();
419
420    for c in text.chars() {
421        let s = char_script(c);
422        if s != Script::Unknown {
423            *counts.entry(s).or_insert(0) += 1;
424        }
425    }
426
427    if counts.is_empty() {
428        return Script::Unknown;
429    }
430
431    // Find maximum count
432    let max_count = *counts.values().max().unwrap_or(&0);
433
434    // Collect scripts that share the maximum count
435    let leaders: Vec<Script> = counts
436        .iter()
437        .filter(|(_, &v)| v == max_count)
438        .map(|(&k, _)| k)
439        .collect();
440
441    // Tie → Unknown
442    if leaders.len() == 1 {
443        leaders[0]
444    } else {
445        Script::Unknown
446    }
447}
448
449/// Map a dominant script to the most likely language hint.
450fn script_to_language_hint(script: Script) -> LanguageHint {
451    match script {
452        Script::Latin => LanguageHint::English,
453        Script::Cyrillic => LanguageHint::Russian,
454        Script::Arabic => LanguageHint::Arabic,
455        Script::CJK => LanguageHint::Chinese,
456        Script::Devanagari => LanguageHint::Hindi,
457        Script::Unknown => LanguageHint::Unknown,
458    }
459}
460
461/// Remove Unicode combining diacritical marks (U+0300-U+036F).
462fn strip_combining_chars(text: &str) -> String {
463    text.chars()
464        .filter(|&c| {
465            let cp = c as u32;
466            !(0x300..=0x36F).contains(&cp)
467        })
468        .collect()
469}
470
471/// Collapse runs of whitespace to a single space and trim.
472fn normalize_whitespace_impl(text: &str) -> String {
473    let mut result = String::with_capacity(text.len());
474    let mut prev_was_space = false;
475
476    for c in text.chars() {
477        if c.is_whitespace() {
478            if !prev_was_space {
479                result.push(' ');
480            }
481            prev_was_space = true;
482        } else {
483            result.push(c);
484            prev_was_space = false;
485        }
486    }
487
488    // Trim leading/trailing
489    result.trim().to_owned()
490}
491
492/// Remove characters that are neither alphanumeric nor ASCII space.
493fn remove_punctuation_impl(text: &str) -> String {
494    text.chars()
495        .filter(|c| c.is_alphanumeric() || *c == ' ')
496        .collect()
497}
498
499/// Retain only characters from `script` and ASCII space.
500fn script_filter_impl(text: &str, script: Script) -> String {
501    text.chars()
502        .filter(|&c| c == ' ' || char_script(c) == script)
503        .collect()
504}
505
506/// Dispatch tokenization to the appropriate strategy.
507fn tokenize_impl(text: &str, strategy: &TokenizationStrategy) -> Vec<String> {
508    match strategy {
509        TokenizationStrategy::Whitespace => tokenize_whitespace(text),
510        TokenizationStrategy::CharacterNgram { n } => tokenize_char_ngram(text, *n),
511        TokenizationStrategy::Subword { vocab_size } => tokenize_subword(text, *vocab_size),
512        TokenizationStrategy::ScriptAware => tokenize_script_aware(text),
513    }
514}
515
516/// Whitespace tokenization: split on whitespace, discard empty strings.
517fn tokenize_whitespace(text: &str) -> Vec<String> {
518    text.split_whitespace().map(|s| s.to_owned()).collect()
519}
520
521/// Sliding-window character n-gram tokenization (step = 1).
522fn tokenize_char_ngram(text: &str, n: usize) -> Vec<String> {
523    if n == 0 {
524        return Vec::new();
525    }
526
527    let chars: Vec<char> = text.chars().collect();
528    if chars.len() < n {
529        return Vec::new();
530    }
531
532    (0..=(chars.len() - n))
533        .map(|i| chars[i..i + n].iter().collect())
534        .collect()
535}
536
537/// Simplified subword tokenization (BPE approximation).
538///
539/// Whitespace-split words longer than 10 characters are chunked into pieces
540/// of `chunk_len` characters.  `chunk_len` is derived as
541/// `max(3, vocab_size / 1_000)` capped at 5.
542fn tokenize_subword(text: &str, vocab_size: usize) -> Vec<String> {
543    let chunk_len = (vocab_size / 1_000).clamp(3, 5);
544    let mut tokens = Vec::new();
545
546    for word in text.split_whitespace() {
547        let chars: Vec<char> = word.chars().collect();
548        if chars.len() > 10 {
549            // Chunk into pieces
550            let mut start = 0;
551            while start < chars.len() {
552                let end = (start + chunk_len).min(chars.len());
553                tokens.push(chars[start..end].iter().collect::<String>());
554                start += chunk_len;
555            }
556        } else {
557            tokens.push(word.to_owned());
558        }
559    }
560
561    tokens
562}
563
564/// Script-aware tokenization: contiguous runs of the same script become tokens.
565///
566/// Whitespace acts as an additional boundary (whitespace is not emitted as a
567/// token).
568fn tokenize_script_aware(text: &str) -> Vec<String> {
569    let mut tokens: Vec<String> = Vec::new();
570    let mut current = String::new();
571    let mut current_script: Option<Script> = None;
572
573    for c in text.chars() {
574        if c.is_whitespace() {
575            if !current.is_empty() {
576                tokens.push(current.clone());
577                current.clear();
578                current_script = None;
579            }
580            continue;
581        }
582
583        let script = char_script(c);
584
585        match current_script {
586            None => {
587                current.push(c);
588                current_script = Some(script);
589            }
590            Some(cs) if cs == script => {
591                current.push(c);
592            }
593            Some(_) => {
594                // Script boundary
595                tokens.push(current.clone());
596                current.clear();
597                current.push(c);
598                current_script = Some(script);
599            }
600        }
601    }
602
603    if !current.is_empty() {
604        tokens.push(current);
605    }
606
607    tokens
608}
609
610// ─────────────────────────────────────────────────────────────────────────────
611// Tests
612// ─────────────────────────────────────────────────────────────────────────────
613
614#[cfg(test)]
615mod tests {
616    use crate::multilingual_normalizer::{
617        char_script, detect_script_impl, normalize_whitespace_impl, remove_punctuation_impl,
618        script_filter_impl, script_to_language_hint, strip_combining_chars, tokenize_char_ngram,
619        tokenize_script_aware, tokenize_subword, tokenize_whitespace, LanguageHint,
620        MultilingualNormalizer, NormalizationOptions, Script, TokenizationStrategy,
621    };
622
623    // ── Helper ────────────────────────────────────────────────────────────
624
625    fn default_norm() -> MultilingualNormalizer {
626        MultilingualNormalizer::with_defaults()
627    }
628
629    fn norm_with(
630        options: NormalizationOptions,
631        strategy: TokenizationStrategy,
632    ) -> MultilingualNormalizer {
633        MultilingualNormalizer::new(options, strategy)
634    }
635
636    // ── char_script ───────────────────────────────────────────────────────
637
638    #[test]
639    fn test_char_script_latin_ascii() {
640        assert_eq!(char_script('A'), Script::Latin);
641        assert_eq!(char_script('z'), Script::Latin);
642        assert_eq!(char_script('M'), Script::Latin);
643    }
644
645    #[test]
646    fn test_char_script_latin_extended() {
647        assert_eq!(char_script('À'), Script::Latin); // U+00C0
648        assert_eq!(char_script('ñ'), Script::Latin); // U+00F1
649    }
650
651    #[test]
652    fn test_char_script_cyrillic() {
653        assert_eq!(char_script('А'), Script::Cyrillic); // U+0410
654        assert_eq!(char_script('я'), Script::Cyrillic); // U+044F
655    }
656
657    #[test]
658    fn test_char_script_arabic() {
659        assert_eq!(char_script('ع'), Script::Arabic); // U+0639
660        assert_eq!(char_script('م'), Script::Arabic); // U+0645
661    }
662
663    #[test]
664    fn test_char_script_cjk() {
665        assert_eq!(char_script('中'), Script::CJK); // U+4E2D
666        assert_eq!(char_script('文'), Script::CJK); // U+6587
667    }
668
669    #[test]
670    fn test_char_script_devanagari() {
671        assert_eq!(char_script('अ'), Script::Devanagari); // U+0905
672        assert_eq!(char_script('ह'), Script::Devanagari); // U+0939
673    }
674
675    #[test]
676    fn test_char_script_unknown() {
677        assert_eq!(char_script(' '), Script::Unknown);
678        assert_eq!(char_script('1'), Script::Unknown);
679        assert_eq!(char_script('!'), Script::Unknown);
680    }
681
682    // ── detect_script_impl ────────────────────────────────────────────────
683
684    #[test]
685    fn test_detect_script_empty() {
686        assert_eq!(detect_script_impl(""), Script::Unknown);
687    }
688
689    #[test]
690    fn test_detect_script_latin_dominant() {
691        assert_eq!(detect_script_impl("Hello World"), Script::Latin);
692    }
693
694    #[test]
695    fn test_detect_script_cyrillic_dominant() {
696        assert_eq!(detect_script_impl("Привет мир"), Script::Cyrillic);
697    }
698
699    #[test]
700    fn test_detect_script_arabic_dominant() {
701        assert_eq!(detect_script_impl("مرحبا"), Script::Arabic);
702    }
703
704    #[test]
705    fn test_detect_script_cjk_dominant() {
706        assert_eq!(detect_script_impl("你好世界"), Script::CJK);
707    }
708
709    #[test]
710    fn test_detect_script_devanagari_dominant() {
711        assert_eq!(detect_script_impl("नमस्ते"), Script::Devanagari);
712    }
713
714    #[test]
715    fn test_detect_script_tie_returns_unknown() {
716        // Equal counts of Latin and Cyrillic → Unknown
717        let text = "ABcd АБвг"; // 4 Latin, 4 Cyrillic (ignoring space)
718        let result = detect_script_impl(text);
719        assert_eq!(result, Script::Unknown);
720    }
721
722    #[test]
723    fn test_detect_script_only_numbers() {
724        // Only Unknown chars
725        assert_eq!(detect_script_impl("12345"), Script::Unknown);
726    }
727
728    // ── script_to_language_hint ───────────────────────────────────────────
729
730    #[test]
731    fn test_language_hint_from_scripts() {
732        assert_eq!(
733            script_to_language_hint(Script::Latin),
734            LanguageHint::English
735        );
736        assert_eq!(
737            script_to_language_hint(Script::Cyrillic),
738            LanguageHint::Russian
739        );
740        assert_eq!(
741            script_to_language_hint(Script::Arabic),
742            LanguageHint::Arabic
743        );
744        assert_eq!(script_to_language_hint(Script::CJK), LanguageHint::Chinese);
745        assert_eq!(
746            script_to_language_hint(Script::Devanagari),
747            LanguageHint::Hindi
748        );
749        assert_eq!(
750            script_to_language_hint(Script::Unknown),
751            LanguageHint::Unknown
752        );
753    }
754
755    // ── strip_combining_chars ─────────────────────────────────────────────
756
757    #[test]
758    fn test_strip_combining_chars_basic() {
759        // 'e' followed by combining grave accent U+0300
760        let s = "e\u{0300}";
761        assert_eq!(strip_combining_chars(s), "e");
762    }
763
764    #[test]
765    fn test_strip_combining_chars_no_combining() {
766        let s = "hello";
767        assert_eq!(strip_combining_chars(s), "hello");
768    }
769
770    #[test]
771    fn test_strip_combining_chars_multiple() {
772        let s = "a\u{0301}b\u{0302}c";
773        assert_eq!(strip_combining_chars(s), "abc");
774    }
775
776    // ── normalize_whitespace_impl ─────────────────────────────────────────
777
778    #[test]
779    fn test_normalize_whitespace_collapses_spaces() {
780        assert_eq!(normalize_whitespace_impl("a  b   c"), "a b c");
781    }
782
783    #[test]
784    fn test_normalize_whitespace_trims() {
785        assert_eq!(normalize_whitespace_impl("  hello  "), "hello");
786    }
787
788    #[test]
789    fn test_normalize_whitespace_tabs_and_newlines() {
790        assert_eq!(normalize_whitespace_impl("a\t\nb"), "a b");
791    }
792
793    // ── remove_punctuation_impl ───────────────────────────────────────────
794
795    #[test]
796    fn test_remove_punctuation_basic() {
797        assert_eq!(remove_punctuation_impl("hello, world!"), "hello world");
798    }
799
800    #[test]
801    fn test_remove_punctuation_keeps_alphanumeric() {
802        assert_eq!(remove_punctuation_impl("abc123"), "abc123");
803    }
804
805    // ── script_filter_impl ────────────────────────────────────────────────
806
807    #[test]
808    fn test_script_filter_latin_only() {
809        let text = "Hello Привет";
810        let result = script_filter_impl(text, Script::Latin);
811        // Keeps Latin chars and spaces; drops Cyrillic
812        assert!(result.contains("Hello"));
813        assert!(!result.contains('П'));
814    }
815
816    #[test]
817    fn test_script_filter_cjk_only() {
818        let text = "你好 hello";
819        let result = script_filter_impl(text, Script::CJK);
820        assert!(result.contains('你'));
821        assert!(!result.contains('h'));
822    }
823
824    // ── tokenize_whitespace ───────────────────────────────────────────────
825
826    #[test]
827    fn test_tokenize_whitespace_basic() {
828        let tokens = tokenize_whitespace("hello world foo");
829        assert_eq!(tokens, vec!["hello", "world", "foo"]);
830    }
831
832    #[test]
833    fn test_tokenize_whitespace_empty() {
834        assert!(tokenize_whitespace("   ").is_empty());
835    }
836
837    // ── tokenize_char_ngram ───────────────────────────────────────────────
838
839    #[test]
840    fn test_tokenize_char_ngram_bigrams() {
841        let tokens = tokenize_char_ngram("abcd", 2);
842        assert_eq!(tokens, vec!["ab", "bc", "cd"]);
843    }
844
845    #[test]
846    fn test_tokenize_char_ngram_too_short() {
847        assert!(tokenize_char_ngram("ab", 3).is_empty());
848    }
849
850    #[test]
851    fn test_tokenize_char_ngram_zero_n() {
852        assert!(tokenize_char_ngram("hello", 0).is_empty());
853    }
854
855    #[test]
856    fn test_tokenize_char_ngram_exact_length() {
857        let tokens = tokenize_char_ngram("abc", 3);
858        assert_eq!(tokens, vec!["abc"]);
859    }
860
861    // ── tokenize_subword ──────────────────────────────────────────────────
862
863    #[test]
864    fn test_tokenize_subword_short_word() {
865        // "hello" is 5 chars — not chunked (≤10)
866        let tokens = tokenize_subword("hello", 5000);
867        assert_eq!(tokens, vec!["hello"]);
868    }
869
870    #[test]
871    fn test_tokenize_subword_long_word() {
872        // "abcdefghijk" is 11 chars — chunked into 5-char pieces
873        let tokens = tokenize_subword("abcdefghijk", 5000);
874        // chunk_len = (5000/1000).clamp(3,5) = 5
875        assert_eq!(tokens[0], "abcde");
876        assert_eq!(tokens[1], "fghij");
877        assert_eq!(tokens[2], "k");
878    }
879
880    #[test]
881    fn test_tokenize_subword_mixed() {
882        let tokens = tokenize_subword("hi abcdefghijk", 5000);
883        assert_eq!(tokens[0], "hi");
884        assert_eq!(tokens[1], "abcde");
885    }
886
887    // ── tokenize_script_aware ─────────────────────────────────────────────
888
889    #[test]
890    fn test_tokenize_script_aware_latin_only() {
891        let tokens = tokenize_script_aware("hello world");
892        assert_eq!(tokens, vec!["hello", "world"]);
893    }
894
895    #[test]
896    fn test_tokenize_script_aware_mixed_scripts() {
897        let text = "hello Привет";
898        let tokens = tokenize_script_aware(text);
899        assert_eq!(tokens.len(), 2);
900        assert_eq!(tokens[0], "hello");
901        assert_eq!(tokens[1], "Привет");
902    }
903
904    #[test]
905    fn test_tokenize_script_aware_script_boundary_no_space() {
906        // Latin then Cyrillic without space
907        let text = "ABСа"; // AB = Latin, Са = Cyrillic
908        let tokens = tokenize_script_aware(text);
909        assert_eq!(tokens.len(), 2);
910    }
911
912    // ── MultilingualNormalizer full pipeline ──────────────────────────────
913
914    #[test]
915    fn test_normalize_lowercase() {
916        let opts = NormalizationOptions {
917            lowercase: true,
918            normalize_whitespace: false,
919            ..Default::default()
920        };
921        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
922        let result = norm.normalize("Hello World");
923        assert_eq!(result.normalized, "hello world");
924    }
925
926    #[test]
927    fn test_normalize_detected_script_and_language() {
928        let norm = default_norm();
929        let result = norm.normalize("Hello World");
930        assert_eq!(result.detected_script, Script::Latin);
931        assert_eq!(result.language_hint, LanguageHint::English);
932    }
933
934    #[test]
935    fn test_normalize_cyrillic_language_hint() {
936        let norm = default_norm();
937        let result = norm.normalize("Привет мир");
938        assert_eq!(result.detected_script, Script::Cyrillic);
939        assert_eq!(result.language_hint, LanguageHint::Russian);
940    }
941
942    #[test]
943    fn test_normalize_tokens_whitespace_strategy() {
944        let opts = NormalizationOptions {
945            lowercase: true,
946            ..Default::default()
947        };
948        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
949        let result = norm.normalize("foo bar baz");
950        assert_eq!(result.token_count, 3);
951        assert_eq!(result.tokens, vec!["foo", "bar", "baz"]);
952    }
953
954    #[test]
955    fn test_normalize_char_count() {
956        let opts = NormalizationOptions {
957            lowercase: false,
958            normalize_whitespace: false,
959            ..Default::default()
960        };
961        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
962        let result = norm.normalize("hello");
963        assert_eq!(result.char_count, 5);
964    }
965
966    #[test]
967    fn test_normalize_max_length() {
968        let opts = NormalizationOptions {
969            lowercase: false,
970            normalize_whitespace: false,
971            max_length: Some(3),
972            ..Default::default()
973        };
974        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
975        let result = norm.normalize("hello");
976        assert_eq!(result.normalized, "hel");
977        assert_eq!(result.char_count, 3);
978    }
979
980    #[test]
981    fn test_normalize_remove_punctuation() {
982        let opts = NormalizationOptions {
983            lowercase: false,
984            normalize_whitespace: false,
985            remove_punctuation: true,
986            ..Default::default()
987        };
988        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
989        let result = norm.normalize("hello, world!");
990        assert_eq!(result.normalized, "hello world");
991    }
992
993    #[test]
994    fn test_normalize_strip_accents() {
995        let opts = NormalizationOptions {
996            lowercase: false,
997            strip_accents: true,
998            normalize_whitespace: false,
999            ..Default::default()
1000        };
1001        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
1002        let result = norm.normalize("e\u{0300}");
1003        assert_eq!(result.normalized, "e");
1004    }
1005
1006    #[test]
1007    fn test_normalize_batch() {
1008        let norm = default_norm();
1009        let results = norm.normalize_batch(&["hello", "world", "foo"]);
1010        assert_eq!(results.len(), 3);
1011        assert_eq!(results[0].normalized, "hello");
1012        assert_eq!(results[1].normalized, "world");
1013    }
1014
1015    #[test]
1016    fn test_normalize_stats_increment() {
1017        let norm = default_norm();
1018        norm.normalize("hello world");
1019        norm.normalize("foo bar");
1020        let stats = norm.stats();
1021        assert_eq!(stats.total_processed, 2);
1022        assert!(stats.total_tokens_generated >= 4);
1023    }
1024
1025    #[test]
1026    fn test_stats_initial_zero() {
1027        let norm = default_norm();
1028        let stats = norm.stats();
1029        assert_eq!(stats.total_processed, 0);
1030        assert_eq!(stats.total_chars_removed, 0);
1031        assert_eq!(stats.total_tokens_generated, 0);
1032        assert_eq!(stats.avg_compression_ratio, 0.0);
1033    }
1034
1035    #[test]
1036    fn test_normalize_original_preserved() {
1037        let norm = default_norm();
1038        let result = norm.normalize("Hello World");
1039        assert_eq!(result.original, "Hello World");
1040    }
1041
1042    #[test]
1043    fn test_normalize_ngram_strategy() {
1044        let opts = NormalizationOptions {
1045            lowercase: false,
1046            normalize_whitespace: false,
1047            ..Default::default()
1048        };
1049        let norm = norm_with(opts, TokenizationStrategy::CharacterNgram { n: 2 });
1050        let result = norm.normalize("abc");
1051        assert_eq!(result.tokens, vec!["ab", "bc"]);
1052    }
1053
1054    #[test]
1055    fn test_normalize_subword_strategy() {
1056        let opts = NormalizationOptions {
1057            lowercase: false,
1058            normalize_whitespace: false,
1059            ..Default::default()
1060        };
1061        let norm = norm_with(opts, TokenizationStrategy::Subword { vocab_size: 5000 });
1062        let result = norm.normalize("abcdefghijk");
1063        // 11 chars → chunked
1064        assert!(result.token_count > 1);
1065    }
1066
1067    #[test]
1068    fn test_normalize_script_aware_strategy() {
1069        let opts = NormalizationOptions {
1070            lowercase: false,
1071            ..Default::default()
1072        };
1073        let norm = norm_with(opts, TokenizationStrategy::ScriptAware);
1074        let result = norm.normalize("hello Привет");
1075        assert_eq!(result.token_count, 2);
1076    }
1077
1078    #[test]
1079    fn test_normalize_script_filter() {
1080        let opts = NormalizationOptions {
1081            lowercase: false,
1082            normalize_whitespace: false,
1083            script_filter: Some(Script::Latin),
1084            ..Default::default()
1085        };
1086        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
1087        let result = norm.normalize("hello Привет");
1088        // Only Latin chars + spaces retained
1089        assert!(!result.normalized.contains('П'));
1090        assert!(result.normalized.contains("hello"));
1091    }
1092
1093    #[test]
1094    fn test_detect_language_method() {
1095        let norm = default_norm();
1096        assert_eq!(norm.detect_language("Hello"), LanguageHint::English);
1097        assert_eq!(norm.detect_language("Привет"), LanguageHint::Russian);
1098        assert_eq!(norm.detect_language("مرحبا"), LanguageHint::Arabic);
1099        assert_eq!(norm.detect_language("你好"), LanguageHint::Chinese);
1100    }
1101
1102    #[test]
1103    fn test_detect_script_method() {
1104        let norm = default_norm();
1105        assert_eq!(norm.detect_script("नमस्ते"), Script::Devanagari);
1106    }
1107
1108    #[test]
1109    fn test_language_hint_bcp47() {
1110        assert_eq!(LanguageHint::English.bcp47(), "en");
1111        assert_eq!(LanguageHint::Russian.bcp47(), "ru");
1112        assert_eq!(LanguageHint::Arabic.bcp47(), "ar");
1113        assert_eq!(LanguageHint::Chinese.bcp47(), "zh");
1114        assert_eq!(LanguageHint::Japanese.bcp47(), "ja");
1115        assert_eq!(LanguageHint::Hindi.bcp47(), "hi");
1116        assert_eq!(LanguageHint::Unknown.bcp47(), "und");
1117    }
1118
1119    #[test]
1120    fn test_script_name() {
1121        assert_eq!(Script::Latin.name(), "Latin");
1122        assert_eq!(Script::CJK.name(), "CJK");
1123        assert_eq!(Script::Unknown.name(), "Unknown");
1124    }
1125
1126    #[test]
1127    fn test_normalize_empty_string() {
1128        let norm = default_norm();
1129        let result = norm.normalize("");
1130        assert_eq!(result.normalized, "");
1131        assert_eq!(result.token_count, 0);
1132        assert_eq!(result.char_count, 0);
1133        assert_eq!(result.detected_script, Script::Unknown);
1134    }
1135
1136    #[test]
1137    fn test_normalize_numbers_only() {
1138        let norm = default_norm();
1139        let result = norm.normalize("12345");
1140        assert_eq!(result.detected_script, Script::Unknown);
1141        assert_eq!(result.language_hint, LanguageHint::Unknown);
1142    }
1143
1144    #[test]
1145    fn test_normalize_full_pipeline_order() {
1146        // lowercase → strip_accents → normalize_whitespace → remove_punctuation → max_length
1147        let opts = NormalizationOptions {
1148            lowercase: true,
1149            strip_accents: true,
1150            normalize_whitespace: true,
1151            remove_punctuation: true,
1152            max_length: Some(5),
1153            script_filter: None,
1154        };
1155        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
1156        let result = norm.normalize("  He\u{0300}llo,  World!  ");
1157        // After lowercase: "  he\u{0300}llo,  world!  "
1158        // After strip_accents: "  hello,  world!  "
1159        // After normalize_whitespace: "hello, world!"
1160        // After remove_punctuation: "hello world"
1161        // After max_length(5): "hello"
1162        assert_eq!(result.normalized, "hello");
1163    }
1164
1165    #[test]
1166    fn test_stats_chars_removed_tracked() {
1167        let opts = NormalizationOptions {
1168            lowercase: false,
1169            normalize_whitespace: false,
1170            strip_accents: false,
1171            remove_punctuation: true,
1172            max_length: None,
1173            script_filter: None,
1174        };
1175        let norm = norm_with(opts, TokenizationStrategy::Whitespace);
1176        norm.normalize("hello!!!");
1177        let stats = norm.stats();
1178        // "!!!" = 3 chars removed
1179        assert_eq!(stats.total_chars_removed, 3);
1180        assert!(stats.avg_compression_ratio > 0.0);
1181    }
1182}