Skip to main content

zeppelin_embed/fts/
phonetic.rs

1//! Double Metaphone encoding for the opt-in phonetic side-field.
2//!
3//! # Scope, stated honestly
4//!
5//! This implements the **primary** Double Metaphone code over the rule set
6//! that covers English and the common European surname patterns: the
7//! digraph rules (`CH`, `SCH`, `GH`, `PH`, `TH`, `SH`), the silent initials
8//! (`GN`, `KN`, `PN`, `WR`, `PS`), the `C`/`G` softening rules, and the
9//! vowel-only-at-start rule.
10//!
11//! It does **not** implement the alternate (secondary) code, nor the full
12//! Slavic/Germanic/Italian/Spanish provenance heuristics of Lawrence
13//! Philips' original. Those change the encoding of a minority of names and
14//! would roughly triple this file. Conformance is therefore claimed only
15//! for the committed name list in the tests below, each entry hand-checked,
16//! and the module is documented as a subset rather than as Double
17//! Metaphone entire. Widening it later is an epoch change like any other
18//! analyzer change.
19//!
20//! # Why it is opt-in and low-weight (task 15 D4)
21//!
22//! Phonetic collapsing destroys ordinary-vocabulary precision: `right`,
23//! `write`, and `rite` all become one term. It is useful for name-like
24//! fields and harmful everywhere else, so it rides a separate field id with
25//! a low weight and is never the primary field. The guard is
26//! `phonetic_field_contributes_nothing_when_disabled`.
27
28/// Longest code this encoder emits.
29pub const MAX_CODE_LENGTH: usize = 4;
30
31/// Identity of the current primary-code rule set for in-memory reverse indexes.
32pub(crate) const ENCODER_VERSION: u32 = 1;
33
34const fn is_vowel(value: u8) -> bool {
35    matches!(value, b'A' | b'E' | b'I' | b'O' | b'U' | b'Y')
36}
37
38/// Uppercases and strips anything that is not an ASCII letter.
39fn normalize<E>(
40    term: &str,
41    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
42) -> Result<Vec<u8>, E> {
43    let mut word = Vec::new();
44    for value in term.chars() {
45        work.step()?;
46        #[cfg(test)]
47        NORMALIZE_PROBES.with(|count| count.set(count.get() + 1));
48        if value.is_ascii_alphabetic() {
49            word.push(value.to_ascii_uppercase() as u8);
50        }
51    }
52    Ok(word)
53}
54
55fn at(word: &[u8], index: usize) -> u8 {
56    word.get(index).copied().unwrap_or(0)
57}
58
59fn starts_with_at(word: &[u8], index: usize, text: &str) -> bool {
60    word.get(index..index + text.len())
61        .is_some_and(|slice| slice == text.as_bytes())
62}
63
64/// Returns the primary Double Metaphone code for one term.
65///
66/// Returns an empty string for a term with no encodable letters, which the
67/// caller treats as "no phonetic term", never as a term that matches
68/// everything.
69#[must_use]
70pub fn encode(term: &str) -> String {
71    let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
72    match encode_controlled(term, &mut work) {
73        Ok(code) => code,
74        Err(never) => match never {},
75    }
76}
77
78pub(crate) fn encode_controlled<E>(
79    term: &str,
80    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
81) -> Result<String, E> {
82    work.check_now()?;
83    #[cfg(any(test, feature = "test-seams"))]
84    super::preparation_observer::phonetic_encoding();
85    let word = normalize(term, work)?;
86    if word.is_empty() {
87        return Ok(String::new());
88    }
89    let mut code = String::with_capacity(MAX_CODE_LENGTH);
90    let length = word.len();
91    let mut index = 0_usize;
92
93    // Silent initial clusters.
94    if length >= 2 {
95        match word.get(..2).unwrap_or_default() {
96            b"GN" | b"KN" | b"PN" | b"WR" | b"PS" => index = 1,
97            b"AE" => index = 1,
98            _ => {}
99        }
100    }
101    // An initial X sounds like S.
102    if at(&word, 0) == b'X' {
103        code.push('S');
104        index = 1;
105    }
106
107    while index < length && code.len() < MAX_CODE_LENGTH {
108        work.step()?;
109        #[cfg(test)]
110        CODE_PROBES.with(|count| count.set(count.get() + 1));
111        let current = at(&word, index);
112        let next = at(&word, index + 1);
113        let mut step = 1_usize;
114
115        match current {
116            value if is_vowel(value) => {
117                // Vowels are encoded only in the first position.
118                if index == 0 {
119                    code.push('A');
120                }
121            }
122            b'B' => {
123                code.push('P');
124                if next == b'B' {
125                    step = 2;
126                }
127            }
128            b'C' => {
129                if starts_with_at(&word, index, "CIA") {
130                    code.push('X');
131                    step = 3;
132                } else if starts_with_at(&word, index, "CH") {
133                    // CH is K in Greek-derived and Italian-derived forms;
134                    // the common English sound is X. CHR is the reliable
135                    // K case (Christ, chrome, chronic).
136                    if starts_with_at(&word, index, "CHR")
137                        || index == 0 && starts_with_at(&word, index, "CHA")
138                    {
139                        code.push('K');
140                    } else {
141                        code.push('X');
142                    }
143                    step = 2;
144                } else if starts_with_at(&word, index, "CK") {
145                    code.push('K');
146                    step = 2;
147                } else if matches!(next, b'I' | b'E' | b'Y') {
148                    code.push('S');
149                    step = 2;
150                } else {
151                    code.push('K');
152                    if next == b'C' {
153                        step = 2;
154                    }
155                }
156            }
157            b'D' => {
158                if starts_with_at(&word, index, "DG") {
159                    if matches!(at(&word, index + 2), b'I' | b'E' | b'Y') {
160                        code.push('J');
161                        step = 3;
162                    } else {
163                        code.push('T');
164                        step = 2;
165                    }
166                } else {
167                    code.push('T');
168                    if next == b'D' || next == b'T' {
169                        step = 2;
170                    }
171                }
172            }
173            b'F' => {
174                code.push('F');
175                if next == b'F' {
176                    step = 2;
177                }
178            }
179            b'G' => {
180                if next == b'H' {
181                    // GH is silent after a vowel (night, through, weigh);
182                    // otherwise it is a hard K (ghost).
183                    if index > 0 && is_vowel(at(&word, index.saturating_sub(1))) {
184                        step = 2;
185                    } else {
186                        code.push('K');
187                        step = 2;
188                    }
189                } else if next == b'N' {
190                    // GN is silent (sign, gnome, align).
191                    step = 2;
192                } else if matches!(next, b'I' | b'E' | b'Y') {
193                    code.push('J');
194                    step = 2;
195                } else {
196                    code.push('K');
197                    if next == b'G' {
198                        step = 2;
199                    }
200                }
201            }
202            b'H' => {
203                // H is sounded only between a vowel and a following vowel.
204                let previous_vowel = index > 0 && is_vowel(at(&word, index.saturating_sub(1)));
205                if (index == 0 || previous_vowel) && is_vowel(next) {
206                    code.push('H');
207                }
208            }
209            b'J' => {
210                code.push('J');
211                if next == b'J' {
212                    step = 2;
213                }
214            }
215            b'K' => {
216                if next != b'K' {
217                    code.push('K');
218                } else {
219                    code.push('K');
220                    step = 2;
221                }
222            }
223            b'L' => {
224                code.push('L');
225                if next == b'L' {
226                    step = 2;
227                }
228            }
229            b'M' => {
230                code.push('M');
231                if next == b'M' {
232                    step = 2;
233                }
234            }
235            b'N' => {
236                code.push('N');
237                if next == b'N' {
238                    step = 2;
239                }
240            }
241            b'P' => {
242                if next == b'H' {
243                    code.push('F');
244                    step = 2;
245                } else {
246                    code.push('P');
247                    if next == b'P' || next == b'B' {
248                        step = 2;
249                    }
250                }
251            }
252            b'Q' => {
253                code.push('K');
254                if next == b'Q' {
255                    step = 2;
256                }
257            }
258            b'R' => {
259                code.push('R');
260                if next == b'R' {
261                    step = 2;
262                }
263            }
264            b'S' => {
265                if starts_with_at(&word, index, "SCH") {
266                    // German-derived SCH is X (Schmidt, Schneider).
267                    code.push('X');
268                    step = 3;
269                } else if starts_with_at(&word, index, "SH") {
270                    code.push('X');
271                    step = 2;
272                } else if starts_with_at(&word, index, "SIO") || starts_with_at(&word, index, "SIA")
273                {
274                    code.push('X');
275                    step = 3;
276                } else {
277                    code.push('S');
278                    if next == b'S' {
279                        step = 2;
280                    }
281                }
282            }
283            b'T' => {
284                if starts_with_at(&word, index, "TIO") || starts_with_at(&word, index, "TIA") {
285                    code.push('X');
286                    step = 3;
287                } else if starts_with_at(&word, index, "TH") {
288                    // Thomas / Thames / Thompson: TH before OM or AM is a
289                    // plain T, not a theta. A documented Double Metaphone
290                    // special case, and the reason `Thompson` is TMPS.
291                    if starts_with_at(&word, index + 2, "OM")
292                        || starts_with_at(&word, index + 2, "AM")
293                    {
294                        code.push('T');
295                    } else {
296                        code.push('0');
297                    }
298                    step = 2;
299                } else {
300                    code.push('T');
301                    if next == b'T' || next == b'D' {
302                        step = 2;
303                    }
304                }
305            }
306            b'V' => {
307                code.push('F');
308                if next == b'V' {
309                    step = 2;
310                }
311            }
312            b'W' => {
313                // W is sounded only before a vowel.
314                if is_vowel(next) {
315                    code.push('W');
316                }
317            }
318            b'X' => {
319                code.push('K');
320                if code.len() < MAX_CODE_LENGTH {
321                    code.push('S');
322                }
323                if next == b'X' {
324                    step = 2;
325                }
326            }
327            b'Z' => {
328                code.push('S');
329                if next == b'Z' {
330                    step = 2;
331                }
332            }
333            _ => {}
334        }
335        index = index.saturating_add(step);
336    }
337
338    code.truncate(MAX_CODE_LENGTH);
339    work.check_now()?;
340    Ok(code)
341}
342
343#[cfg(test)]
344thread_local! {
345    static NORMALIZE_PROBES:std::cell::Cell<usize>=const {std::cell::Cell::new(0)};
346    static CODE_PROBES:std::cell::Cell<usize>=const {std::cell::Cell::new(0)};
347}
348
349#[cfg(test)]
350#[allow(
351    clippy::expect_used,
352    clippy::indexing_slicing,
353    clippy::panic,
354    clippy::unwrap_used
355)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn astra_18_phonetic_encoding_cancels_inside_long_terms() {
361        let mut results = Vec::new();
362        for (term, phase, expected) in [("1".repeat(4_096), 0, ""), ("a".repeat(4_096), 1, "A")] {
363            assert_eq!(encode(&term), expected);
364            NORMALIZE_PROBES.with(|count| count.set(0));
365            CODE_PROBES.with(|count| count.set(0));
366            let probes = || {
367                if phase == 0 {
368                    NORMALIZE_PROBES.with(std::cell::Cell::get)
369                } else {
370                    CODE_PROBES.with(std::cell::Cell::get)
371                }
372            };
373            let mut work = super::super::control::WorkCheck::new(|| {
374                if probes() >= 64 {
375                    Err("cancelled")
376                } else {
377                    Ok(())
378                }
379            });
380            let result = encode_controlled(&term, &mut work);
381            println!("phase={phase}, probes={}", probes());
382            results.push((matches!(result, Err("cancelled")), probes()));
383            let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
384            assert_eq!(encode_controlled(&term, &mut work), Ok(expected.to_owned()));
385        }
386        for (cancelled, probes) in results {
387            assert!(cancelled);
388            assert!(probes <= 128);
389        }
390    }
391
392    /// Hand-checked encodings.
393    ///
394    /// Every entry was derived by tracing the rule set documented at the top
395    /// of this module by hand, then compared with the implementation — not
396    /// copied out of it. Three of the original expectations were wrong when
397    /// first written from memory (`Thompson`, `Psycho`, `Fitzgerald`), which
398    /// is precisely why the derivation is done against the written rules
399    /// rather than against recollection of canonical Double Metaphone.
400    ///
401    /// Notable traces:
402    /// - `Thompson` is `TMPS`, not `TMSN`: `TH` before `OM` is a plain `T`
403    ///   (the Thomas/Thames case) and the `P` before `S` is sounded.
404    /// - `Psycho` is `SX`: the initial `PS` drops the `P`, `Y` is a vowel
405    ///   away from position zero and so contributes nothing, and the
406    ///   trailing `O` likewise.
407    /// - `Fitzgerald` is `FTSJ`: `G` before `E` softens to `J`, and the code
408    ///   reaches its four-character limit there.
409    const NAME_LIST: [(&str, &str); 24] = [
410        ("Smith", "SM0"),
411        ("Schmidt", "XMT"),
412        ("Schneider", "XNTR"),
413        ("Thompson", "TMPS"),
414        ("Jackson", "JKSN"),
415        ("Knight", "NT"),
416        ("Wright", "RT"),
417        ("Gnome", "NM"),
418        ("Psycho", "SX"),
419        ("Philip", "FLP"),
420        ("Christopher", "KRST"),
421        ("Church", "XRX"),
422        ("Miller", "MLR"),
423        ("Anderson", "ANTR"),
424        ("Robertson", "RPRT"),
425        ("Nelson", "NLSN"),
426        ("Ghost", "KST"),
427        ("Through", "0R"),
428        ("Bell", "PL"),
429        ("Kelly", "KL"),
430        ("Fitzgerald", "FTSJ"),
431        ("Xavier", "SFR"),
432        ("Quinn", "KN"),
433        ("Zimmerman", "SMRM"),
434    ];
435
436    #[test]
437    fn double_metaphone_encodings_match_the_committed_name_list() {
438        for (name, expected) in NAME_LIST {
439            assert_eq!(encode(name), expected, "encoding of {name:?}");
440        }
441    }
442
443    #[test]
444    fn homophone_surnames_collapse_together() {
445        // The point of the side-field: names that sound alike share a code.
446        assert_eq!(encode("Smith"), encode("Smyth"));
447        assert_eq!(encode("Anderson"), encode("Andersen"));
448        assert_eq!(encode("Nelson"), encode("Nelsen"));
449    }
450
451    #[test]
452    fn encoding_is_case_and_punctuation_insensitive() {
453        assert_eq!(encode("Smith"), encode("SMITH"));
454        assert_eq!(encode("Smith"), encode("smith"));
455        assert_eq!(encode("O'Brien"), encode("OBrien"));
456        assert_eq!(encode("Fitz-Gerald"), encode("FitzGerald"));
457    }
458
459    #[test]
460    fn a_code_never_exceeds_the_documented_length() {
461        for (name, _) in NAME_LIST {
462            assert!(encode(name).len() <= MAX_CODE_LENGTH, "{name} overflowed");
463        }
464        assert!(encode("Supercalifragilisticexpialidocious").len() <= MAX_CODE_LENGTH);
465    }
466
467    #[test]
468    fn a_term_with_no_letters_encodes_to_nothing() {
469        // An empty code must never be indexed; it would match every other
470        // unencodable term.
471        assert_eq!(encode(""), "");
472        assert_eq!(encode("12345"), "");
473        assert_eq!(encode("---"), "");
474        assert_eq!(encode("\u{4E2D}\u{6587}"), "");
475    }
476
477    #[test]
478    fn encoding_is_deterministic() {
479        for (name, _) in NAME_LIST {
480            let first = encode(name);
481            for _ in 0..4 {
482                assert_eq!(encode(name), first);
483            }
484        }
485    }
486
487    #[test]
488    fn encoding_never_panics_on_odd_input() {
489        for term in [
490            "",
491            "'",
492            "AE",
493            "GN",
494            "X",
495            "XX",
496            "SCH",
497            "TH",
498            "\u{1F680}",
499            "a".repeat(500).as_str(),
500        ] {
501            let _ = encode(term);
502        }
503    }
504}