obadh_engine 0.8.1

A linguistically accurate Roman to Bengali transliteration engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Consonant-skeleton matching for the missing-vowel autocorrect channel.
//!
//! A skeleton is the ordered sequence of a word's base consonants with all vowels,
//! vowel-signs, hasant and marks removed. Two words with the same skeleton are candidates
//! for one another when vowels have been dropped (baseline `ক্রল্ম` and its intended
//! spelling `করলাম` both fold to the same key). The corpus is the sole validator.
//!
//! ## No second index — we query the lexicon fst directly
//!
//! `bn.fst` already stores every word and its frequency. Rather than duplicate that into a
//! separate `skeleton → word` index (megabytes of redundant word/frequency bytes), we read
//! skeleton-mates straight out of `bn.fst` with a custom [`fst::Automaton`]
//! ([`SkeletonAutomaton`]) — the same mechanism the Levenshtein corrector already uses. Zero
//! extra storage, full lexicon coverage, and it can never drift out of sync with `bn.fst`.
//!
//! ## What is folded, and why it is NOT the varga grid
//!
//! The traditional varga grid is *Sanskrit* phonology and does not describe *modern
//! Bengali* sound similarity, so we do NOT derive folding from it. Instead the fold set is
//! grounded in the documented grapheme→IPA mapping (Wikipedia *Help:IPA/Bengali* and
//! *Bengali phonology*): we fold only letters that share an IDENTICAL phoneme:
//!   - শ/ষ  → [ʃ]
//!   - জ/য  → [dʒ]
//!   - ণ/ন  → [n]
//!   - ড়/ঢ় → [ɽ]
//!
//! Everything else is kept DISTINCT — including pairs that are merely *nearby*:
//!   - স [s] ≠ শ/ষ [ʃ]   (adjacent fricatives — a graded phonetic confusion, not identical)
//!   - র [ɾ] ≠ ড়/ঢ় [ɽ]   (tap vs retroflex flap)
//!   - ট [ʈ] ≠ ত [t], ক [k] ≠ খ [kʰ]  (place / aspiration are phonemic)
//!
//! Those nearby-phoneme confusions (স↔শ, র↔ড়) and the `t↔T`/`k↔kh` *typing* confusions
//! belong to graded phonetic and keyboard channels — with costs from feature distance —
//! not to a hard skeleton fold.

use fst::automaton::Automaton;

const PHOTA: char = '\u{09BC}';

/// Class for a base consonant. `phota` is true when the following combining phota
/// (U+09BC) was seen, forming ড়/ঢ়/য় in NFC-decomposed text; the precomposed forms
/// (U+09DC/U+09DD/U+09DF) are matched directly.
fn consonant_class(ch: char, phota: bool) -> Option<u8> {
    if phota {
        return Some(match ch {
            '' => b'R', // ড় [ɽ]
            '' => b'R', // ঢ় [ɽ]
            '' => b'y', // য়
            _ => return consonant_class(ch, false),
        });
    }
    Some(match ch {
        // velar
        '' => b'k',
        '' => b'K',
        '' => b'g',
        '' => b'G',
        '' => b'Y',
        // palatal
        '' => b'c',
        '' => b'C',
        '' | '' => b'j', // homophone fold: জ/য both [dʒ]
        '' => b'J',
        '' => b'V',
        // retroflex (place kept distinct from dental)
        '' => b'T',
        '' => b'U',
        '' => b'D',
        '' => b'E',
        // dental
        '' | '' => b't', // ৎ (khanda-ta) is [t̪] = ত
        '' => b'W',
        '' => b'd',
        '' => b'F',
        // dental/retroflex nasal merged in modern Bengali
        '' | '' => b'n',
        // labial
        '' => b'p',
        '' => b'P',
        '' => b'b',
        '' => b'B',
        '' => b'm',
        // liquids: র [ɾ] and ড়/ঢ় [ɽ] are DISTINCT phonemes (nearby, not identical —
        // the র↔ড় confusion is handled by the graded phonetic channel, not folded here).
        '' => b'r',
        '\u{09DC}' | '\u{09DD}' => b'R', // precomposed ড়/ঢ়
        '' => b'l',
        // sibilants: শ/ষ are both [ʃ] (fold); স is [s], a distinct phoneme.
        '' | '' => b's',
        '' => b'S',
        '' => b'h',
        '\u{09DF}' => b'y', // precomposed য়
        _ => return None,
    })
}

