Skip to main content

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}