Skip to main content

kham_core/
abbrev.rs

1//! Thai abbreviation expansion.
2//!
3//! [`AbbrevMap`] maps abbreviated Thai forms (including dot-containing patterns
4//! such as `ก.ค.` and `พ.ศ.`) to their canonical expansions. It is used in two
5//! ways:
6//!
7//! 1. **Pre-tokenisation** — [`AbbrevMap::expand_text`] scans raw text and
8//!    replaces every known abbreviation with its primary expansion before the
9//!    text is passed to the segmenter. This is how the FTS pipeline uses it.
10//!
11//! 2. **Post-tokenisation lookup** — [`AbbrevMap::lookup`] checks whether a
12//!    single already-segmented token is a known abbreviation and returns all
13//!    its expansions.
14//!
15//! ## Data format
16//!
17//! A tab-separated file, one rule per line:
18//!
19//! ```text
20//! # abbreviated_form    primary_expansion   [alt1   alt2...]
21//! ก.ค.    กรกฎาคม
22//! พ.ศ.    พุทธศักราช
23//! ดร.     ดอกเตอร์
24//! อ.      อาจารย์     อำเภอ
25//! ```
26//!
27//! Lines beginning with `#` and blank lines are ignored. Duplicate keys are
28//! merged — later entries append to the expansion list.
29//!
30//! ## Greedy matching
31//!
32//! [`expand_text`] performs greedy **longest-first** matching: at each position
33//! the longest matching key is consumed. This ensures compound forms like
34//! `ศ.ดร.` (11 bytes) shadow the shorter `ศ.` (4 bytes) when both are in the
35//! map.
36//!
37//! # Examples
38//!
39//! ```rust
40//! use kham_core::abbrev::AbbrevMap;
41//!
42//! let map = AbbrevMap::builtin();
43//!
44//! // Pre-tokenisation expansion
45//! assert_eq!(map.expand_text("วันที่5ก.ค.2567"), "วันที่5กรกฎาคม2567");
46//! assert_eq!(map.expand_text("พ.ศ.2567"), "พุทธศักราช2567");
47//!
48//! // Single-token lookup
49//! let exps = map.lookup("ดร.").unwrap();
50//! assert_eq!(exps, &["ดอกเตอร์"]);
51//! ```
52//!
53//! [`expand_text`]: AbbrevMap::expand_text
54
55use alloc::collections::BTreeMap;
56use alloc::string::String;
57use alloc::vec::Vec;
58use core::cmp::Reverse;
59
60// ---------------------------------------------------------------------------
61// AbbrevMap
62// ---------------------------------------------------------------------------
63
64/// Abbreviation lookup and expansion table.
65///
66/// Construct once via [`AbbrevMap::builtin`] or [`AbbrevMap::from_tsv`] and
67/// reuse across calls.
68pub struct AbbrevMap {
69    /// O(log n) lookup by exact key.
70    map: BTreeMap<String, Vec<String>>,
71    /// Keys paired with their primary expansion, sorted by byte-length DESC.
72    /// Used by [`expand_text`] for greedy longest-first scanning.
73    ///
74    /// [`expand_text`]: AbbrevMap::expand_text
75    sorted: Vec<(String, String)>,
76}
77
78impl AbbrevMap {
79    /// Create an empty map with no entries.
80    pub fn empty() -> Self {
81        Self {
82            map: BTreeMap::new(),
83            sorted: Vec::new(),
84        }
85    }
86
87    /// Load the built-in Thai abbreviation dictionary.
88    ///
89    /// Includes month abbreviations (`ม.ค.`–`ธ.ค.`), era markers (`พ.ศ.`,
90    /// `ค.ศ.`), academic titles (`ดร.`, `ศ.`, `รศ.`, `ผศ.`), and common
91    /// administrative abbreviations (`จ.`, `ต.`, `ถ.`, `ซ.`).
92    pub fn builtin() -> Self {
93        Self::from_tsv(include_str!("../data/abbrev_th.tsv"))
94    }
95
96    /// Parse a tab-separated abbreviation table.
97    ///
98    /// Format: `abbrev\tprimary_expansion[\talt1\talt2…]` — one rule per line.
99    /// Lines beginning with `#` and blank lines are skipped.
100    /// Duplicate keys are merged: later entries append to the expansion list
101    /// and the *last* duplicate's first column becomes the primary expansion
102    /// used by [`expand_text`].
103    pub fn from_tsv(data: &str) -> Self {
104        let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
105
106        for line in data.lines() {
107            let line = line.trim();
108            if line.is_empty() || line.starts_with('#') {
109                continue;
110            }
111            let mut cols = line.split('\t');
112            let key = match cols.next() {
113                Some(k) if !k.is_empty() => String::from(k),
114                _ => continue,
115            };
116            let expansions: Vec<String> = cols
117                .map(str::trim)
118                .filter(|s| !s.is_empty())
119                .map(String::from)
120                .collect();
121            if expansions.is_empty() {
122                continue;
123            }
124            map.entry(key).or_default().extend(expansions);
125        }
126
127        // Build the sorted pairs (key, primary_expansion) for expand_text.
128        // Primary = first expansion in the merged list.
129        let mut sorted: Vec<(String, String)> =
130            map.iter().map(|(k, v)| (k.clone(), v[0].clone())).collect();
131        // Longest key first so greedy matching prefers ศ.ดร. over ศ.
132        sorted.sort_by_key(|pair| Reverse(pair.0.len()));
133
134        Self { map, sorted }
135    }
136
137    /// Return all expansions for `abbrev`, or `None` if not in the map.
138    ///
139    /// # Examples
140    ///
141    /// ```rust
142    /// use kham_core::abbrev::AbbrevMap;
143    ///
144    /// let map = AbbrevMap::builtin();
145    /// let exps = map.lookup("ก.ค.").unwrap();
146    /// assert_eq!(exps[0], "กรกฎาคม");
147    ///
148    /// // Ambiguous abbreviation — multiple expansions.
149    /// let exps = map.lookup("อ.").unwrap();
150    /// assert!(exps.contains(&"อาจารย์".to_string()));
151    ///
152    /// assert_eq!(map.lookup("unknown"), None);
153    /// ```
154    pub fn lookup(&self, abbrev: &str) -> Option<&[String]> {
155        self.map.get(abbrev).map(Vec::as_slice)
156    }
157
158    /// Scan `text` and replace every occurrence of a known abbreviation key
159    /// with its **primary** expansion (the first expansion in the TSV row).
160    ///
161    /// Matching is **greedy and longest-first**: at each position the longest
162    /// matching key is consumed before advancing. Characters that do not start
163    /// any key are copied through unchanged.
164    ///
165    /// Returns an owned [`String`]. Allocates only when at least one replacement
166    /// is made; otherwise returns a copy of the input.
167    ///
168    /// # Examples
169    ///
170    /// ```rust
171    /// use kham_core::abbrev::AbbrevMap;
172    ///
173    /// let map = AbbrevMap::builtin();
174    ///
175    /// assert_eq!(map.expand_text("ก.ค."), "กรกฎาคม");
176    /// assert_eq!(map.expand_text("5ก.ค.2567"), "5กรกฎาคม2567");
177    /// assert_eq!(map.expand_text("ไม่มีอะไร"), "ไม่มีอะไร");
178    ///
179    /// // Compound title: ศ.ดร. matched before ศ.
180    /// assert_eq!(map.expand_text("ศ.ดร.สมชาย"), "ศาสตราจารย์ดอกเตอร์สมชาย");
181    /// ```
182    pub fn expand_text(&self, text: &str) -> String {
183        if self.sorted.is_empty() || text.is_empty() {
184            return String::from(text);
185        }
186
187        let mut result = String::with_capacity(text.len());
188        let mut i = 0usize; // byte position
189
190        'outer: while i < text.len() {
191            let remaining = &text[i..];
192            for (key, expansion) in &self.sorted {
193                if remaining.starts_with(key.as_str()) {
194                    result.push_str(expansion);
195                    i += key.len();
196                    continue 'outer;
197                }
198            }
199            // No key matched — copy one Unicode scalar value through unchanged.
200            let c = remaining.chars().next().unwrap();
201            result.push(c);
202            i += c.len_utf8();
203        }
204
205        result
206    }
207
208    /// Return the number of entries in the map.
209    pub fn len(&self) -> usize {
210        self.map.len()
211    }
212
213    /// Return `true` if the map contains no entries.
214    pub fn is_empty(&self) -> bool {
215        self.map.is_empty()
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Tests
221// ---------------------------------------------------------------------------
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn mini() -> AbbrevMap {
228        AbbrevMap::from_tsv("ก.ค.\tกรกฎาคม\nพ.ศ.\tพุทธศักราช\nดร.\tดอกเตอร์\nศ.ดร.\tศาสตราจารย์ดอกเตอร์\nศ.\tศาสตราจารย์\nอ.\tอาจารย์\tอำเภอ\n")
229    }
230
231    // ── construction ─────────────────────────────────────────────────────────
232
233    #[test]
234    fn empty_has_no_entries() {
235        let m = AbbrevMap::empty();
236        assert!(m.is_empty());
237        assert_eq!(m.len(), 0);
238    }
239
240    #[test]
241    fn from_tsv_parses_entries() {
242        let m = mini();
243        assert!(!m.is_empty());
244        assert!(m.len() >= 5);
245    }
246
247    #[test]
248    fn from_tsv_skips_comments_and_blanks() {
249        let m = AbbrevMap::from_tsv("# comment\n\nก.ค.\tกรกฎาคม\n");
250        assert_eq!(m.len(), 1);
251    }
252
253    #[test]
254    fn from_tsv_skips_lines_without_expansion() {
255        let m = AbbrevMap::from_tsv("ก.ค.\n");
256        assert_eq!(m.len(), 0);
257    }
258
259    #[test]
260    fn from_tsv_duplicate_keys_merge() {
261        let m = AbbrevMap::from_tsv("อ.\tอาจารย์\nอ.\tอำเภอ\n");
262        let exps = m.lookup("อ.").unwrap();
263        assert!(exps.contains(&String::from("อาจารย์")));
264        assert!(exps.contains(&String::from("อำเภอ")));
265    }
266
267    // ── lookup ────────────────────────────────────────────────────────────────
268
269    #[test]
270    fn lookup_known_key() {
271        let m = mini();
272        let exps = m.lookup("ก.ค.").expect("ก.ค. should be in map");
273        assert_eq!(exps, &[String::from("กรกฎาคม")]);
274    }
275
276    #[test]
277    fn lookup_unknown_key_returns_none() {
278        let m = mini();
279        assert_eq!(m.lookup("xyz"), None);
280    }
281
282    #[test]
283    fn lookup_ambiguous_returns_all() {
284        let m = mini();
285        let exps = m.lookup("อ.").unwrap();
286        assert!(exps.contains(&String::from("อาจารย์")));
287        assert!(exps.contains(&String::from("อำเภอ")));
288    }
289
290    // ── expand_text ───────────────────────────────────────────────────────────
291
292    #[test]
293    fn expand_single_abbreviation() {
294        let m = mini();
295        assert_eq!(m.expand_text("ก.ค."), "กรกฎาคม");
296    }
297
298    #[test]
299    fn expand_in_context() {
300        let m = mini();
301        assert_eq!(m.expand_text("5ก.ค.2567"), "5กรกฎาคม2567");
302    }
303
304    #[test]
305    fn expand_multiple_abbreviations() {
306        let m = mini();
307        assert_eq!(m.expand_text("พ.ศ.2567ก.ค."), "พุทธศักราช2567กรกฎาคม");
308    }
309
310    #[test]
311    fn expand_no_match_returns_original() {
312        let m = mini();
313        assert_eq!(m.expand_text("ไม่มีอะไร"), "ไม่มีอะไร");
314    }
315
316    #[test]
317    fn expand_empty_input() {
318        let m = mini();
319        assert_eq!(m.expand_text(""), "");
320    }
321
322    #[test]
323    fn expand_greedy_longest_first() {
324        // ศ.ดร. (longer) must win over ศ. (shorter) at the same position.
325        let m = mini();
326        assert_eq!(m.expand_text("ศ.ดร.สมชาย"), "ศาสตราจารย์ดอกเตอร์สมชาย");
327    }
328
329    #[test]
330    fn expand_shorter_key_after_no_long_match() {
331        // ศ. alone (no ดร. following) → ศาสตราจารย์
332        let m = mini();
333        assert_eq!(m.expand_text("ศ.สมชาย"), "ศาสตราจารย์สมชาย");
334    }
335
336    #[test]
337    fn expand_empty_map_returns_original() {
338        let m = AbbrevMap::empty();
339        assert_eq!(m.expand_text("ก.ค."), "ก.ค.");
340    }
341
342    // ── builtin ───────────────────────────────────────────────────────────────
343
344    #[test]
345    fn builtin_has_all_months() {
346        let m = AbbrevMap::builtin();
347        let months = [
348            ("ม.ค.", "มกราคม"),
349            ("ก.พ.", "กุมภาพันธ์"),
350            ("มี.ค.", "มีนาคม"),
351            ("เม.ย.", "เมษายน"),
352            ("พ.ค.", "พฤษภาคม"),
353            ("มิ.ย.", "มิถุนายน"),
354            ("ก.ค.", "กรกฎาคม"),
355            ("ส.ค.", "สิงหาคม"),
356            ("ก.ย.", "กันยายน"),
357            ("ต.ค.", "ตุลาคม"),
358            ("พ.ย.", "พฤศจิกายน"),
359            ("ธ.ค.", "ธันวาคม"),
360        ];
361        for (abbr, expected) in months {
362            let exps = m
363                .lookup(abbr)
364                .unwrap_or_else(|| panic!("{abbr} missing from builtin"));
365            assert_eq!(
366                exps[0], expected,
367                "primary expansion of {abbr} should be {expected}"
368            );
369        }
370    }
371
372    #[test]
373    fn builtin_has_era_markers() {
374        let m = AbbrevMap::builtin();
375        assert!(m.lookup("พ.ศ.").is_some());
376        assert!(m.lookup("ค.ศ.").is_some());
377    }
378
379    #[test]
380    fn builtin_expands_date_sentence() {
381        let m = AbbrevMap::builtin();
382        let result = m.expand_text("วันที่5ก.ค.พ.ศ.2567");
383        assert!(result.contains("กรกฎาคม"), "got: {result}");
384        assert!(result.contains("พุทธศักราช"), "got: {result}");
385    }
386
387    // ── month ambiguity — ต.ค. over ต. ──────────────────────────────────────
388
389    #[test]
390    fn october_matches_before_tambon() {
391        // ต.ค. (ตุลาคม) must beat ต. (ตำบล) at the same position.
392        let m = AbbrevMap::builtin();
393        let result = m.expand_text("ต.ค.นี้");
394        assert_eq!(result, "ตุลาคมนี้", "got: {result}");
395    }
396
397    #[test]
398    fn tambon_matches_when_not_october() {
399        let m = AbbrevMap::builtin();
400        // ต.สุขุมวิท — ต. not followed by ค. → expands to ตำบล
401        let result = m.expand_text("ต.สุขุมวิท");
402        assert_eq!(result, "ตำบลสุขุมวิท", "got: {result}");
403    }
404
405    // ── police ranks ──────────────────────────────────────────────────────────
406
407    #[test]
408    fn police_generals_expand() {
409        let m = AbbrevMap::builtin();
410        assert_eq!(m.expand_text("พล.ต.อ.วิชัย"), "พลตำรวจเอกวิชัย");
411        assert_eq!(m.expand_text("พล.ต.ท.สมชาย"), "พลตำรวจโทสมชาย");
412        assert_eq!(m.expand_text("พล.ต.ต.สมศรี"), "พลตำรวจตรีสมศรี");
413    }
414
415    #[test]
416    fn police_officers_expand() {
417        let m = AbbrevMap::builtin();
418        assert_eq!(m.expand_text("พ.ต.อ.ณรงค์"), "พันตำรวจเอกณรงค์");
419        assert_eq!(m.expand_text("ร.ต.อ.มานะ"), "ร้อยตำรวจเอกมานะ");
420        assert_eq!(m.expand_text("ด.ต.ประสิทธิ์"), "ดาบตำรวจประสิทธิ์");
421    }
422
423    #[test]
424    fn police_ncos_expand() {
425        let m = AbbrevMap::builtin();
426        assert_eq!(m.expand_text("ส.ต.อ.บุญมี"), "สิบตำรวจเอกบุญมี");
427        assert_eq!(m.expand_text("ส.ต.ต.สุรชัย"), "สิบตำรวจตรีสุรชัย");
428    }
429
430    // ── army ranks ────────────────────────────────────────────────────────────
431
432    #[test]
433    fn army_generals_expand() {
434        let m = AbbrevMap::builtin();
435        assert_eq!(m.expand_text("พล.อ.ประยุทธ์"), "พลเอกประยุทธ์");
436        assert_eq!(m.expand_text("พล.ท.สกล"), "พลโทสกล");
437        assert_eq!(m.expand_text("พล.ต.ชาติ"), "พลตรีชาติ");
438    }
439
440    #[test]
441    fn army_officers_expand() {
442        let m = AbbrevMap::builtin();
443        assert_eq!(m.expand_text("พ.อ.วีระ"), "พันเอกวีระ");
444        assert_eq!(m.expand_text("ร.อ.ธนู"), "ร้อยเอกธนู");
445        assert_eq!(m.expand_text("จ.ส.อ.สมพร"), "จ่าสิบเอกสมพร");
446    }
447
448    // ── police vs army disambiguation ─────────────────────────────────────────
449
450    #[test]
451    fn police_longer_form_shadows_army_shorter_form() {
452        let m = AbbrevMap::builtin();
453        // พล.ต.อ. (police general) must NOT expand as พล.ต. (army major general)
454        let result = m.expand_text("พล.ต.อ.สมบัติ");
455        assert_eq!(result, "พลตำรวจเอกสมบัติ", "got: {result}");
456        // พล.ต. standalone → army
457        let result2 = m.expand_text("พล.ต.วิเชียร");
458        assert_eq!(result2, "พลตรีวิเชียร", "got: {result2}");
459    }
460
461    #[test]
462    fn พตอ_is_police_not_army() {
463        let m = AbbrevMap::builtin();
464        assert_eq!(m.expand_text("พ.ต.อ.กล้า"), "พันตำรวจเอกกล้า");
465        // พ.ต. alone is army
466        assert_eq!(m.expand_text("พ.ต.ดำ"), "พันตรีดำ");
467    }
468
469    // ── navy and air force ────────────────────────────────────────────────────
470
471    #[test]
472    fn navy_admirals_expand() {
473        let m = AbbrevMap::builtin();
474        assert_eq!(m.expand_text("พล.ร.อ.ชุมพล"), "พลเรือเอกชุมพล");
475        assert_eq!(m.expand_text("พล.ร.ต.สิทธิ"), "พลเรือตรีสิทธิ");
476    }
477
478    #[test]
479    fn airforce_generals_shadow_army_พลอ() {
480        let m = AbbrevMap::builtin();
481        // พล.อ.อ. must expand as Air Force, not as พล.อ. (Army) + อ.
482        assert_eq!(m.expand_text("พล.อ.อ.ประจิน"), "พลอากาศเอกประจิน");
483        assert_eq!(m.expand_text("พล.อ.ท.มานัต"), "พลอากาศโทมานัต");
484    }
485
486    // ── state enterprises ─────────────────────────────────────────────────────
487
488    #[test]
489    fn state_enterprises_expand() {
490        let m = AbbrevMap::builtin();
491        assert_eq!(m.lookup("กฟผ.").unwrap()[0], "การไฟฟ้าฝ่ายผลิตแห่งประเทศไทย");
492        assert_eq!(m.lookup("กฟน.").unwrap()[0], "การไฟฟ้านครหลวง");
493        assert_eq!(m.lookup("กฟภ.").unwrap()[0], "การไฟฟ้าส่วนภูมิภาค");
494        assert_eq!(m.lookup("รฟท.").unwrap()[0], "การรถไฟแห่งประเทศไทย");
495        assert_eq!(
496            m.lookup("รฟม.").unwrap()[0],
497            "การรถไฟฟ้าขนส่งมวลชนแห่งประเทศไทย"
498        );
499        assert_eq!(m.lookup("ปตท.").unwrap()[0], "การปิโตรเลียมแห่งประเทศไทย");
500    }
501
502    #[test]
503    fn banking_agencies_expand() {
504        let m = AbbrevMap::builtin();
505        assert_eq!(
506            m.lookup("ธ.ก.ส.").unwrap()[0],
507            "ธนาคารเพื่อการเกษตรและสหกรณ์การเกษตร"
508        );
509        assert_eq!(m.lookup("ธอส.").unwrap()[0], "ธนาคารอาคารสงเคราะห์");
510        assert_eq!(m.lookup("กบข.").unwrap()[0], "กองทุนบำเหน็จบำนาญข้าราชการ");
511    }
512
513    // ── government agencies ───────────────────────────────────────────────────
514
515    #[test]
516    fn government_agencies_expand() {
517        let m = AbbrevMap::builtin();
518        assert_eq!(m.lookup("กทม.").unwrap()[0], "กรุงเทพมหานคร");
519        assert_eq!(m.lookup("กกต.").unwrap()[0], "คณะกรรมการการเลือกตั้ง");
520        assert_eq!(
521            m.lookup("ป.ป.ช.").unwrap()[0],
522            "คณะกรรมการป้องกันและปราบปรามการทุจริตแห่งชาติ"
523        );
524        assert_eq!(
525            m.lookup("สสส.").unwrap()[0],
526            "สำนักงานกองทุนสนับสนุนการสร้างเสริมสุขภาพ"
527        );
528    }
529
530    #[test]
531    fn กทม_expands_in_text() {
532        let m = AbbrevMap::builtin();
533        assert_eq!(m.expand_text("กทม."), "กรุงเทพมหานคร");
534        assert_eq!(m.expand_text("ผู้ว่ากทม."), "ผู้ว่ากรุงเทพมหานคร");
535    }
536}