/// Skeletons of one consonant collide with too much of the lexicon; overlong ones are
/// words where a dropped vowel is not the dominant error.
pub const MIN_SKELETON_LEN: usize = 2;
pub const MAX_SKELETON_LEN: usize = 12;

/// Compute the consonant skeleton of a Bengali word. Empty for pure-vowel words.
pub fn consonant_skeleton(word: &str) -> String {
    let mut skeleton = Vec::with_capacity(word.len() / 3 + 1);
    let mut chars = word.chars().peekable();
    while let Some(ch) = chars.next() {
        let phota = chars.peek() == Some(&PHOTA);
        if phota {
            chars.next(); // consume the combining phota
        }
        if let Some(class) = consonant_class(ch, phota) {
            skeleton.push(class);
        }
    }
    String::from_utf8(skeleton).unwrap_or_default()
}

/// Whether a skeleton is in the indexable/lookup length band.
pub fn is_indexable_skeleton(skeleton: &str) -> bool {
    (MIN_SKELETON_LEN..=MAX_SKELETON_LEN).contains(&skeleton.len())
}

/// A lexicon word that shares the query's consonant skeleton.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkeletonMatch {
    pub word: String,
    pub frequency: u64,
}

/// An [`fst::Automaton`] accepting exactly the lexicon words whose consonant skeleton equals
/// `target`. It reads skeleton-mates straight from the main lexicon fst — no separate index.
/// Vowels, vowel-signs, hasant and other marks are skipped; a combining phota folds into the
/// consonant it follows, mirroring [`consonant_skeleton`] byte-for-byte.
///
/// Matching is *exact*: the word's full consonant sequence must equal `target` (a word with
/// an extra trailing consonant, e.g. skeleton `krn` for query `kr`, is rejected).
pub struct SkeletonAutomaton {
    /// Consonant class bytes — the alphabet emitted by `consonant_class`.
    target: Vec<u8>,
}

impl SkeletonAutomaton {
    /// Build from a baseline word. Returns `None` when the baseline's skeleton is not in the
    /// indexable length band, so the caller can skip the channel entirely.
    pub fn for_baseline(baseline: &str) -> Option<Self> {
        let skeleton = consonant_skeleton(baseline);
        if !is_indexable_skeleton(&skeleton) {
            return None;
        }
        Some(Self {
            target: skeleton.into_bytes(),
        })
    }

    /// Commit a held consonant `ch` (optionally folded with a following phota): advance if it
    /// matches the next expected class, otherwise kill the branch.
    fn commit(&self, mut state: SkeletonState, ch: char, phota: bool) -> SkeletonState {
        match consonant_class(ch, phota) {
            Some(class) => {
                if state.matched < self.target.len() && class == self.target[state.matched] {
                    state.matched += 1;
                    state.held = None;
                } else {
                    state.dead = true;
                }
            }
            // Defensive: a held char that is not a consonant class (should not happen, since
            // we only hold consonants) is treated as a no-op skip.
            None => state.held = None,
        }
        state
    }

    /// Advance the match on a fully decoded character.
    fn on_char(&self, mut state: SkeletonState, ch: char) -> SkeletonState {
        if ch == PHOTA {
            // Fold into the held consonant, if any; a floating phota is ignored.
            if let Some(held) = state.held.take() {
                return self.commit(state, held, true);
            }
            return state;
        }
        // A non-phota char first commits any held consonant (no phota followed it)...
        if let Some(held) = state.held.take() {
            state = self.commit(state, held, false);
            if state.dead {
                return state;
            }
        }
        // ...then either holds this consonant (a phota may still follow) or skips a
        // vowel / vowel-sign / hasant / other mark.
        if consonant_class(ch, false).is_some() {
            state.held = Some(ch);
        }
        state
    }

