raqeem_core/arabic.rs
1//! Arabic text normalization — a Rust port of scout's `normalize_ar` (from the
2//! `arabic-first` convention). We fold audio transcripts to a matching-stable form so
3//! downstream parsers (price extraction, commodity matching) never fuzzy-match raw,
4//! unnormalized Arabic.
5//!
6//! **The reference is `scout/arabic.py`, as of commit `0ffcf36`.** These two are one
7//! function maintained in two languages: a change to either has to land on both, or
8//! text folded on one side stops comparing equal to the same text folded on the other.
9//! That is not hypothetical — this port silently lagged the reference by one whole pass
10//! (`_FORMAT_STRIP`, below) between scout's `0ffcf36` and raqeem 0.3.0. The shared
11//! vectors in `tests/vectors/normalize_ar.json` are generated from the reference and
12//! read by both test suites; add cases there rather than to either suite alone.
13//!
14//! What it does: strip diacritics / tatweel / Quranic marks, strip invisible format
15//! controls, unify alef and hamza carriers, taa-marbuta → haa, Arabic-Indic & Persian
16//! digits → ASCII, and the Arabic decimal separator U+066B → `.` (so «١٢٫٥» folds to
17//! `12.5`, one number, not two).
18//!
19//! **Parity, precisely.** Verified identical across every Arabic block and all of ASCII —
20//! 258,176 single-character probes over the whole BMP plus astral samples, and 200,000
21//! randomised multi-character strings mixing Arabic, diacritics, Quranic marks, both digit
22//! sets, format controls, every whitespace class and the ASCII separators. Zero mismatches,
23//! zero idempotence failures on either side.
24//!
25//! The one accepted divergence is **case mapping of cased non-Arabic, non-ASCII letters**,
26//! and it is not a logic difference: the two runtimes carry different Unicode tables. Rust
27//! 1.93 lowercases eight characters in Cyrillic and Latin Extended-D that scout's Python
28//! (Unicode 15.0.0) does not yet know exist, and Python lowercases a word-final Greek `Σ`
29//! to `ς` where Rust gives `σ`. Nine codepoints, none of them in any Arabic block or in
30//! ASCII, and it resolves itself when that runtime's Unicode data catches up.
31
32/// What the reference collapses into a single space.
33///
34/// The reference's final step is `re.sub(r"\s+", " ", …)`, and Python's `\s` is **not**
35/// `char::is_whitespace`: it additionally matches the ASCII separators U+001C–001F
36/// (file / group / record / unit), which carry no Unicode `White_Space` property. Using
37/// `is_whitespace` alone left those four passing through where the reference removed
38/// them. Vanishingly rare in a transcript, but "identical output" is either true or it
39/// isn't, and an exhaustive sweep against the reference is what surfaced it.
40fn is_separator(ch: char) -> bool {
41 ch.is_whitespace() || matches!(ch, '\u{001C}'..='\u{001F}')
42}
43
44/// Fold Arabic text to a matching-stable form. Idempotent; ASCII passes through
45/// lower-cased, so it is safe to run unconditionally on any transcript.
46pub fn normalize_ar(text: &str) -> String {
47 let mut out = String::with_capacity(text.len());
48 // Whitespace runs are collapsed as we go rather than by re-splitting the finished
49 // string: a space is only committed once a non-space follows it, which trims both
50 // ends for free and saves building a `Vec<&str>` and a second `String`.
51 let mut pending_space = false;
52
53 for ch in text.chars() {
54 // Characters that vanish. Checked before whitespace so that a control sitting
55 // inside a run of spaces cannot break the run in two.
56 match ch {
57 // Quranic annotations, harakat/diacritics, tatweel, superscript alef.
58 '\u{0610}'..='\u{061A}'
59 | '\u{064B}'..='\u{065F}'
60 | '\u{0640}'
61 | '\u{0670}'
62 | '\u{06D6}'..='\u{06ED}' => continue,
63
64 // Invisible format controls: zero-width space and joiners, the bidi marks,
65 // embeddings, overrides and isolates, and the BOM. RTL text pasted out of a
66 // chat app or scraped off a web page carries these routinely; they render as
67 // nothing, mean nothing, and make two identical-looking strings compare
68 // unequal.
69 //
70 // Deliberately a separate arm rather than more ranges bolted onto the class
71 // above. Scout froze its `_ARABIC_STRIP` pattern after a mis-ordered
72 // character range silently swallowed the entire Arabic letter block
73 // (U+0621–064A) — additive-but-separate is the pattern that freeze set, and
74 // keeping the two files structurally alike is how they stay comparable.
75 '\u{200B}'..='\u{200F}'
76 | '\u{202A}'..='\u{202E}'
77 | '\u{2066}'..='\u{2069}'
78 | '\u{FEFF}' => continue,
79
80 _ => {}
81 }
82
83 if is_separator(ch) {
84 // Leading whitespace never becomes a pending space, so the result is trimmed.
85 pending_space = !out.is_empty();
86 continue;
87 }
88 if pending_space {
89 out.push(' ');
90 pending_space = false;
91 }
92
93 match ch {
94 // Alef variants → bare alef.
95 'آ' | 'أ' | 'إ' | 'ٱ' => out.push('ا'),
96 // Hamza-carrier and taa-marbuta folds.
97 'ى' => out.push('ي'), // alef maqsura → yaa
98 'ة' => out.push('ه'), // taa marbuta → haa
99 'ؤ' => out.push('و'),
100 'ئ' => out.push('ي'),
101 // Arabic-Indic (U+0660–0669) and Persian (U+06F0–06F9) digits → ASCII.
102 // `char::to_digit` is ASCII-only, so the offset arithmetic is the way; the
103 // match arms guarantee the subtraction stays in 0..=9.
104 '\u{0660}'..='\u{0669}' => out.push((b'0' + (ch as u32 - 0x0660) as u8) as char),
105 '\u{06F0}'..='\u{06F9}' => out.push((b'0' + (ch as u32 - 0x06F0) as u8) as char),
106 // Arabic decimal separator → ASCII point.
107 '\u{066B}' => out.push('.'),
108 // Everything else: lower-case ASCII, pass Arabic/other through unchanged.
109 other => out.extend(other.to_lowercase()),
110 }
111 }
112
113 out
114}
115
116#[cfg(test)]
117mod tests {
118 use super::normalize_ar;
119
120 #[test]
121 fn folds_alef_and_hamza_carriers() {
122 assert_eq!(normalize_ar("أ"), "ا");
123 assert_eq!(normalize_ar("إ"), "ا");
124 assert_eq!(normalize_ar("آ"), "ا");
125 assert_eq!(normalize_ar("ٱ"), "ا");
126 assert_eq!(normalize_ar("مصطفى"), "مصطفي"); // alef maqsura → yaa
127 assert_eq!(normalize_ar("مسؤول"), "مسوول"); // ؤ → و
128 }
129
130 #[test]
131 fn taa_marbuta_becomes_haa() {
132 let r = normalize_ar("طماطة");
133 assert!(!r.contains('ة'));
134 assert!(r.ends_with('ه'));
135 }
136
137 #[test]
138 fn strips_tatweel_and_diacritics() {
139 assert_eq!(normalize_ar("سـلام"), "سلام"); // tatweel U+0640
140 assert_eq!(normalize_ar("سَلاَم"), "سلام"); // harakat
141 }
142
143 #[test]
144 fn digits_and_decimal_separator() {
145 assert_eq!(normalize_ar("١٢٣"), "123"); // arabic-indic
146 assert_eq!(normalize_ar("۱۲۳"), "123"); // persian
147 assert_eq!(normalize_ar("١٢٫٥"), "12.5"); // U+066B decimal → one number
148 }
149
150 #[test]
151 fn ascii_lowercased_and_whitespace_collapsed() {
152 assert_eq!(normalize_ar("Tomato"), "tomato");
153 assert_eq!(normalize_ar(" a b "), "a b");
154 assert_eq!(normalize_ar("a\t\tb"), "a b");
155 assert_eq!(normalize_ar(""), "");
156 assert_eq!(normalize_ar(" "), "");
157 }
158
159 /// The pass this port was missing. Each of these renders as nothing on screen, so
160 /// without stripping them two visually identical strings compare unequal.
161 #[test]
162 fn strips_invisible_format_controls() {
163 assert_eq!(normalize_ar("طم\u{200C}طم"), "طمطم"); // ZWNJ
164 assert_eq!(normalize_ar("طم\u{200B}طم"), "طمطم"); // ZWSP
165 assert_eq!(normalize_ar("طم\u{200D}طم"), "طمطم"); // ZWJ
166 assert_eq!(normalize_ar("\u{200F}مرحبا"), "مرحبا"); // RLM
167 assert_eq!(normalize_ar("\u{200E}مرحبا"), "مرحبا"); // LRM
168 assert_eq!(normalize_ar("\u{FEFF}مرحبا"), "مرحبا"); // BOM
169 assert_eq!(normalize_ar("\u{2066}مرحبا\u{2069}"), "مرحبا"); // isolate
170 assert_eq!(normalize_ar("\u{202A}مرحبا\u{202C}"), "مرحبا"); // embedding
171 assert_eq!(normalize_ar("\u{202E}مرحبا\u{202C}"), "مرحبا"); // override
172 }
173
174 /// A control adjacent to a space must not split one whitespace run into two.
175 #[test]
176 fn a_format_control_next_to_a_space_does_not_create_one() {
177 assert_eq!(normalize_ar("طماطم\u{200B} ١٢٫٥"), "طماطم 12.5");
178 assert_eq!(normalize_ar("طماطم \u{200B}طم"), "طماطم طم");
179 assert_eq!(normalize_ar("\u{200B} طم"), "طم");
180 assert_eq!(normalize_ar("طم \u{200B}"), "طم");
181 }
182
183 #[test]
184 fn idempotent() {
185 let samples = [
186 "أحمد",
187 "طماطة ١٢٫٥ جنيه",
188 "Hello World",
189 "سـ__لامٌ عليكم",
190 "\u{2066}طم\u{200C}طم\u{2069}",
191 ];
192 for s in samples {
193 let once = normalize_ar(s);
194 assert_eq!(normalize_ar(&once), once, "not idempotent on {s:?}");
195 }
196 }
197
198 /// The previous implementation, kept verbatim to prove that rewriting the whitespace
199 /// collapse into the main loop changed nothing. It predates the `_FORMAT_STRIP` pass,
200 /// so the sweep below skips the characters that pass intentionally changed.
201 fn reference_before_the_rewrite(text: &str) -> String {
202 let mut out = String::with_capacity(text.len());
203 for ch in text.chars() {
204 match ch {
205 '\u{0610}'..='\u{061A}'
206 | '\u{064B}'..='\u{065F}'
207 | '\u{0640}'
208 | '\u{0670}'
209 | '\u{06D6}'..='\u{06ED}' => {}
210 'آ' | 'أ' | 'إ' | 'ٱ' => out.push('ا'),
211 'ى' => out.push('ي'),
212 'ة' => out.push('ه'),
213 'ؤ' => out.push('و'),
214 'ئ' => out.push('ي'),
215 '\u{0660}'..='\u{0669}' => out.push((b'0' + (ch as u32 - 0x0660) as u8) as char),
216 '\u{06F0}'..='\u{06F9}' => out.push((b'0' + (ch as u32 - 0x06F0) as u8) as char),
217 '\u{066B}' => out.push('.'),
218 other => out.extend(other.to_lowercase()),
219 }
220 }
221 out.split_whitespace().collect::<Vec<_>>().join(" ")
222 }
223
224 /// Characters this release deliberately changed the handling of, so the equivalence
225 /// sweep must skip them: the `_FORMAT_STRIP` set that the port was missing, and the
226 /// four ASCII separators that Python's `\s` collapses but `char::is_whitespace`
227 /// does not.
228 fn is_deliberately_changed(c: char) -> bool {
229 matches!(c,
230 '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
231 || matches!(c, '\u{001C}'..='\u{001F}')
232 }
233
234 /// Is this one of the invisible controls the new `_FORMAT_STRIP` arm removes?
235 fn is_format_control(c: char) -> bool {
236 matches!(c,
237 '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}')
238 }
239
240 #[test]
241 fn the_whitespace_rewrite_changed_nothing_else() {
242 // Every char raqeem realistically sees: Arabic, Arabic Supplement/Extended,
243 // Arabic Presentation Forms, ASCII, Latin-1, and the whitespace/control edges.
244 let ranges = [
245 0x0009u32..=0x000D,
246 0x0020..=0x00FF,
247 0x0600..=0x06FF,
248 0x0750..=0x077F,
249 0x08A0..=0x08FF,
250 0x2000..=0x206F,
251 0xFB50..=0xFDFF,
252 0xFE70..=0xFEFF,
253 ];
254 for range in ranges {
255 for cp in range {
256 let Some(c) = char::from_u32(cp) else {
257 continue;
258 };
259 if is_deliberately_changed(c) {
260 continue;
261 }
262 // Bare, and wrapped in context so whitespace handling is exercised too.
263 for probe in [
264 c.to_string(),
265 format!(" a {c} b "),
266 format!("{c}{c} طم {c}"),
267 ] {
268 assert_eq!(
269 normalize_ar(&probe),
270 reference_before_the_rewrite(&probe),
271 "diverged on U+{cp:04X} in {probe:?}"
272 );
273 }
274 }
275 }
276 }
277
278 #[test]
279 fn every_format_control_is_stripped_and_was_not_already() {
280 for cp in 0x0000u32..=0x2FFF {
281 let Some(c) = char::from_u32(cp) else {
282 continue;
283 };
284 if !is_format_control(c) {
285 continue;
286 }
287 let probe = format!("طم{c}طم");
288 assert_eq!(normalize_ar(&probe), "طمطم", "U+{cp:04X} not stripped");
289 assert_ne!(
290 reference_before_the_rewrite(&probe),
291 "طمطم",
292 "U+{cp:04X} was already handled; it does not belong in the new arm"
293 );
294 }
295 }
296
297 /// The reference's `re.sub(r"\s+", " ", …)` treats these as whitespace; Rust's
298 /// `char::is_whitespace` does not. Found by sweeping every codepoint against the
299 /// reference rather than by picking cases — the vector file would never have
300 /// contained a record separator.
301 #[test]
302 fn the_ascii_separators_collapse_like_whitespace() {
303 for c in ['\u{001C}', '\u{001D}', '\u{001E}', '\u{001F}'] {
304 assert_eq!(
305 normalize_ar(&c.to_string()),
306 "",
307 "U+{:04X} survived",
308 c as u32
309 );
310 assert_eq!(normalize_ar(&format!("طم{c}طم")), "طم طم");
311 assert_eq!(normalize_ar(&format!("طم {c} طم")), "طم طم");
312 assert_eq!(normalize_ar(&format!("{c}طم{c}")), "طم");
313 }
314 }
315}