khmer_tokenizer_core/normalize.rs
1//! Orthographic normalization: canonicalizes well-documented, real-world
2//! Khmer Unicode encoding errors before segmentation, so messy input matches
3//! the same dictionary entries as canonically-encoded text. On by default
4//! (see [`KhmerTokenizer::without_normalization`](crate::KhmerTokenizer::without_normalization)).
5//!
6//! Per the Unicode Khmer syllable structure, a base is followed by, in
7//! order: an optional Robat, the subscript stack (one or more
8//! `COENG`+consonant pairs), an optional shifter, a dependent vowel, then
9//! other signs. Two corruptions of that order are repaired here, both by
10//! rightward rotation (never insertion or deletion):
11//!
12//! 1. **Mark typed before the subscript stack** — e.g. "សិទិ្ធ" for the
13//! correct "សិទ្ធិ" (confirmed present in khPOS's own gold corpus:
14//! `docs/BENCHMARKS.md` Phase 5). A mark immediately followed by a
15//! `COENG`+consonant pair is moved to follow the pair.
16//! 2. **Mark stranded *between* `COENG` and its consonant** — the damage
17//! Unicode normalization itself inflicts. Khmer's canonical combining
18//! classes are erroneous and frozen (Unicode TN61 §"Normalization"):
19//! `COENG` has ccc=9 while `U+17DD` ATTHACAN has ccc=230, so NFC/NFD
20//! canonically reorders a typed `<ATTHACAN, COENG>` into
21//! `<COENG, ATTHACAN>` — splitting the `COENG` from the consonant it
22//! subscripts, which would make [`crate::split_kcc`] mis-cluster. Any
23//! NFC-processing pipeline (very common in web scraping) can produce
24//! this. The stranded mark is moved past the consonant, converging on
25//! the same canonical form rule 1 produces.
26//!
27//! Robat (`U+17CC`) is excluded from both rules: it's the one mark that's
28//! *supposed* to precede the subscript stack. The zero-width joiners
29//! `U+200C`/`U+200D` are also excluded — their meaning is tied to their
30//! exact position (e.g. requesting an alternate shifter or subscript
31//! rendering form), so reordering them would change rendering semantics.
32//! Stripping stray joiners entirely was considered and rejected: deleting
33//! characters changes byte length, which would break the eval harness's
34//! span-based scoring (and any caller relying on byte-accurate boundaries)
35//! without an offset map back to the original text — and khPOS's corpus
36//! has zero such occurrences to measure against anyway (see
37//! `docs/ROADMAP.md` Phase 5).
38
39use crate::kcc::{is_khmer_base, is_khmer_combining, COENG, ROBAT};
40
41/// A mark the reorder rules are allowed to move: a Khmer combining mark
42/// that is not `COENG` itself, not Robat (legitimately precedes the
43/// subscript stack), and not a zero-width joiner (position-sensitive
44/// rendering semantics).
45fn is_reorderable_mark(c: char) -> bool {
46 is_khmer_combining(c) && c != COENG && c != ROBAT && !matches!(c as u32, 0x200C..=0x200D)
47}
48
49/// Canonicalize `text`'s Khmer encoding before segmentation. Idempotent:
50/// normalizing already-canonical (or already-normalized) text is a no-op.
51/// Byte-length-preserving: only reorders characters, never adds or removes
52/// any, so span offsets over the original text stay valid.
53pub fn normalize(text: &str) -> String {
54 let mut chars: Vec<char> = text.chars().collect();
55
56 // Fixed point: one rotation can expose a new match (e.g. a mark ahead
57 // of two stacked subscripts cascades through both), so repeat until
58 // nothing changes. Both rules only ever move marks rightward, so this
59 // terminates; combining runs are short, so it converges fast.
60 loop {
61 let mut changed = false;
62 let mut i = 0;
63 while i + 2 < chars.len() {
64 // Rule 1: mark typed before a COENG+consonant pair.
65 if is_reorderable_mark(chars[i]) && chars[i + 1] == COENG {
66 chars[i..i + 3].rotate_left(1);
67 changed = true;
68 }
69 // Rule 2: mark stranded between COENG and the base it
70 // subscripts (NFC canonical-reordering damage). Only fires
71 // when a base actually follows — a trailing or doubly-stranded
72 // mark has no provably-correct repair and is left alone.
73 else if chars[i] == COENG
74 && is_reorderable_mark(chars[i + 1])
75 && is_khmer_base(chars[i + 2])
76 {
77 chars[i + 1..i + 3].rotate_left(1);
78 changed = true;
79 }
80 i += 1;
81 }
82 if !changed {
83 break;
84 }
85 }
86
87 chars.into_iter().collect()
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn reorders_a_vowel_typed_before_a_subscript() {
96 // "សិទិ្ធ" (a common real-world typo for "សិទ្ធិ", "rights"):
97 // the vowel ិ attached to ទ was typed before ្ធ instead of after.
98 assert_eq!(normalize("សិទិ្ធ"), "សិទ្ធិ");
99 }
100
101 #[test]
102 fn reorders_a_shifter_typed_before_a_subscript() {
103 // Base ប + shifter ៊ + COENG-ល + vowel ិ, in that (wrong) order,
104 // should become base + COENG-ល + shifter + vowel.
105 let malformed = "ប\u{17CA}\u{17D2}\u{179B}\u{17B7}";
106 let canonical = "ប\u{17D2}\u{179B}\u{17CA}\u{17B7}";
107 assert_eq!(normalize(malformed), canonical);
108 }
109
110 #[test]
111 fn leaves_a_legitimate_robat_before_a_subscript_alone() {
112 // Robat is the one mark that's supposed to precede the subscript
113 // stack — this must not be touched.
114 let already_canonical = "ក\u{17CC}\u{17D2}\u{1780}";
115 assert_eq!(normalize(already_canonical), already_canonical);
116 }
117
118 #[test]
119 fn is_a_no_op_on_already_canonical_text() {
120 assert_eq!(normalize("កម្ពុជា"), "កម្ពុជា");
121 assert_eq!(normalize("សិទ្ធិ"), "សិទ្ធិ");
122 }
123
124 #[test]
125 fn is_idempotent() {
126 let malformed = "សិទិ្ធ";
127 let once = normalize(malformed);
128 let twice = normalize(&once);
129 assert_eq!(once, twice);
130 }
131
132 #[test]
133 fn preserves_byte_length() {
134 let malformed = "សិទិ្ធ";
135 assert_eq!(normalize(malformed).len(), malformed.len());
136 }
137
138 #[test]
139 fn cascades_through_a_mark_before_a_doubly_stacked_subscript() {
140 // A vowel typed before two stacked subscripts should end up after
141 // both, not just the first.
142 let malformed = "ស\u{17B7}\u{17D2}\u{178F}\u{17D2}\u{179A}";
143 let canonical = "ស\u{17D2}\u{178F}\u{17D2}\u{179A}\u{17B7}";
144 assert_eq!(normalize(malformed), canonical);
145 }
146
147 #[test]
148 fn repairs_nfc_stranded_sign_between_coeng_and_consonant() {
149 // Khmer's frozen-erroneous combining classes (ccc(COENG)=9,
150 // ccc(ATTHACAN)=230) mean NFC turns a typed <base, ATTHACAN,
151 // COENG, base> into <base, COENG, ATTHACAN, base>, splitting the
152 // COENG from its consonant (Unicode TN61). Rule 2 moves the
153 // stranded sign past the consonant.
154 let nfc_damaged = "ក\u{17D2}\u{17DD}ក";
155 let canonical = "ក\u{17D2}ក\u{17DD}";
156 assert_eq!(normalize(nfc_damaged), canonical);
157 }
158
159 #[test]
160 fn both_corruption_orders_converge_on_the_same_canonical_form() {
161 // The same underlying word corrupted two different ways — the raw
162 // typed error (mark before COENG, rule 1) and its NFC-processed
163 // form (mark after COENG, rule 2) — must normalize identically.
164 let typed_error = "ក\u{17DD}\u{17D2}ក"; // what the typist produced
165 let nfc_damaged = "ក\u{17D2}\u{17DD}ក"; // same text after NFC
166 assert_eq!(normalize(typed_error), normalize(nfc_damaged));
167 assert_eq!(normalize(typed_error), "ក\u{17D2}ក\u{17DD}");
168 }
169
170 #[test]
171 fn leaves_zero_width_joiners_alone_in_both_positions() {
172 // ZWNJ/ZWJ meaning is tied to exact position (they request
173 // alternate rendering forms), so neither rule may move them.
174 let zwnj_before_coeng = "ក\u{200C}\u{17D2}ក";
175 assert_eq!(normalize(zwnj_before_coeng), zwnj_before_coeng);
176 let zwj_after_coeng = "ក\u{17D2}\u{200D}ក";
177 assert_eq!(normalize(zwj_after_coeng), zwj_after_coeng);
178 }
179
180 #[test]
181 fn leaves_a_trailing_stranded_mark_alone() {
182 // COENG + mark at end of text: no following base to pair the
183 // COENG with, so there's no provably-correct repair — don't guess.
184 let dangling = "ក\u{17D2}\u{17DD}";
185 assert_eq!(normalize(dangling), dangling);
186 }
187
188 #[test]
189 fn nfc_repair_is_idempotent_and_length_preserving() {
190 let nfc_damaged = "ក\u{17D2}\u{17DD}ក";
191 let once = normalize(nfc_damaged);
192 assert_eq!(normalize(&once), once);
193 assert_eq!(once.len(), nfc_damaged.len());
194 }
195}