    /// The class the held consonant would commit to at word end (no trailing phota), if any.
    fn trailing_matched(&self, state: &SkeletonState) -> Option<usize> {
        match state.held {
            None => Some(state.matched),
            Some(held) => match consonant_class(held, false) {
                Some(class)
                    if state.matched < self.target.len()
                        && class == self.target[state.matched] =>
                {
                    Some(state.matched + 1)
                }
                Some(_) => None, // held trailing consonant does not match → not a word match
                None => Some(state.matched),
            },
        }
    }
}

/// Incremental UTF-8 + skeleton-match state for one path through the lexicon fst.
#[derive(Clone)]
pub struct SkeletonState {
    /// Consonant classes committed so far (index into `target`).
    matched: usize,
    /// A consonant awaiting a possible following phota before it is committed.
    held: Option<char>,
    /// Partial UTF-8 bytes of the character currently being decoded.
    pending: [u8; 4],
    pending_len: u8,
    expected_len: u8,
    dead: bool,
}

impl SkeletonState {
    fn dead(&self) -> Self {
        let mut next = self.clone();
        next.dead = true;
        next
    }
}

impl Automaton for SkeletonAutomaton {
    type State = SkeletonState;

    fn start(&self) -> Self::State {
        SkeletonState {
            matched: 0,
            held: None,
            pending: [0; 4],
            pending_len: 0,
            expected_len: 0,
            dead: false,
        }
    }

    fn is_match(&self, state: &Self::State) -> bool {
        if state.dead || state.pending_len != 0 {
            return false;
        }
        self.trailing_matched(state) == Some(self.target.len())
    }

    fn can_match(&self, state: &Self::State) -> bool {
        !state.dead
    }

    fn accept(&self, state: &Self::State, byte: u8) -> Self::State {
        if state.dead {
            return state.clone();
        }

        if state.pending_len == 0 {
            if byte <= 0x7f {
                // A stray ASCII byte is not part of a Bangla grapheme: treat as a skip
                // (after committing any held consonant).
                return self.on_char(state.clone(), byte as char);
            }
            let Some(expected_len) = utf8_expected_len(byte) else {
                return state.dead();
            };
            let mut next = state.clone();
            next.pending = [0; 4];
            next.pending[0] = byte;
            next.pending_len = 1;
            next.expected_len = expected_len;
            return next;
        }

        if !is_utf8_continuation(byte) {
            return state.dead();
        }

        let mut next = state.clone();
        next.pending[next.pending_len as usize] = byte;
        next.pending_len += 1;
        if next.pending_len < next.expected_len {
            return next;
        }

        let bytes = &next.pending[..next.pending_len as usize];
        let Ok(decoded) = std::str::from_utf8(bytes) else {
            return state.dead();
        };
        let Some(ch) = decoded.chars().next() else {
            return state.dead();
        };
        next.pending = [0; 4];
        next.pending_len = 0;
        next.expected_len = 0;
        self.on_char(next, ch)
    }
}

fn utf8_expected_len(byte: u8) -> Option<u8> {
    match byte {
        0xc2..=0xdf => Some(2),
        0xe0..=0xef => Some(3),
        0xf0..=0xf4 => Some(4),
        _ => None,
    }
}

