1use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Script {
21 Latin,
23 Cyrillic,
25 Arabic,
27 CJK,
29 Devanagari,
31 Unknown,
33}
34
35impl Script {
36 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
49fn 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#[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 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#[derive(Debug, Clone)]
102pub struct NormalizationOptions {
103 pub lowercase: bool,
105 pub strip_accents: bool,
107 pub normalize_whitespace: bool,
110 pub remove_punctuation: bool,
112 pub max_length: Option<usize>,
114 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#[derive(Debug, Clone)]
138pub enum TokenizationStrategy {
139 Whitespace,
141 CharacterNgram {
143 n: usize,
145 },
146 Subword {
149 vocab_size: usize,
152 },
153 ScriptAware,
156}
157
158#[derive(Debug, Clone)]
164pub struct NormalizedText {
165 pub original: String,
167 pub normalized: String,
169 pub detected_script: Script,
171 pub language_hint: LanguageHint,
173 pub tokens: Vec<String>,
175 pub char_count: usize,
177 pub token_count: usize,
179}
180
181#[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#[derive(Debug, Clone)]
210pub struct NormalizerStats {
211 pub total_processed: u64,
213 pub total_chars_removed: u64,
215 pub total_tokens_generated: u64,
217 pub avg_compression_ratio: f64,
220}
221
222pub struct MultilingualNormalizer {
247 pub options: NormalizationOptions,
249 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 pub fn new(options: NormalizationOptions, tokenization: TokenizationStrategy) -> Self {
268 Self {
269 options,
270 tokenization,
271 stats: Arc::new(NormalizerRunStats::default()),
272 }
273 }
274
275 pub fn with_defaults() -> Self {
277 Self::new(
278 NormalizationOptions::default(),
279 TokenizationStrategy::Whitespace,
280 )
281 }
282
283 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 pub fn normalize_batch(&self, texts: &[&str]) -> Vec<NormalizedText> {
322 texts.iter().map(|t| self.normalize(t)).collect()
323 }
324
325 pub fn detect_script(&self, text: &str) -> Script {
330 detect_script_impl(text)
331 }
332
333 pub fn detect_language(&self, text: &str) -> LanguageHint {
335 let script = self.detect_script(text);
336 script_to_language_hint(script)
337 }
338
339 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 pub fn tokenize(&self, text: &str) -> Vec<String> {
382 tokenize_impl(text, &self.tokenization)
383 }
384
385 pub fn strip_combining_chars(&self, text: &str) -> String {
387 strip_combining_chars(text)
388 }
389
390 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 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
412fn 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 let max_count = *counts.values().max().unwrap_or(&0);
433
434 let leaders: Vec<Script> = counts
436 .iter()
437 .filter(|(_, &v)| v == max_count)
438 .map(|(&k, _)| k)
439 .collect();
440
441 if leaders.len() == 1 {
443 leaders[0]
444 } else {
445 Script::Unknown
446 }
447}
448
449fn 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
461fn 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
471fn 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 result.trim().to_owned()
490}
491
492fn remove_punctuation_impl(text: &str) -> String {
494 text.chars()
495 .filter(|c| c.is_alphanumeric() || *c == ' ')
496 .collect()
497}
498
499fn script_filter_impl(text: &str, script: Script) -> String {
501 text.chars()
502 .filter(|&c| c == ' ' || char_script(c) == script)
503 .collect()
504}
505
506fn 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
516fn tokenize_whitespace(text: &str) -> Vec<String> {
518 text.split_whitespace().map(|s| s.to_owned()).collect()
519}
520
521fn 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
537fn 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 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
564fn 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 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#[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 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 #[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); assert_eq!(char_script('ñ'), Script::Latin); }
650
651 #[test]
652 fn test_char_script_cyrillic() {
653 assert_eq!(char_script('А'), Script::Cyrillic); assert_eq!(char_script('я'), Script::Cyrillic); }
656
657 #[test]
658 fn test_char_script_arabic() {
659 assert_eq!(char_script('ع'), Script::Arabic); assert_eq!(char_script('م'), Script::Arabic); }
662
663 #[test]
664 fn test_char_script_cjk() {
665 assert_eq!(char_script('中'), Script::CJK); assert_eq!(char_script('文'), Script::CJK); }
668
669 #[test]
670 fn test_char_script_devanagari() {
671 assert_eq!(char_script('अ'), Script::Devanagari); assert_eq!(char_script('ह'), Script::Devanagari); }
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 #[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 let text = "ABcd АБвг"; let result = detect_script_impl(text);
719 assert_eq!(result, Script::Unknown);
720 }
721
722 #[test]
723 fn test_detect_script_only_numbers() {
724 assert_eq!(detect_script_impl("12345"), Script::Unknown);
726 }
727
728 #[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 #[test]
758 fn test_strip_combining_chars_basic() {
759 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 #[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 #[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 #[test]
808 fn test_script_filter_latin_only() {
809 let text = "Hello Привет";
810 let result = script_filter_impl(text, Script::Latin);
811 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 #[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 #[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 #[test]
864 fn test_tokenize_subword_short_word() {
865 let tokens = tokenize_subword("hello", 5000);
867 assert_eq!(tokens, vec!["hello"]);
868 }
869
870 #[test]
871 fn test_tokenize_subword_long_word() {
872 let tokens = tokenize_subword("abcdefghijk", 5000);
874 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 #[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 let text = "ABСа"; let tokens = tokenize_script_aware(text);
909 assert_eq!(tokens.len(), 2);
910 }
911
912 #[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 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 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 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 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 assert_eq!(stats.total_chars_removed, 3);
1180 assert!(stats.avg_compression_ratio > 0.0);
1181 }
1182}