Skip to main content

laya_core/
lang.rs

1//! Dependency-free script and language detection used to route between checkpoints.
2//!
3//! Routing needs one decision: is this English Latin text, or something the
4//! English checkpoint cannot read? Script detection is exact; the Latin-script
5//! language guess is an explicitly best-effort stopword and diacritic heuristic.
6//!
7//! Two CPython behaviours are reproduced deliberately, because both are
8//! observable in the returned `script_profile`:
9//!
10//! * `str.isalpha()` excludes combining marks that Rust's `char::is_alphabetic`
11//!   accepts (see [`crate::pycompat::py_isalpha`]).
12//! * `max()` over a dict breaks ties by insertion order, so the counts here are
13//!   kept in first-seen order rather than in a hash map.
14
15use crate::lang_tables::{NON_EN_DIACRITICS, SCRIPT_RANGES, STOPWORDS};
16use crate::pycompat::{py_isalpha, py_round, py_words_lowercase};
17use serde::Serialize;
18use serde_json::Value;
19
20const MAX_DETECTION_CHARS: usize = 4000;
21const MAX_DEPTH: usize = 6;
22
23/// Detection result for a state.
24#[derive(Clone, Debug, PartialEq, Serialize)]
25pub struct Detection {
26    pub script: String,
27    /// Fraction of alphabetic characters per script, in upstream's key order.
28    ///
29    /// Kept as ordered pairs rather than a map so the key order is preserved,
30    /// and serialized as a JSON object to match upstream's dictionary.
31    #[serde(serialize_with = "serialize_profile")]
32    pub script_profile: Vec<(String, f64)>,
33    pub language: Option<String>,
34    pub is_english: bool,
35    pub non_latin_fraction: f64,
36}
37
38fn serialize_profile<S>(
39    profile: &[(String, f64)],
40    serializer: S,
41) -> std::result::Result<S::Ok, S::Error>
42where
43    S: serde::Serializer,
44{
45    use serde::ser::SerializeMap;
46    let mut map = serializer.serialize_map(Some(profile.len()))?;
47    for (name, fraction) in profile {
48        map.serialize_entry(name, fraction)?;
49    }
50    map.end()
51}
52
53impl Detection {
54    fn latin_fraction(&self) -> f64 {
55        self.script_profile
56            .iter()
57            .find(|(name, _)| name == "latin")
58            .map_or(0.0, |(_, value)| *value)
59    }
60}
61
62/// Collect the string leaves of a state, so detection sees real content.
63fn iter_text<'a>(state: &'a Value, depth: usize, out: &mut Vec<&'a str>) {
64    if depth > MAX_DEPTH {
65        return;
66    }
67    match state {
68        Value::String(text) => out.push(text),
69        // Keys are ignored: they are usually English even when the values are not.
70        Value::Object(map) => {
71            for value in map.values() {
72                iter_text(value, depth + 1, out);
73            }
74        }
75        Value::Array(items) => {
76            for value in items {
77                iter_text(value, depth + 1, out);
78            }
79        }
80        _ => {}
81    }
82}
83
84/// Flatten a state into the text used for detection.
85pub fn state_text(state: &Value) -> String {
86    let mut leaves = Vec::new();
87    iter_text(state, 0, &mut leaves);
88    let joined = leaves.join(" ");
89    // CPython slices by code point, not by byte.
90    joined.chars().take(MAX_DETECTION_CHARS).collect()
91}
92
93/// Is this codepoint in the Latin range upstream treats as readable?
94#[inline]
95fn is_latin_codepoint(cp: u32) -> bool {
96    cp < 0x0250 || (0x1E00..=0x1EFF).contains(&cp)
97}
98
99/// Per-script counts in first-seen order, plus the Latin count.
100///
101/// `latin_first` controls where the Latin entry sits, which is what decides
102/// tie-breaking in `detect_script` and key order in `script_profile`.
103fn count_scripts(text: &str, latin_first: bool) -> Vec<(String, usize)> {
104    let mut counts: Vec<(String, usize)> = if latin_first {
105        vec![("latin".to_string(), 0)]
106    } else {
107        Vec::new()
108    };
109    let mut latin = 0usize;
110
111    for c in text.chars() {
112        if !py_isalpha(c) {
113            continue;
114        }
115        let cp = c as u32;
116        if is_latin_codepoint(cp) {
117            if latin_first {
118                counts[0].1 += 1;
119            } else {
120                latin += 1;
121            }
122            continue;
123        }
124        for (name, ranges) in SCRIPT_RANGES {
125            if ranges.iter().any(|(lo, hi)| (*lo..=*hi).contains(&cp)) {
126                match counts.iter_mut().find(|(key, _)| key == name) {
127                    Some(entry) => entry.1 += 1,
128                    None => counts.push((name.to_string(), 1)),
129                }
130                break;
131            }
132        }
133    }
134
135    if !latin_first {
136        // Upstream assigns `counts["latin"]` after the loop, so it lands last
137        // and loses ties against any script already present.
138        match counts.iter_mut().find(|(key, _)| key == "latin") {
139            Some(entry) => entry.1 = latin,
140            None => counts.push(("latin".to_string(), latin)),
141        }
142    }
143    counts
144}
145
146/// Dominant script: `"latin"`, `"han"`, … or `"unknown"` when there are no letters.
147pub fn detect_script(text: &str) -> String {
148    let counts = count_scripts(text, false);
149    let total: usize = counts.iter().map(|(_, n)| *n).sum();
150    if total == 0 {
151        return "unknown".to_string();
152    }
153    // `max()` keeps the first maximum in iteration order.
154    counts
155        .into_iter()
156        .max_by_key(|(_, n)| *n)
157        .map(|(name, _)| name)
158        .unwrap_or_else(|| "unknown".to_string())
159}
160
161/// Fraction of alphabetic characters belonging to each detected script.
162pub fn script_profile(text: &str) -> Vec<(String, f64)> {
163    let counts = count_scripts(text, true);
164    let total: usize = counts.iter().map(|(_, n)| *n).sum();
165    if total == 0 {
166        return Vec::new();
167    }
168    counts
169        .into_iter()
170        .filter(|(_, n)| *n > 0)
171        .map(|(name, n)| (name, n as f64 / total as f64))
172        .collect()
173}
174
175/// Best-effort language code for Latin-script text, or `None` when undecided.
176pub fn guess_latin_language(text: &str) -> Option<String> {
177    let words = py_words_lowercase(text);
178    if words.len() < 4 {
179        return None;
180    }
181
182    let scores: Vec<(&str, usize)> = STOPWORDS
183        .iter()
184        .map(|(code, stopwords)| {
185            let hits = words
186                .iter()
187                .filter(|word| stopwords.binary_search(&word.as_str()).is_ok())
188                .count();
189            (*code, hits)
190        })
191        .collect();
192
193    let lowered = text.to_lowercase();
194    let diacritics = lowered
195        .chars()
196        .filter(|c| NON_EN_DIACRITICS.contains(*c))
197        .count();
198    let diacritic_rate = diacritics as f64 / std::cmp::max(1, lowered.chars().count()) as f64;
199
200    let english = scores
201        .iter()
202        .find(|(code, _)| *code == "en")
203        .map_or(0, |(_, hits)| *hits);
204
205    // `max()` over the non-English entries, first maximum wins.
206    let best = scores
207        .iter()
208        .filter(|(code, _)| *code != "en")
209        .copied()
210        .fold(None::<(&str, usize)>, |acc, item| match acc {
211            Some((_, best)) if best >= item.1 => acc,
212            _ => Some(item),
213        });
214    let (best_lang, best_score) = match best {
215        Some((lang, score)) => (Some(lang), score),
216        None => (None, 0),
217    };
218
219    let fallback = || {
220        if english > 0 {
221            Some("en".to_string())
222        } else {
223            None
224        }
225    };
226
227    if best_score == 0 && diacritic_rate < 0.02 {
228        return fallback();
229    }
230    if let Some(lang) = best_lang {
231        // A non-English language needs a clear margin over English function words.
232        if best_score >= std::cmp::max(2, english + 2) {
233            return Some(lang.to_string());
234        }
235        if diacritic_rate >= 0.04 && best_score >= english {
236            return Some(lang.to_string());
237        }
238    }
239    fallback()
240}
241
242/// Full detection result for a state.
243pub fn analyse(state: &Value) -> Detection {
244    let text = state_text(state);
245    let profile = script_profile(&text);
246    let script = detect_script(&text);
247
248    let mut detection = Detection {
249        script: script.clone(),
250        script_profile: profile,
251        language: None,
252        is_english: true,
253        non_latin_fraction: 0.0,
254    };
255
256    if script == "unknown" {
257        return detection;
258    }
259
260    let non_latin = if detection.script_profile.is_empty() {
261        0.0
262    } else {
263        py_round(1.0 - detection.latin_fraction(), 4)
264    };
265
266    if script != "latin" {
267        detection.is_english = false;
268        detection.non_latin_fraction = non_latin;
269        return detection;
270    }
271
272    let language = guess_latin_language(&text);
273    detection.is_english = matches!(language.as_deref(), None | Some("en"));
274    detection.language = language;
275    detection.non_latin_fraction = non_latin;
276    detection
277}
278
279/// True when the English checkpoint can be expected to read this state.
280pub fn is_english(state: &Value) -> bool {
281    analyse(state).is_english
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use serde_json::json;
288
289    #[test]
290    fn detects_scripts() {
291        assert_eq!(detect_script("hello there"), "latin");
292        assert_eq!(detect_script("Мой счёт"), "cyrillic");
293        assert_eq!(detect_script("こんにちは"), "kana");
294        assert_eq!(detect_script("मेरा खाता"), "devanagari");
295        assert_eq!(detect_script("12345 !!!"), "unknown");
296    }
297
298    #[test]
299    fn combining_marks_do_not_count_as_letters() {
300        // U+093E is a combining vowel sign: CPython's isalpha() rejects it, so
301        // it must not inflate the Devanagari share.
302        let profile = script_profile("\u{0915}\u{093E}");
303        assert_eq!(profile, vec![("devanagari".to_string(), 1.0)]);
304    }
305
306    #[test]
307    fn english_text_routes_to_english() {
308        let detection = analyse(&json!("I was charged twice and would like a refund please"));
309        assert_eq!(detection.script, "latin");
310        assert!(detection.is_english);
311    }
312
313    #[test]
314    fn german_text_is_not_english() {
315        let detection = analyse(&json!(
316            "Mein Konto wurde zweimal belastet und ich möchte eine Rückerstattung"
317        ));
318        assert_eq!(detection.script, "latin");
319        assert!(!detection.is_english);
320        assert_eq!(detection.language.as_deref(), Some("de"));
321    }
322
323    #[test]
324    fn non_latin_reports_a_fraction() {
325        let detection = analyse(&json!("Мой счёт был дважды списан"));
326        assert!(!detection.is_english);
327        assert!(detection.non_latin_fraction > 0.9);
328    }
329
330    #[test]
331    fn nested_states_are_flattened() {
332        let detection = analyse(&json!({"message": {"body": ["こんにちは", 42]}}));
333        assert_eq!(detection.script, "kana");
334    }
335
336    #[test]
337    fn short_input_stays_undecided() {
338        assert_eq!(guess_latin_language("hi there"), None);
339    }
340}