fn is_utf8_continuation(byte: u8) -> bool {
    matches!(byte, 0x80..=0xbf)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn baseline_and_target_share_a_skeleton() {
        // A vowel-dropped baseline folds to the intended word's skeleton.
        assert_eq!(consonant_skeleton("ক্রল্ম"), consonant_skeleton("করলাম"));
        assert_eq!(consonant_skeleton("দখলাম"), consonant_skeleton("দেখলাম"));
        assert_eq!(consonant_skeleton("ত্মক"), consonant_skeleton("তোমাকে"));
    }

    #[test]
    fn folds_only_identical_phonemes() {
        // Identical-phoneme folds (so মানুষ/মানুশ share a skeleton):
        assert_eq!(consonant_skeleton(""), consonant_skeleton("")); // both [ʃ]
        assert_eq!(consonant_skeleton(""), consonant_skeleton("")); // both [dʒ]
        assert_eq!(consonant_skeleton(""), consonant_skeleton("")); // both [n]
        assert_eq!(consonant_skeleton("ড়"), consonant_skeleton("ঢ়")); // both [ɽ]
        assert_eq!(consonant_skeleton("মানুষ"), consonant_skeleton("মানুশ"));
    }

    #[test]
    fn nearby_and_distinct_phonemes_stay_distinct() {
        // Nearby but NOT identical — a graded phonetic/keyboard channel handles these:
        assert_ne!(consonant_skeleton(""), consonant_skeleton("")); // [s] vs [ʃ]
        assert_ne!(consonant_skeleton(""), consonant_skeleton("ড়")); // [ɾ] vs [ɽ]
        assert_ne!(consonant_skeleton(""), consonant_skeleton("")); // retroflex vs dental
        assert_ne!(consonant_skeleton(""), consonant_skeleton(""));
        assert_ne!(consonant_skeleton(""), consonant_skeleton("")); // aspiration is phonemic
        assert_ne!(consonant_skeleton(""), consonant_skeleton(""));
        assert_ne!(consonant_skeleton(""), consonant_skeleton("")); // জ folds with য, not ঝ
    }

    #[test]
    fn vowels_signs_hasant_and_marks_are_transparent() {
        assert_eq!(consonant_skeleton("আওই"), "");
        assert_eq!(consonant_skeleton("কাজ"), consonant_skeleton("কজ"));
    }

    #[test]
    fn length_band_excludes_singletons_and_overlong() {
        assert!(!is_indexable_skeleton("k"));
        assert!(is_indexable_skeleton("kj"));
    }

    // ---- automaton: it must accept exactly the words with the query skeleton ----

    /// Reference: does `word` have exactly `baseline`'s skeleton (what the automaton must do)?
    fn shares_skeleton(baseline: &str, word: &str) -> bool {
        consonant_skeleton(baseline) == consonant_skeleton(word)
            && is_indexable_skeleton(&consonant_skeleton(baseline))
    }

    /// Drive the automaton over a word's UTF-8 bytes exactly as the fst walk would.
    fn automaton_accepts(baseline: &str, word: &str) -> bool {
        let Some(automaton) = SkeletonAutomaton::for_baseline(baseline) else {
            return false;
        };
        let mut state = automaton.start();
        for &byte in word.as_bytes() {
            if !automaton.can_match(&state) {
                return false;
            }
            state = automaton.accept(&state, byte);
        }
        automaton.is_match(&state)
    }

    #[test]
    fn automaton_matches_skeleton_mates_and_rejects_others() {
        // Cross-check the automaton against the reference skeleton equality on real words.
        let cases = [
            ("ক্রল্ম", "করলাম", true),  // vowel-dropped baseline → intended word
            ("করলাম", "করলাম", true),   // itself
            ("দখলাম", "দেখলাম", true),
            ("মানুশ", "মানুষ", true),    // শ/ষ homophone fold
            ("কর", "করা", true),         // trailing vowel is transparent
            ("কর", "করান", false),       // extra consonant ন → longer skeleton, rejected
            ("কর", "কম", false),         // second consonant differs
            ("কর", "আকর", true),         // leading vowel is transparent
        ];
        for (baseline, word, expected) in cases {
            assert_eq!(
                automaton_accepts(baseline, word),
                expected,
                "automaton({baseline}, {word}) should be {expected}"
            );
            // The automaton must agree with the reference definition it implements.
            if is_indexable_skeleton(&consonant_skeleton(baseline)) {
                assert_eq!(
                    automaton_accepts(baseline, word),
                    shares_skeleton(baseline, word),
                    "automaton disagrees with skeleton equality for ({baseline}, {word})"
                );
            }
        }
    }

    #[test]
    fn automaton_folds_phota_like_the_skeleton() {
        // বড় (ব + ড + phota) and its vowelled forms share skeleton bR.
        assert!(automaton_accepts("বড়", "বড়"));
        assert!(automaton_accepts("বড়", "বড়ি"));
        // র (rhotic) must NOT match ড় (flap): distinct phonemes.
        assert!(!automaton_accepts("বর", "বড়"));
    }
}